Files
CRM_Montzar/apps/api/src/treasury/treasury-receivables.service.ts
T
Mahdi f5d68d83fc feat: add per-org SMS wallet with adm panel and header credit
Bill all platform SMS (including OTP), allow negative balance with manual/auto cutoffs, and support own-panel bypass without wallet debit.
2026-10-19 15:55:48 +03:30

448 lines
16 KiB
TypeScript

import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import type {
FinancialInstallmentDto,
FinancialInstallmentStatus,
FinancialReceivableDto,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import ExcelJS from 'exceljs';
import type { Actor } from '../common/record-scope';
function dec(value: Prisma.Decimal | number | string | null | undefined) {
return new Prisma.Decimal(value ?? 0);
}
function money(value: Prisma.Decimal | number | string) {
return new Prisma.Decimal(value).toFixed(2);
}
function parseDate(iso?: string | null) {
if (!iso) return null;
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d;
}
function addPeriod(base: Date, interval: 'weekly' | 'monthly', step: number) {
const next = new Date(base);
if (interval === 'weekly') next.setDate(next.getDate() + step * 7);
else next.setMonth(next.getMonth() + step);
return next;
}
type MovementSlice = { amount: Prisma.Decimal; voidedAt: Date | null; installmentId?: string | null };
@Injectable()
export class TreasuryReceivablesService {
private readonly logger = new Logger(TreasuryReceivablesService.name);
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly sms: SmsGatewayService,
) {}
async list(actor: Actor): Promise<FinancialReceivableDto[]> {
const rows = await this.prisma.financialReceivable.findMany({
where: { organizationId: actor.organizationId },
include: {
installments: { include: { movements: true }, orderBy: { position: 'asc' } },
movements: true,
},
orderBy: { createdAt: 'desc' },
take: 100,
});
return rows.map((row) => this.toDto(row));
}
async create(
actor: Actor,
input: {
title: string;
personId?: string | null;
partyName?: string | null;
totalAmount: string | number;
prepaidAmount?: string | number | null;
accountId?: string | null;
startDate?: string;
count?: number;
interval?: 'weekly' | 'monthly';
notes?: string | null;
installments?: Array<{ amount: string | number; dueDate: string }>;
},
): Promise<FinancialReceivableDto> {
const title = input.title.trim();
const total = dec(input.totalAmount);
const prepaid = dec(input.prepaidAmount ?? 0);
if (title.length < 2) throw new BadRequestException('عنوان قرارداد را وارد کنید.');
if (!total.greaterThan(0)) throw new BadRequestException('مبلغ قرارداد باید بزرگ‌تر از صفر باشد.');
if (prepaid.isNegative() || prepaid.greaterThan(total)) {
throw new BadRequestException('پیش‌پرداخت نامعتبر است.');
}
const financed = total.minus(prepaid);
const lines = this.buildSchedule(financed, input);
if (prepaid.greaterThan(0) && !input.accountId) {
throw new BadRequestException('برای پیش‌پرداخت، حساب مقصد را انتخاب کنید.');
}
if (input.accountId) {
const account = await this.prisma.financialAccount.findFirst({
where: { id: input.accountId, organizationId: actor.organizationId, isActive: true },
});
if (!account) throw new BadRequestException('حساب مقصد پیدا نشد.');
}
const start = parseDate(input.startDate) ?? new Date();
const created = await this.prisma.$transaction(async (tx) => {
const receivable = await tx.financialReceivable.create({
data: {
organizationId: actor.organizationId,
title,
personId: input.personId || null,
partyName: input.partyName?.trim() || null,
totalAmount: total,
notes: input.notes?.trim() || null,
createdById: actor.userId,
status: financed.equals(0) ? 'settled' : 'open',
installments: {
create: lines.map((line, index) => ({
position: index + 1,
dueDate: line.dueDate,
amount: line.amount,
})),
},
},
include: { installments: true },
});
if (prepaid.greaterThan(0) && input.accountId) {
await tx.financialMovement.create({
data: {
organizationId: actor.organizationId,
accountId: input.accountId,
receivableId: receivable.id,
direction: 'in',
amount: prepaid,
kind: 'receipt',
occurredAt: start,
memo: `پیش‌پرداخت ${title}`,
createdById: actor.userId,
},
});
}
return receivable.id;
});
return this.get(actor, created);
}
async collect(
actor: Actor,
installmentId: string,
input: {
amount: string | number;
accountId: string;
occurredAt?: string;
notes?: string | null;
clientKey?: string | null;
},
): Promise<FinancialReceivableDto> {
const installment = await this.prisma.financialInstallment.findFirst({
where: { id: installmentId, receivable: { organizationId: actor.organizationId } },
include: { movements: true, receivable: true },
});
if (!installment || installment.receivable.voidedAt) {
throw new NotFoundException('قسط پیدا نشد.');
}
const amount = dec(input.amount);
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ دریافت باید بزرگ‌تر از صفر باشد.');
const paid = this.paidOf(installment.movements);
const remaining = installment.amount.minus(paid);
if (!remaining.greaterThan(0)) {
throw new BadRequestException('این قسط قبلاً تسویه شده است.');
}
if (amount.greaterThan(remaining)) {
throw new BadRequestException('مبلغ از مانده قسط بیشتر است.');
}
const clientKey = input.clientKey?.trim();
if (clientKey) {
const dup = await this.prisma.financialMovement.findFirst({
where: { installmentId, reference: clientKey, voidedAt: null },
});
if (dup) throw new BadRequestException('این دریافت قبلاً ثبت شده است.');
}
const account = await this.prisma.financialAccount.findFirst({
where: { id: input.accountId, organizationId: actor.organizationId, isActive: true },
});
if (!account) throw new BadRequestException('حساب مقصد پیدا نشد.');
await this.prisma.$transaction(async (tx) => {
await tx.financialMovement.create({
data: {
organizationId: actor.organizationId,
accountId: account.id,
receivableId: installment.receivableId,
installmentId: installment.id,
direction: 'in',
amount,
kind: 'receipt',
occurredAt: parseDate(input.occurredAt) ?? new Date(),
memo: input.notes?.trim() || `قسط ${installment.position} ${installment.receivable.title}`,
reference: clientKey || null,
createdById: actor.userId,
},
});
const nextPaid = paid.plus(amount);
const all = await tx.financialInstallment.findMany({
where: { receivableId: installment.receivableId },
include: { movements: true },
});
const settled = all.every((row) => {
const rowPaid = row.id === installment.id ? nextPaid : this.paidOf(row.movements);
return rowPaid.greaterThanOrEqualTo(row.amount);
});
if (settled) {
await tx.financialReceivable.update({
where: { id: installment.receivableId },
data: { status: 'settled' },
});
}
});
return this.get(actor, installment.receivableId);
}
async excel(actor: Actor) {
const rows = await this.list(actor);
const book = new ExcelJS.Workbook();
const sheet = book.addWorksheet('اقساط');
sheet.addRow(['قرارداد', 'طرف حساب', 'قسط', 'سررسید', 'مبلغ', 'مانده', 'وضعیت']);
for (const row of rows) {
for (const item of row.installments) {
sheet.addRow([
row.title,
row.partyName ?? '',
item.position,
item.dueDate.slice(0, 10),
item.amount,
item.remaining,
item.status,
]);
}
}
return Buffer.from(await book.xlsx.writeBuffer());
}
async upcoming(actor: Actor) {
const rows = await this.prisma.financialInstallment.findMany({
where: {
receivable: { organizationId: actor.organizationId, status: { not: 'void' }, voidedAt: null },
},
include: { movements: true, receivable: true },
orderBy: { dueDate: 'asc' },
take: 200,
});
return rows
.map((row) => this.toInstallment(row))
.filter((row) => row.status === 'open' || row.status === 'partial' || row.status === 'overdue')
.slice(0, 12);
}
async payables(actor: Actor) {
return this.prisma.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
kind: 'expense',
status: { in: ['open', 'partial'] },
},
include: { category: true, account: true, movements: true },
orderBy: [{ dueAt: 'asc' }, { bookedAt: 'asc' }],
take: 12,
});
}
@Cron('15 8 * * *')
async remindDue() {
const now = new Date();
const horizon = new Date(now);
horizon.setDate(horizon.getDate() + 3);
const rows = await this.prisma.financialInstallment.findMany({
where: {
dueDate: { lte: horizon },
reminderNotifiedAt: null,
receivable: { status: 'open', voidedAt: null },
},
include: { movements: true, receivable: true },
take: 80,
});
for (const row of rows) {
const dto = this.toInstallment(row);
if (dto.status === 'settled') continue;
const admins = await this.prisma.membership.findMany({
where: {
organizationId: row.receivable.organizationId,
isActive: true,
role: { in: ['owner', 'admin'] },
},
select: { userId: true },
});
const title = dto.status === 'overdue' ? 'قسط معوق' : 'قسط نزدیک سررسید';
const body = `${row.receivable.title} · قسط ${row.position} · مانده ${dto.remaining}`;
for (const admin of admins) {
await this.notifications.create({
organizationId: row.receivable.organizationId,
userId: admin.userId,
type: 'treasury_installment',
title,
body,
link: '/finance/receivables',
entityType: 'financial_installment',
entityId: row.id,
dedupe: true,
});
const user = await this.prisma.user.findUnique({
where: { id: admin.userId },
select: { mobile: true },
});
if (user?.mobile) {
await this.sms
.send({
organizationId: row.receivable.organizationId,
phone: user.mobile,
text: `${title}: ${body}`,
source: 'treasury_reminder',
})
.catch(() => undefined);
}
}
await this.prisma.financialInstallment.update({
where: { id: row.id },
data: { reminderNotifiedAt: now },
});
}
if (rows.length) this.logger.log(`treasury installment reminders: ${rows.length}`);
}
private async get(actor: Actor, id: string): Promise<FinancialReceivableDto> {
const row = await this.prisma.financialReceivable.findFirst({
where: { id, organizationId: actor.organizationId },
include: {
installments: { include: { movements: true }, orderBy: { position: 'asc' } },
movements: true,
},
});
if (!row) throw new NotFoundException('قرارداد پیدا نشد.');
return this.toDto(row);
}
private buildSchedule(
financed: Prisma.Decimal,
input: {
startDate?: string;
count?: number;
interval?: 'weekly' | 'monthly';
installments?: Array<{ amount: string | number; dueDate: string }>;
},
) {
if (financed.equals(0)) return [];
if (input.installments?.length) {
const lines = input.installments.map((line) => {
const dueDate = parseDate(line.dueDate);
const amount = dec(line.amount);
if (!dueDate || !amount.greaterThan(0)) {
throw new BadRequestException('مبلغ و سررسید هر قسط لازم است.');
}
return { amount, dueDate };
});
const sum = lines.reduce((total, line) => total.plus(line.amount), new Prisma.Decimal(0));
if (!sum.equals(financed)) {
throw new BadRequestException('جمع اقساط باید برابر مانده پس از پیش‌پرداخت باشد.');
}
return lines;
}
const count = Math.floor(input.count ?? 0);
if (count < 1 || count > 60) {
throw new BadRequestException('تعداد اقساط باید بین ۱ و ۶۰ باشد.');
}
const interval = input.interval === 'weekly' ? 'weekly' : 'monthly';
const start = parseDate(input.startDate) ?? new Date();
const base = financed.dividedBy(count).toDecimalPlaces(2, Prisma.Decimal.ROUND_DOWN);
const lines = Array.from({ length: count }, (_, index) => ({
amount: base,
dueDate: addPeriod(start, interval, index),
}));
const assigned = base.mul(count);
lines[lines.length - 1]!.amount = lines[lines.length - 1]!.amount.plus(financed.minus(assigned));
return lines;
}
private paidOf(movements: MovementSlice[]) {
return movements
.filter((row) => !row.voidedAt)
.reduce((sum, row) => sum.plus(row.amount), new Prisma.Decimal(0));
}
private toInstallment(row: {
id: string;
position: number;
dueDate: Date;
amount: Prisma.Decimal;
movements: MovementSlice[];
}): FinancialInstallmentDto {
const paid = this.paidOf(row.movements);
const remainingRaw = row.amount.minus(paid);
const remaining = remainingRaw.isNegative() ? new Prisma.Decimal(0) : remainingRaw;
let status: FinancialInstallmentStatus = 'open';
if (remaining.equals(0)) status = 'settled';
else if (row.dueDate.getTime() < Date.now()) status = 'overdue';
else if (paid.greaterThan(0)) status = 'partial';
return {
id: row.id,
position: row.position,
dueDate: row.dueDate.toISOString(),
amount: money(row.amount),
paidAmount: money(paid),
remaining: money(remaining),
status,
};
}
private toDto(row: {
id: string;
title: string;
personId: string | null;
partyName: string | null;
totalAmount: Prisma.Decimal;
notes: string | null;
status: string;
createdAt: Date;
movements: MovementSlice[];
installments: Array<{
id: string;
position: number;
dueDate: Date;
amount: Prisma.Decimal;
movements: MovementSlice[];
}>;
}): FinancialReceivableDto {
const paid = this.paidOf(row.movements);
const remainingRaw = row.totalAmount.minus(paid);
const remaining = remainingRaw.isNegative() ? new Prisma.Decimal(0) : remainingRaw;
return {
id: row.id,
title: row.title,
personId: row.personId,
partyName: row.partyName,
totalAmount: money(row.totalAmount),
paidAmount: money(paid),
remaining: money(remaining),
status: row.status as FinancialReceivableDto['status'],
notes: row.notes,
installments: row.installments.map((item) => this.toInstallment(item)),
createdAt: row.createdAt.toISOString(),
};
}
}