Unify invoices and quotes UI with discount, tax, and credit notes.

Match list/form mockups on desktop and mobile, wire real APIs for billing fields, and merge proforma into the invoices section.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 17:53:50 +03:30
co-authored by Cursor
parent 7862b6f374
commit 2ece79b3d6
15 changed files with 3101 additions and 1995 deletions
@@ -0,0 +1,9 @@
-- AlterTable Quote
ALTER TABLE "Quote" ADD COLUMN IF NOT EXISTS "discountAmount" DECIMAL(18,2) NOT NULL DEFAULT 0;
ALTER TABLE "Quote" ADD COLUMN IF NOT EXISTS "taxPercent" DECIMAL(8,2) NOT NULL DEFAULT 0;
ALTER TABLE "Quote" ADD COLUMN IF NOT EXISTS "issuedAt" TIMESTAMP(3);
-- AlterTable Invoice
ALTER TABLE "Invoice" ADD COLUMN IF NOT EXISTS "kind" TEXT NOT NULL DEFAULT 'invoice';
ALTER TABLE "Invoice" ADD COLUMN IF NOT EXISTS "discountAmount" DECIMAL(18,2) NOT NULL DEFAULT 0;
ALTER TABLE "Invoice" ADD COLUMN IF NOT EXISTS "taxPercent" DECIMAL(8,2) NOT NULL DEFAULT 0;
+6
View File
@@ -614,6 +614,9 @@ model Quote {
status QuoteStatus @default(draft)
currency String @default("IRR")
notes String?
discountAmount Decimal @default(0) @db.Decimal(18, 2)
taxPercent Decimal @default(0) @db.Decimal(8, 2)
issuedAt DateTime?
validUntil DateTime?
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
@@ -661,8 +664,11 @@ model Invoice {
number String
title String
status InvoiceStatus @default(draft)
kind String @default("invoice") // invoice | credit
currency String @default("IRR")
notes String?
discountAmount Decimal @default(0) @db.Decimal(18, 2)
taxPercent Decimal @default(0) @db.Decimal(8, 2)
issuedAt DateTime?
dueDate DateTime?
personId String?
+60
View File
@@ -20,6 +20,8 @@ const INVOICE_STATUSES = [
'canceled',
] as const;
const INVOICE_KINDS = ['invoice', 'credit'] as const;
const PAYMENT_METHODS = [
'cash',
'transfer',
@@ -79,6 +81,10 @@ export class CreateInvoiceDto {
@IsIn(INVOICE_STATUSES)
status?: (typeof INVOICE_STATUSES)[number];
@IsOptional()
@IsIn(INVOICE_KINDS)
kind?: (typeof INVOICE_KINDS)[number];
@IsOptional()
@IsString()
@MaxLength(8)
@@ -89,6 +95,18 @@ export class CreateInvoiceDto {
@MaxLength(4000)
notes?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
discountAmount?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
taxPercent?: number;
@IsOptional()
@IsString()
issuedAt?: string | null;
@@ -146,6 +164,10 @@ export class UpdateInvoiceDto {
@IsIn(INVOICE_STATUSES)
status?: (typeof INVOICE_STATUSES)[number];
@IsOptional()
@IsIn(INVOICE_KINDS)
kind?: (typeof INVOICE_KINDS)[number];
@IsOptional()
@IsString()
@MaxLength(8)
@@ -156,6 +178,18 @@ export class UpdateInvoiceDto {
@MaxLength(4000)
notes?: string | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
discountAmount?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
taxPercent?: number;
@IsOptional()
@IsString()
issuedAt?: string | null;
@@ -231,6 +265,10 @@ export class ListInvoicesQueryDto {
@IsIn(INVOICE_STATUSES)
status?: (typeof INVOICE_STATUSES)[number];
@IsOptional()
@IsIn(INVOICE_KINDS)
kind?: (typeof INVOICE_KINDS)[number];
@IsOptional()
@IsString()
dealId?: string;
@@ -239,10 +277,32 @@ export class ListInvoicesQueryDto {
@IsString()
personId?: string;
@IsOptional()
@IsString()
companyId?: string;
@IsOptional()
@IsString()
quoteId?: string;
@IsOptional()
@IsString()
from?: string;
@IsOptional()
@IsString()
to?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
amountMin?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
amountMax?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@@ -34,9 +34,15 @@ export class InvoicesController {
return this.invoicesService.list(user.organizationId, user, {
q: query.q,
status: query.status,
kind: query.kind,
dealId: query.dealId,
personId: query.personId,
companyId: query.companyId,
quoteId: query.quoteId,
from: query.from,
to: query.to,
amountMin: query.amountMin,
amountMax: query.amountMax,
page: query.page ?? 1,
limit: query.limit ?? 50,
});
+91 -4
View File
@@ -45,22 +45,32 @@ export class InvoicesService {
opts: {
q?: string;
status?: string;
kind?: string;
dealId?: string;
personId?: string;
companyId?: string;
quoteId?: string;
from?: string;
to?: string;
amountMin?: number;
amountMax?: number;
page?: number;
limit?: number;
},
): Promise<PaginatedResponse<InvoiceDto>> {
const page = opts.page ?? 1;
const limit = opts.limit ?? 50;
const dateFilter = buildDateRangeFilter(opts.from, opts.to);
const where: Prisma.InvoiceWhereInput = {
organizationId,
...ownerWhere(actor),
...(opts.status ? { status: opts.status as never } : {}),
...(opts.kind ? { kind: opts.kind } : {}),
...(opts.dealId ? { dealId: opts.dealId } : {}),
...(opts.personId ? { personId: opts.personId } : {}),
...(opts.companyId ? { companyId: opts.companyId } : {}),
...(opts.quoteId ? { quoteId: opts.quoteId } : {}),
...(dateFilter ? dateFilter : {}),
...(opts.q?.trim()
? {
OR: [
@@ -72,6 +82,28 @@ export class InvoicesService {
: {}),
};
const hasAmountFilter =
opts.amountMin != null || opts.amountMax != null;
if (hasAmountFilter) {
const rows = await this.prisma.invoice.findMany({
where,
include,
orderBy: { updatedAt: 'desc' },
});
const filtered = rows
.map((row) => this.toDto(row))
.filter((dto) => {
const amount = Math.abs(dto.payableAmount);
if (opts.amountMin != null && amount < opts.amountMin) return false;
if (opts.amountMax != null && amount > opts.amountMax) return false;
return true;
});
const total = filtered.length;
const items = filtered.slice((page - 1) * limit, page * limit);
return { items, total, page, limit };
}
const [rows, total] = await this.prisma.$transaction([
this.prisma.invoice.findMany({
where,
@@ -191,6 +223,7 @@ export class InvoicesService {
const number = await this.nextNumber(organizationId);
const installments = dto.installments ?? [];
const kind = dto.kind === 'credit' ? 'credit' : 'invoice';
const row = await this.prisma.invoice.create({
data: {
@@ -198,8 +231,11 @@ export class InvoicesService {
number,
title,
status,
kind,
currency,
notes,
discountAmount: new Prisma.Decimal(dto.discountAmount ?? 0),
taxPercent: new Prisma.Decimal(dto.taxPercent ?? 0),
issuedAt,
dueDate,
personId,
@@ -268,6 +304,15 @@ export class InvoicesService {
data.currency = dto.currency.trim() || 'IRR';
}
if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null);
if (dto.kind !== undefined) {
data.kind = dto.kind === 'credit' ? 'credit' : 'invoice';
}
if (dto.discountAmount !== undefined) {
data.discountAmount = new Prisma.Decimal(dto.discountAmount);
}
if (dto.taxPercent !== undefined) {
data.taxPercent = new Prisma.Decimal(dto.taxPercent);
}
if ('issuedAt' in dto) {
data.issuedAt = dto.issuedAt ? new Date(dto.issuedAt) : null;
}
@@ -439,10 +484,18 @@ export class InvoicesService {
return;
}
const total = invoice.lineItems.reduce(
const subtotal = invoice.lineItems.reduce(
(sum, item) => sum + Number(item.quantity) * Number(item.unitPrice),
0,
);
const discountAmount = Number(invoice.discountAmount ?? 0);
const taxPercent = Number(invoice.taxPercent ?? 0);
const taxAmount = roundMoney(
Math.max(0, subtotal - discountAmount) * (taxPercent / 100),
);
const total = Math.abs(
roundMoney(subtotal - discountAmount + taxAmount),
);
const paid = invoice.payments.reduce(
(sum, item) => sum + Number(item.amount),
0,
@@ -580,7 +633,15 @@ export class InvoicesService {
lineTotal: quantity * unitPrice,
};
});
const totalAmount = lineItems.reduce((sum, item) => sum + item.lineTotal, 0);
const subtotal = lineItems.reduce((sum, item) => sum + item.lineTotal, 0);
const discountAmount = Number(row.discountAmount ?? 0);
const taxPercent = Number(row.taxPercent ?? 0);
const taxAmount = roundMoney(
Math.max(0, subtotal - discountAmount) * (taxPercent / 100),
);
const signedPayable = roundMoney(subtotal - discountAmount + taxAmount);
const kind = row.kind === 'credit' ? 'credit' : 'invoice';
const payableAmount = kind === 'credit' ? -signedPayable : signedPayable;
const payments: PaymentDto[] = row.payments.map((p) => ({
id: p.id,
amount: Number(p.amount),
@@ -608,13 +669,19 @@ export class InvoicesService {
number: row.number,
title: row.title,
status: row.status,
kind,
currency: row.currency,
notes: row.notes,
discountAmount,
taxPercent,
issuedAt: row.issuedAt?.toISOString() ?? null,
dueDate: row.dueDate?.toISOString() ?? null,
totalAmount,
totalAmount: subtotal,
subtotal,
taxAmount,
payableAmount,
paidAmount,
remainingAmount: Math.max(0, totalAmount - paidAmount),
remainingAmount: Math.max(0, Math.abs(payableAmount) - paidAmount),
person: row.person
? {
id: row.person.id,
@@ -639,3 +706,23 @@ function emptyToNull(value?: string | null): string | null {
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
function roundMoney(value: number): number {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function buildDateRangeFilter(
from?: string,
to?: string,
): Prisma.InvoiceWhereInput | null {
if (!from && !to) return null;
const range: Prisma.DateTimeFilter = {};
if (from) range.gte = new Date(from);
if (to) range.lte = new Date(to);
return {
OR: [
{ issuedAt: range },
{ AND: [{ issuedAt: null }, { createdAt: range }] },
],
};
}
+50
View File
@@ -66,6 +66,28 @@ export class CreateQuoteDto {
@MaxLength(4000)
notes?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
discountAmount?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
taxPercent?: number;
@IsOptional()
@IsString()
issuedAt?: string | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
validityDays?: number;
@IsOptional()
@IsString()
validUntil?: string | null;
@@ -115,6 +137,22 @@ export class UpdateQuoteDto {
@MaxLength(4000)
notes?: string | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
discountAmount?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
taxPercent?: number;
@IsOptional()
@IsString()
issuedAt?: string | null;
@IsOptional()
@IsString()
validUntil?: string | null;
@@ -155,6 +193,18 @@ export class ListQuotesQueryDto {
@IsString()
personId?: string;
@IsOptional()
@IsString()
companyId?: string;
@IsOptional()
@IsString()
from?: string;
@IsOptional()
@IsString()
to?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
+3
View File
@@ -35,6 +35,9 @@ export class QuotesController {
status: query.status,
dealId: query.dealId,
personId: query.personId,
companyId: query.companyId,
from: query.from,
to: query.to,
page: query.page ?? 1,
limit: query.limit ?? 50,
});
+66 -3
View File
@@ -38,18 +38,24 @@ export class QuotesService {
status?: string;
dealId?: string;
personId?: string;
companyId?: string;
from?: string;
to?: string;
page?: number;
limit?: number;
},
): Promise<PaginatedResponse<QuoteDto>> {
const page = opts.page ?? 1;
const limit = opts.limit ?? 50;
const dateFilter = buildDateRangeFilter(opts.from, opts.to);
const where: Prisma.QuoteWhereInput = {
organizationId,
...ownerWhere(actor),
...(opts.status ? { status: opts.status as never } : {}),
...(opts.dealId ? { dealId: opts.dealId } : {}),
...(opts.personId ? { personId: opts.personId } : {}),
...(opts.companyId ? { companyId: opts.companyId } : {}),
...(dateFilter ? dateFilter : {}),
...(opts.q?.trim()
? {
OR: [
@@ -137,6 +143,19 @@ export class QuotesService {
await this.assertProducts(organizationId, lineItems);
}
const issuedAt = dto.issuedAt ? new Date(dto.issuedAt) : null;
let validUntil = dto.validUntil ? new Date(dto.validUntil) : null;
if (
dto.validityDays != null &&
Number.isFinite(dto.validityDays) &&
dto.validityDays > 0 &&
!validUntil
) {
const base = issuedAt ?? new Date();
validUntil = new Date(base);
validUntil.setDate(validUntil.getDate() + Math.floor(dto.validityDays));
}
const number = await this.nextNumber(organizationId);
const row = await this.prisma.quote.create({
data: {
@@ -146,7 +165,10 @@ export class QuotesService {
status: (dto.status as never) || 'draft',
currency,
notes,
validUntil: dto.validUntil ? new Date(dto.validUntil) : null,
discountAmount: new Prisma.Decimal(dto.discountAmount ?? 0),
taxPercent: new Prisma.Decimal(dto.taxPercent ?? 0),
issuedAt,
validUntil,
personId,
companyId,
dealId,
@@ -201,6 +223,15 @@ export class QuotesService {
data.currency = dto.currency.trim() || 'IRR';
}
if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null);
if (dto.discountAmount !== undefined) {
data.discountAmount = new Prisma.Decimal(dto.discountAmount);
}
if (dto.taxPercent !== undefined) {
data.taxPercent = new Prisma.Decimal(dto.taxPercent);
}
if ('issuedAt' in dto) {
data.issuedAt = dto.issuedAt ? new Date(dto.issuedAt) : null;
}
if ('validUntil' in dto) {
data.validUntil = dto.validUntil ? new Date(dto.validUntil) : null;
}
@@ -334,7 +365,13 @@ export class QuotesService {
lineTotal: quantity * unitPrice,
};
});
const totalAmount = lineItems.reduce((sum, item) => sum + item.lineTotal, 0);
const subtotal = lineItems.reduce((sum, item) => sum + item.lineTotal, 0);
const discountAmount = Number(row.discountAmount ?? 0);
const taxPercent = Number(row.taxPercent ?? 0);
const taxAmount = roundMoney(
Math.max(0, subtotal - discountAmount) * (taxPercent / 100),
);
const payableAmount = roundMoney(subtotal - discountAmount + taxAmount);
return {
id: row.id,
number: row.number,
@@ -342,8 +379,14 @@ export class QuotesService {
status: row.status,
currency: row.currency,
notes: row.notes,
discountAmount,
taxPercent,
issuedAt: row.issuedAt?.toISOString() ?? null,
validUntil: row.validUntil?.toISOString() ?? null,
totalAmount,
totalAmount: subtotal,
subtotal,
taxAmount,
payableAmount,
person: row.person
? {
id: row.person.id,
@@ -365,3 +408,23 @@ function emptyToNull(value?: string | null): string | null {
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
function roundMoney(value: number): number {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function buildDateRangeFilter(
from?: string,
to?: string,
): Prisma.QuoteWhereInput | null {
if (!from && !to) return null;
const range: Prisma.DateTimeFilter = {};
if (from) range.gte = new Date(from);
if (to) range.lte = new Date(to);
return {
OR: [
{ issuedAt: range },
{ AND: [{ issuedAt: null }, { createdAt: range }] },
],
};
}