feat: Enhance proforma issuance with tax regime support

- Added tax regime to ProformaIssueReadModel and related services.
- Implemented tax regime resolution in ProformaToIssuedInvoiceConverter.
- Updated IssuedInvoiceFullSnapshotBuilder to include tax regime details.
- Enhanced ProformaIssueReadModelAssembler to handle tax regime fetching.
- Introduced IssueProformaResponseSnapshotBuilder for response mapping.
- Updated routes and controllers to accommodate new proforma issuance logic.
- Refactored request and response DTOs to include tax regime information.
- Added command structure for issuing proformas.
This commit is contained in:
David Arranz 2026-07-09 13:07:48 +02:00
parent 9d7e18d73d
commit 1c0235de75
30 changed files with 337 additions and 57 deletions

View File

@ -1,5 +1,8 @@
import type { Proforma } from "../../../domain"; import type { Proforma } from "../../../domain";
import type { InvoicePaymentMethodReadModel } from "../../common/models"; import type {
InvocieTaxRegimeFullReadModel,
InvoicePaymentMethodReadModel,
} from "../../common/models";
/** /**
* Modelo de lectura usado exclusivamente durante la emisión de una proforma. * Modelo de lectura usado exclusivamente durante la emisión de una proforma.
@ -10,4 +13,5 @@ import type { InvoicePaymentMethodReadModel } from "../../common/models";
export interface ProformaIssueReadModel { export interface ProformaIssueReadModel {
proforma: Proforma; proforma: Proforma;
paymentMethod: InvoicePaymentMethodReadModel; paymentMethod: InvoicePaymentMethodReadModel;
taxRegime: InvocieTaxRegimeFullReadModel;
} }

View File

@ -7,6 +7,7 @@ import {
type IIssuedInvoiceCreateProps, type IIssuedInvoiceCreateProps,
InvoicePaymentMethod, InvoicePaymentMethod,
InvoiceStatus, InvoiceStatus,
InvoiceTaxRegime,
IssuedInvoiceItem, IssuedInvoiceItem,
IssuedInvoiceRecipient, IssuedInvoiceRecipient,
IssuedInvoiceTax, IssuedInvoiceTax,
@ -57,6 +58,12 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
return Result.fail(paymentResult.error); return Result.fail(paymentResult.error);
} }
const taxRegimeResult = this.resolveTaxRegime(source);
if (taxRegimeResult.isFailure) {
return Result.fail(taxRegimeResult.error);
}
const proformaTotals = proforma.totals(); const proformaTotals = proforma.totals();
return Result.ok({ return Result.ok({
@ -78,6 +85,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
reference: proforma.reference, reference: proforma.reference,
paymentMethod: paymentResult.data, paymentMethod: paymentResult.data,
taxRegime: taxRegimeResult.data,
customerId: proforma.customerId, customerId: proforma.customerId,
recipient: recipientResult.data, recipient: recipientResult.data,
@ -251,4 +259,17 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
return Result.ok(paymentMethodResult.data); return Result.ok(paymentMethodResult.data);
} }
private resolveTaxRegime(source: ProformaIssueReadModel): Result<InvoiceTaxRegime, Error> {
const taxRegimeResult = InvoiceTaxRegime.create({
code: source.taxRegime.code,
description: source.taxRegime.description || source.taxRegime.code,
});
if (taxRegimeResult.isFailure) {
return Result.fail(taxRegimeResult.error);
}
return Result.ok(taxRegimeResult.data);
}
} }

View File

@ -31,6 +31,11 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps
payment_description: invoice.paymentMethod.name.toString(), payment_description: invoice.paymentMethod.name.toString(),
}; };
const tax_regime = {
code: invoice.taxRegime.code,
description: invoice.taxRegime.description,
};
return { return {
id: invoice.id.toString(), id: invoice.id.toString(),
company_id: invoice.companyId.toString(), company_id: invoice.companyId.toString(),
@ -58,6 +63,7 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps
taxes, taxes,
payment_method, payment_method,
tax_regime,
subtotal_amount: invoice.subtotalAmount.toObjectString(), subtotal_amount: invoice.subtotalAmount.toObjectString(),

View File

@ -44,6 +44,10 @@ export interface IIssuedInvoiceFullSnapshot {
payment_id: string; payment_id: string;
payment_description: string; payment_description: string;
} | null; } | null;
tax_regime: {
code: string;
description: string | null;
};
subtotal_amount: { value: string; scale: string; currency_code: string }; subtotal_amount: { value: string; scale: string; currency_code: string };

View File

@ -0,0 +1 @@
export * from "./issue-proforma.command";

View File

@ -0,0 +1,5 @@
export type IssueProformaCommand = {
companyId: string;
proformaId: string;
actorUserId?: string;
};

View File

@ -1,4 +1,4 @@
import type { IPaymentMethodPublicFinder } from "@erp/catalogs/api"; import type { IPaymentMethodPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api";
import type { IProformaToIssuedInvoiceConverter } from "../../issued-invoices"; import type { IProformaToIssuedInvoiceConverter } from "../../issued-invoices";
import { type IProformaIssuer, ProformaIssueReadModelAssembler, ProformaIssuer } from "../services"; import { type IProformaIssuer, ProformaIssueReadModelAssembler, ProformaIssuer } from "../services";
@ -13,8 +13,10 @@ export const buildProformaIssuer = (params: {
export const buildProformaIssueReadModelAssembler = (params: { export const buildProformaIssueReadModelAssembler = (params: {
paymentMethodFinder: IPaymentMethodPublicFinder; paymentMethodFinder: IPaymentMethodPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder;
}) => { }) => {
return new ProformaIssueReadModelAssembler({ return new ProformaIssueReadModelAssembler({
paymentMethodFinder: params.paymentMethodFinder, paymentMethodFinder: params.paymentMethodFinder,
taxRegimeFinder: params.taxRegimeFinder,
}); });
}; };

View File

@ -1,6 +1,7 @@
// application/issued-invoices/di/snapshot-builders.di.ts // application/issued-invoices/di/snapshot-builders.di.ts
import { import {
IssueProformaResponseSnapshotBuilder,
ProformaFullSnapshotBuilder, ProformaFullSnapshotBuilder,
ProformaItemReportSnapshotBuilder, ProformaItemReportSnapshotBuilder,
ProformaItemsFullSnapshotBuilder, ProformaItemsFullSnapshotBuilder,
@ -32,9 +33,11 @@ export function buildProformaSnapshotBuilders() {
itemsReportBuilder, itemsReportBuilder,
taxesReportBuilder taxesReportBuilder
); );
const issueResponseSnapshotBuilder = new IssueProformaResponseSnapshotBuilder();
return { return {
full: fullSnapshotBuilder, full: fullSnapshotBuilder,
issue: issueResponseSnapshotBuilder,
summary: summarySnapshotBuilder, summary: summarySnapshotBuilder,
report: reportSnapshotBuilder, report: reportSnapshotBuilder,
}; };

View File

@ -16,6 +16,7 @@ import type {
} from "../services"; } from "../services";
import type { import type {
IProformaFullSnapshotBuilder, IProformaFullSnapshotBuilder,
IIssueProformaResponseSnapshotBuilder,
IProformaReportSnapshotBuilder, IProformaReportSnapshotBuilder,
IProformaSummarySnapshotBuilder, IProformaSummarySnapshotBuilder,
} from "../snapshot-builders"; } from "../snapshot-builders";
@ -121,6 +122,7 @@ export function buildIssueProformaUseCase(deps: {
finder: IProformaFinder; finder: IProformaFinder;
issuer: IProformaIssuer; issuer: IProformaIssuer;
issueReadModelAssembler: IProformaIssueReadModelAssembler; issueReadModelAssembler: IProformaIssueReadModelAssembler;
issueResponseSnapshotBuilder: IIssueProformaResponseSnapshotBuilder;
repository: IProformaRepository; repository: IProformaRepository;
transactionManager: ITransactionManager; transactionManager: ITransactionManager;
}) { }) {
@ -128,6 +130,7 @@ export function buildIssueProformaUseCase(deps: {
finder, finder,
issuer, issuer,
issueReadModelAssembler, issueReadModelAssembler,
issueResponseSnapshotBuilder,
repository, repository,
transactionManager, transactionManager,
publicServices: { issuedInvoiceServices }, publicServices: { issuedInvoiceServices },
@ -138,6 +141,7 @@ export function buildIssueProformaUseCase(deps: {
finder, finder,
issuer, issuer,
issueReadModelAssembler, issueReadModelAssembler,
issueResponseSnapshotBuilder,
repository, repository,
transactionManager, transactionManager,
}); });

View File

@ -1,3 +1,4 @@
export * from "./commands";
export * from "./di"; export * from "./di";
export * from "./errors"; export * from "./errors";
export * from "./mappers"; export * from "./mappers";

View File

@ -15,7 +15,6 @@ import { Maybe, NumberHelper, Result } from "@repo/rdx-utils";
import type { CreateProformaRequestDTO } from "../../../../common"; import type { CreateProformaRequestDTO } from "../../../../common";
import { import {
InvoiceNumber,
type InvoiceRecipient, type InvoiceRecipient,
InvoiceSerie, InvoiceSerie,
InvoiceStatus, InvoiceStatus,
@ -59,12 +58,6 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper {
errors errors
); );
const invoiceNumber = extractOrPushError(
InvoiceNumber.create(dto.invoice_number),
"invoice_number",
errors
);
const series = extractOrPushError( const series = extractOrPushError(
maybeFromNullableResult(dto.series, (value) => InvoiceSerie.create(value)), maybeFromNullableResult(dto.series, (value) => InvoiceSerie.create(value)),
"series", "series",

View File

@ -1,4 +1,4 @@
import type { IPaymentMethodPublicFinder } from "@erp/catalogs/api"; import type { IPaymentMethodPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api";
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
@ -23,6 +23,7 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA
public constructor( public constructor(
private readonly deps: { private readonly deps: {
paymentMethodFinder: IPaymentMethodPublicFinder; paymentMethodFinder: IPaymentMethodPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder;
} }
) {} ) {}
@ -47,6 +48,12 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA
return Result.fail(paymentMethodResult.error); return Result.fail(paymentMethodResult.error);
} }
const taxRegimeResult = await this.resolveTaxRegime(params);
if (taxRegimeResult.isFailure) {
return Result.fail(taxRegimeResult.error);
}
const paymentMethod = paymentMethodResult.data; const paymentMethod = paymentMethodResult.data;
return Result.ok({ return Result.ok({
@ -56,6 +63,43 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA
name: paymentMethod.name, name: paymentMethod.name,
description: paymentMethod.description, description: paymentMethod.description,
}, },
taxRegime: taxRegimeResult.data,
});
}
private async resolveTaxRegime(params: {
companyId: UniqueID;
proforma: Proforma;
transaction?: unknown;
}) {
if (params.proforma.taxRegimeCode.isNone()) {
return Result.fail(new Error("Tax regime is required to issue proforma"));
}
const taxRegimeCode = params.proforma.taxRegimeCode.unwrap();
const taxRegimeResult = await this.deps.taxRegimeFinder.findByCodeInCompany({
companyId: params.companyId,
code: taxRegimeCode,
transaction: params.transaction,
});
if (taxRegimeResult.isFailure) {
return Result.fail(taxRegimeResult.error);
}
if (taxRegimeResult.data.isNone()) {
return Result.ok({
code: taxRegimeCode,
description: "",
});
}
const taxRegime = taxRegimeResult.data.unwrap();
return Result.ok({
code: taxRegime.code,
description: taxRegime.description,
}); });
} }
} }

View File

@ -1,3 +1,4 @@
export * from "./full"; export * from "./full";
export * from "./issue";
export * from "./report"; export * from "./report";
export * from "./summary"; export * from "./summary";

View File

@ -0,0 +1 @@
export * from "./issue-proforma-response.snapshot-builder";

View File

@ -0,0 +1,39 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import type { IssueProformaByIdResponseDTO } from "../../../../../common";
import type { IssuedInvoice } from "../../../../domain";
export interface IIssueProformaResponseSnapshotBuilder
extends ISnapshotBuilder<IssuedInvoice, IssueProformaByIdResponseDTO> {}
export class IssueProformaResponseSnapshotBuilder
implements IIssueProformaResponseSnapshotBuilder
{
toOutput(invoice: IssuedInvoice): IssueProformaByIdResponseDTO {
return {
issued_invoice: {
id: invoice.id.toString(),
proforma_id: invoice.linkedProformaId.toString(),
series: invoice.series.toString(),
invoice_number: invoice.invoiceNumber.toString(),
invoice_date: invoice.invoiceDate.toDateString(),
status: invoice.status.toPrimitive(),
recipient: {
name: invoice.recipient.name.toString(),
tin: invoice.recipient.tin.toString(),
},
tax_regime: {
code: invoice.taxRegime.code,
description: invoice.taxRegime.description,
},
totals: {
taxable_base: invoice.taxableAmount.toObjectString(),
tax_total: invoice.taxesAmount.toObjectString(),
retention_total: invoice.retentionAmount.toObjectString(),
grand_total: invoice.totalAmount.toObjectString(),
currency_code: invoice.currencyCode.toPrimitive(),
},
},
};
}
}

View File

@ -3,17 +3,14 @@ import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import type { IIssuedInvoicePublicServices } from "../../issued-invoices"; import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
import type { IssueProformaCommand } from "../commands";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import type { import type {
IProformaFinder, IProformaFinder,
IProformaIssueReadModelAssembler, IProformaIssueReadModelAssembler,
IProformaIssuer, IProformaIssuer,
} from "../services"; } from "../services";
import type { IIssueProformaResponseSnapshotBuilder } from "../snapshot-builders";
type IssueProformaUseCaseInput = {
companyId: UniqueID;
proforma_id: string;
};
/** /**
* Caso de uso: conversión de una proforma en factura definitiva. * Caso de uso: conversión de una proforma en factura definitiva.
@ -31,15 +28,22 @@ export class IssueProformaUseCase {
finder: IProformaFinder; finder: IProformaFinder;
issuer: IProformaIssuer; issuer: IProformaIssuer;
issueReadModelAssembler: IProformaIssueReadModelAssembler; issueReadModelAssembler: IProformaIssueReadModelAssembler;
issueResponseSnapshotBuilder: IIssueProformaResponseSnapshotBuilder;
repository: IProformaRepository; repository: IProformaRepository;
transactionManager: ITransactionManager; transactionManager: ITransactionManager;
} }
) {} ) {}
public execute(params: IssueProformaUseCaseInput) { public execute(command: IssueProformaCommand) {
const { proforma_id, companyId } = params; const companyIdResult = UniqueID.create(command.companyId);
const proformaIdResult = UniqueID.create(proforma_id); if (companyIdResult.isFailure) {
return Result.fail(companyIdResult.error);
}
const companyId = companyIdResult.data;
const proformaIdResult = UniqueID.create(command.proformaId);
if (proformaIdResult.isFailure) { if (proformaIdResult.isFailure) {
return Result.fail(proformaIdResult.error); return Result.fail(proformaIdResult.error);
@ -106,11 +110,7 @@ export class IssueProformaUseCase {
return Result.fail(markAsIssuedResult.error); return Result.fail(markAsIssuedResult.error);
} }
return Result.ok({ return Result.ok(this.deps.issueResponseSnapshotBuilder.toOutput(invoiceResult.data));
issuedinvoice_id: issuedInvoiceId.toString(),
proforma_id: proformaId.toString(),
customer_id: proforma.customerId.toString(),
});
} catch (error: unknown) { } catch (error: unknown) {
return Result.fail(error as Error); return Result.fail(error as Error);
} }

View File

@ -17,6 +17,7 @@ import type {
InvoicePaymentMethod, InvoicePaymentMethod,
InvoiceSerie, InvoiceSerie,
InvoiceStatus, InvoiceStatus,
InvoiceTaxRegime,
} from "../../common"; } from "../../common";
import { import {
type IIssuedInvoiceItemCreateProps, type IIssuedInvoiceItemCreateProps,
@ -51,6 +52,7 @@ export interface IIssuedInvoiceCreateProps {
currencyCode: CurrencyCode; currencyCode: CurrencyCode;
paymentMethod: InvoicePaymentMethod; paymentMethod: InvoicePaymentMethod;
taxRegime: InvoiceTaxRegime;
items: IIssuedInvoiceItemCreateProps[]; items: IIssuedInvoiceItemCreateProps[];
taxes: IssuedInvoiceTaxes; taxes: IssuedInvoiceTaxes;
@ -139,6 +141,16 @@ export class IssuedInvoice
); );
} }
if (!props.taxRegime) {
return Result.fail(
new DomainValidationError(
"MISSING_TAX_REGIME",
"taxRegime",
"Issued invoice requires tax regime"
)
);
}
return Result.ok(); return Result.ok();
} }
@ -222,6 +234,10 @@ export class IssuedInvoice
return this.props.paymentMethod; return this.props.paymentMethod;
} }
public get taxRegime(): InvoiceTaxRegime {
return this.props.taxRegime;
}
public get languageCode(): LanguageCode { public get languageCode(): LanguageCode {
return this.props.languageCode; return this.props.languageCode;
} }

View File

@ -1,6 +1,7 @@
import { DiscountPercentage, type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api"; import { DiscountPercentage, type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import { import {
CurrencyCode, CurrencyCode,
DomainValidationError,
LanguageCode, LanguageCode,
TextValue, TextValue,
UniqueID, UniqueID,
@ -20,6 +21,7 @@ import {
InvoicePaymentMethod, InvoicePaymentMethod,
InvoiceSerie, InvoiceSerie,
InvoiceStatus, InvoiceStatus,
InvoiceTaxRegime,
IssuedInvoice, IssuedInvoice,
IssuedInvoiceItems, IssuedInvoiceItems,
IssuedInvoiceTaxes, IssuedInvoiceTaxes,
@ -154,6 +156,23 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper<
} }
} }
const taxRegime = extractOrPushError(
!isNullishOrEmpty(raw.tax_regime_code)
? InvoiceTaxRegime.create({
code: String(raw.tax_regime_code),
description: String(raw.tax_regime_description ?? raw.tax_regime_code),
})
: Result.fail(
new DomainValidationError(
"MISSING_TAX_REGIME",
"tax_regime",
"Issued invoice requires persisted tax regime"
)
),
"tax_regime",
errors
);
const subtotalAmount = extractOrPushError( const subtotalAmount = extractOrPushError(
InvoiceAmount.create({ InvoiceAmount.create({
value: raw.subtotal_amount_value, value: raw.subtotal_amount_value,
@ -270,6 +289,7 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper<
languageCode, languageCode,
currencyCode, currencyCode,
paymentMethod, paymentMethod,
taxRegime,
subtotalAmount, subtotalAmount,
itemsDiscountAmount, itemsDiscountAmount,
@ -384,6 +404,7 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper<
totalAmount: attributes.totalAmount!, totalAmount: attributes.totalAmount!,
paymentMethod: attributes.paymentMethod!, paymentMethod: attributes.paymentMethod!,
taxRegime: attributes.taxRegime!,
taxes, taxes,
verifactu, verifactu,
@ -483,6 +504,8 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper<
payment_method_id: source.paymentMethod.toObjectString().id, payment_method_id: source.paymentMethod.toObjectString().id,
payment_method_description: source.paymentMethod.toObjectString().name, payment_method_description: source.paymentMethod.toObjectString().name,
tax_regime_code: source.taxRegime.code,
tax_regime_description: source.taxRegime.description,
subtotal_amount_value: source.subtotalAmount.value, subtotal_amount_value: source.subtotalAmount.value,
subtotal_amount_scale: source.subtotalAmount.scale, subtotal_amount_scale: source.subtotalAmount.scale,

View File

@ -117,6 +117,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
const issueReadModelAssembler = buildProformaIssueReadModelAssembler({ const issueReadModelAssembler = buildProformaIssueReadModelAssembler({
paymentMethodFinder: catalogs.paymentMethod.finder, paymentMethodFinder: catalogs.paymentMethod.finder,
taxRegimeFinder: catalogs.taxRegime.finder,
}); });
const companyReportProfileFinder = new CompanyReportProfileFinder({ const companyReportProfileFinder = new CompanyReportProfileFinder({
@ -188,6 +189,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
finder, finder,
issuer, issuer,
issueReadModelAssembler, issueReadModelAssembler,
issueResponseSnapshotBuilder: snapshotBuilders.issue,
repository, repository,
transactionManager, transactionManager,
}), }),

View File

@ -5,11 +5,16 @@ import {
requireCompanyContextGuard, requireCompanyContextGuard,
} from "@erp/core/api"; } from "@erp/core/api";
import { IssueProformaByIdResponseSchema } from "../../../../../common";
import type { IssueProformaUseCase } from "../../../../application/proformas"; import type { IssueProformaUseCase } from "../../../../application/proformas";
import type { IIssueProformaByIdRequestMapper } from "../mappers";
import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts"; import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts";
export class IssueProformaController extends ExpressController { export class IssueProformaController extends ExpressController {
public constructor(private readonly useCase: IssueProformaUseCase) { public constructor(
private readonly useCase: IssueProformaUseCase,
private readonly requestMapper: IIssueProformaByIdRequestMapper
) {
super(); super();
this.errorMapper = proformasApiErrorMapper; this.errorMapper = proformasApiErrorMapper;
@ -27,15 +32,26 @@ export class IssueProformaController extends ExpressController {
return this.forbiddenError("Tenant ID not found"); return this.forbiddenError("Tenant ID not found");
} }
const { proforma_id } = this.req.params; const params = this.req.params;
if (!proforma_id) { const proformaId = params.id ?? params.proforma_id;
if (!proformaId) {
return this.invalidInputError("Proforma ID missing"); return this.invalidInputError("Proforma ID missing");
} }
const result = await this.useCase.execute({ proforma_id, companyId }); const command = this.requestMapper.map({
companyId: companyId.toString(),
params: params as
| { id: string }
| {
proforma_id: string;
},
});
const result = await this.useCase.execute(command);
return result.match( return result.match(
(data) => this.ok(data), (data) => this.ok(IssueProformaByIdResponseSchema.parse(data)),
(err) => this.handleError(err) (err) => this.handleError(err)
); );
} }

View File

@ -1,2 +1,3 @@
export * from "./controllers"; export * from "./controllers";
export * from "./mappers";
export * from "./proformas.routes"; export * from "./proformas.routes";

View File

@ -0,0 +1 @@
export * from "./issue-proforma-by-id-request.mapper";

View File

@ -0,0 +1,27 @@
import type {
IssueProformaByIdParamsRequestDTO,
LegacyIssueProformaByIdParamsRequestDTO,
} from "../../../../../common";
import type { IssueProformaCommand } from "../../../../application/proformas";
type IssueProformaRequestMapperInput = {
companyId: string;
params: IssueProformaByIdParamsRequestDTO | LegacyIssueProformaByIdParamsRequestDTO;
actorUserId?: string;
};
export interface IIssueProformaByIdRequestMapper {
map(params: IssueProformaRequestMapperInput): IssueProformaCommand;
}
export class IssueProformaByIdRequestMapper implements IIssueProformaByIdRequestMapper {
map(params: IssueProformaRequestMapperInput): IssueProformaCommand {
const proformaId = "id" in params.params ? params.params.id : params.params.proforma_id;
return {
companyId: params.companyId,
proformaId,
actorUserId: params.actorUserId,
};
}
}

View File

@ -15,6 +15,7 @@ import {
ChangeStatusProformaByIdParamsRequestSchema, ChangeStatusProformaByIdParamsRequestSchema,
ChangeStatusProformaByIdRequestSchema, ChangeStatusProformaByIdRequestSchema,
CreateProformaRequestSchema, CreateProformaRequestSchema,
LegacyIssueProformaByIdParamsRequestSchema,
GetProformaByIdRequestSchema, GetProformaByIdRequestSchema,
IssueProformaByIdParamsRequestSchema, IssueProformaByIdParamsRequestSchema,
ListProformasRequestSchema, ListProformasRequestSchema,
@ -27,6 +28,7 @@ import type { IIssuedInvoicePublicServices } from "../../../application";
import { ChangeStatusProformaController } from "./controllers/change-status-proforma.controller"; import { ChangeStatusProformaController } from "./controllers/change-status-proforma.controller";
import { CreateProformaController } from "./controllers/create-proforma.controller"; import { CreateProformaController } from "./controllers/create-proforma.controller";
import { IssueProformaByIdRequestMapper } from "./mappers";
import { UpdateProformaController } from "./controllers/update-proforma.controller"; import { UpdateProformaController } from "./controllers/update-proforma.controller";
export const proformasRouter = (params: StartParams) => { export const proformasRouter = (params: StartParams) => {
@ -141,17 +143,29 @@ export const proformasRouter = (params: StartParams) => {
} }
); );
const issueProformaHandler = (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.issueProforma(publicServices);
const requestMapper = new IssueProformaByIdRequestMapper();
const controller = new IssueProformaController(useCase, requestMapper);
return controller.execute(req, res, next);
};
router.post(
"/:id/issue",
validateRequest(IssueProformaByIdParamsRequestSchema, "params"),
issueProformaHandler
);
router.post(
"/:proforma_id/issue",
validateRequest(LegacyIssueProformaByIdParamsRequestSchema, "params"),
issueProformaHandler
);
router.put( router.put(
"/:proforma_id/issue", "/:proforma_id/issue",
//checkTabContext, validateRequest(LegacyIssueProformaByIdParamsRequestSchema, "params"),
issueProformaHandler
validateRequest(IssueProformaByIdParamsRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.issueProforma(publicServices);
const controller = new IssueProformaController(useCase);
return controller.execute(req, res, next);
}
); );
app.use(`${config.server.apiBasePath}/proformas`, router); app.use(`${config.server.apiBasePath}/proformas`, router);

View File

@ -1,6 +1,6 @@
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils"; import { type Maybe, Result } from "@repo/rdx-utils";
import { type Transaction, type WhereOptions, literal } from "sequelize"; import { type Transaction, type WhereOptions } from "sequelize";
import type { IProformaNumberGenerator } from "../../../../../application/proformas"; import type { IProformaNumberGenerator } from "../../../../../application/proformas";
import { InvoiceNumber, type InvoiceSerie } from "../../../../../domain"; import { InvoiceNumber, type InvoiceSerie } from "../../../../../domain";
@ -11,6 +11,9 @@ import { CustomerInvoiceModel } from "../../../../common/persistence";
* La serie elegida en la proforma solo referencia la futura InvoiceSeries. * La serie elegida en la proforma solo referencia la futura InvoiceSeries.
*/ */
export class SequelizeProformaNumberGenerator implements IProformaNumberGenerator { export class SequelizeProformaNumberGenerator implements IProformaNumberGenerator {
private static readonly PREFIX = "PF-";
private static readonly DIGITS = 4;
public async getNextForCompany( public async getNextForCompany(
companyId: UniqueID, companyId: UniqueID,
_series: Maybe<InvoiceSerie>, _series: Maybe<InvoiceSerie>,
@ -22,24 +25,20 @@ export class SequelizeProformaNumberGenerator implements IProformaNumberGenerato
}; };
try { try {
const lastInvoice = await CustomerInvoiceModel.findOne({ const existingInvoices = await CustomerInvoiceModel.findAll({
attributes: ["invoice_number"], attributes: ["invoice_number"],
where, where,
// Orden numérico real: CAST(... AS UNSIGNED)
order: [literal("CAST(invoice_number AS UNSIGNED) DESC")],
transaction, transaction,
raw: true, raw: true,
// Bloqueo opcional para evitar carreras si estás dentro de una TX lock: transaction.LOCK.UPDATE,
lock: transaction.LOCK.UPDATE, // requiere InnoDB y TX abierta
}); });
let nextValue = "0001"; // valor inicial por defecto const maxSequence = existingInvoices.reduce((max, currentInvoice) => {
const currentSequence = this.extractSequence(currentInvoice.invoice_number);
return Math.max(max, currentSequence);
}, 0);
if (lastInvoice) { const nextValue = this.formatSequence(maxSequence + 1);
const current = Number(lastInvoice.invoice_number);
const next = Number.isFinite(current) && current > 0 ? current + 1 : 1;
nextValue = String(next).padStart(4, "0");
}
const numberResult = InvoiceNumber.create(nextValue); const numberResult = InvoiceNumber.create(nextValue);
if (numberResult.isFailure) { if (numberResult.isFailure) {
@ -50,9 +49,27 @@ export class SequelizeProformaNumberGenerator implements IProformaNumberGenerato
} catch (error) { } catch (error) {
return Result.fail( return Result.fail(
new Error( new Error(
`Error generating invoice number for company ${companyId}: ${(error as Error).message}` `Error generating proforma number for company ${companyId}: ${(error as Error).message}`
) )
); );
} }
} }
private extractSequence(invoiceNumber: string): number {
const normalized = invoiceNumber.trim().toUpperCase();
if (normalized.startsWith(SequelizeProformaNumberGenerator.PREFIX)) {
const suffix = normalized.slice(SequelizeProformaNumberGenerator.PREFIX.length);
const parsed = Number(suffix);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
private formatSequence(sequence: number): string {
const padded = String(sequence).padStart(SequelizeProformaNumberGenerator.DIGITS, "0");
return `${SequelizeProformaNumberGenerator.PREFIX}${padded}`;
}
} }

