Add invoice bank account templates API and migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-21 23:31:58 +03:30
co-authored by Cursor
parent 503b70a690
commit 2a01873c95
5 changed files with 390 additions and 0 deletions
@@ -0,0 +1,27 @@
-- Reusable bank account templates (platform or business), parallel to invoice_item_templates.
CREATE TABLE IF NOT EXISTS invoice_account_templates (
id BIGSERIAL PRIMARY KEY,
owner_scope invoice_owner_scope NOT NULL,
business_id BIGINT NULL REFERENCES businesses (id) ON DELETE CASCADE ON UPDATE NO ACTION,
bank_name VARCHAR(255) NOT NULL,
account_holder_name VARCHAR(255) NULL,
card_number VARCHAR(64) NULL,
iban VARCHAR(64) NULL,
sort_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_invoice_account_templates_owner_scope
ON invoice_account_templates (owner_scope, sort_order);
CREATE INDEX IF NOT EXISTS idx_invoice_account_templates_business_id
ON invoice_account_templates (business_id);
DROP TRIGGER IF EXISTS invoice_account_templates_set_updated_at ON invoice_account_templates;
CREATE TRIGGER invoice_account_templates_set_updated_at
BEFORE UPDATE ON invoice_account_templates
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
+20
View File
@@ -143,6 +143,7 @@ model Business {
expertReviews ExpertReview[]
favorites Favorite[]
invoiceItemTemplates InvoiceItemTemplate[]
invoiceAccountTemplates InvoiceAccountTemplate[]
invoiceTemplates InvoiceTemplate[]
invoices Invoice[] @relation("InvoiceBusiness")
invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness")
@@ -1370,6 +1371,25 @@ model InvoiceItemTemplate {
@@map("invoice_item_templates")
}
model InvoiceAccountTemplate {
id BigInt @id @default(autoincrement())
ownerScope InvoiceOwnerScope @map("owner_scope")
businessId BigInt? @map("business_id")
bankName String @map("bank_name") @db.VarChar(255)
accountHolderName String? @map("account_holder_name") @db.VarChar(255)
cardNumber String? @map("card_number") @db.VarChar(64)
iban String? @db.VarChar(64)
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([ownerScope, sortOrder], map: "idx_invoice_account_templates_owner_scope")
@@index([businessId], map: "idx_invoice_account_templates_business_id")
@@map("invoice_account_templates")
}
model InvoiceTemplate {
id BigInt @id @default(autoincrement())
ownerScope InvoiceOwnerScope @map("owner_scope")
+59
View File
@@ -297,6 +297,65 @@ export class UpdateInvoiceItemTemplateDto {
isActive?: boolean;
}
export class CreateInvoiceAccountTemplateDto {
@IsString()
@MinLength(1)
@MaxLength(255)
bankName!: string;
@IsOptional()
@IsString()
@MaxLength(255)
accountHolderName?: string;
@IsOptional()
@IsString()
@MaxLength(64)
cardNumber?: string;
@IsOptional()
@IsString()
@MaxLength(64)
iban?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
sortOrder?: number;
}
export class UpdateInvoiceAccountTemplateDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
bankName?: string;
@IsOptional()
@IsString()
@MaxLength(255)
accountHolderName?: string | null;
@IsOptional()
@IsString()
@MaxLength(64)
cardNumber?: string | null;
@IsOptional()
@IsString()
@MaxLength(64)
iban?: string | null;
@IsOptional()
@Type(() => Number)
@IsInt()
sortOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class InvoiceTemplateItemInputDto {
@IsOptional()
@IsString()
+78
View File
@@ -20,9 +20,11 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { AuthUser } from '../auth/auth.types';
import {
CreateInvoiceDto,
CreateInvoiceAccountTemplateDto,
CreateInvoiceItemTemplateDto,
CreateInvoiceTemplateDto,
ListInvoicesDto,
UpdateInvoiceAccountTemplateDto,
UpdateInvoiceDto,
UpdateInvoiceItemTemplateDto,
UpdateInvoiceStatusDto,
@@ -63,6 +65,41 @@ export class InvoicesController {
return this.service.deletePlatformTemplate(templateId, user);
}
// Platform invoice account templates (settings)
@Get('invoice-account-templates')
@UseGuards(JwtAuthGuard)
listAccountTemplates(@CurrentUser() user: AuthUser) {
return this.service.listPlatformAccountTemplates(user);
}
@Post('invoice-account-templates')
@UseGuards(JwtAuthGuard)
createAccountTemplate(
@Body() dto: CreateInvoiceAccountTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.createPlatformAccountTemplate(dto, user);
}
@Patch('invoice-account-templates/:templateId')
@UseGuards(JwtAuthGuard)
updateAccountTemplate(
@Param('templateId') templateId: string,
@Body() dto: UpdateInvoiceAccountTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.updatePlatformAccountTemplate(templateId, dto, user);
}
@Delete('invoice-account-templates/:templateId')
@UseGuards(JwtAuthGuard)
deleteAccountTemplate(
@Param('templateId') templateId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.deletePlatformAccountTemplate(templateId, user);
}
// Platform invoice templates (settings)
@Get('invoice-templates')
@UseGuards(JwtAuthGuard)
@@ -237,6 +274,47 @@ export class BusinessInvoicesController {
return this.service.deleteBusinessItemTemplate(businessId, templateId, user);
}
// Invoice account templates (business-scoped)
@Get('invoice-account-templates')
@RequireBusinessPermission('invoice_templates.read')
listAccountTemplatesBusiness(
@Param('businessId') businessId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.listBusinessAccountTemplates(businessId, user);
}
@Post('invoice-account-templates')
@RequireBusinessPermission('invoice_templates.manage')
createAccountTemplateBusiness(
@Param('businessId') businessId: string,
@Body() dto: CreateInvoiceAccountTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.createBusinessAccountTemplate(businessId, dto, user);
}
@Patch('invoice-account-templates/:templateId')
@RequireBusinessPermission('invoice_templates.manage')
updateAccountTemplateBusiness(
@Param('businessId') businessId: string,
@Param('templateId') templateId: string,
@Body() dto: UpdateInvoiceAccountTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.updateBusinessAccountTemplate(businessId, templateId, dto, user);
}
@Delete('invoice-account-templates/:templateId')
@RequireBusinessPermission('invoice_templates.manage')
deleteAccountTemplateBusiness(
@Param('businessId') businessId: string,
@Param('templateId') templateId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.deleteBusinessAccountTemplate(businessId, templateId, user);
}
// Invoice templates (business-scoped)
@Get('invoice-templates')
@RequireBusinessPermission('invoice_templates.read')
+206
View File
@@ -11,6 +11,7 @@ import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import {
CreateInvoiceDto,
CreateInvoiceAccountTemplateDto,
CreateInvoiceItemTemplateDto,
CreateInvoiceTemplateDto,
InvoiceAccountInputDto,
@@ -19,6 +20,7 @@ import {
InvoicePaymentMethodInputDto,
InvoiceTemplateItemInputDto,
ListInvoicesDto,
UpdateInvoiceAccountTemplateDto,
UpdateInvoiceDto,
UpdateInvoiceItemTemplateDto,
UpdateInvoiceStatusDto,
@@ -232,6 +234,34 @@ export class InvoicesService {
};
}
private serializeAccountTemplate(row: {
id: bigint;
ownerScope: InvoiceOwnerScope;
businessId: bigint | null;
bankName: string;
accountHolderName: string | null;
cardNumber: string | null;
iban: string | null;
sortOrder: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: row.id.toString(),
ownerScope: row.ownerScope,
businessId: row.businessId?.toString() ?? null,
bankName: row.bankName,
accountHolderName: row.accountHolderName,
cardNumber: row.cardNumber,
iban: row.iban,
sortOrder: row.sortOrder,
isActive: row.isActive,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private readonly invoiceInclude = {
business: {
select: {
@@ -794,6 +824,83 @@ export class InvoicesService {
return { ok: true };
}
// --- Account templates (platform settings) ---
async listPlatformAccountTemplates(actor: AuthUser) {
await this.assertSuperAdmin(actor);
const rows = await this.prisma.invoiceAccountTemplate.findMany({
where: { ownerScope: InvoiceOwnerScope.platform },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
return { items: rows.map((row) => this.serializeAccountTemplate(row)) };
}
async createPlatformAccountTemplate(
dto: CreateInvoiceAccountTemplateDto,
actor: AuthUser,
) {
await this.assertSuperAdmin(actor);
const bankName = dto.bankName.trim();
if (!bankName) {
throw new BadRequestException('Bank name is required');
}
const row = await this.prisma.invoiceAccountTemplate.create({
data: {
ownerScope: InvoiceOwnerScope.platform,
bankName,
accountHolderName: dto.accountHolderName?.trim() || null,
cardNumber: dto.cardNumber?.trim() || null,
iban: dto.iban?.trim() || null,
sortOrder: dto.sortOrder ?? 0,
},
});
return this.serializeAccountTemplate(row);
}
async updatePlatformAccountTemplate(
templateIdRaw: string,
dto: UpdateInvoiceAccountTemplateDto,
actor: AuthUser,
) {
await this.assertSuperAdmin(actor);
const templateId = BigInt(templateIdRaw);
const existing = await this.prisma.invoiceAccountTemplate.findFirst({
where: { id: templateId, ownerScope: InvoiceOwnerScope.platform },
});
if (!existing) {
throw new NotFoundException('Invoice account template not found');
}
const row = await this.prisma.invoiceAccountTemplate.update({
where: { id: templateId },
data: {
...(dto.bankName !== undefined ? { bankName: dto.bankName.trim() } : {}),
...(dto.accountHolderName !== undefined
? { accountHolderName: dto.accountHolderName?.trim() || null }
: {}),
...(dto.cardNumber !== undefined
? { cardNumber: dto.cardNumber?.trim() || null }
: {}),
...(dto.iban !== undefined ? { iban: dto.iban?.trim() || null } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
},
});
return this.serializeAccountTemplate(row);
}
async deletePlatformAccountTemplate(templateIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const templateId = BigInt(templateIdRaw);
const existing = await this.prisma.invoiceAccountTemplate.findFirst({
where: { id: templateId, ownerScope: InvoiceOwnerScope.platform },
});
if (!existing) {
throw new NotFoundException('Invoice account template not found');
}
await this.prisma.invoiceAccountTemplate.delete({ where: { id: templateId } });
return { ok: true };
}
// --- Invoice templates (platform settings) ---
async listPlatformInvoiceTemplates(actor: AuthUser) {
@@ -1129,6 +1236,105 @@ export class InvoicesService {
return { ok: true };
}
// --- Account templates (business-scoped) ---
async listBusinessAccountTemplates(businessIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertBusinessExists(businessId);
await this.assertPermission(businessId, actor.id, 'invoice_templates.read');
const rows = await this.prisma.invoiceAccountTemplate.findMany({
where: { ownerScope: InvoiceOwnerScope.business, businessId },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
return { items: rows.map((row) => this.serializeAccountTemplate(row)) };
}
async createBusinessAccountTemplate(
businessIdRaw: string,
dto: CreateInvoiceAccountTemplateDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertBusinessExists(businessId);
await this.assertPermission(businessId, actor.id, 'invoice_templates.manage');
const bankName = dto.bankName.trim();
if (!bankName) {
throw new BadRequestException('Bank name is required');
}
const row = await this.prisma.invoiceAccountTemplate.create({
data: {
ownerScope: InvoiceOwnerScope.business,
businessId,
bankName,
accountHolderName: dto.accountHolderName?.trim() || null,
cardNumber: dto.cardNumber?.trim() || null,
iban: dto.iban?.trim() || null,
sortOrder: dto.sortOrder ?? 0,
},
});
return this.serializeAccountTemplate(row);
}
async updateBusinessAccountTemplate(
businessIdRaw: string,
templateIdRaw: string,
dto: UpdateInvoiceAccountTemplateDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertBusinessExists(businessId);
await this.assertPermission(businessId, actor.id, 'invoice_templates.manage');
const templateId = BigInt(templateIdRaw);
const existing = await this.prisma.invoiceAccountTemplate.findFirst({
where: { id: templateId, ownerScope: InvoiceOwnerScope.business, businessId },
});
if (!existing) {
throw new NotFoundException('Invoice account template not found');
}
const row = await this.prisma.invoiceAccountTemplate.update({
where: { id: templateId },
data: {
...(dto.bankName !== undefined ? { bankName: dto.bankName.trim() } : {}),
...(dto.accountHolderName !== undefined
? { accountHolderName: dto.accountHolderName?.trim() || null }
: {}),
...(dto.cardNumber !== undefined
? { cardNumber: dto.cardNumber?.trim() || null }
: {}),
...(dto.iban !== undefined ? { iban: dto.iban?.trim() || null } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
},
});
return this.serializeAccountTemplate(row);
}
async deleteBusinessAccountTemplate(
businessIdRaw: string,
templateIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertBusinessExists(businessId);
await this.assertPermission(businessId, actor.id, 'invoice_templates.manage');
const templateId = BigInt(templateIdRaw);
const existing = await this.prisma.invoiceAccountTemplate.findFirst({
where: { id: templateId, ownerScope: InvoiceOwnerScope.business, businessId },
});
if (!existing) {
throw new NotFoundException('Invoice account template not found');
}
await this.prisma.invoiceAccountTemplate.delete({ where: { id: templateId } });
return { ok: true };
}
// --- Invoice templates (business-scoped) ---
async listBusinessInvoiceTemplates(businessIdRaw: string, actor: AuthUser) {