fix(treasury): apply a full receipt or payment to the party's open balance
A settled document of the same amount no longer leaves an existing remaining unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -129,6 +129,29 @@ export class TreasuryController {
|
||||
return this.treasury.partyBalances(user, kind === 'expense' ? 'expense' : 'income');
|
||||
}
|
||||
|
||||
@Post('party-balances/collect')
|
||||
collectPartyBalance(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body()
|
||||
body: {
|
||||
kind?: string;
|
||||
partyKind?: 'person' | 'company' | 'user';
|
||||
partyId?: string;
|
||||
amount?: string | number;
|
||||
accountId?: string;
|
||||
occurredAt?: string;
|
||||
},
|
||||
) {
|
||||
return this.treasury.collectPartyBalance(user, {
|
||||
kind: body.kind === 'expense' ? 'expense' : 'income',
|
||||
partyKind: body.partyKind ?? 'person',
|
||||
partyId: body.partyId ?? '',
|
||||
amount: body.amount ?? 0,
|
||||
accountId: body.accountId ?? '',
|
||||
occurredAt: body.occurredAt,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('documents')
|
||||
documents(
|
||||
@CurrentUser() user: Actor,
|
||||
|
||||
@@ -561,7 +561,69 @@ export class TreasuryService {
|
||||
}
|
||||
}
|
||||
const bookedAt = startOfDay(input.bookedAt) ?? new Date();
|
||||
const payExisting =
|
||||
party.partyKind !== 'other' && settle.equals(amount) && settle.greaterThan(0);
|
||||
const doc = await this.prisma.$transaction(async (tx) => {
|
||||
let amountLeft = amount;
|
||||
let settleLeft = settle;
|
||||
let touchedId = '';
|
||||
if (payExisting && input.accountId) {
|
||||
const scope = await this.accountScope(actor);
|
||||
const openDocs = await tx.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
kind: input.kind,
|
||||
voidedAt: null,
|
||||
status: { in: ['open', 'partial'] },
|
||||
partyKind: party.partyKind,
|
||||
...(party.personId ? { personId: party.personId } : {}),
|
||||
...(party.companyId ? { companyId: party.companyId } : {}),
|
||||
...(party.userId ? { userId: party.userId } : {}),
|
||||
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
|
||||
},
|
||||
include: { movements: { select: { amount: true, voidedAt: true } } },
|
||||
orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
for (const row of openDocs) {
|
||||
if (!settleLeft.greaterThan(0)) break;
|
||||
const remaining = row.amount.minus(this.settledOf(row.movements));
|
||||
if (!remaining.greaterThan(0)) continue;
|
||||
const slice = settleLeft.lessThan(remaining) ? settleLeft : remaining;
|
||||
const status = slice.equals(remaining) ? 'settled' : 'partial';
|
||||
await tx.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: input.accountId,
|
||||
documentId: row.id,
|
||||
direction: input.kind === 'income' ? 'in' : 'out',
|
||||
amount: slice,
|
||||
kind: input.kind === 'income' ? 'receipt' : 'payment',
|
||||
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
|
||||
memo: title,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
await tx.financialDocument.update({
|
||||
where: { id: row.id },
|
||||
data: { status, accountId: input.accountId },
|
||||
});
|
||||
if (status === 'settled') {
|
||||
await tx.financialRecurringOccurrence.updateMany({
|
||||
where: { documentId: row.id, status: 'pending' },
|
||||
data: { status: 'confirmed' },
|
||||
});
|
||||
}
|
||||
touchedId = row.id;
|
||||
settleLeft = settleLeft.minus(slice);
|
||||
amountLeft = amountLeft.minus(slice);
|
||||
}
|
||||
}
|
||||
if (!amountLeft.greaterThan(0)) {
|
||||
return tx.financialDocument.findUniqueOrThrow({
|
||||
where: { id: touchedId },
|
||||
include: { category: true, account: true, movements: true },
|
||||
});
|
||||
}
|
||||
const reference = await this.nextDocumentReference(
|
||||
tx,
|
||||
actor.organizationId,
|
||||
@@ -574,7 +636,7 @@ export class TreasuryService {
|
||||
organizationId: actor.organizationId,
|
||||
kind: input.kind,
|
||||
title,
|
||||
amount,
|
||||
amount: amountLeft,
|
||||
bookedAt,
|
||||
dueAt: input.dueAt ? startOfDay(input.dueAt) : null,
|
||||
categoryId: input.categoryId || null,
|
||||
@@ -585,20 +647,20 @@ export class TreasuryService {
|
||||
partyName: party.partyName,
|
||||
accountId: input.accountId || null,
|
||||
method: input.method || null,
|
||||
status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
|
||||
status: settleLeft.equals(0) ? 'open' : settleLeft.equals(amountLeft) ? 'settled' : 'partial',
|
||||
reference,
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
if (settle.greaterThan(0) && input.accountId) {
|
||||
if (settleLeft.greaterThan(0) && input.accountId) {
|
||||
await tx.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: input.accountId,
|
||||
documentId: created.id,
|
||||
direction: input.kind === 'income' ? 'in' : 'out',
|
||||
amount: settle,
|
||||
amount: settleLeft,
|
||||
kind: input.kind === 'income' ? 'receipt' : 'payment',
|
||||
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
|
||||
memo: title,
|
||||
@@ -728,6 +790,7 @@ export class TreasuryService {
|
||||
amount: Prisma.Decimal;
|
||||
settled: Prisma.Decimal;
|
||||
remaining: Prisma.Decimal;
|
||||
documentRemaining: Prisma.Decimal;
|
||||
}>();
|
||||
for (const row of rows) {
|
||||
if (row.partyKind === 'other') continue;
|
||||
@@ -748,10 +811,12 @@ export class TreasuryService {
|
||||
amount: new Prisma.Decimal(0),
|
||||
settled: new Prisma.Decimal(0),
|
||||
remaining: new Prisma.Decimal(0),
|
||||
documentRemaining: new Prisma.Decimal(0),
|
||||
};
|
||||
current.amount = current.amount.plus(row.amount);
|
||||
current.settled = current.settled.plus(settled);
|
||||
current.remaining = current.remaining.plus(rawRemaining);
|
||||
current.documentRemaining = current.documentRemaining.plus(rawRemaining);
|
||||
buckets.set(key, current);
|
||||
}
|
||||
|
||||
@@ -770,6 +835,7 @@ export class TreasuryService {
|
||||
amount: new Prisma.Decimal(0),
|
||||
settled: new Prisma.Decimal(0),
|
||||
remaining: new Prisma.Decimal(0),
|
||||
documentRemaining: new Prisma.Decimal(0),
|
||||
};
|
||||
current.amount = current.amount.plus(amount);
|
||||
current.settled = current.settled.plus(settled);
|
||||
@@ -790,9 +856,122 @@ export class TreasuryService {
|
||||
amount: money(row.amount),
|
||||
settledAmount: money(row.settled),
|
||||
remaining: money(row.remaining),
|
||||
documentRemaining: money(row.documentRemaining),
|
||||
}));
|
||||
}
|
||||
|
||||
/** دریافت یا پرداختِ بخشی از ماندهٔ سندهای باز یک طرف حساب، بدون ساخت سند جدید. */
|
||||
async collectPartyBalance(
|
||||
actor: Actor,
|
||||
input: {
|
||||
kind: 'income' | 'expense';
|
||||
partyKind: 'person' | 'company' | 'user';
|
||||
partyId: string;
|
||||
amount: string | number;
|
||||
accountId: string;
|
||||
occurredAt?: string;
|
||||
},
|
||||
): Promise<{ applied: string; documentCount: number }> {
|
||||
const amount = dec(input.amount);
|
||||
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگتر از صفر باشد.');
|
||||
const partyId = input.partyId.trim();
|
||||
if (!partyId) throw new BadRequestException('طرف حساب را انتخاب کنید.');
|
||||
if (input.partyKind !== 'person' && input.partyKind !== 'company' && input.partyKind !== 'user') {
|
||||
throw new BadRequestException('این مانده به سند بازی وصل نیست.');
|
||||
}
|
||||
if (!input.accountId.trim()) throw new BadRequestException('حساب را انتخاب کنید.');
|
||||
const account = await this.requireAccount(actor.organizationId, input.accountId, actor);
|
||||
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
|
||||
const scope = await this.accountScope(actor);
|
||||
const docs = await this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
kind: input.kind,
|
||||
voidedAt: null,
|
||||
status: { in: ['open', 'partial'] },
|
||||
partyKind: input.partyKind,
|
||||
...(input.partyKind === 'person' ? { personId: partyId } : {}),
|
||||
...(input.partyKind === 'company' ? { companyId: partyId } : {}),
|
||||
...(input.partyKind === 'user' ? { userId: partyId } : {}),
|
||||
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
|
||||
},
|
||||
include: { movements: { select: { amount: true, voidedAt: true } } },
|
||||
orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
const open = docs
|
||||
.map((doc) => {
|
||||
const remaining = doc.amount.minus(this.settledOf(doc.movements));
|
||||
return { doc, remaining };
|
||||
})
|
||||
.filter((row) => row.remaining.greaterThan(0));
|
||||
const available = open.reduce((sum, row) => sum.plus(row.remaining), new Prisma.Decimal(0));
|
||||
if (!available.greaterThan(0)) {
|
||||
throw new BadRequestException(
|
||||
'سند بازی برای کم کردن این مانده نیست. اگر مانده از فاکتور است، دریافت را روی خود فاکتور ثبت کنید.',
|
||||
);
|
||||
}
|
||||
if (amount.greaterThan(available)) {
|
||||
throw new BadRequestException('مبلغ از ماندهٔ سندهای باز این طرف حساب بیشتر است.');
|
||||
}
|
||||
if (input.kind === 'expense') {
|
||||
const balance = await this.accountBalance(account.id);
|
||||
if (balance.lessThan(amount)) {
|
||||
throw new BadRequestException('موجودی حساب برای این پرداخت کافی نیست.');
|
||||
}
|
||||
}
|
||||
const occurredAt = startOfDay(input.occurredAt) ?? new Date();
|
||||
let left = amount;
|
||||
const touched: string[] = [];
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const row of open) {
|
||||
if (!left.greaterThan(0)) break;
|
||||
const slice = left.lessThan(row.remaining) ? left : row.remaining;
|
||||
const status = slice.equals(row.remaining) ? 'settled' : 'partial';
|
||||
await tx.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: account.id,
|
||||
documentId: row.doc.id,
|
||||
direction: input.kind === 'income' ? 'in' : 'out',
|
||||
amount: slice,
|
||||
kind: input.kind === 'income' ? 'receipt' : 'payment',
|
||||
occurredAt,
|
||||
memo: input.kind === 'income' ? 'دریافت از مانده' : 'پرداخت از مانده',
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
await tx.financialDocument.update({
|
||||
where: { id: row.doc.id },
|
||||
data: { status, accountId: account.id },
|
||||
});
|
||||
if (status === 'settled') {
|
||||
await tx.financialRecurringOccurrence.updateMany({
|
||||
where: { documentId: row.doc.id, status: 'pending' },
|
||||
data: { status: 'confirmed' },
|
||||
});
|
||||
}
|
||||
touched.push(row.doc.id);
|
||||
left = left.minus(slice);
|
||||
}
|
||||
});
|
||||
await this.prisma.financialAuditLog.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
entityType: 'document',
|
||||
entityId: touched[0] ?? partyId,
|
||||
action: 'settle',
|
||||
after: {
|
||||
amount: amount.toFixed(2),
|
||||
accountId: account.id,
|
||||
documentIds: touched,
|
||||
partyId,
|
||||
},
|
||||
actorUserId: actor.userId,
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
return { applied: money(amount), documentCount: touched.length };
|
||||
}
|
||||
|
||||
private async resolveParty(
|
||||
actor: Actor,
|
||||
input: {
|
||||
|
||||
Reference in New Issue
Block a user