From 2a01873c95084fd31f8e6b79b1c6e59b3c6bd966 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Mon, 21 Sep 2026 23:31:58 +0330 Subject: [PATCH] Add invoice bank account templates API and migration. Co-authored-by: Cursor --- .../097_invoice_account_templates.sql | 27 +++ prisma/schema.prisma | 20 ++ src/invoices/dto/invoice.dto.ts | 59 +++++ src/invoices/invoices.controller.ts | 78 +++++++ src/invoices/invoices.service.ts | 206 ++++++++++++++++++ 5 files changed, 390 insertions(+) create mode 100644 database/migrations/097_invoice_account_templates.sql diff --git a/database/migrations/097_invoice_account_templates.sql b/database/migrations/097_invoice_account_templates.sql new file mode 100644 index 0000000..11c9167 --- /dev/null +++ b/database/migrations/097_invoice_account_templates.sql @@ -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(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fd42603..4abc546 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/invoices/dto/invoice.dto.ts b/src/invoices/dto/invoice.dto.ts index b2e7d50..add25d8 100644 --- a/src/invoices/dto/invoice.dto.ts +++ b/src/invoices/dto/invoice.dto.ts @@ -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() diff --git a/src/invoices/invoices.controller.ts b/src/invoices/invoices.controller.ts index e5329f9..75c9581 100644 --- a/src/invoices/invoices.controller.ts +++ b/src/invoices/invoices.controller.ts @@ -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') diff --git a/src/invoices/invoices.service.ts b/src/invoices/invoices.service.ts index 66c49cf..60063b7 100644 --- a/src/invoices/invoices.service.ts +++ b/src/invoices/invoices.service.ts @@ -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) {