View File

@ -28,7 +28,7 @@ export type CreateProformaItemRequestDTO = z.infer<typeof CreateProformaItemRequ
export const CreateProformaRequestSchema = z.object({ export const CreateProformaRequestSchema = z.object({
id: z.uuid(), id: z.uuid(),
invoice_number: z.string(), invoice_number: z.string().nullable().optional(),
series: z.string().nullable(), series: z.string().nullable(),
invoice_date: IsoDateSchema, invoice_date: IsoDateSchema,

View File

@ -1,9 +1,17 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
export const IssueProformaByIdParamsRequestSchema = z.object({ export const IssueProformaByIdParamsRequestSchema = z.object({
proforma_id: z.string(), id: z.string(),
}); });
export type IssueProformaByIdParamsRequestDTO = z.infer< export type IssueProformaByIdParamsRequestDTO = z.infer<
typeof IssueProformaByIdParamsRequestSchema typeof IssueProformaByIdParamsRequestSchema
>; >;
export const LegacyIssueProformaByIdParamsRequestSchema = z.object({
proforma_id: z.string(),
});
export type LegacyIssueProformaByIdParamsRequestDTO = z.infer<
typeof LegacyIssueProformaByIdParamsRequestSchema
>;

View File

@ -46,7 +46,7 @@ export const GetIssuedInvoiceByIdResponseSchema = z.object({
payment_method: PaymentMethodRefSchema.nullable(), payment_method: PaymentMethodRefSchema.nullable(),
tax_regime: TaxRegimeRefSchema.nullable(), tax_regime: TaxRegimeRefSchema,
subtotal_amount: MoneySchema, subtotal_amount: MoneySchema,

View File

@ -1,9 +1,35 @@
import { CurrencyCodeSchema, IsoDateSchema, MoneySchema } from "@erp/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { IssuedInvoiceStatusSchema, TaxRegimeRefSchema } from "../../shared";
export const IssueProformaIssuedInvoiceRecipientSchema = z.object({
name: z.string(),
tin: z.string().nullable(),
});
export const IssueProformaIssuedInvoiceTotalsSchema = z.object({
taxable_base: MoneySchema,
tax_total: MoneySchema,
retention_total: MoneySchema,
grand_total: MoneySchema,
currency_code: CurrencyCodeSchema,
});
export const IssueProformaIssuedInvoiceSchema = z.object({
id: z.uuid(),
proforma_id: z.uuid().nullable(),
series: z.string(),
invoice_number: z.string(),
invoice_date: IsoDateSchema,
status: IssuedInvoiceStatusSchema,
recipient: IssueProformaIssuedInvoiceRecipientSchema,
tax_regime: TaxRegimeRefSchema,
totals: IssueProformaIssuedInvoiceTotalsSchema,
});
export const IssueProformaByIdResponseSchema = z.object({ export const IssueProformaByIdResponseSchema = z.object({
issuedinvoice_id: z.string(), issued_invoice: IssueProformaIssuedInvoiceSchema,
proforma_id: z.string(),
customer_id: z.string(),
}); });
export type IssueProformaByIdResponseDTO = z.infer<typeof IssueProformaByIdResponseSchema>; export type IssueProformaByIdResponseDTO = z.infer<typeof IssueProformaByIdResponseSchema>;

View File

@ -2,7 +2,7 @@ import { z } from "zod/v4";
export const TaxRegimeRefSchema = z.object({ export const TaxRegimeRefSchema = z.object({
code: z.string(), code: z.string(),
description: z.string(), description: z.string().nullable(),
}); });
export type TaxRegimeRefDTO = z.infer<typeof TaxRegimeRefSchema>; export type TaxRegimeRefDTO = z.infer<typeof TaxRegimeRefSchema>;