feat: store payment method before preorder pricing

Reject pricing until the customer picks cash or check terms, then open that payment path after the invoice.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-15 11:25:05 +03:30
co-authored by Cursor
parent 7b25c565fc
commit 29124b06a5
8 changed files with 195 additions and 32 deletions
@@ -0,0 +1,2 @@
-- روش پرداخت و مدت چک قبل از قیمت‌گذاری روی سفارش ذخیره می‌شود
ALTER TABLE "Order" ADD COLUMN "checkTermMonths" INTEGER;
+3 -1
View File
@@ -397,8 +397,10 @@ model Order {
userId String
companyId String
companyName String
// روش تسویه بعد از قیمت‌گذاری ادمین توسط مشتری انتخاب می‌شود
// روش پرداخت هنگام ثبت پیش‌سفارش انتخاب می‌شود؛ قیمت‌گذاری به آن وابسته است
settlementType SettlementType?
/// مدت چک به ماه (۱ تا ۴) وقتی روش پرداخت چکی یا نصف‌نقد نصف‌چکی است
checkTermMonths Int?
checkMode CheckRegistrationMode?
status OrderStatus @default(awaiting_review)
totalMeterage Float @default(0)
@@ -0,0 +1,2 @@
-- روش پرداخت و مدت چک قبل از قیمت‌گذاری روی سفارش ذخیره می‌شود
ALTER TABLE "Order" ADD COLUMN "checkTermMonths" INTEGER;
+3 -1
View File
@@ -397,8 +397,10 @@ model Order {
userId String
companyId String
companyName String
// روش تسویه بعد از قیمت‌گذاری ادمین توسط مشتری انتخاب می‌شود
// روش پرداخت هنگام ثبت پیش‌سفارش انتخاب می‌شود؛ قیمت‌گذاری به آن وابسته است
settlementType SettlementType?
/// مدت چک به ماه (۱ تا ۴) وقتی روش پرداخت چکی یا نصف‌نقد نصف‌چکی است
checkTermMonths Int?
checkMode CheckRegistrationMode?
status OrderStatus @default(awaiting_review)
totalMeterage Float @default(0)
+2 -2
View File
@@ -6,8 +6,8 @@ import { ORDER_STATUS_LABELS } from './order-status';
* گذرهای مجاز گردش سفارش. enum دیتابیس وضعیت‌های بیشتری دارد که از نسخه‌های
* قبلی مانده‌اند؛ فقط همین‌ها در جریان واقعی استفاده می‌شوند.
*
* ترتیب کار: ثبت سفارش ← قیمت‌گذاری ادمین ← ارسال فاکتور ← انتخاب تسویه توسط
* مشتری ← شاخه نقدی یا چکی ← تأیید نهایی ← ارسال.
* ترتیب کار: ثبت پیش‌سفارش همراه روش پرداخت ← قیمت‌گذاری ادمین ← ارسال فاکتور
* و ورود خودکار به شاخه نقدی یا چکی ← تأیید نهایی ← ارسال.
*/
export const ORDER_TRANSITIONS: Record<OrderStatus, OrderStatus[]> = {
awaiting_review: [OrderStatus.pricing],
+36 -6
View File
@@ -3,9 +3,11 @@ import {
IsArray,
IsDateString,
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
Min,
ValidateNested,
} from 'class-validator';
@@ -94,18 +96,22 @@ export class SubmitOrderDto {
@IsString()
companyId!: string;
/**
* روش تسویه بعد از قیمت‌گذاری ادمین انتخاب می‌شود، نه اینجا.
* برای سازگاری با کلاینت‌های قدیمی پذیرفته و نادیده گرفته می‌شود.
*/
@IsOptional()
/** روش پرداخت؛ قیمت‌گذاری ادمین به این انتخاب وابسته است. */
@IsEnum(SettlementType)
settlementType?: SettlementType;
settlementType!: SettlementType;
@IsOptional()
@IsEnum(SettlementType)
settlementMethod?: SettlementType;
/** مدت چک به ماه؛ برای چکی و نصف‌نقد نصف‌چکی اجباری است (۱ تا ۴). */
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(4)
checkTermMonths?: number;
@IsOptional()
@IsEnum(CheckRegistrationMode)
checkMode?: CheckRegistrationMode;
@@ -134,6 +140,30 @@ export class ChooseSettlementDto {
@IsOptional()
@IsEnum(CheckRegistrationMode)
checkMode?: CheckRegistrationMode;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(4)
checkTermMonths?: number;
}
/** ثبت روش پرداخت برای پیش‌سفارش‌هایی که هنوز قیمت نخورده‌اند (مثلاً ثبت‌شده توسط ادمین). */
export class SetPaymentPlanDto {
@IsEnum(SettlementType)
settlementType!: SettlementType;
@IsOptional()
@IsEnum(SettlementType)
settlementMethod?: SettlementType;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(4)
checkTermMonths?: number;
}
export class AdminCreateOrderDto {
+11
View File
@@ -14,6 +14,7 @@ import type { AuthPrincipal } from '../common/decorators/current-user.decorator'
import {
AdminCreateOrderDto,
ChooseSettlementDto,
SetPaymentPlanDto,
SubmitOrderDto,
UpdateOrderChecksDto,
UpdateOrderPricingDto,
@@ -42,6 +43,16 @@ export class CustomerOrdersController {
return this.ordersService.submitOrder(user.id, dto);
}
@AuthType('customer')
@Post(':id/payment-plan')
setPaymentPlan(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
@Body() dto: SetPaymentPlanDto,
) {
return this.ordersService.setPaymentPlan(user.id, id, dto);
}
@AuthType('customer')
@Post(':id/settlement')
chooseSettlement(
+136 -22
View File
@@ -15,6 +15,7 @@ import {
AdminCreateOrderDto,
ChooseSettlementDto,
SubmitOrderCheckDto,
SetPaymentPlanDto,
SubmitOrderDto,
SubmitOrderItemDto,
UpdateOrderChecksDto,
@@ -149,8 +150,8 @@ export class OrdersService {
async submitOrder(userId: string, dto: SubmitOrderDto) {
const { itemsData, totalMeterage, primaryCompany, companyNames, uniqueProductIds } =
await this.resolveOrderLines(dto.items, dto.companyId);
const plan = this.resolvePaymentPlan(dto);
// روش تسویه و چک‌ها بعد از قیمت‌گذاری ادمین گرفته می‌شوند، نه اینجا.
const order = await this.createWithUniqueOrderNumber((orderNumber) =>
this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({
@@ -159,7 +160,8 @@ export class OrdersService {
userId,
companyId: primaryCompany.id,
companyName: companyNames.join('، '),
settlementType: null,
settlementType: plan.settlementType,
checkTermMonths: plan.checkTermMonths,
checkMode: null,
status: OrderStatus.awaiting_review,
totalMeterage,
@@ -226,6 +228,11 @@ export class OrdersService {
'انتخاب روش تسویه فقط پس از صدور فاکتور توسط مدیریت امکان‌پذیر است',
);
}
if (order.settlementType && order.settlementType !== dto.settlementType) {
throw new BadRequestException(
'روش پرداخت هنگام ثبت پیش‌سفارش مشخص شده و پس از قیمت‌گذاری قابل تغییر نیست',
);
}
const cashFirst = usesCashReceiptPhase(dto.settlementType);
const checkOnly =
@@ -277,6 +284,31 @@ export class OrdersService {
return this.findMyOrder(userId, id);
}
/**
* پیش‌سفارش‌های بدون روش پرداخت (ثبت ادمین یا سفارش قدیمی)
* تا قبل از شروع قیمت‌گذاری می‌توانند روش و مدت چک را ثبت کنند.
*/
async setPaymentPlan(userId: string, id: string, dto: SetPaymentPlanDto) {
const order = await this.getOrder(id);
if (order.userId !== userId) {
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
}
if (order.status !== OrderStatus.awaiting_review) {
throw new BadRequestException(
'روش پرداخت فقط تا قبل از قیمت‌گذاری قابل ثبت است',
);
}
const plan = this.resolvePaymentPlan(dto);
await this.prisma.order.update({
where: { id },
data: {
settlementType: plan.settlementType,
checkTermMonths: plan.checkTermMonths,
},
});
return this.findMyOrder(userId, id);
}
async adminCreate(adminId: string, dto: AdminCreateOrderDto) {
if (!dto.items?.length) {
throw new BadRequestException(
@@ -357,6 +389,11 @@ export class OrdersService {
async updatePricing(id: string, adminId: string, dto: UpdateOrderPricingDto) {
const order = await this.getOrder(id);
if (!order.settlementType) {
throw new BadRequestException(
'مشتری هنوز روش پرداخت را مشخص نکرده است. تا قبل از آن قیمت‌گذاری ممکن نیست.',
);
}
const existingIds = new Set(order.items.map((item) => item.id));
const deletedIds = [...new Set(dto.deletedItemIds ?? [])];
@@ -536,7 +573,18 @@ export class OrdersService {
return this.mapOrder(order, true);
}
assertTransition(order.status, dto.status);
const paymentStatus =
dto.status === OrderStatus.awaiting_settlement
? this.statusAfterPricedInvoice(order.settlementType)
: null;
const storedStatus = paymentStatus ?? dto.status;
if (paymentStatus) {
assertTransition(order.status, OrderStatus.awaiting_settlement);
assertTransition(OrderStatus.awaiting_settlement, paymentStatus);
} else {
assertTransition(order.status, dto.status);
}
// فاکتور بدون مبلغ نباید برای مشتری ارسال شود
if (
@@ -547,10 +595,16 @@ export class OrdersService {
'ابتدا سفارش را قیمت‌گذاری کنید تا فاکتور قابل ارسال باشد',
);
}
if (dto.status === OrderStatus.awaiting_settlement && !order.settlementType) {
throw new BadRequestException(
'مشتری روش پرداخت را مشخص نکرده است. تا قبل از اعلام قیمت، روش پرداخت باید ثبت شده باشد.',
);
}
const needsCashExpiry =
(dto.status === OrderStatus.cash_invoice_issued ||
dto.status === OrderStatus.awaiting_cash_payment) &&
(storedStatus === OrderStatus.cash_invoice_issued ||
storedStatus === OrderStatus.awaiting_cash_payment ||
(paymentStatus != null && usesCashReceiptPhase(order.settlementType))) &&
!order.cashInvoiceExpiresAt;
const cashExpiry = needsCashExpiry
? earliestCashInvoiceExpiry(await this.companiesForOrder(order))
@@ -559,32 +613,55 @@ export class OrdersService {
await this.prisma.$transaction(async (tx) => {
await tx.order.update({
where: { id },
data: { status: dto.status },
});
await tx.orderStatusHistory.create({
data: {
orderId: id,
fromStatus: order.status,
toStatus: dto.status,
changedBy: `admin:${adminId}`,
note: dto.note || null,
reason: null,
status: storedStatus,
...(cashExpiry ? { cashInvoiceExpiresAt: cashExpiry } : {}),
},
});
if (cashExpiry) {
await tx.order.update({
where: { id },
data: { cashInvoiceExpiresAt: cashExpiry },
if (paymentStatus) {
await tx.orderStatusHistory.create({
data: {
orderId: id,
fromStatus: order.status,
toStatus: OrderStatus.awaiting_settlement,
changedBy: `admin:${adminId}`,
note: dto.note || 'فاکتور برای مشتری صادر شد',
},
});
await tx.orderStatusHistory.create({
data: {
orderId: id,
fromStatus: OrderStatus.awaiting_settlement,
toStatus: paymentStatus,
changedBy: 'system',
note: 'مسیر پرداخت بر اساس روش انتخاب‌شده در پیش‌سفارش باز شد',
},
});
} else {
await tx.orderStatusHistory.create({
data: {
orderId: id,
fromStatus: order.status,
toStatus: dto.status,
changedBy: `admin:${adminId}`,
note: dto.note || null,
reason: null,
},
});
}
});
const event = eventForStatus(dto.status);
if (event) {
this.notifications.dispatchOrderEvent(id, event, {
if (dto.status === OrderStatus.awaiting_settlement) {
this.notifications.dispatchOrderEvent(id, 'invoice_priced', {
note: dto.note ?? null,
});
} else {
const event = eventForStatus(dto.status);
if (event) {
this.notifications.dispatchOrderEvent(id, event, {
note: dto.note ?? null,
});
}
}
if (dto.status === OrderStatus.awaiting_settlement) {
@@ -726,6 +803,42 @@ export class OrdersService {
}
}
private resolvePaymentPlan(input: {
settlementType?: SettlementType | null;
settlementMethod?: SettlementType | null;
checkTermMonths?: number | null;
}) {
const settlementType = input.settlementType ?? input.settlementMethod;
if (!settlementType) {
throw new BadRequestException(
'قبل از ثبت پیش‌سفارش روش پرداخت را انتخاب کنید',
);
}
if (!usesCheckSettlement(settlementType)) {
return { settlementType, checkTermMonths: null as number | null };
}
const months = input.checkTermMonths;
if (months == null || months < 1 || months > 4) {
throw new BadRequestException(
'برای پرداخت چکی مدت چک را از ۱ تا ۴ ماه انتخاب کنید',
);
}
return { settlementType, checkTermMonths: months };
}
private statusAfterPricedInvoice(
settlementType: SettlementType | null,
): OrderStatus {
if (!settlementType) {
throw new BadRequestException(
'مشتری روش پرداخت را مشخص نکرده است. تا قبل از اعلام قیمت، روش پرداخت باید ثبت شده باشد.',
);
}
return usesCashReceiptPhase(settlementType)
? OrderStatus.awaiting_cash_payment
: OrderStatus.check_in_progress;
}
private async companiesForOrder(order: OrderFull) {
const companyIds = [
...new Set(
@@ -911,6 +1024,7 @@ export class OrdersService {
companyName: order.companyName,
settlementType: order.settlementType,
settlementMethod: order.settlementType,
checkTermMonths: order.checkTermMonths ?? undefined,
checkMode: order.checkMode ?? undefined,
status: order.status,
adminStatus: toAdminStatus(order.status),