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:
2026-11-10 10:45:59 +03:30
co-authored by Cursor
parent 96105468d4
commit ba7f942059
5 changed files with 318 additions and 27 deletions
@@ -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,
+183 -4
View File
@@ -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: {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
@@ -142,6 +142,19 @@ async function createDoc() {
error.value = 'برای سایر، مبلغ پرداخت‌شده باید با مبلغ کل یکی باشد.';
return;
}
const partyKey =
form.value.partyKind && form.value.partyKind !== 'other' && form.value.partyId
? `${form.value.partyKind}:${form.value.partyId}`
: '';
const openBalance = partyKey
? Number(
balances.value.find((row) => row.key === partyKey)?.documentRemaining ||
balances.value.find((row) => row.key === partyKey)?.remaining ||
0,
)
: 0;
const collectsBalance =
Boolean(partyKey) && settleAmount === amount && settleAmount > 0 && openBalance > 0;
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
kind,
title: form.value.title,
@@ -168,9 +181,11 @@ async function createDoc() {
method: 'transfer',
notes: '',
};
ok.value = data.reference
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
: 'سند ثبت شد.';
ok.value = collectsBalance
? `${toman(settleAmount)} از ماندهٔ طرف حساب کم شد.`
: data.reference
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
: 'سند ثبت شد.';
await load();
} catch (e) {
error.value = apiMessage(e);
@@ -179,6 +194,25 @@ async function createDoc() {
}
}
const partyRules = [
{
validator: async () => {
if (!form.value.partyKind) throw new Error('طرف حساب الزامی است');
if (form.value.partyKind !== 'other' && !form.value.partyId) {
throw new Error('طرف حساب را انتخاب کنید');
}
},
},
];
const partyRemainingLabel = computed(() => {
const kind = form.value.partyKind;
const id = form.value.partyId;
if (!kind || kind === 'other' || !id) return '';
const row = balances.value.find((item) => item.key === `${kind}:${id}`);
return `مانده ${toman(row?.remaining || 0)}`;
});
async function voidDoc(id: string) {
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
try {
@@ -210,6 +244,18 @@ onMounted(() => {
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="طرف حساب" name="partyKind" :rules="partyRules">
<a-row :gutter="8" align="middle">
<a-col :span="partyRemainingLabel ? 16 : 24">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-col>
<a-col v-if="partyRemainingLabel" :span="8">
<a-typography-text type="secondary">{{ partyRemainingLabel }}</a-typography-text>
</a-col>
</a-row>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item
label="عنوان"
@@ -247,11 +293,6 @@ onMounted(() => {
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="طرف حساب" name="partyKind">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-select show-search option-filter-prop="label"
@@ -121,6 +121,25 @@ const formTabOptions = [
},
];
const partyRules = [
{
validator: async () => {
if (!form.value.partyKind) throw new Error('طرف حساب الزامی است');
if (form.value.partyKind !== 'other' && !form.value.partyId) {
throw new Error('طرف حساب را انتخاب کنید');
}
},
},
];
const partyRemainingLabel = computed(() => {
const kind = form.value.partyKind;
const id = form.value.partyId;
if (!kind || kind === 'other' || !id) return '';
const row = balances.value.find((item) => item.key === `${kind}:${id}`);
return `مانده ${toman(row?.remaining || 0)}`;
});
const remainingAmount = computed(() => {
const total = parseMoneyNumber(String(form.value.amount ?? ''), 0) ?? 0;
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
@@ -203,6 +222,19 @@ async function createDoc() {
error.value = 'برای سایر، مبلغ دریافت‌شده باید با مبلغ کل یکی باشد.';
return;
}
const partyKey =
form.value.partyKind && form.value.partyKind !== 'other' && form.value.partyId
? `${form.value.partyKind}:${form.value.partyId}`
: '';
const openBalance = partyKey
? Number(
balances.value.find((row) => row.key === partyKey)?.documentRemaining ||
balances.value.find((row) => row.key === partyKey)?.remaining ||
0,
)
: 0;
const collectsBalance =
Boolean(partyKey) && settleAmount === amount && settleAmount > 0 && openBalance > 0;
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
kind,
title: form.value.title,
@@ -219,9 +251,11 @@ async function createDoc() {
bookedAt: form.value.bookedAt || undefined,
});
resetForm();
ok.value = data.reference
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
: 'سند ثبت شد.';
ok.value = collectsBalance
? `${toman(settleAmount)} از ماندهٔ طرف حساب کم شد.`
: data.reference
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
: 'سند ثبت شد.';
await load();
} catch (e) {
error.value = apiMessage(e);
@@ -358,6 +392,16 @@ onMounted(() => {
<a-card :bordered="false">
<a-form ref="mobileFormRef" layout="vertical" :model="form" @finish="createDoc">
<div v-show="formTab === 'main'">
<a-form-item label="طرف حساب" name="partyKind" :rules="partyRules">
<a-row :gutter="8" align="middle">
<a-col :span="partyRemainingLabel ? 16 : 24">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-col>
<a-col v-if="partyRemainingLabel" :span="8">
<a-typography-text type="secondary">{{ partyRemainingLabel }}</a-typography-text>
</a-col>
</a-row>
</a-form-item>
<a-form-item
label="عنوان درآمد"
name="title"
@@ -372,12 +416,7 @@ onMounted(() => {
</a-form-item>
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item label="طرف حساب" name="partyKind">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-col :span="24">
<a-form-item name="method">
<template #label>
<a-space :size="6">
@@ -599,6 +638,18 @@ onMounted(() => {
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="طرف حساب" name="partyKind" :rules="partyRules">
<a-row :gutter="8" align="middle">
<a-col :span="partyRemainingLabel ? 16 : 24">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-col>
<a-col v-if="partyRemainingLabel" :span="8">
<a-typography-text type="secondary">{{ partyRemainingLabel }}</a-typography-text>
</a-col>
</a-row>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item
label="عنوان"
@@ -636,11 +687,6 @@ onMounted(() => {
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="طرف حساب" name="partyKind">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-select show-search option-filter-prop="label"
+2
View File
@@ -6630,6 +6630,8 @@ export interface TreasuryPartyBalanceDto {
amount: string;
settledAmount: string;
remaining: string;
/** بخشی از مانده که با دریافت/پرداخت روی سندهای باز کم می‌شود */
documentRemaining: string;
}
export interface TreasuryDashboardDto {