@accounter/server
Version:
373 lines (337 loc) • 12.2 kB
text/typescript
import { GraphQLError } from 'graphql';
import { Injector } from 'graphql-modules';
import type { Document } from '@accounter/green-invoice-graphql';
import { GreenInvoiceClientProvider } from '@modules/app-providers/green-invoice-client.js';
import { ChargesProvider } from '@modules/charges/providers/charges.provider.js';
import { IssuedDocumentsProvider } from '@modules/documents/providers/issued-documents.provider.js';
import {
IGetDocumentsByChargeIdResult,
IGetIssuedDocumentsByIdsResult,
} from '@modules/documents/types';
import { FinancialAccountsProvider } from '@modules/financial-accounts/providers/financial-accounts.provider.js';
import { BusinessesProvider } from '@modules/financial-entities/providers/businesses.provider.js';
import { ClientsProvider } from '@modules/financial-entities/providers/clients.provider.js';
import { IGetTransactionsByChargeIdsResult } from '@modules/transactions/types';
import { DocumentType } from '@shared/enums';
import {
Currency,
GreenInvoiceClient,
GreenInvoiceIncome,
GreenInvoicePayment,
GreenInvoicePaymentType,
GreenInvoiceVatType,
NewDocumentInfo,
NewDocumentInput,
} from '@shared/gql-types';
import { dateToTimelessDateString } from '@shared/helpers';
import { TimelessDateString } from '@shared/types';
import {
convertDocumentInputIntoGreenInvoiceInput,
getVatTypeFromGreenInvoiceDocument,
insertNewDocumentFromGreenInvoice,
normalizeDocumentType,
} from './green-invoice.helper.js';
export async function getPaymentsFromTransactions(
injector: Injector,
transactions: IGetTransactionsByChargeIdsResult[],
): Promise<GreenInvoicePayment[]> {
const payments = await Promise.all(
transactions.map(async transaction => {
// get account
const account = await injector
.get(FinancialAccountsProvider)
.getFinancialAccountByAccountIDLoader.load(transaction.account_id);
if (!account) {
throw new Error(`Account with ID ${transaction.account_id} not found`);
}
// get account type
let type: GreenInvoicePaymentType = 'OTHER';
switch (account.type) {
case 'BANK_ACCOUNT':
type = 'WIRE_TRANSFER';
break;
case 'CREDIT_CARD':
type = 'CREDIT_CARD';
break;
}
// get further fields
let paymentTypeSpecificAttributes: Partial<GreenInvoicePayment> = {};
switch (type as string) {
case 'CHEQUE':
paymentTypeSpecificAttributes = {
// chequeNum: _____,
};
break;
case 'CREDIT_CARD':
paymentTypeSpecificAttributes = {
cardType: 'MASTERCARD', // TODO: add logic to support other card types
cardNum: account.account_number,
dealType: 'STANDARD', // TODO: add logic to support other deal types
numPayments: 1,
firstPayment: transaction.event_date.getTime() / 1000, // assuming first payment is the transaction date
};
break;
case 'WIRE_TRANSFER':
paymentTypeSpecificAttributes = {
bankName: account.bank_number?.toString(),
bankBranch: account.branch_number?.toString(),
bankAccount: account.account_number?.toString(),
};
break;
case 'PAYMENT_APP':
paymentTypeSpecificAttributes = {
// appType: _____,
// accountId: _____,
// transactionId: _____,
};
break;
case 'OTHER':
paymentTypeSpecificAttributes = {
// subType: _____,
// accountId: _____,
// transactionId: _____,
};
break;
case 'PAYPAL':
paymentTypeSpecificAttributes = {
// accountId: _____,
// transactionId: _____,
};
break;
case 'CASH':
case 'TAX_DEDUCTION':
case 'OTHER_DEDUCTION':
break;
}
const payment: GreenInvoicePayment = {
currency: transaction.currency as Currency,
currencyRate: undefined,
date: dateToTimelessDateString(transaction.debit_date ?? transaction.event_date),
price: Number(transaction.amount),
type,
transactionId: transaction.id,
accountId: account.id,
...paymentTypeSpecificAttributes,
};
return payment;
}),
);
return payments;
}
export function getIncomeFromDocuments(
documents: {
document: IGetDocumentsByChargeIdResult;
issuedDocument: IGetIssuedDocumentsByIdsResult;
greenInvoiceDocument: Document;
}[],
): GreenInvoiceIncome[] {
const incomes = documents
.map(({ greenInvoiceDocument }) => {
return greenInvoiceDocument.income.filter(Boolean).map(originIncome => {
if (!originIncome?.currency) {
throw new Error('Income currency is missing');
}
const income: GreenInvoiceIncome = {
description: originIncome.description,
quantity: originIncome.quantity,
price: originIncome.price,
currency: originIncome.currency as Currency,
currencyRate: undefined,
vatRate: originIncome.vatRate,
itemId: originIncome.itemId,
vatType: getVatTypeFromGreenInvoiceDocument(greenInvoiceDocument.vatType),
};
return income;
});
})
.flat();
return incomes;
}
export function getTypeFromDocumentsAndTransactions(
greenInvoiceDocuments: Document[],
transactions: IGetTransactionsByChargeIdsResult[],
): DocumentType {
if (!greenInvoiceDocuments.length) {
if (transactions.length) {
return DocumentType.InvoiceReceipt;
}
return DocumentType.Proforma;
}
const documentsTypes = new Set<DocumentType>(
greenInvoiceDocuments.map(doc => normalizeDocumentType(doc.type)),
);
if (documentsTypes.size === 1) {
switch (Array.from(documentsTypes)[0]) {
case DocumentType.Invoice:
return DocumentType.Receipt;
case DocumentType.Proforma:
return DocumentType.InvoiceReceipt;
}
}
return DocumentType.Proforma;
}
export function filterAndHandleSwiftTransactions(
originTransactions: IGetTransactionsByChargeIdsResult[],
swiftBusinessId: string | null,
): IGetTransactionsByChargeIdsResult[] {
const swiftTransactions: IGetTransactionsByChargeIdsResult[] = [];
const incomeTransactions: IGetTransactionsByChargeIdsResult[] = [];
for (const transaction of originTransactions) {
if (swiftBusinessId && transaction.business_id === swiftBusinessId) {
swiftTransactions.push(transaction);
} else if (Number(transaction.amount) > 0) {
incomeTransactions.push(transaction);
}
}
if (swiftTransactions.length === 0) {
return incomeTransactions;
}
if (swiftTransactions.length === 1 && incomeTransactions.length === 1) {
const incomeTransaction = incomeTransactions[0];
return [
{
...incomeTransaction,
amount: (Number(incomeTransaction.amount) - Number(swiftTransactions[0].amount)).toFixed(2),
},
];
}
throw new GraphQLError(
"Unable to process transactions. Couldn't match swift fees to transactions",
);
}
export function getLinkedDocumentsAttributes(
issuedDocuments: IGetIssuedDocumentsByIdsResult[],
): Pick<NewDocumentInfo, 'linkedDocumentIds' | 'linkType'> {
const linkedDocumentIds = issuedDocuments.map(doc => doc.external_id);
return {
linkedDocumentIds,
linkType: linkedDocumentIds.length ? 'LINK' : undefined,
};
}
export function getDocumentDateOutOfTransactions(
transactions: IGetTransactionsByChargeIdsResult[],
): TimelessDateString | undefined {
const debitDates = transactions.map(tx => tx.debit_date).filter(Boolean) as Date[];
// if no debit dates, use current date
if (!debitDates.length) return dateToTimelessDateString(new Date());
// Sort dates and take the first one
const sortedDates = [...debitDates].sort((a, b) => b.getTime() - a.getTime());
const firstDate = sortedDates[0];
// Return the date in the required format
return dateToTimelessDateString(firstDate);
}
export async function getClientFromGreenInvoiceClient(
injector: Injector,
businessId: string,
useGreenInvoiceId = false,
): Promise<GreenInvoiceClient | undefined> {
const client = await injector.get(ClientsProvider).getClientByIdLoader.load(businessId);
if (!client) {
return useGreenInvoiceId ? undefined : { id: businessId };
}
const greenInvoiceClient = await injector
.get(GreenInvoiceClientProvider)
.clientLoader.load(client.green_invoice_id);
if (!greenInvoiceClient) {
return useGreenInvoiceId ? undefined : { id: businessId };
}
return {
id: useGreenInvoiceId && greenInvoiceClient.id ? greenInvoiceClient.id : businessId,
country: greenInvoiceClient.country,
emails: [
...((greenInvoiceClient.emails?.filter(Boolean) as string[]) ?? []),
'ap@the-guild.dev',
],
name: greenInvoiceClient.name,
phone: greenInvoiceClient.phone,
taxId: greenInvoiceClient.taxId,
address: greenInvoiceClient.address,
city: greenInvoiceClient.city,
zip: greenInvoiceClient.zip,
fax: greenInvoiceClient.fax,
mobile: greenInvoiceClient.mobile,
};
}
export async function deduceVatTypeFromBusiness(
injector: Injector,
businessId?: string | null,
): Promise<GreenInvoiceVatType> {
if (!businessId) return 'DEFAULT';
const business = await injector.get(BusinessesProvider).getBusinessByIdLoader.load(businessId);
if (!business) return 'DEFAULT';
// Deduce VAT type based on business information
if (business.country === 'Israel') {
return 'MIXED';
}
return 'EXEMPT';
}
export async function executeDocumentIssue(
injector: Injector,
adminBusinessId: string,
initialInput: NewDocumentInput,
emailContent?: string,
attachment = true,
chargeId?: string,
sendEmail = false,
) {
try {
const coreInput = await convertDocumentInputIntoGreenInvoiceInput(initialInput, injector);
const input = {
...coreInput,
client:
!coreInput.client || sendEmail
? coreInput.client
: {
...coreInput.client,
emails: [], // if client exists, and sendEmail is false, we don't want to send emails
},
emailContent,
attachment,
};
const document = await injector.get(GreenInvoiceClientProvider).addDocuments({ input });
if (!document) {
throw new GraphQLError('Failed to issue new document');
}
if ('id' in document && document.id) {
const greenInvoiceDocument = await injector
.get(GreenInvoiceClientProvider)
.documentLoader.load(document.id);
if (!greenInvoiceDocument) {
console.error('Failed to fetch issued document from Green Invoice', document);
throw new GraphQLError('Failed to issue new document');
}
// Insert new issued document to DB
const newDocument = await insertNewDocumentFromGreenInvoice(
injector,
greenInvoiceDocument,
adminBusinessId,
chargeId,
);
if (!newDocument.charge_id) {
console.error('New document does not have a charge ID', newDocument);
throw new GraphQLError('Failed to issue new document');
}
// Close linked documents
if (coreInput.linkedDocumentIds?.length) {
await Promise.all(
coreInput.linkedDocumentIds.map(async id => {
if (id) {
await injector
.get(IssuedDocumentsProvider)
.updateIssuedDocumentByExternalId({ externalId: id, status: 'CLOSED' })
.catch(e => {
throw new Error(`Failed to close linked document with ID ${id}: ${e.message}`);
});
}
}),
);
}
return injector.get(ChargesProvider).getChargeByIdLoader.load(newDocument.charge_id);
}
console.error('Document issue failed', document);
throw new GraphQLError('Document issue failed');
} catch (error) {
console.error('Error issuing document:', error);
throw new GraphQLError('Error issuing document');
}
}