feat: add AFIP Factura C issuance support to ArcaCredentials and update cash flow model
This commit is contained in:
@@ -45,6 +45,29 @@ export type ArcaWsfeLastVoucherResult = {
|
|||||||
lastVoucherNumber: number;
|
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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
|||||||
+368
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+437
-66
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user