@accounter/server
Version:
363 lines (326 loc) • 12.5 kB
text/typescript
import { endOfDay, startOfDay } from 'date-fns';
import type { Injector } from 'graphql-modules';
import { DeelClientProvider } from '@modules/app-providers/deel/deel-client.provider.js';
import type {
DeelWorker,
Invoice,
PaymentBreakdownRecord,
PaymentReceipts,
} from '@modules/app-providers/deel/schemas.js';
import { ChargesProvider } from '@modules/charges/providers/charges.provider.js';
import { uploadToCloudinary } from '@modules/documents/helpers/upload.helper.js';
import { DocumentsProvider } from '@modules/documents/providers/documents.provider.js';
import type {
IGetDocumentsByChargeIdResult,
IInsertDocumentsParams,
} from '@modules/documents/types';
import { TaxCategoriesProvider } from '@modules/financial-entities/providers/tax-categories.provider.js';
import { Currency, DocumentType } from '@shared/enums';
import { dateToTimelessDateString } from '@shared/helpers';
import type { LedgerProto, StrictLedgerProto } from '@shared/types';
import { DeelContractsProvider } from '../providers/deel-contracts.provider.js';
import { DeelInvoicesProvider } from '../providers/deel-invoices.provider.js';
import type {
IGetEmployeeIDsByContractIdsResult,
IInsertDeelInvoiceRecordsParams,
} from '../types.js';
const DEEL_BUSINESS_ID = '8d34f668-7233-4ce3-9c9c-82550b0839ff'; // TODO: replace with DB based business id
export function isDeelDocument(document: IGetDocumentsByChargeIdResult): boolean {
const isDeelSide =
document.creditor_id === DEEL_BUSINESS_ID || document.debtor_id === DEEL_BUSINESS_ID;
const isFinancialDocument =
document.type === 'INVOICE' ||
document.type === 'INVOICE_RECEIPT' ||
document.type === 'CREDIT_INVOICE';
return isDeelSide && isFinancialDocument;
}
export async function getDeelEmployeeId(
context: GraphQLModules.ModuleContext,
document: IGetDocumentsByChargeIdResult,
ledgerEntry: StrictLedgerProto,
ledgerEntries: LedgerProto[],
updateLedgerBalance: (entry: LedgerProto) => void,
): Promise<void> {
if (!isDeelDocument(document)) {
return;
}
const isDeelCreditor = ledgerEntry.creditAccountID1 === DEEL_BUSINESS_ID;
// naive fetch employee id from deel
let employeeId = await context.injector
.get(DeelContractsProvider)
.getEmployeeIdByDocumentIdLoader.load(document.id);
if (!employeeId && document.date && document.type) {
// figure out through deel records
const records = await context.injector
.get(DeelInvoicesProvider)
.getInvoicesByIssueDates(startOfDay(document.date), endOfDay(document.date));
const matchingRecord = records.find(r => {
if (dateToTimelessDateString(r.issued_at) !== dateToTimelessDateString(document.date!)) {
return false;
}
if (r.label !== document.serial_number) {
return false;
}
if (r.currency !== document.currency_code) {
return false;
}
if (Number(r.total) !== document.total_amount) {
if (records.length === 1) {
return false;
}
const spreadRecords = records.filter(r => r.label === document.serial_number);
if (spreadRecords.length === 1) {
return false;
}
const totalAmount = spreadRecords.reduce((acc, r) => acc + Number(r.amount), 0);
if (totalAmount !== document.total_amount) {
return false;
}
}
return true;
});
if (matchingRecord?.contract_id) {
employeeId = await context.injector
.get(DeelContractsProvider)
.getEmployeeIDByContractIdLoader.load(matchingRecord.contract_id);
}
}
if (employeeId) {
// get employee tax category
const taxCategoryId = await context.injector
.get(TaxCategoriesProvider)
.taxCategoryByBusinessAndOwnerIDsLoader.load({
businessId: employeeId,
ownerId: context.adminContext.defaultAdminBusinessId,
})
.then(taxCategory => taxCategory?.id);
let newEntry: LedgerProto;
if (isDeelCreditor) {
newEntry = {
...ledgerEntry,
debitAccountID1: employeeId,
};
ledgerEntry.creditAccountID1 = employeeId;
ledgerEntry.debitAccountID1 = taxCategoryId ?? ledgerEntry.debitAccountID1;
} else {
newEntry = {
...ledgerEntry,
creditAccountID1: employeeId,
};
ledgerEntry.creditAccountID1 = taxCategoryId ?? ledgerEntry.creditAccountID1;
ledgerEntry.debitAccountID1 = employeeId;
}
updateLedgerBalance(newEntry);
ledgerEntries.push(newEntry);
}
return;
}
type Prefixer<Type> = {
[Property in keyof Type as `breakdown_${string & Property}`]: Type[Property];
};
export type PrefixedBreakdown = Prefixer<PaymentBreakdownRecord & { receipt_id: string }>;
export type DeelInvoiceMatch = Invoice & PrefixedBreakdown;
export async function uploadDeelInvoice(
receiptChargeMap: Map<string, string>,
match: DeelInvoiceMatch,
injector: Injector,
ownerId: string,
): Promise<string> {
try {
const chargeId = receiptChargeMap.get(match.breakdown_receipt_id as string);
if (!chargeId) {
throw new Error('Charge not found for invoice');
}
// fetch file from Deel
const file = await injector.get(DeelClientProvider).getSalaryInvoiceFile(match.id);
// upload file to cloudinary
const { fileUrl, imageUrl } = await uploadToCloudinary(injector, file);
// create the new document object
const newDocumentFromInvoice: IInsertDocumentsParams['document'][number] = {
image: imageUrl ?? null,
file: fileUrl ?? null,
documentType:
match.breakdown_contract_type === 'prepaid_billing'
? DocumentType.Other
: DocumentType.Invoice,
serialNumber: match.label,
date: match.issued_at,
amount: Number(match.breakdown_total_payment_currency),
currencyCode: match.breakdown_payment_currency as Currency,
vat: Number(match.vat_total),
chargeId,
vatReportDateOverride: null,
noVatAmount: null,
debtorId: ownerId,
creditorId: DEEL_BUSINESS_ID,
allocationNumber: null,
exchangeRateOverride: null,
};
// upload the document
const [document] = await injector.get(DocumentsProvider).insertDocuments({
document: [newDocumentFromInvoice],
});
if (!document.id) {
throw new Error('Document not uploaded to DB');
}
return document.id;
} catch (error) {
console.error(error);
throw new Error('Error uploading Deel invoice');
}
}
function nullifyEmptyStrings(raw: string) {
return raw === '' ? null : raw;
}
export function convertMatchToDeelInvoiceRecord(
match: DeelInvoiceMatch,
documentId: string,
): IInsertDeelInvoiceRecordsParams {
return {
adjustment: match.breakdown_adjustment,
amount: match.amount,
approveDate: nullifyEmptyStrings(match.breakdown_approve_date),
approvers: match.breakdown_approvers,
bonus: match.breakdown_bonus,
commissions: match.breakdown_commissions,
contractCountry: nullifyEmptyStrings(match.breakdown_contract_country),
contractId: match.contract_id,
contractStartDate: nullifyEmptyStrings(match.breakdown_contract_start_date),
contractType: nullifyEmptyStrings(match.breakdown_contract_type),
contractorEmail: nullifyEmptyStrings(match.breakdown_contractor_email),
contractorEmployeeName: match.breakdown_contractor_employee_name,
contractorUniqueIdentifier: nullifyEmptyStrings(match.breakdown_contractor_unique_identifier),
createdAt: match.created_at,
currency: match.currency as Currency,
deductions: match.breakdown_deductions,
deelFee: match.deel_fee,
documentId,
dueDate: match.due_date,
expenses: match.breakdown_expenses,
frequency: nullifyEmptyStrings(match.breakdown_frequency),
generalLedgerAccount: nullifyEmptyStrings(match.breakdown_general_ledger_account),
groupId: nullifyEmptyStrings(match.breakdown_group_id),
id: match.id,
isOverdue: match.is_overdue,
issuedAt: match.issued_at,
label: match.label,
others: match.breakdown_others,
overtime: match.breakdown_overtime,
paidAt: match.paid_at,
paymentCurrency: match.breakdown_payment_currency as Currency,
paymentId: match.breakdown_receipt_id,
processingFee: match.breakdown_processing_fee,
proRata: match.breakdown_pro_rata,
status: match.status,
total: match.total,
totalPaymentCurrency: match.breakdown_total_payment_currency,
vatId: nullifyEmptyStrings(match.vat_id),
vatPercentage: nullifyEmptyStrings(match.vat_percentage),
vatTotal: match.vat_total,
work: match.breakdown_work,
};
}
export async function getDeelChargeDescription(injector: Injector, workers?: DeelWorker[]) {
const contractIds = (workers?.map(w => w.contract_id).filter(id => !!id) as string[]) ?? [];
const contracts = await injector
.get(DeelContractsProvider)
.getEmployeeByContractIdLoader.loadMany(contractIds)
.then(
contract =>
contract.filter(
id => !!id && !(id instanceof Error),
) as IGetEmployeeIDsByContractIdsResult[],
);
const workerNames = contracts.map(c => c.contractor_name.split(' ')[0]);
const workersDescription = workerNames?.length ? ` for ${workerNames.join(', ')}` : '';
const description = `Deel payment${workersDescription}`;
return description;
}
export async function fetchAndFilterInvoices(injector: Injector) {
try {
const invoices = await injector.get(DeelClientProvider).getSalaryInvoices(); // TODO: enable setting up period
const filteredInvoices: Invoice[] = [];
await Promise.all(
invoices.data.map(async invoice => {
const dbInvoice = await injector
.get(DeelInvoicesProvider)
.getInvoicesByIdLoader.load(invoice.id)
.catch(e => {
console.error(e);
throw new Error('Error fetching invoice');
});
if (!dbInvoice) {
filteredInvoices.push(invoice);
}
}),
);
return { invoices: filteredInvoices };
} catch (error) {
console.error(error);
throw new Error('Error fetching Deel invoices');
}
}
export async function fetchReceipts(injector: Injector) {
try {
const res = await injector.get(DeelClientProvider).getPaymentReceipts(); // TODO: use PERION_IN_MONTHS
return res.data.rows;
} catch (error) {
console.error(error);
throw new Error('Error fetching Deel receipts');
}
}
export async function fetchPaymentBreakdowns(injector: Injector, receipts: PaymentReceipts[]) {
const receiptsBreakDown: Array<PaymentBreakdownRecord & { receipt_id: string }> = [];
for (const receipt of receipts) {
if (receipt.id) {
const breakDown = await injector.get(DeelClientProvider).getPaymentBreakdown(receipt.id);
receiptsBreakDown.push(...breakDown.data.map(row => ({ ...row, receipt_id: receipt.id })));
}
}
return receiptsBreakDown;
}
export async function getChargeMatchesForPayments(
injector: Injector,
ownerId: string,
receipts: PaymentReceipts[],
) {
const receiptChargeMap = await injector.get(DeelInvoicesProvider).getReceiptToCharge();
for (const receipt of receipts) {
if (receiptChargeMap.has(receipt.id)) {
continue;
}
const description = await getDeelChargeDescription(injector, receipt.workers);
const [charge] = await injector.get(ChargesProvider).generateCharge({
ownerId,
userDescription: description,
});
receiptChargeMap.set(receipt.id, charge.id);
// TODO: upload receipt whenever available via Deel API
}
return receiptChargeMap;
}
export function matchInvoicesWithPayments(
invoices: Invoice[],
paymentBreakdowns: PaymentBreakdownRecord[],
) {
const matches: DeelInvoiceMatch[] = [];
invoices.map(invoice => {
const optionalMatches = paymentBreakdowns.filter(
receipt =>
invoice.currency === receipt.currency &&
invoice.total === receipt.total &&
invoice.created_at === receipt.date &&
invoice.paid_at === receipt.payment_date,
);
if (optionalMatches.length === 1) {
const adjustedBreakdown: Record<string, unknown> = {};
Object.entries(optionalMatches[0]).map(([key, value]) => {
adjustedBreakdown[`breakdown_${key}`] = value;
});
matches.push({ ...(adjustedBreakdown as PrefixedBreakdown), ...invoice });
} else {
throw new Error(`No payment match found for invoice ${invoice.id}`);
}
});
return matches;
}