feat: paginate customer orders and polish the dashboard hero
Add paged orders API and infinite scroll, short order numbers, and align the guest invite card with the logged-in hero shell. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import { envOr } from './env';
|
||||
* docs/BRANDING.md for the full list.
|
||||
*/
|
||||
export function brandName(): string {
|
||||
return envOr('BRAND_NAME', 'زرتاج');
|
||||
return envOr('BRAND_NAME', 'تهران تاج');
|
||||
}
|
||||
|
||||
export function brandSiteUrl(): string {
|
||||
|
||||
@@ -367,21 +367,26 @@ export class AccountingSyncService {
|
||||
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
|
||||
});
|
||||
const stale = !c?.accountingBalanceAt || Date.now() - c.accountingBalanceAt.getTime() > BALANCE_FRESH_MS;
|
||||
if (stale) {
|
||||
try {
|
||||
const [b] = await a.provider.getBalances(
|
||||
{ ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, BALANCE_ON_DEMAND_TIMEOUT_MS) },
|
||||
[link.externalCode],
|
||||
|
||||
// Already have a stored figure: hand it back immediately and refresh in the
|
||||
// background. Waiting on Tahesab/Zarrin here blocked GET /customer/me for up
|
||||
// to BALANCE_ON_DEMAND_TIMEOUT_MS on almost every dashboard open (freshness
|
||||
// is only a few seconds), so the app painted the guest invite while the
|
||||
// token was still valid.
|
||||
if (c?.accountingBalanceAt && stale) {
|
||||
void this.refreshStoredBalance(a, customerId, link.externalCode).catch((err) => {
|
||||
this.logger.warn(
|
||||
`Background balance refresh for ${customerId} failed: ${(err as Error).message}`,
|
||||
);
|
||||
if (b) {
|
||||
c = await this.prisma.customer.update({
|
||||
where: { id: customerId },
|
||||
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: new Date() },
|
||||
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (stale) {
|
||||
// First read — nothing to show yet, so wait (with the short timeout).
|
||||
try {
|
||||
c = (await this.refreshStoredBalance(a, customerId, link.externalCode)) ?? c;
|
||||
} catch (err) {
|
||||
this.logger.warn(`On-demand balance for ${customerId} failed, serving the stored one: ${(err as Error).message}`);
|
||||
this.logger.warn(
|
||||
`On-demand balance for ${customerId} failed, serving the stored one: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!c?.accountingBalanceAt) return null;
|
||||
@@ -393,6 +398,32 @@ export class AccountingSyncService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Pull one linked customer's balance from the provider and persist it. */
|
||||
private async refreshStoredBalance(
|
||||
a: ActiveProvider,
|
||||
customerId: string,
|
||||
externalCode: string,
|
||||
) {
|
||||
const [b] = await a.provider.getBalances(
|
||||
{ ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, BALANCE_ON_DEMAND_TIMEOUT_MS) },
|
||||
[externalCode],
|
||||
);
|
||||
if (!b) return null;
|
||||
return this.prisma.customer.update({
|
||||
where: { id: customerId },
|
||||
data: {
|
||||
accountingGoldBalance: b.goldGrams,
|
||||
accountingRialBalance: b.rial,
|
||||
accountingBalanceAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
accountingGoldBalance: true,
|
||||
accountingRialBalance: true,
|
||||
accountingBalanceAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Real-time: change poll + live reads
|
||||
|
||||
@@ -133,6 +133,12 @@ describe('CatalogService — paging and filtering', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts several brands as a comma-separated brandId', async () => {
|
||||
const { service, findMany } = setup();
|
||||
await service.findProducts({ page: 1, brandId: 'b1,b2' });
|
||||
expect(lastWhere(findMany).brandId).toEqual({ in: ['b1', 'b2'] });
|
||||
});
|
||||
|
||||
it('matches a gram range against the variants, as the app did', async () => {
|
||||
// A product qualifies when ANY of its variants is in range
|
||||
// (SearchView.vue:189-195), not when all of them are.
|
||||
|
||||
@@ -143,7 +143,17 @@ export class CatalogService {
|
||||
where.title = { contains: query.search.trim(), mode: 'insensitive' };
|
||||
}
|
||||
if (query.categoryId) where.categoryId = query.categoryId;
|
||||
if (query.brandId) where.brandId = query.brandId;
|
||||
// Apps' filter sheet allows several brands at once. Accept a single id or
|
||||
// a comma-separated list (`?brandId=a,b`) so Search does not have to fake
|
||||
// multi-brand by loading the whole catalogue.
|
||||
if (query.brandId) {
|
||||
const brandIds = query.brandId
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (brandIds.length === 1) where.brandId = brandIds[0];
|
||||
else if (brandIds.length > 1) where.brandId = { in: brandIds };
|
||||
}
|
||||
if (query.collectionId) where.collections = { some: { collectionId: query.collectionId } };
|
||||
|
||||
// A product is in range when at least ONE of its variants is, which is what
|
||||
|
||||
@@ -97,8 +97,19 @@ export class CustomerAuthController {
|
||||
|
||||
@CustomerAuth()
|
||||
@Get('me/orders')
|
||||
myOrders(@CurrentCustomer() me: AuthenticatedCustomer) {
|
||||
return this.customers.getOrders(me.id);
|
||||
myOrders(
|
||||
@CurrentCustomer() me: AuthenticatedCustomer,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('q') q?: string,
|
||||
) {
|
||||
return this.customers.getOrders(me.id, {
|
||||
page: Number(page) || 1,
|
||||
limit: limit !== undefined && limit !== '' ? Number(limit) : undefined,
|
||||
status,
|
||||
q,
|
||||
});
|
||||
}
|
||||
|
||||
@CustomerAuth()
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { OrderStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AccountingSyncService } from '../accounting-sync/accounting-sync.service';
|
||||
|
||||
/** Default page size for GET /customer/me/orders (mobile infinite scroll). */
|
||||
const ORDERS_PAGE_LIMIT = 20;
|
||||
const ORDERS_PAGE_LIMIT_MAX = 50;
|
||||
|
||||
const ORDER_STATUSES = new Set<string>(Object.values(OrderStatus));
|
||||
|
||||
export interface GetOrdersQuery {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
/** One OrderStatus value, or omit / ALL for every status. */
|
||||
status?: string;
|
||||
/** Substring match on `orderNumber`. */
|
||||
q?: string;
|
||||
}
|
||||
|
||||
// passwordHash must never leave the API — every customer read goes through this.
|
||||
const PUBLIC_CUSTOMER_SELECT = {
|
||||
id: true,
|
||||
@@ -36,6 +51,8 @@ const PUBLIC_CUSTOMER_SELECT = {
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
private readonly logger = new Logger(CustomersService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly accounting: AccountingSyncService,
|
||||
@@ -51,52 +68,105 @@ export class CustomersService {
|
||||
// D27 — with the accounting integration on, the customer sees the balance
|
||||
// the accounting software holds. The apps only display these two fields;
|
||||
// the credit-limit check runs server-side on the internal ledger, untouched.
|
||||
const accounting = await this.accounting.customerBalance(customerId);
|
||||
if (accounting) {
|
||||
return {
|
||||
...customer,
|
||||
goldDebtGrams: accounting.gold,
|
||||
fiatDebtRial: accounting.rial,
|
||||
balanceSource: 'accounting' as const,
|
||||
balanceAt: accounting.at,
|
||||
};
|
||||
//
|
||||
// Accounting must never take down /customer/me: the dashboard treats a
|
||||
// failed me() as "no customer" and shows the guest invite while the token
|
||||
// (and logout) still work — orders keep loading because they skip this path.
|
||||
try {
|
||||
const accounting = await this.accounting.customerBalance(customerId);
|
||||
if (accounting) {
|
||||
return {
|
||||
...customer,
|
||||
goldDebtGrams: accounting.gold,
|
||||
fiatDebtRial: accounting.rial,
|
||||
balanceSource: 'accounting' as const,
|
||||
balanceAt: accounting.at,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Balance overlay for ${customerId} failed; serving app ledger: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
return { ...customer, balanceSource: 'app' as const, balanceAt: null };
|
||||
}
|
||||
|
||||
getOrders(customerId: string) {
|
||||
return this.prisma.order.findMany({
|
||||
where: { customerId },
|
||||
include: {
|
||||
items: {
|
||||
// Each item carried only `productVariantId`, so the apps looked the
|
||||
// title up by loading the ENTIRE catalogue and joining in the browser
|
||||
// (composables/useOrderItemProduct.ts). With ~16k products that is
|
||||
// the wrong trade for a few row labels, and it also failed for an
|
||||
// order whose product has since left the storefront — the catalogue
|
||||
// hides it, so the row rendered blank.
|
||||
//
|
||||
// A nested read here is one join and always answers, including for
|
||||
// products that are no longer on sale.
|
||||
include: {
|
||||
productVariant: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
product: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
images: { orderBy: { sortOrder: 'asc' }, take: 1, select: { url: true } },
|
||||
},
|
||||
/**
|
||||
* Paginated order history for the signed-in customer.
|
||||
*
|
||||
* Shape matches the report endpoints (`result` / `count` / `page` / `limit`)
|
||||
* so the apps can infinite-scroll the same way. Status and search are applied
|
||||
* server-side — client-side filtering after a page slice would miss matches
|
||||
* that sit on later pages.
|
||||
*/
|
||||
async getOrders(customerId: string, query: GetOrdersQuery = {}) {
|
||||
const page = Math.max(1, Math.floor(query.page ?? 1) || 1);
|
||||
const limit = Math.min(
|
||||
ORDERS_PAGE_LIMIT_MAX,
|
||||
Math.max(1, Math.floor(query.limit ?? ORDERS_PAGE_LIMIT) || ORDERS_PAGE_LIMIT),
|
||||
);
|
||||
|
||||
const where: Prisma.OrderWhereInput = { customerId };
|
||||
if (query.status && query.status !== 'ALL' && ORDER_STATUSES.has(query.status)) {
|
||||
where.status = query.status as OrderStatus;
|
||||
}
|
||||
const q = query.q?.trim();
|
||||
if (q) {
|
||||
where.orderNumber = { contains: q, mode: 'insensitive' };
|
||||
}
|
||||
|
||||
const include = {
|
||||
items: {
|
||||
// Each item carried only `productVariantId`, so the apps looked the
|
||||
// title up by loading the ENTIRE catalogue and joining in the browser
|
||||
// (composables/useOrderItemProduct.ts). With ~16k products that is
|
||||
// the wrong trade for a few row labels, and it also failed for an
|
||||
// order whose product has since left the storefront — the catalogue
|
||||
// hides it, so the row rendered blank.
|
||||
//
|
||||
// A nested read here is one join and always answers, including for
|
||||
// products that are no longer on sale.
|
||||
include: {
|
||||
productVariant: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
product: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
images: { orderBy: { sortOrder: 'asc' as const }, take: 1, select: { url: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
// Chronological status changes for the order-detail timeline in the app.
|
||||
statusEvents: {
|
||||
orderBy: { createdAt: 'asc' as const },
|
||||
select: {
|
||||
id: true,
|
||||
fromStatus: true,
|
||||
toStatus: true,
|
||||
note: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [result, count] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
include,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
]);
|
||||
|
||||
return { result, count, page, limit };
|
||||
}
|
||||
|
||||
getLedgerEntries(customerId: string) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
describe('CustomersService.getOrThrow', () => {
|
||||
const base = {
|
||||
id: 'c1',
|
||||
fullName: 'Honar',
|
||||
mobile: '09382507041',
|
||||
nationalCode: null,
|
||||
cityId: null,
|
||||
referredByAgentId: null,
|
||||
goldCreditLimitGrams: '100',
|
||||
goldDebtGrams: '1.5',
|
||||
fiatDebtRial: '2000',
|
||||
verificationStep: 'VERIFIED',
|
||||
nationalIdImageUrl: null,
|
||||
businessLicenseUrl: null,
|
||||
shopImageUrl: null,
|
||||
isActive: true,
|
||||
isArchived: false,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
};
|
||||
|
||||
it('falls back to the app ledger when accounting throws', async () => {
|
||||
const prisma = {
|
||||
customer: { findUnique: jest.fn().mockResolvedValue(base) },
|
||||
};
|
||||
const accounting = {
|
||||
customerBalance: jest.fn().mockRejectedValue(new Error('tahesab down')),
|
||||
};
|
||||
const service = new CustomersService(prisma as never, accounting as never);
|
||||
|
||||
await expect(service.getOrThrow('c1')).resolves.toMatchObject({
|
||||
id: 'c1',
|
||||
goldDebtGrams: '1.5',
|
||||
fiatDebtRial: '2000',
|
||||
balanceSource: 'app',
|
||||
balanceAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('overlays the accounting balance when available', async () => {
|
||||
const prisma = {
|
||||
customer: { findUnique: jest.fn().mockResolvedValue(base) },
|
||||
};
|
||||
const accounting = {
|
||||
customerBalance: jest.fn().mockResolvedValue({
|
||||
gold: '3.25',
|
||||
rial: '9000',
|
||||
at: new Date('2026-10-17'),
|
||||
provider: 'تهحساب',
|
||||
}),
|
||||
};
|
||||
const service = new CustomersService(prisma as never, accounting as never);
|
||||
|
||||
await expect(service.getOrThrow('c1')).resolves.toMatchObject({
|
||||
goldDebtGrams: '3.25',
|
||||
fiatDebtRial: '9000',
|
||||
balanceSource: 'accounting',
|
||||
});
|
||||
});
|
||||
|
||||
it('404s when the customer is missing', async () => {
|
||||
const prisma = {
|
||||
customer: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
};
|
||||
const service = new CustomersService(prisma as never, { customerBalance: jest.fn() } as never);
|
||||
await expect(service.getOrThrow('missing')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -16,10 +16,11 @@ import { CustomersService } from './customers.service';
|
||||
describe('CustomersService.getOrders — items carry their product', () => {
|
||||
function setup() {
|
||||
const findMany = jest.fn().mockResolvedValue([]);
|
||||
const prisma = { order: { findMany } };
|
||||
const count = jest.fn().mockResolvedValue(0);
|
||||
const prisma = { order: { findMany, count } };
|
||||
// getOrders touches neither the accounting sync nor anything on it.
|
||||
const service = new CustomersService(prisma as never, {} as never);
|
||||
return { service, findMany };
|
||||
return { service, findMany, count };
|
||||
}
|
||||
|
||||
it('includes the variant, its product and the first image', async () => {
|
||||
@@ -39,6 +40,22 @@ describe('CustomersService.getOrders — items carry their product', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('includes status events oldest-first for the detail timeline', async () => {
|
||||
const { service, findMany } = setup();
|
||||
await service.getOrders('c1');
|
||||
|
||||
expect(findMany.mock.calls[0][0].include.statusEvents).toMatchObject({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
fromStatus: true,
|
||||
toStatus: true,
|
||||
note: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('is scoped to the caller and newest first', async () => {
|
||||
// Guards the two properties that were already here: one customer must never
|
||||
// receive another's orders, and the list is reverse-chronological.
|
||||
@@ -59,4 +76,34 @@ describe('CustomersService.getOrders — items carry their product', () => {
|
||||
const product = findMany.mock.calls[0][0].include.items.include.productVariant.select.product;
|
||||
expect(product.where).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pages with skip/take and returns count', async () => {
|
||||
const { service, findMany, count } = setup();
|
||||
findMany.mockResolvedValue([{ id: 'o1' }]);
|
||||
count.mockResolvedValue(41);
|
||||
|
||||
await expect(service.getOrders('c1', { page: 2, limit: 20 })).resolves.toEqual({
|
||||
result: [{ id: 'o1' }],
|
||||
count: 41,
|
||||
page: 2,
|
||||
limit: 20,
|
||||
});
|
||||
expect(findMany.mock.calls[0][0]).toMatchObject({ skip: 20, take: 20 });
|
||||
});
|
||||
|
||||
it('filters by status and orderNumber on the server', async () => {
|
||||
const { service, findMany } = setup();
|
||||
await service.getOrders('c1', { status: 'PLACED', q: '12' });
|
||||
expect(findMany.mock.calls[0][0].where).toEqual({
|
||||
customerId: 'c1',
|
||||
status: 'PLACED',
|
||||
orderNumber: { contains: '12', mode: 'insensitive' },
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores an unknown status instead of throwing', async () => {
|
||||
const { service, findMany } = setup();
|
||||
await service.getOrders('c1', { status: 'NOT_A_STATUS' });
|
||||
expect(findMany.mock.calls[0][0].where).toEqual({ customerId: 'c1' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('IamService', () => {
|
||||
IAM_ISSUER: 'https://auth.test.local',
|
||||
IAM_CLIENT_ID: 'goldbank_api',
|
||||
IAM_CLIENT_SECRET: 'server-secret',
|
||||
IAM_TENANT_SLUG: 'zartaj',
|
||||
IAM_TENANT_SLUG: 'tehrantaj',
|
||||
IAM_LOGIN_DELEGATION: 'fallback',
|
||||
...overrides,
|
||||
};
|
||||
@@ -110,7 +110,7 @@ describe('IamService', () => {
|
||||
expect(body.identifier).toBe('09120000000');
|
||||
expect(body.client_id).toBe('goldbank_api');
|
||||
expect(body.client_secret).toBe('server-secret');
|
||||
expect(body.tenant_slug).toBe('zartaj');
|
||||
expect(body.tenant_slug).toBe('tehrantaj');
|
||||
// Without this the IAM would rate-limit on this server's IP and lock
|
||||
// out every admin after a handful of attempts.
|
||||
expect(body.client_ip).toBe('203.0.113.7');
|
||||
|
||||
@@ -47,7 +47,11 @@ export class CreateOrderDto {
|
||||
/**
|
||||
* Which sales channel placed the order. Legacy had no such field because the
|
||||
* two channels lived in separate tables (orders vs orders_orders); the one
|
||||
* Order model carries the distinction here. Defaults to RETAIL.
|
||||
* Order model carries the distinction here.
|
||||
*
|
||||
* On the customer (app) path the server forces WHOLESALE and ignores this
|
||||
* field. Operators may set it; admin MakeOrder always sends WHOLESALE.
|
||||
* Default when an operator omits it remains RETAIL (retail list builder).
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsEnum(OrderChannel)
|
||||
|
||||
@@ -5,6 +5,9 @@ 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
|
||||
@@ -20,6 +23,7 @@ describe('OrdersService.createOrder — sales expert credit', () => {
|
||||
return { id: 'o1', ...data };
|
||||
}),
|
||||
},
|
||||
$queryRaw: nextOrderNumberRaw(),
|
||||
};
|
||||
const prisma = {
|
||||
customer: {
|
||||
@@ -48,6 +52,12 @@ describe('OrdersService.createOrder — sales expert credit', () => {
|
||||
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');
|
||||
@@ -63,7 +73,10 @@ const wageStub = {
|
||||
/** 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 tx = {
|
||||
order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) },
|
||||
$queryRaw: nextOrderNumberRaw(),
|
||||
};
|
||||
const prisma = {
|
||||
customer: {
|
||||
findUnique: jest.fn(async () => ({
|
||||
@@ -98,7 +111,15 @@ describe('OrdersService.createOrder — order ban', () => {
|
||||
describe('OrdersService.createOrder — server-side making charge', () => {
|
||||
function build() {
|
||||
const created: Record<string, any>[] = [];
|
||||
const tx = { order: { create: jest.fn(async ({ data }: { data: Record<string, any> }) => { created.push(data); return { id: 'o1', ...data }; }) } };
|
||||
const tx = {
|
||||
order: {
|
||||
create: jest.fn(async ({ data }: { data: Record<string, any> }) => {
|
||||
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)),
|
||||
@@ -200,6 +221,75 @@ describe('OrdersService.createOrder — server-side making charge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** App orders are B2B wholesale; RETAIL is only for the admin retail list. */
|
||||
describe('OrdersService.createOrder — channel', () => {
|
||||
function build() {
|
||||
const created: Record<string, any>[] = [];
|
||||
const tx = {
|
||||
order: {
|
||||
create: jest.fn(async ({ data }: { data: Record<string, any> }) => {
|
||||
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.
|
||||
@@ -207,7 +297,10 @@ describe('OrdersService.createOrder — server-side making charge', () => {
|
||||
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 })) } };
|
||||
const tx = {
|
||||
order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) },
|
||||
$queryRaw: nextOrderNumberRaw(),
|
||||
};
|
||||
const prisma = {
|
||||
customer: {
|
||||
findUnique: jest.fn(async () => ({
|
||||
@@ -241,7 +334,7 @@ describe('OrdersService.createOrder — order SMS', () => {
|
||||
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
|
||||
await settle();
|
||||
const toCustomer = sent.find((s) => s.to === '09120000001');
|
||||
expect(toCustomer?.body).toContain('به ثبت رسید. زرتاج');
|
||||
expect(toCustomer?.body).toContain('به ثبت رسید. تهران تاج');
|
||||
expect(toCustomer?.body).not.toContain('معلق');
|
||||
});
|
||||
|
||||
|
||||
@@ -135,13 +135,29 @@ export class OrdersService {
|
||||
.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: this.generateOrderNumber(),
|
||||
orderNumber: await this.nextOrderNumber(tx),
|
||||
customerId,
|
||||
branchId: dto.branchId,
|
||||
channel: dto.channel ?? OrderChannel.RETAIL,
|
||||
channel,
|
||||
status: initialStatus,
|
||||
totalGoldWeightGrams,
|
||||
wageWeightGrams,
|
||||
@@ -360,8 +376,21 @@ export class OrdersService {
|
||||
return order;
|
||||
}
|
||||
|
||||
private generateOrderNumber(): string {
|
||||
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||
return `ORD-${Date.now()}-${random}`;
|
||||
/**
|
||||
* 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<string> {
|
||||
const rows = await tx.$queryRaw<Array<{ n: bigint | number | string }>>`
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,18 +310,18 @@ describe('SmsService.send', () => {
|
||||
|
||||
/**
|
||||
* The OTP text keeps the reference's shape (ApiMobileController.php:47) with
|
||||
* the brand زرتاج — the first line and the trailing token are the Android SMS
|
||||
* the brand تهران تاج — 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` +
|
||||
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن تهران تاج ${code} می باشد\n` +
|
||||
`aMo6iFyUQaY`;
|
||||
|
||||
it('matches the expected text byte for byte', () => {
|
||||
expect(build('1234')).toBe(
|
||||
'<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن زرتاج 1234 می باشد\naMo6iFyUQaY',
|
||||
'<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن تهران تاج 1234 می باشد\naMo6iFyUQaY',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user