Add invoice bank account templates API and migration.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
503b70a690
commit
2a01873c95
@@ -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();
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user