Files
tehran_taj/apps/api/src/modules/orders/orders.service.ts
T
s.esmaeiliandClaude Opus 5 024e3dd045 parity(api,apps): close SEC-02 - orders take the customer from the token
The user declined to defer this to Phase 2, correctly: a public POST /orders
with customerId in the body let anyone place an order on any customer's
account, and an order lands on that customer's gold debt and ledger.

POST /orders is now behind CustomerAuth. OrdersService.createOrder takes the
customer id as a separate argument that the controller fills from the token;
the body's customerId is ignored and a mismatch is logged. The field stays
optional in the DTO and in shared-types only so both apps keep validating
while they still send it -- they drop it in Phase 2. The admin builder is
unaffected: it legitimately places orders for a chosen customer and passes
that id explicitly, behind the admin guard.

GET /orders/:id is scoped to the owner. A foreign or nonexistent id both
return 404, never 403, so ids stay unenumerable. Operators read through the
new GET /orders/admin/:id behind rid order.list, declared before the :id
route because Nest matches in declaration order.

Both apps' CartView now gates on session.isLoggedIn rather than a remembered
customerId, which is no longer a credential.

OTP audit (requested): no master code, no bypass, no hardcoded code anywhere;
one code path issues a token and only after bcrypt.compare. No SMS provider is
wired up -- in development the code is logged once, guarded by
NODE_ENV !== 'production'.

Fixed while auditing (SEC-11): deliver() persisted the real message text, code
included, into SmsMessage.body, which the admin SMS grid lists and full-text
searches. Any operator with the sms rid could read live codes, defeating the
hashing of codeHash. The stored row is now a redacted audit line.

Also logged MON-08: wagePercent/discountPercent/flatRialCharge are still taken
from the client on order creation. The reference is worse -- it takes
rash_weight from the client too -- so this is already a partial improvement;
logged rather than changed, per D4.

Tests: auth-smoke.sh now 53 passed, 0 failed, including a spoofed body
customerId being ignored in favour of the token. Typecheck clean on api,
user-app, user-app-v2 and shared-types.

[ref: henza_server ApiMobileController.php:533,536-545,615-632]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 07:09:33 +03:30

187 lines
7.2 KiB
TypeScript

import { 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 { CreateOrderDto } from './dto/create-order.dto';
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,
) {}
/**
* Places a stock order. Per line item:
* w = rashWeightGrams * (1 + (wagePercent - discountPercent) / 100)
* mirrors legacy ApiMobileController::setOrder. Debt is NOT posted here —
* only once the order reaches DELIVERED_TO_AGENT (see updateStatus).
*/
/**
* @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) {
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
// 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.`,
);
}
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 wagePercent = new Prisma.Decimal(item.wagePercent);
const discountPercent = new Prisma.Decimal(item.discountPercent ?? 0);
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,
flatRialCharge: new Prisma.Decimal(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;
return this.prisma.$transaction(async (tx) => {
const order = await tx.order.create({
data: {
orderNumber: this.generateOrderNumber(),
customerId,
branchId: dto.branchId,
channel: dto.channel ?? OrderChannel.RETAIL,
status: initialStatus,
totalGoldWeightGrams,
wageWeightGrams,
flatRialCharge,
isSuspendedOverCreditLimit,
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 },
});
return order;
});
}
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 recognition point — mirrors legacy sts>4, which first becomes true
// at sts=5 ("تحویل مسئول فروش" / delivered to agent), not sts=6 (delivered
// to customer). Only fires once: a second transition into this status
// (e.g. a data-fix re-save) would double-post debt, so guard on the order
// not already having deliveredAt.
if (newStatus === OrderStatus.DELIVERED_TO_AGENT && !order.deliveredAt) {
await this.ledger.postOrderDebt(orderId, tx);
}
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;
}
private generateOrderNumber(): string {
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
return `ORD-${Date.now()}-${random}`;
}
}