feat(treasury): separate receipts from income and fit finance on a phone
A receipt or payment only reduces the party balance, while an income or expense keeps both amounts. Finance screens stack into cards on a phone so the forms and lists stay readable. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -360,6 +360,7 @@ export class TreasuryService {
|
||||
organizationId: actor.organizationId,
|
||||
accountId,
|
||||
voidedAt: null,
|
||||
kind: { not: 'allocation' },
|
||||
...(from || to
|
||||
? {
|
||||
occurredAt: {
|
||||
@@ -378,6 +379,7 @@ export class TreasuryService {
|
||||
organizationId: actor.organizationId,
|
||||
accountId,
|
||||
voidedAt: null,
|
||||
kind: { not: 'allocation' },
|
||||
occurredAt: { lt: from },
|
||||
},
|
||||
select: { direction: true, amount: true },
|
||||
@@ -531,6 +533,8 @@ export class TreasuryService {
|
||||
settleAmount?: string | number | null;
|
||||
accountId?: string | null;
|
||||
settledAt?: string | null;
|
||||
/** دریافت یا پرداخت روی مانده؛ سند تعهدی جدید نمیسازد. */
|
||||
settlement?: boolean;
|
||||
},
|
||||
): Promise<FinancialDocumentDto> {
|
||||
const title = input.title.trim();
|
||||
@@ -561,69 +565,18 @@ export class TreasuryService {
|
||||
}
|
||||
}
|
||||
const bookedAt = startOfDay(input.bookedAt) ?? new Date();
|
||||
const payExisting =
|
||||
party.partyKind !== 'other' && settle.equals(amount) && settle.greaterThan(0);
|
||||
const settlement = input.settlement === true;
|
||||
if (settlement) {
|
||||
if (party.partyKind === 'other') {
|
||||
throw new BadRequestException('برای دریافت یا پرداخت، طرف حساب واقعی لازم است.');
|
||||
}
|
||||
if (!settle.equals(amount) || !settle.greaterThan(0)) {
|
||||
throw new BadRequestException('مبلغ دریافت یا پرداخت را وارد کنید.');
|
||||
}
|
||||
if (!input.accountId) throw new BadRequestException('حساب را انتخاب کنید.');
|
||||
}
|
||||
const payExisting = settlement;
|
||||
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,
|
||||
@@ -636,7 +589,7 @@ export class TreasuryService {
|
||||
organizationId: actor.organizationId,
|
||||
kind: input.kind,
|
||||
title,
|
||||
amount: amountLeft,
|
||||
amount,
|
||||
bookedAt,
|
||||
dueAt: input.dueAt ? startOfDay(input.dueAt) : null,
|
||||
categoryId: input.categoryId || null,
|
||||
@@ -647,20 +600,20 @@ export class TreasuryService {
|
||||
partyName: party.partyName,
|
||||
accountId: input.accountId || null,
|
||||
method: input.method || null,
|
||||
status: settleLeft.equals(0) ? 'open' : settleLeft.equals(amountLeft) ? 'settled' : 'partial',
|
||||
status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
|
||||
reference,
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
if (settleLeft.greaterThan(0) && input.accountId) {
|
||||
if (settle.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: settleLeft,
|
||||
amount: settle,
|
||||
kind: input.kind === 'income' ? 'receipt' : 'payment',
|
||||
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
|
||||
memo: title,
|
||||
@@ -669,6 +622,45 @@ export class TreasuryService {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (payExisting && input.accountId) {
|
||||
const leftover = await this.allocatePartyBalance(tx, actor, {
|
||||
party,
|
||||
sourceId: created.id,
|
||||
kind: input.kind,
|
||||
amount: settle,
|
||||
accountId: input.accountId,
|
||||
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
|
||||
memo: title,
|
||||
});
|
||||
if (leftover.greaterThan(0)) {
|
||||
const opposite = input.kind === 'income' ? 'expense' : 'income';
|
||||
const oppositeRef = await this.nextDocumentReference(
|
||||
tx,
|
||||
actor.organizationId,
|
||||
opposite,
|
||||
input.bookedAt,
|
||||
bookedAt,
|
||||
);
|
||||
await tx.financialDocument.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
kind: opposite,
|
||||
title,
|
||||
amount: leftover,
|
||||
bookedAt,
|
||||
personId: party.personId,
|
||||
companyId: party.companyId,
|
||||
userId: party.userId,
|
||||
partyKind: party.partyKind,
|
||||
partyName: party.partyName,
|
||||
status: 'open',
|
||||
reference: oppositeRef,
|
||||
notes: `source:${created.id}`,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return tx.financialDocument.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: { category: true, account: true, movements: true },
|
||||
@@ -1115,16 +1107,51 @@ export class TreasuryService {
|
||||
});
|
||||
if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.');
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.financialMovement.updateMany({
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const allocations = await tx.financialMovement.findMany({
|
||||
where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null },
|
||||
select: { documentId: true },
|
||||
});
|
||||
await tx.financialMovement.updateMany({
|
||||
where: { documentId: doc.id, voidedAt: null },
|
||||
data: { voidedAt: now },
|
||||
}),
|
||||
this.prisma.financialDocument.update({
|
||||
});
|
||||
await tx.financialMovement.updateMany({
|
||||
where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null },
|
||||
data: { voidedAt: now },
|
||||
});
|
||||
await tx.financialDocument.update({
|
||||
where: { id: doc.id },
|
||||
data: { status: 'void', voidedAt: now, voidReason: reason?.trim() || 'ابطال' },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
const excess = await tx.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
notes: `source:${doc.id}`,
|
||||
voidedAt: null,
|
||||
status: { in: ['open', 'partial'] },
|
||||
},
|
||||
include: { movements: { select: { voidedAt: true } } },
|
||||
});
|
||||
for (const child of excess) {
|
||||
if (child.movements.some((row) => !row.voidedAt)) continue;
|
||||
await tx.financialDocument.update({
|
||||
where: { id: child.id },
|
||||
data: { status: 'void', voidedAt: now, voidReason: 'ابطال سند مبدأ' },
|
||||
});
|
||||
}
|
||||
const affected = [...new Set(allocations.map((row) => row.documentId).filter((id): id is string => Boolean(id)))];
|
||||
for (const documentId of affected) {
|
||||
const row = await tx.financialDocument.findUnique({
|
||||
where: { id: documentId },
|
||||
include: { movements: { select: { amount: true, voidedAt: true } } },
|
||||
});
|
||||
if (!row || row.voidedAt || row.status === 'void') continue;
|
||||
const settled = this.settledOf(row.movements);
|
||||
const status = settled.equals(0) ? 'open' : settled.greaterThanOrEqualTo(row.amount) ? 'settled' : 'partial';
|
||||
await tx.financialDocument.update({ where: { id: row.id }, data: { status } });
|
||||
}
|
||||
});
|
||||
const fresh = await this.prisma.financialDocument.findUniqueOrThrow({
|
||||
where: { id: doc.id },
|
||||
include: { category: true, account: true, movements: true },
|
||||
@@ -1329,6 +1356,76 @@ export class TreasuryService {
|
||||
};
|
||||
}
|
||||
|
||||
/** مبلغ قطعی را از ماندهٔ باز همان طرف کم میکند؛ اول سندهای همنوع، بعد طرف مقابل. روی موجودی حساب اثر ندارد. */
|
||||
private async allocatePartyBalance(
|
||||
tx: Prisma.TransactionClient,
|
||||
actor: Actor,
|
||||
input: {
|
||||
party: { partyKind: string; personId: string | null; companyId: string | null; userId: string | null };
|
||||
sourceId: string;
|
||||
kind: 'income' | 'expense';
|
||||
amount: Prisma.Decimal;
|
||||
accountId: string;
|
||||
occurredAt: Date;
|
||||
memo: string;
|
||||
},
|
||||
): Promise<Prisma.Decimal> {
|
||||
const scope = await this.accountScope(actor);
|
||||
const openDocs = await tx.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
id: { not: input.sourceId },
|
||||
voidedAt: null,
|
||||
status: { in: ['open', 'partial'] },
|
||||
partyKind: input.party.partyKind,
|
||||
...(input.party.personId ? { personId: input.party.personId } : {}),
|
||||
...(input.party.companyId ? { companyId: input.party.companyId } : {}),
|
||||
...(input.party.userId ? { userId: input.party.userId } : {}),
|
||||
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
|
||||
},
|
||||
include: { movements: { select: { amount: true, voidedAt: true } } },
|
||||
orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
const ordered = [
|
||||
...openDocs.filter((row) => row.kind === input.kind),
|
||||
...openDocs.filter((row) => row.kind !== input.kind),
|
||||
];
|
||||
let left = input.amount;
|
||||
for (const row of ordered) {
|
||||
if (!left.greaterThan(0)) break;
|
||||
const remaining = row.amount.minus(this.settledOf(row.movements));
|
||||
if (!remaining.greaterThan(0)) continue;
|
||||
const slice = left.lessThan(remaining) ? left : remaining;
|
||||
const status = slice.equals(remaining) ? 'settled' : 'partial';
|
||||
await tx.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: input.accountId,
|
||||
documentId: row.id,
|
||||
direction: row.kind === 'income' ? 'in' : 'out',
|
||||
amount: slice,
|
||||
kind: 'allocation',
|
||||
occurredAt: input.occurredAt,
|
||||
memo: input.memo,
|
||||
reference: `alloc:${input.sourceId}`,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
await tx.financialDocument.update({
|
||||
where: { id: row.id },
|
||||
data: { status },
|
||||
});
|
||||
if (status === 'settled') {
|
||||
await tx.financialRecurringOccurrence.updateMany({
|
||||
where: { documentId: row.id, status: 'pending' },
|
||||
data: { status: 'confirmed' },
|
||||
});
|
||||
}
|
||||
left = left.minus(slice);
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private settledOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) {
|
||||
return movements
|
||||
.filter((row) => !row.voidedAt)
|
||||
@@ -1337,7 +1434,7 @@ export class TreasuryService {
|
||||
|
||||
private async accountBalance(accountId: string) {
|
||||
const rows = await this.prisma.financialMovement.findMany({
|
||||
where: { accountId, voidedAt: null },
|
||||
where: { accountId, voidedAt: null, kind: { not: 'allocation' } },
|
||||
select: { direction: true, amount: true },
|
||||
});
|
||||
return rows.reduce(
|
||||
@@ -1412,7 +1509,7 @@ export class TreasuryService {
|
||||
const from = startOfDay(range?.from);
|
||||
const to = startOfDay(range?.to);
|
||||
const movements = await this.prisma.financialMovement.findMany({
|
||||
where: { accountId: account.id, voidedAt: null },
|
||||
where: { accountId: account.id, voidedAt: null, kind: { not: 'allocation' } },
|
||||
select: { direction: true, amount: true, kind: true, occurredAt: true },
|
||||
});
|
||||
let balance = new Prisma.Decimal(0);
|
||||
|
||||
Reference in New Issue
Block a user