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:
@@ -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;
|
||||
@@ -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?
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user