feat: add AFIP Factura C issuance support to ArcaCredentials and update cash flow model

This commit is contained in:
2026-08-07 14:23:47 -03:00
parent a93681357f
commit ebd8096112
9 changed files with 1332 additions and 94 deletions
@@ -45,6 +45,29 @@ export type ArcaWsfeLastVoucherResult = {
lastVoucherNumber: number; 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 = { export type ArcaWsfeDiagnosticsParams = {
companyId: string; companyId: string;
sessionUser: string; sessionUser: string;
@@ -154,6 +177,7 @@ export interface IArcaCredentialsManager {
uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView>; uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView>;
testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult>; testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult>;
getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult>; getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult>;
issueFacturaC(data: ArcaWsfeIssueFacturaCParams): Promise<ArcaWsfeIssueFacturaCResult>;
getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult>; getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult>;
find(data: FindArcaCredentialsParams): Promise<ArcaCredentialView[]>; find(data: FindArcaCredentialsParams): Promise<ArcaCredentialView[]>;
} }
@@ -18,6 +18,9 @@ import {
ArcaCredentialView, ArcaCredentialView,
ArcaWsfeDiagnosticsParams, ArcaWsfeDiagnosticsParams,
ArcaWsfeDiagnosticsResult, ArcaWsfeDiagnosticsResult,
ArcaWsfeIssueFacturaCParams,
ArcaWsfeIssueFacturaCResult,
ArcaWsfeIssueFacturaCAttempt,
ArcaWsfeLastVoucherParams, ArcaWsfeLastVoucherParams,
ArcaWsfeLastVoucherResult, ArcaWsfeLastVoucherResult,
FindArcaCredentialsParams, FindArcaCredentialsParams,
@@ -29,6 +32,23 @@ import {
UploadArcaCredentialCertificateParams, UploadArcaCredentialCertificateParams,
} from "./ArcaCredentials.Interface"; } 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 { class ArcaCredentialsManager implements IArcaCredentialsManager {
arcaCredentials: ArcaCredentialsAdapterMongoose; arcaCredentials: ArcaCredentialsAdapterMongoose;
@@ -312,6 +332,78 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
</soapenv:Envelope>`; </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 { private buildWsfeDummySoap(): string {
return `<?xml version="1.0" encoding="UTF-8"?> return `<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"> <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"; 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 { private extractHtmlTitle(html: string): string | undefined {
const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, " ").trim(); const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, " ").trim();
return title ? this.decodeXmlEntities(title) : undefined; return title ? this.decodeXmlEntities(title) : undefined;
@@ -441,6 +541,37 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
return parsedVoucherNumber; 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"] { private parseWsfeDummyReturn(xml: string): ArcaWsfeDiagnosticsResult["dummy"] {
const errorMessage = this.extractWsfeResponseError(xml); const errorMessage = this.extractWsfeResponseError(xml);
@@ -649,7 +780,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
await this.validateCompanyAdmin(data.companyId, data.sessionUser); await this.validateCompanyAdmin(data.companyId, data.sessionUser);
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser); const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
const loginTicket = await this.getWsaaLoginTicket(data.companyId); const loginTicket = await this.getWsaaLoginTicket(data.companyId);
const voucherType = 11; const voucherType = FACTURA_C_VOUCHER_TYPE;
let responseXml: string; let responseXml: string;
try { 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> { public async getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser); await this.validateCompanyAdmin(data.companyId, data.sessionUser);
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser); const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
@@ -6,6 +6,7 @@ import {
IInvoicesAdapter, IInvoicesAdapter,
INVOICE_STATUS, INVOICE_STATUS,
INVOICE_TYPE, INVOICE_TYPE,
UpdateInvoiceParams,
} from "./Invoices.Interface"; } from "./Invoices.Interface";
export interface IInvoiceDocument extends Omit<IInvoice, "id">, Document {} export interface IInvoiceDocument extends Omit<IInvoice, "id">, Document {}
@@ -40,6 +41,9 @@ export class InvoicesAdapterMongoose implements IInvoicesAdapter {
amount: { type: Number, required: true }, amount: { type: Number, required: true },
type: { type: String, required: true, enum: Object.values(INVOICE_TYPE) }, type: { type: String, required: true, enum: Object.values(INVOICE_TYPE) },
status: { type: String, required: true, enum: Object.values(INVOICE_STATUS), index: true }, 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 }, cae: { type: String, required: false },
caeExpiresAt: { type: Date, required: false }, caeExpiresAt: { type: Date, required: false },
pointOfSale: { type: Number, 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.id) searchCriteria._id = filters.id;
if (filters.cashMovementId) searchCriteria.cashMovementId = filters.cashMovementId; 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.companyId) searchCriteria.companyId = filters.companyId;
if (filters.status) searchCriteria.status = filters.status; if (filters.status) searchCriteria.status = filters.status;
@@ -79,4 +86,10 @@ export class InvoicesAdapterMongoose implements IInvoicesAdapter {
return this.invoiceList.findOne(searchCriteria).exec(); 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", 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 = { export type CreateInvoiceFromCashMovementParams = {
cashMovementId: string; cashMovementId: string;
billingDate: Date | string;
paymentMethod: INVOICE_PAYMENT_METHOD | string;
description: string;
sessionUser: string; sessionUser: string;
}; };
export type FindInvoicesParams = { export type FindInvoicesParams = {
id?: string; id?: string;
cashMovementId?: string; cashMovementId?: string;
cashMovementIds?: string[];
companyId?: string; companyId?: string;
status?: INVOICE_STATUS; status?: INVOICE_STATUS;
sessionUser?: string; sessionUser?: string;
@@ -35,6 +47,9 @@ export type CreateInvoiceParams = {
amount: number; amount: number;
type: INVOICE_TYPE; type: INVOICE_TYPE;
status: INVOICE_STATUS; status: INVOICE_STATUS;
billingDate: Date;
paymentMethod: INVOICE_PAYMENT_METHOD | string;
description: string;
cae?: string; cae?: string;
caeExpiresAt?: Date; caeExpiresAt?: Date;
pointOfSale?: number; pointOfSale?: number;
@@ -46,6 +61,8 @@ export type CreateInvoiceParams = {
updatedAt: Date; updatedAt: Date;
}; };
export type UpdateInvoiceParams = FindInvoicesParams & Partial<CreateInvoiceParams>;
export interface IInvoice { export interface IInvoice {
id?: string; id?: string;
cashMovementId: string; cashMovementId: string;
@@ -57,6 +74,9 @@ export interface IInvoice {
amount: number; amount: number;
type: INVOICE_TYPE; type: INVOICE_TYPE;
status: INVOICE_STATUS; status: INVOICE_STATUS;
billingDate: Date;
paymentMethod: INVOICE_PAYMENT_METHOD | string;
description: string;
cae?: string; cae?: string;
caeExpiresAt?: Date; caeExpiresAt?: Date;
pointOfSale?: number; pointOfSale?: number;
@@ -72,6 +92,7 @@ export interface IInvoicesAdapter {
create(data: CreateInvoiceParams): Promise<IInvoice>; create(data: CreateInvoiceParams): Promise<IInvoice>;
find(filters: FindInvoicesParams): Promise<IInvoice[]>; find(filters: FindInvoicesParams): Promise<IInvoice[]>;
findOne(filters: FindInvoicesParams): Promise<IInvoiceDocument | null>; findOne(filters: FindInvoicesParams): Promise<IInvoiceDocument | null>;
update(data: UpdateInvoiceParams): Promise<IInvoiceDocument | null>;
} }
export interface IInvoicesManager { export interface IInvoicesManager {
+208 -4
View File
@@ -1,18 +1,29 @@
import CashFlow from "../CashFlow/CashFlow"; import CashFlow from "../CashFlow/CashFlow";
import { CLIENT_ACCOUNT_MOVEMENT_TYPES } from "../ClientAccountMovements/ClientAccountMovements.Interface"; import { CLIENT_ACCOUNT_MOVEMENT_TYPES } from "../ClientAccountMovements/ClientAccountMovements.Interface";
import CompaniesList from "../Companies/Companies";
import OrganizationFiscalProfiles from "../OrganizationFiscalProfiles/OrganizationFiscalProfiles"; import OrganizationFiscalProfiles from "../OrganizationFiscalProfiles/OrganizationFiscalProfiles";
import { ORGANIZATION_FISCAL_PROFILE_STATUS } from "../OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface"; 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 { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import { InvoicesAdapterMongoose } from "./Invoices.Adapter.Mongoose"; import { InvoicesAdapterMongoose } from "./Invoices.Adapter.Mongoose";
import { import {
CreateInvoiceFromCashMovementParams, CreateInvoiceFromCashMovementParams,
CreateInvoiceParams,
FindInvoicesParams, FindInvoicesParams,
IInvoice, IInvoice,
IInvoicesManager, IInvoicesManager,
INVOICE_PAYMENT_METHOD,
INVOICE_STATUS, INVOICE_STATUS,
INVOICE_TYPE, INVOICE_TYPE,
} from "./Invoices.Interface"; } 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 { class InvoicesManager implements IInvoicesManager {
invoices: InvoicesAdapterMongoose; invoices: InvoicesAdapterMongoose;
@@ -24,6 +35,78 @@ class InvoicesManager implements IInvoicesManager {
return typeof error === "object" && error !== null && "code" in error && error.code === 11000; 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> { public async createFromCashMovement(data: CreateInvoiceFromCashMovementParams): Promise<IInvoice> {
await validateSessionUser({ sessionUser: data.sessionUser }); 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"); 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({ const movement = await CashFlow.movements.movementList.findOne({
_id: data.cashMovementId, _id: data.cashMovementId,
}); });
@@ -41,6 +141,7 @@ class InvoicesManager implements IInvoicesManager {
const companyId = String(movement.companyId); const companyId = String(movement.companyId);
await validatePermissionsByCompany({ companyId, sessionUser: data.sessionUser }); await validatePermissionsByCompany({ companyId, sessionUser: data.sessionUser });
await this.validatePaymentsPlan(companyId);
if (movement.type !== CLIENT_ACCOUNT_MOVEMENT_TYPES.DEPOSIT || movement.amount <= 0) { if (movement.type !== CLIENT_ACCOUNT_MOVEMENT_TYPES.DEPOSIT || movement.amount <= 0) {
throw new Error("El movimiento de caja no es facturable"); throw new Error("El movimiento de caja no es facturable");
@@ -60,12 +161,28 @@ class InvoicesManager implements IInvoicesManager {
cashMovementId: data.cashMovementId, cashMovementId: data.cashMovementId,
}); });
if (existingInvoice) { if (existingInvoice?.status === INVOICE_STATUS.APPROVED) {
return existingInvoice; return existingInvoice;
} }
try { if (existingInvoice?.status === INVOICE_STATUS.PENDING_VERIFICATION) {
return await this.invoices.create({ return existingInvoice;
}
if (existingInvoice?.status === INVOICE_STATUS.PENDING_ARCA && existingInvoice.voucherNumber) {
const updatedPendingInvoice = await this.invoices.update({
cashMovementId: data.cashMovementId,
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(),
});
return updatedPendingInvoice || existingInvoice;
}
const baseInvoiceData: CreateInvoiceParams = {
cashMovementId: data.cashMovementId, cashMovementId: data.cashMovementId,
companyId, companyId,
clientId: movement.clientId ? String(movement.clientId) : undefined, clientId: movement.clientId ? String(movement.clientId) : undefined,
@@ -77,9 +194,18 @@ class InvoicesManager implements IInvoicesManager {
amount: movement.amount, amount: movement.amount,
type: INVOICE_TYPE.FACTURA_C, type: INVOICE_TYPE.FACTURA_C,
status: INVOICE_STATUS.PENDING_ARCA, status: INVOICE_STATUS.PENDING_ARCA,
billingDate,
paymentMethod,
description,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}); };
let invoice: IInvoice | null = existingInvoice;
if (!invoice) {
try {
invoice = await this.invoices.create(baseInvoiceData);
} catch (error) { } catch (error) {
if (this.isDuplicateKeyError(error)) { if (this.isDuplicateKeyError(error)) {
const duplicateInvoice = await this.invoices.findOne({ const duplicateInvoice = await this.invoices.findOne({
@@ -95,6 +221,82 @@ class InvoicesManager implements IInvoicesManager {
} }
} }
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[]> { public async find(data: FindInvoicesParams): Promise<IInvoice[]> {
if (!data.sessionUser) { if (!data.sessionUser) {
throw new Error("Se requiere un usuario autenticado"); throw new Error("Se requiere un usuario autenticado");
@@ -108,6 +310,7 @@ class InvoicesManager implements IInvoicesManager {
companyId: data.companyId, companyId: data.companyId,
sessionUser, sessionUser,
}); });
await this.validatePaymentsPlan(data.companyId);
} else if (data.id || data.cashMovementId) { } else if (data.id || data.cashMovementId) {
const invoice = await this.invoices.findOne(data); const invoice = await this.invoices.findOne(data);
@@ -119,6 +322,7 @@ class InvoicesManager implements IInvoicesManager {
companyId: String(invoice.companyId), companyId: String(invoice.companyId),
sessionUser, sessionUser,
}); });
await this.validatePaymentsPlan(String(invoice.companyId));
return [invoice]; return [invoice];
} else { } else {
+1
View File
@@ -72,6 +72,7 @@ export type DeleteByClientAccountMovementParams = {
export interface ICashFlow { export interface ICashFlow {
id: string; id: string;
_id?: string;
companyId: string; companyId: string;
paymentId?: string; paymentId?: string;
clientId?: string; clientId?: string;
@@ -26,6 +26,41 @@ export type ArcaWsfeLastVoucherResult = {
lastVoucherNumber: number; lastVoucherNumber: number;
}; };
export type CreateInvoiceFromCashMovementParams = {
cashMovementId: string;
billingDate: string | Date;
paymentMethod: string;
description: string;
sessionUser: string;
};
export type FindInvoicesParams = {
companyId?: string;
cashMovementId?: string;
cashMovementIds?: string[];
sessionUser: string;
};
export type InvoiceStatus = "PENDING_ARCA" | "APPROVED" | "PENDING_VERIFICATION" | "REJECTED" | "FAILED";
export type Invoice = {
id?: string;
cashMovementId: string;
companyId: string;
amount: number;
type: "FACTURA_C";
status: InvoiceStatus;
billingDate: Date;
paymentMethod: string;
description: string;
cae?: string;
caeExpiresAt?: Date;
pointOfSale?: number;
voucherNumber?: number;
voucherType?: string;
arcaErrorMessage?: string;
};
const schemaCreateCashFlowMovement = Yup.object().shape({ const schemaCreateCashFlowMovement = Yup.object().shape({
paymentMethod: Yup.string().required("Debe introducir el tipo de movimiento."), paymentMethod: Yup.string().required("Debe introducir el tipo de movimiento."),
companyId: Yup.string().required("La organización no es valida."), companyId: Yup.string().required("La organización no es valida."),
@@ -91,3 +126,13 @@ export const getArcaWsfeLastVoucher = async (
): Promise<ArcaWsfeLastVoucherResult> => { ): Promise<ArcaWsfeLastVoucherResult> => {
return ApiRequest.post<ArcaWsfeLastVoucherResult>("arca-credentials/wsfe-last-voucher", data); return ApiRequest.post<ArcaWsfeLastVoucherResult>("arca-credentials/wsfe-last-voucher", data);
}; };
export const createInvoiceFromCashMovement = async (
data: CreateInvoiceFromCashMovementParams
): Promise<Invoice> => {
return ApiRequest.post<Invoice>("invoices/create-from-cash-movement", data);
};
export const findInvoices = async (data: FindInvoicesParams): Promise<Invoice[]> => {
return ApiRequest.post<Invoice[]>("invoices/find", data);
};
@@ -0,0 +1,368 @@
.movementsPage {
padding: 16px;
padding-bottom: 32px;
}
.movementsList {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.movementCard {
border: 1px solid var(--gray-light);
border-radius: 18px;
background: linear-gradient(135deg, var(--white) 0%, #f9fafb 100%);
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.08);
overflow: hidden;
padding: 16px;
position: relative;
}
.movementCard::before {
background: radial-gradient(circle, rgba(255, 255, 255, 0.9) 0%, transparent 68%);
border-radius: 999px;
content: "";
height: 150px;
position: absolute;
right: -74px;
top: -80px;
width: 150px;
}
.movementCardIcon {
color: var(--movement-card-accent, var(--gray-dark));
height: 74px;
opacity: 0.2;
pointer-events: none;
position: absolute;
right: 24px;
bottom: 14px;
width: 74px;
z-index: 0;
}
.movementContent {
position: relative;
z-index: 1;
}
.movementCardCash {
--movement-card-accent: #16803f;
background: linear-gradient(135deg, #ffffff 0%, #f0fbf4 100%);
}
.movementCardBankTransfer {
--movement-card-accent: #2563eb;
background: linear-gradient(135deg, #ffffff 0%, #eef5ff 100%);
}
.movementCardCreditCard {
--movement-card-accent: #7c3aed;
background: linear-gradient(135deg, #ffffff 0%, #f5f0ff 100%);
}
.movementCardDebitCard {
--movement-card-accent: #0f766e;
background: linear-gradient(135deg, #ffffff 0%, #edfbf9 100%);
}
.movementCardClientAccount {
--movement-card-accent: #b45309;
background: linear-gradient(135deg, #ffffff 0%, #fff7ed 100%);
}
.movementCardOther {
--movement-card-accent: #64748b;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
}
.movementHeader {
display: flex;
flex-direction: column;
gap: 14px;
}
.movementSummary {
min-width: 0;
}
.movementDescription {
color: var(--black-light);
font-size: 16px;
line-height: 1.25;
margin: 0 0 6px;
}
.movementDate {
color: var(--gray-dark);
display: block;
font-size: 13px;
}
.movementAmount {
align-self: flex-start;
border-radius: 999px;
font-size: 18px;
font-weight: 800;
padding: 8px 12px;
}
.movementAmountPositive {
background: rgba(29, 141, 75, 0.12);
color: var(--green-darkestX2);
}
.movementAmountNegative {
background: rgba(188, 32, 42, 0.12);
color: var(--red);
}
.movementMeta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.movementMetaItem {
background: rgba(255, 255, 255, 0.72);
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.22);
color: var(--gray-dark);
font-size: 12px;
font-weight: 600;
backdrop-filter: blur(8px);
padding: 6px 10px;
}
.movementActionsSlot {
display: flex;
margin-top: 16px;
}
.movementInvoiceButton {
background: var(--movement-card-accent, var(--black-light));
border: 0;
border-radius: 999px;
color: var(--white);
cursor: pointer;
font-size: 13px;
font-weight: 700;
padding: 8px 14px;
}
.movementInvoiceButton:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.movementInvoiceBadge {
background: rgba(22, 128, 63, 0.12);
border: 1px solid rgba(22, 128, 63, 0.24);
border-radius: 999px;
color: #166534;
font-size: 12px;
font-weight: 700;
padding: 7px 10px;
}
.emptyState {
background: var(--white);
border: 1px dashed var(--gray-light);
border-radius: 18px;
color: var(--gray-dark);
padding: 28px 16px;
text-align: center;
}
.paginationWrapper {
display: flex;
justify-content: center;
margin-top: 18px;
}
.invoiceModalBackdrop {
align-items: flex-end;
background: rgba(15, 23, 42, 0.48);
display: flex;
inset: 0;
justify-content: center;
padding: 14px;
position: fixed;
z-index: 1000;
}
.invoiceModal {
background: var(--white);
border-radius: 24px 24px 18px 18px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28);
max-height: calc(100vh - 28px);
overflow: auto;
padding: 18px;
width: 100%;
}
.invoiceModalHeader {
align-items: flex-start;
display: flex;
gap: 12px;
justify-content: space-between;
margin-bottom: 16px;
}
.invoiceModalHeader h2 {
color: var(--black-light);
font-size: 20px;
line-height: 1.2;
margin: 0;
}
.invoiceModalClose {
align-items: center;
background: #f1f5f9;
border: 0;
border-radius: 999px;
color: var(--gray-dark);
cursor: pointer;
display: flex;
font-size: 24px;
height: 36px;
justify-content: center;
line-height: 1;
width: 36px;
}
.invoiceAmountBox {
background: linear-gradient(135deg, #f0fbf4 0%, #ffffff 100%);
border: 1px solid rgba(22, 128, 63, 0.16);
border-radius: 18px;
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 16px;
padding: 14px;
}
.invoiceAmountBox span {
color: var(--gray-dark);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.invoiceAmountBox strong {
color: var(--green-darkestX2);
font-size: 22px;
}
.invoiceField {
color: var(--black-light);
display: flex;
flex-direction: column;
font-size: 13px;
font-weight: 700;
gap: 7px;
margin-bottom: 14px;
}
.invoiceField input,
.invoiceField select,
.invoiceField textarea {
background: var(--white);
border: 1px solid var(--gray-light);
border-radius: 14px;
color: var(--black-light);
font: inherit;
font-weight: 500;
padding: 11px 12px;
width: 100%;
}
.invoiceField textarea {
min-height: 104px;
resize: vertical;
}
.invoiceModalActions {
display: flex;
flex-direction: column-reverse;
gap: 10px;
margin-top: 18px;
}
.invoiceCancelButton,
.invoiceConfirmButton {
border: 0;
border-radius: 999px;
cursor: pointer;
font-size: 14px;
font-weight: 800;
padding: 12px 16px;
}
.invoiceCancelButton {
background: #f1f5f9;
color: var(--gray-dark);
}
.invoiceConfirmButton {
background: var(--green-darkestX2);
color: var(--white);
}
.invoiceCancelButton:disabled,
.invoiceConfirmButton:disabled,
.invoiceModalClose:disabled {
cursor: not-allowed;
opacity: 0.55;
}
@media (min-width: 768px) {
.movementsPage {
padding: 22px;
}
.movementsList {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.movementHeader {
align-items: flex-start;
flex-direction: row;
justify-content: space-between;
}
.movementAmount {
align-self: auto;
text-align: right;
white-space: nowrap;
}
.movementCardIcon {
height: 82px;
right: 28px;
width: 82px;
}
.invoiceModalBackdrop {
align-items: center;
}
.invoiceModal {
border-radius: 24px;
max-width: 520px;
}
.invoiceModalActions {
flex-direction: row;
justify-content: flex-end;
}
}
@media (min-width: 1200px) {
.movementsList {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@@ -7,7 +7,11 @@ import { useLocationStore } from "@store/Location.Store";
import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store"; import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store";
import AccountBalanceOutlinedIcon from "@mui/icons-material/AccountBalanceOutlined"; import AccountBalanceOutlinedIcon from "@mui/icons-material/AccountBalanceOutlined";
import AccountMovementsIcon from "@mui/icons-material/CurrencyExchangeOutlined"; import AccountMovementsIcon from "@mui/icons-material/CurrencyExchangeOutlined";
import DataGrid, { DataGridColumn } from "@core/app/components/DataGrid/DataGrid"; import AccountBalanceWalletOutlinedIcon from "@mui/icons-material/AccountBalanceWalletOutlined";
import CreditCardOutlinedIcon from "@mui/icons-material/CreditCardOutlined";
import MoreHorizOutlinedIcon from "@mui/icons-material/MoreHorizOutlined";
import PaymentOutlinedIcon from "@mui/icons-material/PaymentOutlined";
import PointOfSaleOutlinedIcon from "@mui/icons-material/PointOfSaleOutlined";
import { formatPrice } from "@core/app/helpers/Numbers"; import { formatPrice } from "@core/app/helpers/Numbers";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { import {
@@ -15,7 +19,7 @@ import {
PaginateCashFlowParams, PaginateCashFlowParams,
PaginateCashFlowResults, PaginateCashFlowResults,
} from "@core/Models/CashFlow.model"; } from "@core/Models/CashFlow.model";
import { cashFlowPaginate } from "../Cash.Service"; import { cashFlowPaginate, createInvoiceFromCashMovement, findInvoices, Invoice } from "../Cash.Service";
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan"; import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
import Filterable from "@core/app/components/Filterable/Filterable"; import Filterable from "@core/app/components/Filterable/Filterable";
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model"; import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
@@ -26,7 +30,18 @@ import { useFilterStore } from "@core/Store/Filter.Store";
import { FindEmployeesParams } from "@core/Models/Collaborators.model"; import { FindEmployeesParams } from "@core/Models/Collaborators.model";
import filterObjects from "@components/TextObjectFilter/Filter.Service"; import filterObjects from "@components/TextObjectFilter/Filter.Service";
import { FindClientsParams } from "@core/Models/Clients.model"; import { FindClientsParams } from "@core/Models/Clients.model";
import { paymentMethods, PAYMENT_METHODS } from "@core/Models/Payments.model"; import {
paymentMethods,
PAYMENT_METHODS,
PAYMENT_METHODS_CLIENT_ACCOUNT,
} from "@core/Models/Payments.model";
import {
CLIENT_ACCOUNT_MOVEMENT_TYPES,
CLIENT_ACCOUNT_PAYMENT_METHODS,
} from "@core/Models/ClientAccountMovements.model";
import PaginationControls from "@components/PaginationControls/PaginationControls";
import { useAlert } from "@core/Store/Alert.Store";
import style from "./page.module.css";
type UrlData = { type UrlData = {
id: string; id: string;
@@ -161,6 +176,101 @@ const FilterContent = (props: FilterContentProps) => {
); );
}; };
const movementTypeLabels: Record<string, string> = {
deposit: "Ingreso",
withdrawal: "Egreso",
};
const getPaymentMethodName = (paymentMethod: string) => {
return paymentMethods.find((method) => method.id === paymentMethod)?.name || paymentMethod;
};
const getMovementId = (movement: ICashFlow) => movement.id || movement._id || "";
const todayInputValue = () => dayjs().format("YYYY-MM-DD");
const invoicePaymentMethodOptions = [
{ id: CLIENT_ACCOUNT_PAYMENT_METHODS.CASH, name: "Efectivo" },
{ id: CLIENT_ACCOUNT_PAYMENT_METHODS.BANK_TRANSFER, name: "Transferencia bancaria" },
{ id: CLIENT_ACCOUNT_PAYMENT_METHODS.CREDIT_CARD, name: "Otros medios de pago electrónicos" },
{ id: CLIENT_ACCOUNT_PAYMENT_METHODS.DEBIT_CARD, name: "Tarjeta de débito" },
{ id: CLIENT_ACCOUNT_PAYMENT_METHODS.OTHER, name: "Otros" },
];
const isInvoicePaymentMethod = (paymentMethod?: string) => {
return invoicePaymentMethodOptions.some((option) => option.id === paymentMethod);
};
const getInvoicePaymentMethod = (paymentMethod?: string) => {
if (paymentMethod && isInvoicePaymentMethod(paymentMethod)) {
return paymentMethod;
}
return CLIENT_ACCOUNT_PAYMENT_METHODS.CASH;
};
type InvoiceDraft = {
movement: ICashFlow;
movementId: string;
billingDate: string;
paymentMethod: string;
description: string;
};
const getPaymentMethodCardVisual = (paymentMethod?: PAYMENT_METHODS | string) => {
switch (paymentMethod) {
case CLIENT_ACCOUNT_PAYMENT_METHODS.CASH:
return {
Icon: PointOfSaleOutlinedIcon,
className: style.movementCardCash,
};
case CLIENT_ACCOUNT_PAYMENT_METHODS.BANK_TRANSFER:
return {
Icon: AccountBalanceOutlinedIcon,
className: style.movementCardBankTransfer,
};
case CLIENT_ACCOUNT_PAYMENT_METHODS.CREDIT_CARD:
return {
Icon: CreditCardOutlinedIcon,
className: style.movementCardCreditCard,
};
case CLIENT_ACCOUNT_PAYMENT_METHODS.DEBIT_CARD:
return {
Icon: PaymentOutlinedIcon,
className: style.movementCardDebitCard,
};
case PAYMENT_METHODS_CLIENT_ACCOUNT.CLIENT_ACCOUNT:
return {
Icon: AccountBalanceWalletOutlinedIcon,
className: style.movementCardClientAccount,
};
default:
return {
Icon: MoreHorizOutlinedIcon,
className: style.movementCardOther,
};
}
};
const getInvoiceStatusMessage = (invoice: Invoice) => {
const voucher = invoice.pointOfSale && invoice.voucherNumber
? ` Comprobante intentado: ${invoice.pointOfSale}-${invoice.voucherNumber}.`
: "";
switch (invoice.status) {
case "APPROVED":
return "Factura emitida correctamente.";
case "PENDING_ARCA":
case "PENDING_VERIFICATION":
return `${invoice.arcaErrorMessage || "La factura quedó pendiente de verificación en ARCA."}${voucher}`;
case "REJECTED":
case "FAILED":
return invoice.arcaErrorMessage || "ARCA no autorizó la factura.";
default:
return "No se pudo confirmar el estado de la factura.";
}
};
export default function CashFlowMovementsList() { export default function CashFlowMovementsList() {
const data = useParams<UrlData>(); const data = useParams<UrlData>();
const id = data.id; const id = data.id;
@@ -168,6 +278,7 @@ export default function CashFlowMovementsList() {
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const location = useLocationStore(); const location = useLocationStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const alert = useAlert();
const filter = useFilterStore(); const filter = useFilterStore();
const [clientFilterData, setClientFilterData] = useState<TextObjectFilterResult[]>([]); const [clientFilterData, setClientFilterData] = useState<TextObjectFilterResult[]>([]);
@@ -181,52 +292,6 @@ export default function CashFlowMovementsList() {
const [methodFilterData, setMethodFilterData] = useState<TextObjectFilterResult[]>([]); const [methodFilterData, setMethodFilterData] = useState<TextObjectFilterResult[]>([]);
const [methodId, setMethodId] = useState<string>(""); const [methodId, setMethodId] = useState<string>("");
const [dataGridColumns] = useState<DataGridColumn<ICashFlow>[]>([
{
field: "description",
title: "Detalle",
width: null,
render: (value: ICashFlow) => (
<>
<div
style={{
paddingLeft: "10px",
paddingRight: "10px",
paddingTop: "10px",
}}
>
{value.description}
</div>
<div
style={{
paddingLeft: "10px",
paddingRight: "10px",
color: "var(--gray-dark)",
}}
>
{dayjs(value.createdAt).format("DD MMMM YYYY - HH:mm")}
</div>
</>
),
},
{
field: "amount",
title: "Importe",
width: 150,
render: (value: ICashFlow) => (
<div
style={{
padding: "10px",
textAlign: "right",
color: value.amount > 0 ? "var(--green-darkestX2)" : "var(--red)",
}}
>
{formatPrice(value.amount)}
</div>
),
},
]);
const [pageSize] = useState<number>( const [pageSize] = useState<number>(
parseInt( parseInt(
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
@@ -235,6 +300,10 @@ export default function CashFlowMovementsList() {
) )
); );
const [movements, setMovements] = useState<PaginateCashFlowResults>(); const [movements, setMovements] = useState<PaginateCashFlowResults>();
const [invoicesByMovementId, setInvoicesByMovementId] = useState<Record<string, Invoice>>({});
const [invoicingMovementId, setInvoicingMovementId] = useState<string>("");
const [invoiceDraft, setInvoiceDraft] = useState<InvoiceDraft | null>(null);
const [invoiceDraftsByMovementId, setInvoiceDraftsByMovementId] = useState<Record<string, InvoiceDraft>>({});
const movementsRef = useRef(movements); const movementsRef = useRef(movements);
const loadMovements = (page: number = 1, pageSize: number = 30) => { const loadMovements = (page: number = 1, pageSize: number = 30) => {
@@ -262,6 +331,20 @@ export default function CashFlowMovementsList() {
cashFlowPaginate(filterData) cashFlowPaginate(filterData)
.then((res) => { .then((res) => {
setMovements(res); setMovements(res);
const movementIds = res.data
.map((movement) => getMovementId(movement))
.filter((movementId): movementId is string => Boolean(movementId));
if (movementIds.length > 0) {
return findInvoices({ companyId: id, cashMovementIds: movementIds, sessionUser: SessionInfo.userId }).then((invoices) => {
setInvoicesByMovementId((current) => ({
...current,
...Object.fromEntries(
invoices.map((invoice) => [String(invoice.cashMovementId), invoice])
),
}));
}).catch(() => undefined);
}
}) })
.finally(() => { .finally(() => {
eventHandler.setEventType(EVENT_TYPES.CLEAR); eventHandler.setEventType(EVENT_TYPES.CLEAR);
@@ -345,6 +428,132 @@ export default function CashFlowMovementsList() {
loadMovements(loadPage, pageSize); loadMovements(loadPage, pageSize);
}, [clientId, collaboratorId, methodId]); }, [clientId, collaboratorId, methodId]);
const renderMovementMeta = (movement: ICashFlow) => {
const details = [
movement.paymentMethod ? getPaymentMethodName(movement.paymentMethod) : null,
movement.type ? movementTypeLabels[movement.type] || movement.type : null,
movement.reference ? `Ref. ${movement.reference}` : null,
].filter((detail): detail is string => Boolean(detail));
if (details.length === 0) {
return null;
}
return (
<div className={style.movementMeta}>
{details.map((detail) => (
<span key={detail} className={style.movementMetaItem}>
{detail}
</span>
))}
</div>
);
};
const canInvoiceMovement = (movement: ICashFlow) => {
return movement.type === CLIENT_ACCOUNT_MOVEMENT_TYPES.DEPOSIT && movement.amount > 0;
};
const openInvoiceModal = (movement: ICashFlow) => {
const movementId = getMovementId(movement);
if (!canInvoiceMovement(movement) || !movementId || invoicingMovementId) {
return;
}
const savedDraft = invoiceDraftsByMovementId[movementId];
setInvoiceDraft(savedDraft ? { ...savedDraft, movement } : {
movement,
movementId,
billingDate: dayjs(movement.createdAt).format("YYYY-MM-DD"),
paymentMethod: getInvoicePaymentMethod(movement.paymentMethod),
description: movement.description || "",
});
};
const closeInvoiceModal = () => {
if (!invoicingMovementId) {
setInvoiceDraftsByMovementId((current) => {
if (!invoiceDraft) {
return current;
}
const { [invoiceDraft.movementId]: _discarded, ...remaining } = current;
return remaining;
});
setInvoiceDraft(null);
}
};
const handleInvoiceMovement = () => {
if (!invoiceDraft || invoicingMovementId) {
return;
}
if (!invoiceDraft.billingDate) {
alert.showError("Debe seleccionar la fecha del comprobante.");
return;
}
if (!invoiceDraft.paymentMethod) {
alert.showError("Debe seleccionar el medio de pago.");
return;
}
if (!invoiceDraft.description.trim()) {
alert.showError("Debe ingresar el detalle de la factura.");
return;
}
setInvoicingMovementId(invoiceDraft.movementId);
eventHandler.setEventType(EVENT_TYPES.LOADING);
createInvoiceFromCashMovement({
cashMovementId: invoiceDraft.movementId,
billingDate: invoiceDraft.billingDate,
paymentMethod: invoiceDraft.paymentMethod,
description: invoiceDraft.description.trim(),
sessionUser: SessionInfo.userId,
})
.then((invoice) => {
setInvoicesByMovementId((current) => ({
...current,
[invoiceDraft.movementId]: invoice,
}));
if (invoice.status === "APPROVED") {
setInvoiceDraftsByMovementId((current) => {
const { [invoiceDraft.movementId]: _discarded, ...remaining } = current;
return remaining;
});
alert.showSuccess(getInvoiceStatusMessage(invoice));
return;
}
if (invoice.status === "REJECTED" || invoice.status === "FAILED") {
setInvoiceDraftsByMovementId((current) => ({
...current,
[invoiceDraft.movementId]: invoiceDraft,
}));
}
alert.showError(getInvoiceStatusMessage(invoice));
})
.catch((error: Error) => {
setInvoiceDraftsByMovementId((current) => ({
...current,
[invoiceDraft.movementId]: invoiceDraft,
}));
alert.showError(error.message || "No se pudo emitir la factura.");
})
.finally(() => {
setInvoicingMovementId("");
setInvoiceDraft(null);
eventHandler.setEventType(EVENT_TYPES.CLEAR);
});
};
return ( return (
<> <>
<Location /> <Location />
@@ -352,28 +561,190 @@ export default function CashFlowMovementsList() {
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}> <SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
<> <>
<div <div className={style.movementsPage}>
style={{ <section className={style.movementsList} aria-label="Movimientos de caja">
position: "absolute", {movements && movements.data.length === 0 && (
top: "80px", <div className={style.emptyState}>
left: "0px", No se encontraron movimientos con los filtros aplicados.
right: "0px", </div>
bottom: "30px", )}
}}
{(movements ? movements.data : []).map((movement, index) => {
const visual = getPaymentMethodCardVisual(movement.paymentMethod);
const PaymentMethodIcon = visual.Icon;
const movementId = getMovementId(movement);
const movementKey = movementId || `${movement.createdAt}-${movement.reference}-${index}`;
const invoice = movementId ? invoicesByMovementId[movementId] : undefined;
const isInvoiced = invoice?.status === "APPROVED";
const isPendingInvoice = invoice?.status === "PENDING_ARCA" || invoice?.status === "PENDING_VERIFICATION";
return (
<article
key={movementKey}
className={`${style.movementCard} ${visual.className}`}
> >
<DataGrid <PaymentMethodIcon
columns={dataGridColumns} className={style.movementCardIcon}
data={movements ? movements.data : []} aria-hidden="true"
pagination={true}
page={movements?.page}
pages={movements?.pages}
pageSize={pageSize}
handleRefresh={loadMovements}
handleRowClick={() => { }}
/> />
<div className={style.movementContent}>
<div className={style.movementHeader}>
<div className={style.movementSummary}>
<h3 className={style.movementDescription}>
{movement.description}
</h3>
<time className={style.movementDate}>
{dayjs(movement.createdAt).format("DD MMMM YYYY - HH:mm")}
</time>
</div>
<div
className={`${style.movementAmount} ${
movement.amount > 0
? style.movementAmountPositive
: style.movementAmountNegative
}`}
>
{formatPrice(movement.amount)}
</div>
</div>
{renderMovementMeta(movement)}
<div className={style.movementActionsSlot}>
{isInvoiced ? (
<span className={style.movementInvoiceBadge}>
Factura C {invoice?.pointOfSale}-{invoice?.voucherNumber}
</span>
) : isPendingInvoice ? (
<span className={style.movementInvoiceBadge} title={getInvoiceStatusMessage(invoice)}>
Factura pendiente
</span>
) : !canInvoiceMovement(movement) ? null : (
<button
type="button"
className={style.movementInvoiceButton}
disabled={invoicingMovementId === movementId}
onClick={() => openInvoiceModal(movement)}
>
{invoicingMovementId === movementId ? "Facturando..." : "Facturar"}
</button>
)}
</div>
</div>
</article>
);
})}
</section>
{movements && movements.pages > 1 && (
<div className={style.paginationWrapper}>
<PaginationControls
current={movements.page}
size={pageSize}
max={movements.pages}
onChange={loadMovements}
/>
</div>
)}
</div> </div>
<div style={{ marginTop: "15px" }}></div> <div style={{ marginTop: "15px" }}></div>
{invoiceDraft && (
<div className={style.invoiceModalBackdrop} role="presentation">
<section
className={style.invoiceModal}
role="dialog"
aria-modal="true"
aria-labelledby="invoice-modal-title"
>
<div className={style.invoiceModalHeader}>
<h2 id="invoice-modal-title">Emitir Factura C</h2>
<button
type="button"
className={style.invoiceModalClose}
onClick={closeInvoiceModal}
disabled={Boolean(invoicingMovementId)}
aria-label="Cerrar"
>
×
</button>
</div>
<div className={style.invoiceAmountBox}>
<span>Importe</span>
<strong>{formatPrice(invoiceDraft.movement.amount)}</strong>
</div>
<label className={style.invoiceField}>
<span>Fecha de comprobante</span>
<input
type="date"
value={invoiceDraft.billingDate}
max={todayInputValue()}
onChange={(event) =>
setInvoiceDraft((current) =>
current ? { ...current, billingDate: event.target.value } : current
)
}
disabled={Boolean(invoicingMovementId)}
/>
</label>
<label className={style.invoiceField}>
<span>Medio de pago</span>
<select
value={invoiceDraft.paymentMethod}
onChange={(event) =>
setInvoiceDraft((current) =>
current ? { ...current, paymentMethod: event.target.value } : current
)
}
disabled={Boolean(invoicingMovementId)}
>
{invoicePaymentMethodOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))}
</select>
</label>
<label className={style.invoiceField}>
<span>Detalle</span>
<textarea
value={invoiceDraft.description}
rows={4}
onChange={(event) =>
setInvoiceDraft((current) =>
current ? { ...current, description: event.target.value } : current
)
}
disabled={Boolean(invoicingMovementId)}
/>
</label>
<div className={style.invoiceModalActions}>
<button
type="button"
className={style.invoiceCancelButton}
onClick={closeInvoiceModal}
disabled={Boolean(invoicingMovementId)}
>
Cancelar
</button>
<button
type="button"
className={style.invoiceConfirmButton}
onClick={handleInvoiceMovement}
disabled={Boolean(invoicingMovementId)}
>
{invoicingMovementId ? "Facturando..." : "Emitir factura"}
</button>
</div>
</section>
</div>
)}
</> </>
</SecureByPlan> </SecureByPlan>
</> </>