feat: add AFIP Factura C issuance support to ArcaCredentials and update cash flow model
This commit is contained in:
@@ -45,6 +45,29 @@ export type ArcaWsfeLastVoucherResult = {
|
||||
lastVoucherNumber: number;
|
||||
};
|
||||
|
||||
export type ArcaWsfeIssueFacturaCParams = {
|
||||
companyId: string;
|
||||
amount: number;
|
||||
billingDate: Date;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type ArcaWsfeIssueFacturaCResult = {
|
||||
environment: "homologation";
|
||||
service: "wsfe";
|
||||
pointOfSale: number;
|
||||
voucherType: 11;
|
||||
voucherNumber: number;
|
||||
cae: string;
|
||||
caeExpiresAt: Date;
|
||||
};
|
||||
|
||||
export type ArcaWsfeIssueFacturaCAttempt = {
|
||||
pointOfSale: number;
|
||||
voucherType: 11;
|
||||
voucherNumber: number;
|
||||
};
|
||||
|
||||
export type ArcaWsfeDiagnosticsParams = {
|
||||
companyId: string;
|
||||
sessionUser: string;
|
||||
@@ -154,6 +177,7 @@ export interface IArcaCredentialsManager {
|
||||
uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView>;
|
||||
testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult>;
|
||||
getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult>;
|
||||
issueFacturaC(data: ArcaWsfeIssueFacturaCParams): Promise<ArcaWsfeIssueFacturaCResult>;
|
||||
getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult>;
|
||||
find(data: FindArcaCredentialsParams): Promise<ArcaCredentialView[]>;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
ArcaCredentialView,
|
||||
ArcaWsfeDiagnosticsParams,
|
||||
ArcaWsfeDiagnosticsResult,
|
||||
ArcaWsfeIssueFacturaCParams,
|
||||
ArcaWsfeIssueFacturaCResult,
|
||||
ArcaWsfeIssueFacturaCAttempt,
|
||||
ArcaWsfeLastVoucherParams,
|
||||
ArcaWsfeLastVoucherResult,
|
||||
FindArcaCredentialsParams,
|
||||
@@ -29,6 +32,23 @@ import {
|
||||
UploadArcaCredentialCertificateParams,
|
||||
} from "./ArcaCredentials.Interface";
|
||||
|
||||
export class ArcaWsfeIssueFacturaCError extends Error {
|
||||
attempt: ArcaWsfeIssueFacturaCAttempt;
|
||||
isAmbiguous: boolean;
|
||||
|
||||
constructor(message: string, attempt: ArcaWsfeIssueFacturaCAttempt, isAmbiguous: boolean) {
|
||||
super(message);
|
||||
this.name = "ArcaWsfeIssueFacturaCError";
|
||||
this.attempt = attempt;
|
||||
this.isAmbiguous = isAmbiguous;
|
||||
}
|
||||
}
|
||||
|
||||
const FACTURA_C_VOUCHER_TYPE = 11;
|
||||
const FINAL_CONSUMER_DOCUMENT_TYPE = 99;
|
||||
const FINAL_CONSUMER_DOCUMENT_NUMBER = 0;
|
||||
const FINAL_CONSUMER_IVA_CONDITION = 5;
|
||||
|
||||
class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
arcaCredentials: ArcaCredentialsAdapterMongoose;
|
||||
|
||||
@@ -312,6 +332,78 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
</soapenv:Envelope>`;
|
||||
}
|
||||
|
||||
private formatArcaDate(date: Date): string {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "America/Argentina/Buenos_Aires",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(date);
|
||||
const year = parts.find((part) => part.type === "year")?.value || "";
|
||||
const month = parts.find((part) => part.type === "month")?.value || "";
|
||||
const day = parts.find((part) => part.type === "day")?.value || "";
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
private parseArcaDate(value: string): Date {
|
||||
const year = Number(value.slice(0, 4));
|
||||
const month = Number(value.slice(4, 6)) - 1;
|
||||
const day = Number(value.slice(6, 8));
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
private buildWsfeIssueFacturaCSoap(data: {
|
||||
token: string;
|
||||
sign: string;
|
||||
cuit: string;
|
||||
pointOfSale: number;
|
||||
voucherNumber: number;
|
||||
amount: number;
|
||||
voucherDate: string;
|
||||
}): string {
|
||||
const amount = data.amount.toFixed(2);
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soapenv:Header/>
|
||||
<soapenv:Body>
|
||||
<FECAESolicitar xmlns="http://ar.gov.afip.dif.FEV1/">
|
||||
<Auth>
|
||||
<Token>${data.token}</Token>
|
||||
<Sign>${data.sign}</Sign>
|
||||
<Cuit>${data.cuit}</Cuit>
|
||||
</Auth>
|
||||
<FeCAEReq>
|
||||
<FeCabReq>
|
||||
<CantReg>1</CantReg>
|
||||
<PtoVta>${data.pointOfSale}</PtoVta>
|
||||
<CbteTipo>11</CbteTipo>
|
||||
</FeCabReq>
|
||||
<FeDetReq>
|
||||
<FECAEDetRequest>
|
||||
<Concepto>1</Concepto>
|
||||
<DocTipo>${FINAL_CONSUMER_DOCUMENT_TYPE}</DocTipo>
|
||||
<DocNro>${FINAL_CONSUMER_DOCUMENT_NUMBER}</DocNro>
|
||||
<CbteDesde>${data.voucherNumber}</CbteDesde>
|
||||
<CbteHasta>${data.voucherNumber}</CbteHasta>
|
||||
<CbteFch>${data.voucherDate}</CbteFch>
|
||||
<ImpTotal>${amount}</ImpTotal>
|
||||
<ImpTotConc>0.00</ImpTotConc>
|
||||
<ImpNeto>${amount}</ImpNeto>
|
||||
<ImpOpEx>0.00</ImpOpEx>
|
||||
<ImpTrib>0.00</ImpTrib>
|
||||
<ImpIVA>0.00</ImpIVA>
|
||||
<MonId>PES</MonId>
|
||||
<MonCotiz>1</MonCotiz>
|
||||
<CondicionIVAReceptorId>${FINAL_CONSUMER_IVA_CONDITION}</CondicionIVAReceptorId>
|
||||
</FECAEDetRequest>
|
||||
</FeDetReq>
|
||||
</FeCAEReq>
|
||||
</FECAESolicitar>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>`;
|
||||
}
|
||||
|
||||
private buildWsfeDummySoap(): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
@@ -408,6 +500,14 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
return error.message || "No pudimos conectar con ARCA WSFE";
|
||||
}
|
||||
|
||||
private isAmbiguousWsfeIssueError(error: unknown): boolean {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !error.response || error.code === "ECONNABORTED" || (error.response.status >= 500 && error.response.status < 600);
|
||||
}
|
||||
|
||||
private extractHtmlTitle(html: string): string | undefined {
|
||||
const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, " ").trim();
|
||||
return title ? this.decodeXmlEntities(title) : undefined;
|
||||
@@ -441,6 +541,37 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
return parsedVoucherNumber;
|
||||
}
|
||||
|
||||
private parseWsfeIssueFacturaCReturn(
|
||||
xml: string,
|
||||
pointOfSale: number,
|
||||
voucherNumber: number
|
||||
): ArcaWsfeIssueFacturaCResult {
|
||||
const faultString = this.extractXmlValue(xml, "faultstring");
|
||||
|
||||
if (faultString) {
|
||||
throw new Error(`ARCA WSFE rechazó la emisión: ${this.decodeXmlEntities(faultString)}`);
|
||||
}
|
||||
|
||||
const cae = this.extractXmlValue(xml, "CAE");
|
||||
const caeExpiresAt = this.extractXmlValue(xml, "CAEFchVto");
|
||||
const result = this.extractXmlValue(xml, "Resultado");
|
||||
|
||||
if (result !== "A" || !cae || !caeExpiresAt) {
|
||||
const errorMessage = this.extractWsfeResponseError(xml);
|
||||
throw new Error(errorMessage || "ARCA WSFE no autorizó la factura");
|
||||
}
|
||||
|
||||
return {
|
||||
environment: ARCA_WSAA_ENVIRONMENT.HOMOLOGATION,
|
||||
service: ARCA_WSAA_SERVICE.WSFE,
|
||||
pointOfSale,
|
||||
voucherType: FACTURA_C_VOUCHER_TYPE,
|
||||
voucherNumber,
|
||||
cae,
|
||||
caeExpiresAt: this.parseArcaDate(caeExpiresAt),
|
||||
};
|
||||
}
|
||||
|
||||
private parseWsfeDummyReturn(xml: string): ArcaWsfeDiagnosticsResult["dummy"] {
|
||||
const errorMessage = this.extractWsfeResponseError(xml);
|
||||
|
||||
@@ -649,7 +780,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
|
||||
const voucherType = 11;
|
||||
const voucherType = FACTURA_C_VOUCHER_TYPE;
|
||||
let responseXml: string;
|
||||
|
||||
try {
|
||||
@@ -676,6 +807,66 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
};
|
||||
}
|
||||
|
||||
public async issueFacturaC(data: ArcaWsfeIssueFacturaCParams): Promise<ArcaWsfeIssueFacturaCResult> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
|
||||
if (data.amount <= 0) {
|
||||
throw new Error("El importe de la factura debe ser mayor a cero");
|
||||
}
|
||||
|
||||
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
|
||||
const voucherType = 11;
|
||||
let lastVoucherNumber: number;
|
||||
|
||||
try {
|
||||
lastVoucherNumber = this.parseWsfeLastVoucherReturn(
|
||||
await this.postWsfe(
|
||||
this.buildWsfeLastVoucherSoap({
|
||||
token: loginTicket.token,
|
||||
sign: loginTicket.sign,
|
||||
cuit: fiscalProfile.cuit,
|
||||
pointOfSale: fiscalProfile.pointOfSale,
|
||||
voucherType,
|
||||
}),
|
||||
"FECompUltimoAutorizado"
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(this.buildWsfeErrorMessage(error));
|
||||
}
|
||||
const voucherNumber = lastVoucherNumber + 1;
|
||||
|
||||
try {
|
||||
return this.parseWsfeIssueFacturaCReturn(
|
||||
await this.postWsfe(
|
||||
this.buildWsfeIssueFacturaCSoap({
|
||||
token: loginTicket.token,
|
||||
sign: loginTicket.sign,
|
||||
cuit: fiscalProfile.cuit,
|
||||
pointOfSale: fiscalProfile.pointOfSale,
|
||||
voucherNumber,
|
||||
amount: data.amount,
|
||||
voucherDate: this.formatArcaDate(data.billingDate),
|
||||
}),
|
||||
"FECAESolicitar"
|
||||
),
|
||||
fiscalProfile.pointOfSale,
|
||||
voucherNumber
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ArcaWsfeIssueFacturaCError(
|
||||
this.buildWsfeErrorMessage(error),
|
||||
{
|
||||
pointOfSale: fiscalProfile.pointOfSale,
|
||||
voucherType,
|
||||
voucherNumber,
|
||||
},
|
||||
this.isAmbiguousWsfeIssueError(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IInvoicesAdapter,
|
||||
INVOICE_STATUS,
|
||||
INVOICE_TYPE,
|
||||
UpdateInvoiceParams,
|
||||
} from "./Invoices.Interface";
|
||||
|
||||
export interface IInvoiceDocument extends Omit<IInvoice, "id">, Document {}
|
||||
@@ -40,6 +41,9 @@ export class InvoicesAdapterMongoose implements IInvoicesAdapter {
|
||||
amount: { type: Number, required: true },
|
||||
type: { type: String, required: true, enum: Object.values(INVOICE_TYPE) },
|
||||
status: { type: String, required: true, enum: Object.values(INVOICE_STATUS), index: true },
|
||||
billingDate: { type: Date, required: true },
|
||||
paymentMethod: { type: String, required: true },
|
||||
description: { type: String, required: true },
|
||||
cae: { type: String, required: false },
|
||||
caeExpiresAt: { type: Date, required: false },
|
||||
pointOfSale: { type: Number, required: false },
|
||||
@@ -63,6 +67,9 @@ export class InvoicesAdapterMongoose implements IInvoicesAdapter {
|
||||
|
||||
if (filters.id) searchCriteria._id = filters.id;
|
||||
if (filters.cashMovementId) searchCriteria.cashMovementId = filters.cashMovementId;
|
||||
if (filters.cashMovementIds && filters.cashMovementIds.length > 0) {
|
||||
searchCriteria.cashMovementId = { $in: filters.cashMovementIds };
|
||||
}
|
||||
if (filters.companyId) searchCriteria.companyId = filters.companyId;
|
||||
if (filters.status) searchCriteria.status = filters.status;
|
||||
|
||||
@@ -79,4 +86,10 @@ export class InvoicesAdapterMongoose implements IInvoicesAdapter {
|
||||
return this.invoiceList.findOne(searchCriteria).exec();
|
||||
}
|
||||
|
||||
public async update(data: UpdateInvoiceParams): Promise<IInvoiceDocument | null> {
|
||||
const { sessionUser, id, cashMovementId, cashMovementIds, companyId, ...updateData } = data;
|
||||
const searchCriteria = this.buildSearchCriteria({ id, cashMovementId, companyId });
|
||||
return this.invoiceList.findOneAndUpdate(searchCriteria, updateData, { new: true }).exec();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -12,14 +12,26 @@ export enum INVOICE_STATUS {
|
||||
FAILED = "FAILED",
|
||||
}
|
||||
|
||||
export enum INVOICE_PAYMENT_METHOD {
|
||||
CASH = "cash",
|
||||
BANK_TRANSFER = "bank_transfer",
|
||||
CREDIT_CARD = "credit_card",
|
||||
DEBIT_CARD = "debit_card",
|
||||
OTHER = "other",
|
||||
}
|
||||
|
||||
export type CreateInvoiceFromCashMovementParams = {
|
||||
cashMovementId: string;
|
||||
billingDate: Date | string;
|
||||
paymentMethod: INVOICE_PAYMENT_METHOD | string;
|
||||
description: string;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type FindInvoicesParams = {
|
||||
id?: string;
|
||||
cashMovementId?: string;
|
||||
cashMovementIds?: string[];
|
||||
companyId?: string;
|
||||
status?: INVOICE_STATUS;
|
||||
sessionUser?: string;
|
||||
@@ -35,6 +47,9 @@ export type CreateInvoiceParams = {
|
||||
amount: number;
|
||||
type: INVOICE_TYPE;
|
||||
status: INVOICE_STATUS;
|
||||
billingDate: Date;
|
||||
paymentMethod: INVOICE_PAYMENT_METHOD | string;
|
||||
description: string;
|
||||
cae?: string;
|
||||
caeExpiresAt?: Date;
|
||||
pointOfSale?: number;
|
||||
@@ -46,6 +61,8 @@ export type CreateInvoiceParams = {
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type UpdateInvoiceParams = FindInvoicesParams & Partial<CreateInvoiceParams>;
|
||||
|
||||
export interface IInvoice {
|
||||
id?: string;
|
||||
cashMovementId: string;
|
||||
@@ -57,6 +74,9 @@ export interface IInvoice {
|
||||
amount: number;
|
||||
type: INVOICE_TYPE;
|
||||
status: INVOICE_STATUS;
|
||||
billingDate: Date;
|
||||
paymentMethod: INVOICE_PAYMENT_METHOD | string;
|
||||
description: string;
|
||||
cae?: string;
|
||||
caeExpiresAt?: Date;
|
||||
pointOfSale?: number;
|
||||
@@ -72,6 +92,7 @@ export interface IInvoicesAdapter {
|
||||
create(data: CreateInvoiceParams): Promise<IInvoice>;
|
||||
find(filters: FindInvoicesParams): Promise<IInvoice[]>;
|
||||
findOne(filters: FindInvoicesParams): Promise<IInvoiceDocument | null>;
|
||||
update(data: UpdateInvoiceParams): Promise<IInvoiceDocument | null>;
|
||||
}
|
||||
|
||||
export interface IInvoicesManager {
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import CashFlow from "../CashFlow/CashFlow";
|
||||
import { CLIENT_ACCOUNT_MOVEMENT_TYPES } from "../ClientAccountMovements/ClientAccountMovements.Interface";
|
||||
import CompaniesList from "../Companies/Companies";
|
||||
import OrganizationFiscalProfiles from "../OrganizationFiscalProfiles/OrganizationFiscalProfiles";
|
||||
import { ORGANIZATION_FISCAL_PROFILE_STATUS } from "../OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface";
|
||||
import ArcaCredentials, { ArcaWsfeIssueFacturaCError } from "../ArcaCredentials/ArcaCredentials";
|
||||
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
|
||||
import { PlanFeatures } from "../Plans/Plans.interface";
|
||||
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
||||
import { InvoicesAdapterMongoose } from "./Invoices.Adapter.Mongoose";
|
||||
import {
|
||||
CreateInvoiceFromCashMovementParams,
|
||||
CreateInvoiceParams,
|
||||
FindInvoicesParams,
|
||||
IInvoice,
|
||||
IInvoicesManager,
|
||||
INVOICE_PAYMENT_METHOD,
|
||||
INVOICE_STATUS,
|
||||
INVOICE_TYPE,
|
||||
} from "./Invoices.Interface";
|
||||
|
||||
const FACTURA_C_VOUCHER_TYPE = "11";
|
||||
const ARGENTINA_TIME_ZONE = "America/Argentina/Buenos_Aires";
|
||||
const DESCRIPTION_MAX_LENGTH = 500;
|
||||
const YYYY_MM_DD_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
|
||||
class InvoicesManager implements IInvoicesManager {
|
||||
invoices: InvoicesAdapterMongoose;
|
||||
|
||||
@@ -24,6 +35,78 @@ class InvoicesManager implements IInvoicesManager {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === 11000;
|
||||
}
|
||||
|
||||
private async validatePaymentsPlan(companyId: string): Promise<void> {
|
||||
const company = await CompaniesList.companies.findOne({ _id: companyId });
|
||||
|
||||
if (!company) {
|
||||
throw new Error("No se ha encontrado la organización");
|
||||
}
|
||||
|
||||
const canAccess = await PlanSubscriptionsList.checkFeature({
|
||||
userId: String(company.ownerId),
|
||||
feature: PlanFeatures.PAYMENTS,
|
||||
});
|
||||
|
||||
if (!canAccess) {
|
||||
throw new Error("La organizacion no tiene un plan que soporte el módulo de pagos");
|
||||
}
|
||||
}
|
||||
|
||||
private formatArgentinaCalendarDate(date: Date): string {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: ARGENTINA_TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(date);
|
||||
const year = parts.find((part) => part.type === "year")?.value || "";
|
||||
const month = parts.find((part) => part.type === "month")?.value || "";
|
||||
const day = parts.find((part) => part.type === "day")?.value || "";
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
private parseBillingDate(billingDate: Date | string): Date {
|
||||
if (!billingDate) {
|
||||
throw new Error("Se requiere la fecha del comprobante");
|
||||
}
|
||||
|
||||
let parsedDate: Date;
|
||||
|
||||
if (typeof billingDate === "string") {
|
||||
const localDateMatch = billingDate.match(YYYY_MM_DD_PATTERN);
|
||||
|
||||
if (localDateMatch) {
|
||||
const [, year, month, day] = localDateMatch;
|
||||
parsedDate = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), 12));
|
||||
} else {
|
||||
parsedDate = new Date(billingDate);
|
||||
}
|
||||
} else {
|
||||
parsedDate = billingDate;
|
||||
}
|
||||
|
||||
if (Number.isNaN(parsedDate.getTime())) {
|
||||
throw new Error("La fecha del comprobante no es válida");
|
||||
}
|
||||
|
||||
const selectedDate = this.formatArgentinaCalendarDate(parsedDate);
|
||||
const todayDate = this.formatArgentinaCalendarDate(new Date());
|
||||
|
||||
if (selectedDate > todayDate) {
|
||||
throw new Error("La fecha del comprobante no puede ser futura");
|
||||
}
|
||||
|
||||
return parsedDate;
|
||||
}
|
||||
|
||||
private validatePaymentMethod(paymentMethod: string): INVOICE_PAYMENT_METHOD {
|
||||
if (!Object.values(INVOICE_PAYMENT_METHOD).includes(paymentMethod as INVOICE_PAYMENT_METHOD)) {
|
||||
throw new Error("El medio de pago de la factura no es válido");
|
||||
}
|
||||
|
||||
return paymentMethod as INVOICE_PAYMENT_METHOD;
|
||||
}
|
||||
|
||||
public async createFromCashMovement(data: CreateInvoiceFromCashMovementParams): Promise<IInvoice> {
|
||||
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||
|
||||
@@ -31,6 +114,23 @@ class InvoicesManager implements IInvoicesManager {
|
||||
throw new Error("Se requiere un movimiento de caja para emitir la factura");
|
||||
}
|
||||
|
||||
const billingDate = this.parseBillingDate(data.billingDate);
|
||||
const description = data.description?.trim();
|
||||
|
||||
if (!description) {
|
||||
throw new Error("Se requiere el detalle de la factura");
|
||||
}
|
||||
|
||||
if (description.length > DESCRIPTION_MAX_LENGTH) {
|
||||
throw new Error(`El detalle de la factura no puede superar los ${DESCRIPTION_MAX_LENGTH} caracteres`);
|
||||
}
|
||||
|
||||
if (!data.paymentMethod || !data.paymentMethod.trim()) {
|
||||
throw new Error("Se requiere el medio de pago de la factura");
|
||||
}
|
||||
|
||||
const paymentMethod = this.validatePaymentMethod(data.paymentMethod.trim());
|
||||
|
||||
const movement = await CashFlow.movements.movementList.findOne({
|
||||
_id: data.cashMovementId,
|
||||
});
|
||||
@@ -41,6 +141,7 @@ class InvoicesManager implements IInvoicesManager {
|
||||
|
||||
const companyId = String(movement.companyId);
|
||||
await validatePermissionsByCompany({ companyId, sessionUser: data.sessionUser });
|
||||
await this.validatePaymentsPlan(companyId);
|
||||
|
||||
if (movement.type !== CLIENT_ACCOUNT_MOVEMENT_TYPES.DEPOSIT || movement.amount <= 0) {
|
||||
throw new Error("El movimiento de caja no es facturable");
|
||||
@@ -60,39 +161,140 @@ class InvoicesManager implements IInvoicesManager {
|
||||
cashMovementId: data.cashMovementId,
|
||||
});
|
||||
|
||||
if (existingInvoice) {
|
||||
if (existingInvoice?.status === INVOICE_STATUS.APPROVED) {
|
||||
return existingInvoice;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.invoices.create({
|
||||
if (existingInvoice?.status === INVOICE_STATUS.PENDING_VERIFICATION) {
|
||||
return existingInvoice;
|
||||
}
|
||||
|
||||
if (existingInvoice?.status === INVOICE_STATUS.PENDING_ARCA && existingInvoice.voucherNumber) {
|
||||
const updatedPendingInvoice = await this.invoices.update({
|
||||
cashMovementId: data.cashMovementId,
|
||||
companyId,
|
||||
clientId: movement.clientId ? String(movement.clientId) : undefined,
|
||||
paymentId: movement.paymentId ? String(movement.paymentId) : undefined,
|
||||
clientAccountMovementId: movement.clientAccountMovementId
|
||||
? String(movement.clientAccountMovementId)
|
||||
: undefined,
|
||||
appointmentId: movement.appointmentId ? String(movement.appointmentId) : undefined,
|
||||
amount: movement.amount,
|
||||
type: INVOICE_TYPE.FACTURA_C,
|
||||
status: INVOICE_STATUS.PENDING_ARCA,
|
||||
createdAt: new Date(),
|
||||
status: INVOICE_STATUS.PENDING_VERIFICATION,
|
||||
arcaErrorMessage:
|
||||
existingInvoice.arcaErrorMessage ||
|
||||
"La emisión quedó pendiente de verificación en ARCA. Revisá el comprobante antes de reintentar.",
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.isDuplicateKeyError(error)) {
|
||||
const duplicateInvoice = await this.invoices.findOne({
|
||||
cashMovementId: data.cashMovementId,
|
||||
});
|
||||
|
||||
if (duplicateInvoice) {
|
||||
return duplicateInvoice;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
return updatedPendingInvoice || existingInvoice;
|
||||
}
|
||||
|
||||
const baseInvoiceData: CreateInvoiceParams = {
|
||||
cashMovementId: data.cashMovementId,
|
||||
companyId,
|
||||
clientId: movement.clientId ? String(movement.clientId) : undefined,
|
||||
paymentId: movement.paymentId ? String(movement.paymentId) : undefined,
|
||||
clientAccountMovementId: movement.clientAccountMovementId
|
||||
? String(movement.clientAccountMovementId)
|
||||
: undefined,
|
||||
appointmentId: movement.appointmentId ? String(movement.appointmentId) : undefined,
|
||||
amount: movement.amount,
|
||||
type: INVOICE_TYPE.FACTURA_C,
|
||||
status: INVOICE_STATUS.PENDING_ARCA,
|
||||
billingDate,
|
||||
paymentMethod,
|
||||
description,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
let invoice: IInvoice | null = existingInvoice;
|
||||
|
||||
if (!invoice) {
|
||||
try {
|
||||
invoice = await this.invoices.create(baseInvoiceData);
|
||||
} catch (error) {
|
||||
if (this.isDuplicateKeyError(error)) {
|
||||
const duplicateInvoice = await this.invoices.findOne({
|
||||
cashMovementId: data.cashMovementId,
|
||||
});
|
||||
|
||||
if (duplicateInvoice) {
|
||||
return duplicateInvoice;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
throw new Error("No se pudo crear el registro local de factura");
|
||||
}
|
||||
|
||||
if (
|
||||
existingInvoice?.status === INVOICE_STATUS.PENDING_ARCA ||
|
||||
existingInvoice?.status === INVOICE_STATUS.REJECTED ||
|
||||
existingInvoice?.status === INVOICE_STATUS.FAILED
|
||||
) {
|
||||
invoice = await this.invoices.update({
|
||||
cashMovementId: data.cashMovementId,
|
||||
billingDate,
|
||||
paymentMethod,
|
||||
description,
|
||||
updatedAt: new Date(),
|
||||
}) || invoice;
|
||||
}
|
||||
|
||||
let invoiceData: Pick<
|
||||
CreateInvoiceParams,
|
||||
| "status"
|
||||
| "cae"
|
||||
| "caeExpiresAt"
|
||||
| "pointOfSale"
|
||||
| "voucherNumber"
|
||||
| "voucherType"
|
||||
| "arcaErrorMessage"
|
||||
>;
|
||||
|
||||
try {
|
||||
const arcaInvoice = await ArcaCredentials.issueFacturaC({
|
||||
companyId,
|
||||
amount: movement.amount,
|
||||
billingDate,
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
|
||||
invoiceData = {
|
||||
status: INVOICE_STATUS.APPROVED,
|
||||
cae: arcaInvoice.cae,
|
||||
caeExpiresAt: arcaInvoice.caeExpiresAt,
|
||||
pointOfSale: arcaInvoice.pointOfSale,
|
||||
voucherNumber: arcaInvoice.voucherNumber,
|
||||
voucherType: String(arcaInvoice.voucherType),
|
||||
arcaErrorMessage: "",
|
||||
};
|
||||
} catch (error) {
|
||||
const errorOccurred = error as Error;
|
||||
const arcaError = error as ArcaWsfeIssueFacturaCError;
|
||||
const hasArcaAttempt = error instanceof ArcaWsfeIssueFacturaCError;
|
||||
invoiceData = {
|
||||
status: hasArcaAttempt
|
||||
? arcaError.isAmbiguous
|
||||
? INVOICE_STATUS.PENDING_VERIFICATION
|
||||
: INVOICE_STATUS.REJECTED
|
||||
: INVOICE_STATUS.FAILED,
|
||||
pointOfSale: hasArcaAttempt ? arcaError.attempt.pointOfSale : undefined,
|
||||
voucherNumber: hasArcaAttempt ? arcaError.attempt.voucherNumber : undefined,
|
||||
voucherType: hasArcaAttempt ? String(arcaError.attempt.voucherType) : FACTURA_C_VOUCHER_TYPE,
|
||||
arcaErrorMessage: errorOccurred.message,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedInvoice = await this.invoices.update({
|
||||
cashMovementId: data.cashMovementId,
|
||||
...invoiceData,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
if (!updatedInvoice) {
|
||||
return invoice;
|
||||
}
|
||||
|
||||
return updatedInvoice;
|
||||
}
|
||||
|
||||
public async find(data: FindInvoicesParams): Promise<IInvoice[]> {
|
||||
@@ -108,6 +310,7 @@ class InvoicesManager implements IInvoicesManager {
|
||||
companyId: data.companyId,
|
||||
sessionUser,
|
||||
});
|
||||
await this.validatePaymentsPlan(data.companyId);
|
||||
} else if (data.id || data.cashMovementId) {
|
||||
const invoice = await this.invoices.findOne(data);
|
||||
|
||||
@@ -119,6 +322,7 @@ class InvoicesManager implements IInvoicesManager {
|
||||
companyId: String(invoice.companyId),
|
||||
sessionUser,
|
||||
});
|
||||
await this.validatePaymentsPlan(String(invoice.companyId));
|
||||
|
||||
return [invoice];
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user