Initialize the API and add managed app banners.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user