From ba7f94205983d77412f082c3a2e811731b94e67a Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 10 Nov 2026 10:45:59 +0330 Subject: [PATCH] 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 --- apps/api/src/treasury/treasury.controller.ts | 23 +++ apps/api/src/treasury/treasury.service.ts | 187 +++++++++++++++++- .../views/treasury/TreasuryExpensesView.vue | 59 +++++- .../views/treasury/TreasuryIncomesView.vue | 74 +++++-- packages/shared/src/index.ts | 2 + 5 files changed, 318 insertions(+), 27 deletions(-) diff --git a/apps/api/src/treasury/treasury.controller.ts b/apps/api/src/treasury/treasury.controller.ts index 994a98a..75880d4 100644 --- a/apps/api/src/treasury/treasury.controller.ts +++ b/apps/api/src/treasury/treasury.controller.ts @@ -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, diff --git a/apps/api/src/treasury/treasury.service.ts b/apps/api/src/treasury/treasury.service.ts index a4952e4..a341e2e 100644 --- a/apps/api/src/treasury/treasury.service.ts +++ b/apps/api/src/treasury/treasury.service.ts @@ -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: { diff --git a/apps/web/src/views/treasury/TreasuryExpensesView.vue b/apps/web/src/views/treasury/TreasuryExpensesView.vue index ba07a14..2f75786 100644 --- a/apps/web/src/views/treasury/TreasuryExpensesView.vue +++ b/apps/web/src/views/treasury/TreasuryExpensesView.vue @@ -1,5 +1,5 @@