Initialize the API and add managed app banners.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 17:08:47 +03:30
co-authored by Cursor
commit b15fefe438
158 changed files with 25624 additions and 0 deletions
@@ -0,0 +1,51 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { CashPaymentStatus } from '@prisma/client';
import { CashPaymentsService } from './cash-payments.service';
import { AuthType, Roles } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
import {
SubmitCashReceiptDto,
UpdateCashPaymentStatusDto,
} from './dto/cash-payment.dto';
@Controller()
export class CashPaymentsController {
constructor(private readonly cashPaymentsService: CashPaymentsService) {}
@AuthType('customer')
@Post('orders/:orderId/cash-receipt')
submitReceipt(
@CurrentUser() user: AuthPrincipal,
@Param('orderId') orderId: string,
@Body() dto: SubmitCashReceiptDto,
) {
return this.cashPaymentsService.submitReceipt(user.id, orderId, dto);
}
@AuthType('admin')
@Roles('admin', 'finance')
@Get('admin/cash-payments')
findAll(@Query('status') status?: CashPaymentStatus) {
return this.cashPaymentsService.findAll(status);
}
@AuthType('admin')
@Roles('admin', 'finance')
@Patch('admin/cash-payments/:id/status')
updateStatus(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
@Body() dto: UpdateCashPaymentStatusDto,
) {
return this.cashPaymentsService.updateStatus(id, user.id, dto);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { CashPaymentsController } from './cash-payments.controller';
import { CashPaymentsService } from './cash-payments.service';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [CashPaymentsController],
providers: [CashPaymentsService],
exports: [CashPaymentsService],
})
export class CashPaymentsModule {}
+271
View File
@@ -0,0 +1,271 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CashPaymentStatus, OrderStatus, SettlementType } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
SubmitCashReceiptDto,
UpdateCashPaymentStatusDto,
} from './dto/cash-payment.dto';
import { joinFullName } from '../common/utils/name';
import { NotificationsService } from '../notifications/notifications.service';
/** بعد از تأیید پرداخت یا ارسال، رسید جدید نباید وضعیت را به عقب برگرداند. */
const RECEIPT_SUBMITTABLE_STATUSES: OrderStatus[] = [
OrderStatus.cash_in_progress,
OrderStatus.cash_invoice_issued,
OrderStatus.awaiting_cash_payment,
OrderStatus.cash_payment_submitted,
OrderStatus.awaiting_cash_confirmation,
];
@Injectable()
export class CashPaymentsService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
) {}
async submitReceipt(
userId: string,
orderId: string,
dto: SubmitCashReceiptDto,
) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { cashReceipt: true, items: true },
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
if (order.userId !== userId) {
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
}
if (order.settlementType !== SettlementType.cash) {
throw new BadRequestException('این سفارش تسویه نقدی نیست');
}
if (!RECEIPT_SUBMITTABLE_STATUSES.includes(order.status)) {
throw new BadRequestException(
'در وضعیت فعلی سفارش امکان ثبت رسید وجود ندارد',
);
}
if (
order.cashInvoiceExpiresAt &&
order.cashInvoiceExpiresAt.getTime() < Date.now()
) {
throw new BadRequestException(
'مهلت پرداخت فاکتور نقدی به پایان رسیده است',
);
}
const companyIds = [
...new Set(
[
order.companyId,
...order.items.map((item) => item.companyId).filter(Boolean),
].filter(Boolean),
),
];
const account = await this.prisma.bankAccount.findFirst({
where: {
id: dto.destinationAccountId,
companyId: { in: companyIds },
isActive: true,
},
});
if (!account) {
throw new BadRequestException('حساب مقصد نامعتبر است');
}
const receipt = await this.prisma.$transaction(async (tx) => {
const data = {
amount: dto.amount,
paidAmount: dto.paidAmount ?? dto.amount,
transferDate: new Date(dto.transferDate),
transferTime: dto.transferTime.trim(),
trackingNumber: dto.trackingNumber.trim(),
destinationAccountId: dto.destinationAccountId,
receiptImage: dto.receiptImage || null,
notes: dto.notes?.trim() || null,
expiresAt: order.cashInvoiceExpiresAt,
status: CashPaymentStatus.pending_review,
submittedAt: new Date(),
};
const saved = order.cashReceipt
? await tx.cashPaymentReceipt.update({
where: { orderId },
data,
})
: await tx.cashPaymentReceipt.create({
data: { orderId, ...data },
});
await tx.order.update({
where: { id: orderId },
data: { status: OrderStatus.awaiting_cash_confirmation },
});
await tx.orderStatusHistory.create({
data: {
orderId,
fromStatus: order.status,
toStatus: OrderStatus.awaiting_cash_confirmation,
changedBy: 'customer',
note: 'رسید پرداخت نقدی ارسال شد',
},
});
return saved;
});
this.notifications.dispatchAdmin({
title: 'رسید پرداخت نقدی ثبت شد',
body: `مشتری برای سفارش ${order.orderNumber} رسید به مبلغ ${Math.round(
dto.amount,
).toLocaleString('fa-IR')} تومان ثبت کرد.`,
link: `/orders/${orderId}`,
});
return this.mapReceipt(receipt, order.orderNumber, '');
}
async findAll(status?: CashPaymentStatus) {
const receipts = await this.prisma.cashPaymentReceipt.findMany({
where: status ? { status } : undefined,
orderBy: { createdAt: 'desc' },
include: {
order: {
include: { user: true },
},
},
});
return receipts.map((r) =>
this.mapReceipt(
r,
r.order.orderNumber,
joinFullName(r.order.user.firstName, r.order.user.lastName),
),
);
}
async updateStatus(
id: string,
adminId: string,
dto: UpdateCashPaymentStatusDto,
) {
const receipt = await this.prisma.cashPaymentReceipt.findUnique({
where: { id },
include: { order: { include: { user: true } } },
});
if (!receipt) throw new NotFoundException('رسید نقدی یافت نشد');
const updated = await this.prisma.$transaction(async (tx) => {
const result = await tx.cashPaymentReceipt.update({
where: { id },
data: {
status: dto.status,
notes: dto.note
? [receipt.notes, dto.note].filter(Boolean).join('\n')
: receipt.notes,
},
include: { order: { include: { user: true } } },
});
// رد رسید باید مشتری را به مرحله پرداخت برگرداند تا بتواند رسید تازه بفرستد.
let nextStatus: OrderStatus | null = null;
if (dto.status === CashPaymentStatus.approved) {
nextStatus = OrderStatus.cash_confirmed;
} else if (dto.status === CashPaymentStatus.rejected) {
nextStatus = OrderStatus.awaiting_cash_payment;
} else if (dto.status === CashPaymentStatus.expired) {
nextStatus = OrderStatus.expired;
}
if (nextStatus && nextStatus !== receipt.order.status) {
await tx.order.update({
where: { id: receipt.orderId },
data: { status: nextStatus },
});
await tx.orderStatusHistory.create({
data: {
orderId: receipt.orderId,
fromStatus: receipt.order.status,
toStatus: nextStatus,
changedBy: `admin:${adminId}`,
note: dto.note || `وضعیت پرداخت نقدی: ${dto.status}`,
},
});
}
return result;
});
if (dto.status === CashPaymentStatus.approved) {
this.notifications.dispatchOrderEvent(
receipt.orderId,
'receipt_approved',
);
} else if (dto.status === CashPaymentStatus.rejected) {
this.notifications.dispatchOrderEvent(
receipt.orderId,
'receipt_rejected',
{ note: dto.note ?? null },
);
} else if (dto.status === CashPaymentStatus.expired) {
this.notifications.dispatchOrderEvent(
receipt.orderId,
'cash_invoice_expired',
);
}
return this.mapReceipt(
updated,
updated.order.orderNumber,
joinFullName(updated.order.user.firstName, updated.order.user.lastName),
);
}
private mapReceipt(
r: {
id: string;
orderId: string;
amount: number;
paidAmount: number | null;
transferDate: Date | null;
transferTime: string | null;
trackingNumber: string | null;
destinationAccountId: string | null;
receiptImage: string | null;
notes: string | null;
expiresAt: Date | null;
status: CashPaymentStatus;
submittedAt: Date | null;
createdAt: Date;
updatedAt: Date;
},
orderNumber: string,
customerName: string,
) {
return {
id: r.id,
orderId: r.orderId,
orderNumber,
customerName,
amount: r.amount,
paidAmount: r.paidAmount,
transferDate: r.transferDate?.toISOString() ?? null,
transferTime: r.transferTime ?? undefined,
trackingNumber: r.trackingNumber ?? '',
trackingCode: r.trackingNumber ?? '',
destinationAccountId: r.destinationAccountId ?? undefined,
receiptImage: r.receiptImage ?? undefined,
notes: r.notes ?? undefined,
expiresAt: r.expiresAt?.toISOString() ?? '',
status: r.status,
submittedAt: r.submittedAt?.toISOString(),
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
};
}
}
+55
View File
@@ -0,0 +1,55 @@
import {
IsDateString,
IsEnum,
IsNumber,
IsOptional,
IsString,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
import { CashPaymentStatus } from '@prisma/client';
export class SubmitCashReceiptDto {
@Type(() => Number)
@IsNumber()
@Min(1)
amount!: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
paidAmount?: number;
@IsDateString()
transferDate!: string;
@IsString()
@MinLength(1)
transferTime!: string;
@IsString()
@MinLength(1)
trackingNumber!: string;
@IsString()
destinationAccountId!: string;
@IsOptional()
@IsString()
receiptImage?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateCashPaymentStatusDto {
@IsEnum(CashPaymentStatus)
status!: CashPaymentStatus;
@IsOptional()
@IsString()
note?: string;
}