From ebd80961120f86eea860ed8c0152065b81af5094 Mon Sep 17 00:00:00 2001 From: Horacio Daniel Ros Date: Fri, 7 Aug 2026 14:23:47 -0300 Subject: [PATCH] feat: add AFIP Factura C issuance support to ArcaCredentials and update cash flow model --- .../ArcaCredentials.Interface.ts | 24 + .../Models/ArcaCredentials/ArcaCredentials.ts | 193 ++++++- .../Invoices/Invoices.Adapter.Mongoose.ts | 13 + .../src/Models/Invoices/Invoices.Interface.ts | 21 + server/src/Models/Invoices/Invoices.ts | 254 ++++++++- txclient/src/Models/CashFlow.model.ts | 1 + .../org/profile/[id]/cash/Cash.Service.tsx | 45 ++ .../[id]/cash/movements/page.module.css | 368 +++++++++++++ .../org/profile/[id]/cash/movements/page.tsx | 507 +++++++++++++++--- 9 files changed, 1332 insertions(+), 94 deletions(-) create mode 100644 txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/movements/page.module.css diff --git a/server/src/Models/ArcaCredentials/ArcaCredentials.Interface.ts b/server/src/Models/ArcaCredentials/ArcaCredentials.Interface.ts index 0ad24d6..f7d158e 100644 --- a/server/src/Models/ArcaCredentials/ArcaCredentials.Interface.ts +++ b/server/src/Models/ArcaCredentials/ArcaCredentials.Interface.ts @@ -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; testWsaaLogin(data: TestArcaWsaaLoginParams): Promise; getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise; + issueFacturaC(data: ArcaWsfeIssueFacturaCParams): Promise; getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise; find(data: FindArcaCredentialsParams): Promise; } diff --git a/server/src/Models/ArcaCredentials/ArcaCredentials.ts b/server/src/Models/ArcaCredentials/ArcaCredentials.ts index 0384d58..8b51139 100644 --- a/server/src/Models/ArcaCredentials/ArcaCredentials.ts +++ b/server/src/Models/ArcaCredentials/ArcaCredentials.ts @@ -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 { `; } + 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 ` + + + + + +${data.token} +${data.sign} +${data.cuit} + + + +1 +${data.pointOfSale} +11 + + + +1 +${FINAL_CONSUMER_DOCUMENT_TYPE} +${FINAL_CONSUMER_DOCUMENT_NUMBER} +${data.voucherNumber} +${data.voucherNumber} +${data.voucherDate} +${amount} +0.00 +${amount} +0.00 +0.00 +0.00 +PES +1 +${FINAL_CONSUMER_IVA_CONDITION} + + + + + +`; + } + private buildWsfeDummySoap(): string { return ` @@ -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(/]*>([\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 { + 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 { await this.validateCompanyAdmin(data.companyId, data.sessionUser); const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser); diff --git a/server/src/Models/Invoices/Invoices.Adapter.Mongoose.ts b/server/src/Models/Invoices/Invoices.Adapter.Mongoose.ts index 4e5bb6d..94bf1c5 100644 --- a/server/src/Models/Invoices/Invoices.Adapter.Mongoose.ts +++ b/server/src/Models/Invoices/Invoices.Adapter.Mongoose.ts @@ -6,6 +6,7 @@ import { IInvoicesAdapter, INVOICE_STATUS, INVOICE_TYPE, + UpdateInvoiceParams, } from "./Invoices.Interface"; export interface IInvoiceDocument extends Omit, 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 { + const { sessionUser, id, cashMovementId, cashMovementIds, companyId, ...updateData } = data; + const searchCriteria = this.buildSearchCriteria({ id, cashMovementId, companyId }); + return this.invoiceList.findOneAndUpdate(searchCriteria, updateData, { new: true }).exec(); + } + } diff --git a/server/src/Models/Invoices/Invoices.Interface.ts b/server/src/Models/Invoices/Invoices.Interface.ts index 30e941e..e925bef 100644 --- a/server/src/Models/Invoices/Invoices.Interface.ts +++ b/server/src/Models/Invoices/Invoices.Interface.ts @@ -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; + 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; find(filters: FindInvoicesParams): Promise; findOne(filters: FindInvoicesParams): Promise; + update(data: UpdateInvoiceParams): Promise; } export interface IInvoicesManager { diff --git a/server/src/Models/Invoices/Invoices.ts b/server/src/Models/Invoices/Invoices.ts index 0d3e0a7..9a6ede5 100644 --- a/server/src/Models/Invoices/Invoices.ts +++ b/server/src/Models/Invoices/Invoices.ts @@ -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 { + 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 { 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 { @@ -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 { diff --git a/txclient/src/Models/CashFlow.model.ts b/txclient/src/Models/CashFlow.model.ts index 6ee00e2..3f19874 100644 --- a/txclient/src/Models/CashFlow.model.ts +++ b/txclient/src/Models/CashFlow.model.ts @@ -72,6 +72,7 @@ export type DeleteByClientAccountMovementParams = { export interface ICashFlow { id: string; + _id?: string; companyId: string; paymentId?: string; clientId?: string; diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/Cash.Service.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/Cash.Service.tsx index 40ce401..c10ce74 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/Cash.Service.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/Cash.Service.tsx @@ -26,6 +26,41 @@ export type ArcaWsfeLastVoucherResult = { 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({ paymentMethod: Yup.string().required("Debe introducir el tipo de movimiento."), companyId: Yup.string().required("La organización no es valida."), @@ -91,3 +126,13 @@ export const getArcaWsfeLastVoucher = async ( ): Promise => { return ApiRequest.post("arca-credentials/wsfe-last-voucher", data); }; + +export const createInvoiceFromCashMovement = async ( + data: CreateInvoiceFromCashMovementParams +): Promise => { + return ApiRequest.post("invoices/create-from-cash-movement", data); +}; + +export const findInvoices = async (data: FindInvoicesParams): Promise => { + return ApiRequest.post("invoices/find", data); +}; diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/movements/page.module.css b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/movements/page.module.css new file mode 100644 index 0000000..87f3542 --- /dev/null +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/movements/page.module.css @@ -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)); + } +} diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/movements/page.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/movements/page.tsx index 3a357d1..cd3cb41 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/movements/page.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/movements/page.tsx @@ -7,7 +7,11 @@ import { useLocationStore } from "@store/Location.Store"; import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store"; import AccountBalanceOutlinedIcon from "@mui/icons-material/AccountBalanceOutlined"; 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 dayjs from "dayjs"; import { @@ -15,7 +19,7 @@ import { PaginateCashFlowParams, PaginateCashFlowResults, } 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 Filterable from "@core/app/components/Filterable/Filterable"; 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 filterObjects from "@components/TextObjectFilter/Filter.Service"; 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 = { id: string; @@ -161,6 +176,101 @@ const FilterContent = (props: FilterContentProps) => { ); }; +const movementTypeLabels: Record = { + 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() { const data = useParams(); const id = data.id; @@ -168,6 +278,7 @@ export default function CashFlowMovementsList() { const SessionInfo = useSessionStore(); const location = useLocationStore(); const eventHandler = useEventHandlerStore(); + const alert = useAlert(); const filter = useFilterStore(); const [clientFilterData, setClientFilterData] = useState([]); @@ -181,52 +292,6 @@ export default function CashFlowMovementsList() { const [methodFilterData, setMethodFilterData] = useState([]); const [methodId, setMethodId] = useState(""); - const [dataGridColumns] = useState[]>([ - { - field: "description", - title: "Detalle", - width: null, - render: (value: ICashFlow) => ( - <> -
- {value.description} -
-
- {dayjs(value.createdAt).format("DD MMMM YYYY - HH:mm")} -
- - ), - }, - { - field: "amount", - title: "Importe", - width: 150, - render: (value: ICashFlow) => ( -
0 ? "var(--green-darkestX2)" : "var(--red)", - }} - > - {formatPrice(value.amount)} -
- ), - }, - ]); - const [pageSize] = useState( parseInt( process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE @@ -235,6 +300,10 @@ export default function CashFlowMovementsList() { ) ); const [movements, setMovements] = useState(); + const [invoicesByMovementId, setInvoicesByMovementId] = useState>({}); + const [invoicingMovementId, setInvoicingMovementId] = useState(""); + const [invoiceDraft, setInvoiceDraft] = useState(null); + const [invoiceDraftsByMovementId, setInvoiceDraftsByMovementId] = useState>({}); const movementsRef = useRef(movements); const loadMovements = (page: number = 1, pageSize: number = 30) => { @@ -262,6 +331,20 @@ export default function CashFlowMovementsList() { cashFlowPaginate(filterData) .then((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(() => { eventHandler.setEventType(EVENT_TYPES.CLEAR); @@ -345,6 +428,132 @@ export default function CashFlowMovementsList() { loadMovements(loadPage, pageSize); }, [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 ( +
+ {details.map((detail) => ( + + {detail} + + ))} +
+ ); + }; + + 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 ( <> @@ -352,28 +561,190 @@ export default function CashFlowMovementsList() { <> -
- { }} - /> +
+
+ {movements && movements.data.length === 0 && ( +
+ No se encontraron movimientos con los filtros aplicados. +
+ )} + + {(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 ( +
+
+ ); + })} +
+ + {movements && movements.pages > 1 && ( +
+ +
+ )}
+ + {invoiceDraft && ( +
+
+
+

Emitir Factura C

+ +
+ +
+ Importe + {formatPrice(invoiceDraft.movement.amount)} +
+ + + + + +