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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user