feat(treasury): show who still owes on the finance pages

Partial receipts and payments stay tied to a customer, company, or user, and the open balance is listed on the income and expense screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-04 11:36:55 +03:30
co-authored by Cursor
parent 1ccbefa7a3
commit d71c2dbef4
11 changed files with 755 additions and 38 deletions
@@ -92,6 +92,29 @@ export class TreasuryController {
return this.treasury.transfer(user, body);
}
@Get('parties')
parties(@CurrentUser() user: Actor, @Query('q') q?: string) {
return this.treasury.searchParties(user, q);
}
@Post('parties/person')
createPartyPerson(
@CurrentUser() user: Actor,
@Body() body: { firstName?: string; lastName?: string },
) {
return this.treasury.createPartyPerson(user, body);
}
@Post('parties/company')
createPartyCompany(@CurrentUser() user: Actor, @Body() body: { name?: string }) {
return this.treasury.createPartyCompany(user, body);
}
@Get('party-balances')
partyBalances(@CurrentUser() user: Actor, @Query('kind') kind?: string) {
return this.treasury.partyBalances(user, kind === 'expense' ? 'expense' : 'income');
}
@Get('documents')
documents(
@CurrentUser() user: Actor,
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ContactsModule } from '../contacts/contacts.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { TreasuryController } from './treasury.controller';
import { TreasuryAccessGuard } from './treasury-access.guard';
@@ -9,7 +10,7 @@ import { TreasuryReceivablesService } from './treasury-receivables.service';
import { TreasuryService } from './treasury.service';
@Module({
imports: [AuthModule, NotificationsModule],
imports: [AuthModule, NotificationsModule, ContactsModule],
controllers: [TreasuryController],
providers: [
TreasuryService,
+214 -2
View File
@@ -13,8 +13,13 @@ import type {
FinancialCategoryDto,
FinancialDocumentDto,
FinancialMovementDto,
FinancialPartyKind,
TreasuryDashboardDto,
TreasuryPartyBalanceDto,
TreasuryPartySearchDto,
} from '@mento/shared';
import { CompaniesService } from '../contacts/companies.service';
import { PeopleService } from '../contacts/people.service';
import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
@@ -65,6 +70,8 @@ export class TreasuryService {
private readonly receivables: TreasuryReceivablesService,
private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService,
private readonly people: PeopleService,
private readonly companies: CompaniesService,
) {}
async ensureCategories(organizationId: string) {
@@ -462,7 +469,10 @@ export class TreasuryService {
bookedAt?: string;
dueAt?: string | null;
categoryId?: string | null;
partyKind?: FinancialPartyKind | null;
personId?: string | null;
companyId?: string | null;
userId?: string | null;
partyName?: string | null;
method?: string | null;
reference?: string | null;
@@ -476,9 +486,13 @@ export class TreasuryService {
const amount = dec(input.amount);
if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.');
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.');
const party = await this.resolveParty(actor, input);
const settle = input.settleAmount == null || input.settleAmount === ''
? new Prisma.Decimal(0)
: dec(input.settleAmount);
if (party.partyKind === 'other' && !settle.equals(amount)) {
throw new BadRequestException('برای سایر، مبلغ دریافت یا پرداخت باید با مبلغ کل یکی باشد.');
}
if (settle.isNegative() || settle.greaterThan(amount)) {
throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.');
}
@@ -506,8 +520,11 @@ export class TreasuryService {
bookedAt,
dueAt: input.dueAt ? startOfDay(input.dueAt) : null,
categoryId: input.categoryId || null,
personId: input.personId || null,
partyName: input.partyName?.trim() || null,
personId: party.personId,
companyId: party.companyId,
userId: party.userId,
partyKind: party.partyKind,
partyName: party.partyName,
accountId: input.accountId || null,
method: input.method || null,
status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
@@ -540,6 +557,195 @@ export class TreasuryService {
return this.toDocumentDto(doc);
}
async searchParties(actor: Actor, q?: string): Promise<TreasuryPartySearchDto> {
const term = q?.trim() ?? '';
const contains = term ? { contains: term, mode: 'insensitive' as const } : undefined;
const [people, companies, users] = await Promise.all([
this.prisma.person.findMany({
where: {
organizationId: actor.organizationId,
...(contains ? { OR: [{ firstName: contains }, { lastName: contains }] } : {}),
},
select: { id: true, firstName: true, lastName: true },
orderBy: { updatedAt: 'desc' },
take: 20,
}),
this.prisma.company.findMany({
where: {
organizationId: actor.organizationId,
...(contains ? { name: contains } : {}),
},
select: { id: true, name: true },
orderBy: { updatedAt: 'desc' },
take: 20,
}),
this.prisma.user.findMany({
where: {
organizationId: actor.organizationId,
isActive: true,
...(contains ? { fullName: contains } : {}),
},
select: { id: true, fullName: true },
orderBy: { fullName: 'asc' },
take: 20,
}),
]);
return {
people: people.map((row) => ({
id: row.id,
name: `${row.firstName} ${row.lastName}`.trim() || 'مشتری',
})),
companies,
users: users.map((row) => ({ id: row.id, name: row.fullName })),
};
}
async createPartyPerson(actor: Actor, input: { firstName?: string; lastName?: string }) {
const firstName = input.firstName?.trim() ?? '';
const lastName = input.lastName?.trim() ?? '';
if (`${firstName} ${lastName}`.trim().length < 2) {
throw new BadRequestException('نام شخص را وارد کنید.');
}
const person = await this.people.create(actor, { firstName, lastName });
return { id: person.id, name: person.fullName, kind: 'person' as const };
}
async createPartyCompany(actor: Actor, input: { name?: string }) {
const name = input.name?.trim() ?? '';
if (name.length < 2) throw new BadRequestException('نام شرکت را وارد کنید.');
const company = await this.companies.create(actor, { name });
return { id: company.id, name: company.name, kind: 'company' as const };
}
async partyBalances(actor: Actor, kind: 'income' | 'expense'): Promise<TreasuryPartyBalanceDto[]> {
const scope = await this.accountScope(actor);
const rows = await this.prisma.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
kind,
voidedAt: null,
status: { in: ['open', 'partial'] },
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
},
select: {
amount: true,
partyKind: true,
personId: true,
companyId: true,
userId: true,
partyName: true,
movements: { select: { amount: true, voidedAt: true } },
},
});
const buckets = new Map<string, {
key: string;
partyKind: FinancialPartyKind | null;
partyName: string;
amount: Prisma.Decimal;
settled: Prisma.Decimal;
remaining: Prisma.Decimal;
}>();
for (const row of rows) {
if (row.partyKind === 'other') continue;
const settled = this.settledOf(row.movements);
const rawRemaining = row.amount.minus(settled);
if (!rawRemaining.greaterThan(0)) continue;
const linkedId = row.personId || row.companyId || row.userId;
const partyKind = row.partyKind === 'person' || row.partyKind === 'company' || row.partyKind === 'user'
? row.partyKind
: null;
const key = partyKind && linkedId
? `${partyKind}:${linkedId}`
: `legacy:${(row.partyName || '').trim() || 'بدون طرف حساب'}`;
const current = buckets.get(key) ?? {
key,
partyKind,
partyName: row.partyName?.trim() || 'بدون طرف حساب',
amount: new Prisma.Decimal(0),
settled: new Prisma.Decimal(0),
remaining: new Prisma.Decimal(0),
};
current.amount = current.amount.plus(row.amount);
current.settled = current.settled.plus(settled);
current.remaining = current.remaining.plus(rawRemaining);
buckets.set(key, current);
}
return [...buckets.values()]
.sort((a, b) => b.remaining.comparedTo(a.remaining))
.map((row) => ({
key: row.key,
partyKind: row.partyKind,
partyName: row.partyName,
amount: money(row.amount),
settledAmount: money(row.settled),
remaining: money(row.remaining),
}));
}
private async resolveParty(
actor: Actor,
input: {
partyKind?: FinancialPartyKind | null;
personId?: string | null;
companyId?: string | null;
userId?: string | null;
},
) {
const kind = input.partyKind;
if (kind === 'other') {
return { partyKind: 'other' as const, personId: null, companyId: null, userId: null, partyName: 'سایر' };
}
if (kind === 'person') {
const id = input.personId?.trim();
if (!id) throw new BadRequestException('مشتری را انتخاب کنید.');
const person = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId },
select: { id: true, firstName: true, lastName: true },
});
if (!person) throw new BadRequestException('مشتری پیدا نشد.');
return {
partyKind: 'person' as const,
personId: person.id,
companyId: null,
userId: null,
partyName: `${person.firstName} ${person.lastName}`.trim() || 'مشتری',
};
}
if (kind === 'company') {
const id = input.companyId?.trim();
if (!id) throw new BadRequestException('شرکت را انتخاب کنید.');
const company = await this.prisma.company.findFirst({
where: { id, organizationId: actor.organizationId },
select: { id: true, name: true },
});
if (!company) throw new BadRequestException('شرکت پیدا نشد.');
return {
partyKind: 'company' as const,
personId: null,
companyId: company.id,
userId: null,
partyName: company.name,
};
}
if (kind === 'user') {
const id = input.userId?.trim();
if (!id) throw new BadRequestException('کاربر را انتخاب کنید.');
const user = await this.prisma.user.findFirst({
where: { id, organizationId: actor.organizationId },
select: { id: true, fullName: true },
});
if (!user) throw new BadRequestException('کاربر پیدا نشد.');
return {
partyKind: 'user' as const,
personId: null,
companyId: null,
userId: user.id,
partyName: user.fullName,
};
}
throw new BadRequestException('طرف حساب را انتخاب کنید.');
}
async settleDocument(
actor: Actor,
id: string,
@@ -948,7 +1154,10 @@ export class TreasuryService {
bookedAt: Date;
dueAt: Date | null;
categoryId: string | null;
partyKind?: string | null;
personId: string | null;
companyId?: string | null;
userId?: string | null;
partyName: string | null;
accountId: string | null;
method: string | null;
@@ -975,7 +1184,10 @@ export class TreasuryService {
dueAt: row.dueAt ? row.dueAt.toISOString() : null,
categoryId: row.categoryId,
categoryName: row.category?.name ?? null,
partyKind: (row.partyKind as FinancialDocumentDto['partyKind']) ?? null,
personId: row.personId,
companyId: row.companyId ?? null,
userId: row.userId ?? null,
partyName: row.partyName,
accountId: row.accountId,
accountName: row.account?.name ?? null,