Bill all platform SMS (including OTP), allow negative balance with manual/auto cutoffs, and support own-panel bypass without wallet debit.
448 lines
16 KiB
TypeScript
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(),
|
|
};
|
|
}
|
|
}
|