@accounter/server
Version:
Accounter GraphQL server
570 lines (500 loc) • 20.7 kB
text/typescript
import { addMonths, endOfMonth, format, startOfMonth, subMonths } from 'date-fns';
import { GraphQLError } from 'graphql';
import type { _DOLLAR_defs_Document } from '@accounter/green-invoice-graphql';
import type { BillingCycle, ResolversTypes } from '../../../__generated__/types.js';
import { Currency, DocumentType } from '../../../shared/enums.js';
import { errorSimplifier } from '../../../shared/errors.js';
import { dateToTimelessDateString } from '../../../shared/helpers/index.js';
import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js';
import { GreenInvoiceClientProvider } from '../../app-providers/green-invoice-client.js';
import {
getChargeBusinesses,
getChargeDocumentsMeta,
} from '../../charges/helpers/common.helper.js';
import { ChargesProvider } from '../../charges/providers/charges.provider.js';
import {
getProductName,
getSubscriptionPlanName,
normalizeProduct,
normalizeSubscriptionPlan,
} from '../../contracts/helpers/contracts.helper.js';
import { ContractsProvider } from '../../contracts/providers/contracts.provider.js';
import { IGetContractsByIdsResult } from '../../contracts/types.js';
import { validateClientIntegrations } from '../../financial-entities/helpers/clients.helper.js';
import { BusinessesProvider } from '../../financial-entities/providers/businesses.provider.js';
import { ClientsProvider } from '../../financial-entities/providers/clients.provider.js';
import {
convertDocumentInputIntoGreenInvoiceInput,
getDocumentNameFromGreenInvoiceType,
normalizeGreenInvoiceDocumentType,
} from '../../green-invoice/helpers/green-invoice.helper.js';
import { TransactionsProvider } from '../../transactions/providers/transactions.provider.js';
import { getDocumentNameFromType } from '../helpers/common.helper.js';
import {
convertContractToDraft,
createRemarks,
deduceVatTypeFromBusiness,
executeDocumentIssue,
filterAndHandleSwiftTransactions,
getDocumentDateOutOfTransactions,
getIncomeRecordsFromDocuments,
getLinkedDocumentsAttributes,
getPaymentsFromTransactions,
getTypeFromDocumentsAndTransactions,
} from '../helpers/issue-document.helper.js';
import { DocumentsProvider } from '../providers/documents.provider.js';
import { IssuedDocumentsProvider } from '../providers/issued-documents.provider.js';
import type { DocumentsModule, IGetIssuedDocumentsByIdsResult } from '../types.js';
import { normalizeDocumentType } from './common.js';
export const documentsIssuingResolvers: DocumentsModule.Resolvers = {
Query: {
newDocumentDraftByCharge: async (_, { chargeId }, { injector }) => {
if (!chargeId) {
throw new GraphQLError('Charge ID is required to fetch document draft');
}
const {
defaultCryptoConversionFiatCurrency,
financialAccounts: { swiftBusinessId },
locality,
} = await injector.get(AdminContextProvider).getVerifiedAdminContext();
const chargePromise = injector.get(ChargesProvider).getChargeByIdLoader.load(chargeId);
const documentsPromise = injector
.get(DocumentsProvider)
.getDocumentsByChargeIdLoader.load(chargeId);
const transactionsPromise = injector
.get(TransactionsProvider)
.transactionsByChargeIDLoader.load(chargeId)
.then(res => filterAndHandleSwiftTransactions(res, swiftBusinessId));
const [charge, documents, transactions, { documentsCurrency }, { mainBusinessId }] =
await Promise.all([
chargePromise,
documentsPromise,
transactionsPromise,
getChargeDocumentsMeta(chargeId, injector),
getChargeBusinesses(chargeId, injector),
]);
if (!charge) {
throw new GraphQLError(`Charge with ID "${chargeId}" not found`);
}
const clientPromise = mainBusinessId
? injector.get(ClientsProvider).getClientByIdLoader.load(mainBusinessId)
: Promise.resolve(undefined);
const openIssuedDocumentsPromise = injector
.get(IssuedDocumentsProvider)
.getIssuedDocumentsByIdLoader.loadMany(documents.map(d => d.id))
.then(
res =>
res.filter(r => {
if (!r) return false;
if (r instanceof Error) {
console.error('Failed to fetch issued document', r);
return false;
}
if (r.status !== 'OPEN') {
return false;
}
return true;
}) as IGetIssuedDocumentsByIdsResult[],
);
const paymentPromise = getPaymentsFromTransactions(injector, transactions);
const vatTypePromise = deduceVatTypeFromBusiness(injector, locality, mainBusinessId);
const [client, openIssuedDocuments, payment, vatType] = await Promise.all([
clientPromise,
openIssuedDocumentsPromise,
paymentPromise,
vatTypePromise,
]);
const greenInvoiceDocuments = await Promise.all(
openIssuedDocuments.map(doc =>
injector
.get(GreenInvoiceClientProvider)
.documentLoader.load(doc.external_id)
.then(res => {
if (!res) {
console.error('Failed to fetch document from Green Invoice', doc.external_id);
return null;
}
return res;
}),
),
).then(res => res.filter(Boolean) as _DOLLAR_defs_Document[]);
const greenInvoiceClientId = validateClientIntegrations(
client?.integrations ?? {},
).greenInvoiceId;
if (!greenInvoiceClientId) {
throw new GraphQLError(
`Green invoice integration missing for business ID="${mainBusinessId}"`,
);
}
const income = getIncomeRecordsFromDocuments(
openIssuedDocuments.map(
doc => greenInvoiceDocuments.find(gd => gd.id === doc.external_id)!,
),
);
if (income.length === 0 && transactions.length > 0) {
income.push(
...transactions.map(transaction => ({
description: transaction.source_description ?? '',
quantity: 1,
price: Number(transaction.amount),
currency: transaction.currency as Currency,
currencyRate: undefined,
vatType,
})),
);
}
const type = getTypeFromDocumentsAndTransactions(
greenInvoiceDocuments.map(doc => ({ type: normalizeGreenInvoiceDocumentType(doc.type) })),
transactions,
);
const linkedDocsAttributes = getLinkedDocumentsAttributes(
openIssuedDocuments,
type === DocumentType.CreditInvoice,
);
let remarks = charge.user_description;
if (greenInvoiceDocuments.length) {
remarks = greenInvoiceDocuments.map(doc => doc.remarks).join(', ');
switch (type) {
case DocumentType.Receipt:
case DocumentType.InvoiceReceipt:
remarks = `${getDocumentNameFromType(type)} for ${greenInvoiceDocuments.map(doc => `${getDocumentNameFromGreenInvoiceType(doc.type)} ${doc.number}`).join(', ')}`;
break;
}
} else if (client?.remark) {
remarks = client.remark;
}
const documentDate = getDocumentDateOutOfTransactions(transactions);
const transactionsCurrencies = Array.from(new Set(transactions.map(t => t.currency)));
const transactionsCurrency =
transactionsCurrencies.length === 1 ? transactionsCurrencies[0] : undefined;
const draft: ResolversTypes['DocumentDraft'] = {
remarks,
// description: ____,
// footer: ____,
type,
date: documentDate,
dueDate: dateToTimelessDateString(endOfMonth(new Date())),
language: 'ENGLISH',
currency: (transactionsCurrency ||
documentsCurrency ||
defaultCryptoConversionFiatCurrency) as Currency,
vatType,
rounding: false,
signed: true,
client,
income,
payment,
// linkedPaymentId: ____,
// maxPayments: _____,
// discount: _____,
...linkedDocsAttributes,
};
return draft;
},
newDocumentDraftByDocument: async (_, { documentId }, { injector }) => {
if (!documentId) {
throw new GraphQLError('Document ID is required to fetch document draft');
}
const document = await injector
.get(DocumentsProvider)
.getDocumentsByIdLoader.load(documentId);
if (!document) {
throw new GraphQLError(`Document with ID "${documentId}" not found`);
}
const {
defaultCryptoConversionFiatCurrency,
financialAccounts: { swiftBusinessId },
locality,
} = await injector.get(AdminContextProvider).getVerifiedAdminContext();
const chargePromise = document.charge_id
? injector.get(ChargesProvider).getChargeByIdLoader.load(document.charge_id)
: Promise.resolve(undefined);
const transactionsPromise = document.charge_id
? injector
.get(TransactionsProvider)
.transactionsByChargeIDLoader.load(document.charge_id)
.then(res => filterAndHandleSwiftTransactions(res, swiftBusinessId))
: Promise.resolve(undefined);
const mainBusinessIdPromise = document.charge_id
? getChargeBusinesses(document.charge_id, injector).then(res => res.mainBusinessId)
: Promise.resolve(undefined);
const [charge, transactions, mainBusinessId] = await Promise.all([
chargePromise,
transactionsPromise,
mainBusinessIdPromise,
]);
if (!charge) {
throw new GraphQLError(`Charge with ID "${document.charge_id}" not found`);
}
const clientPromise = mainBusinessId
? injector.get(ClientsProvider).getClientByIdLoader.load(mainBusinessId)
: Promise.resolve(undefined);
const openIssuedDocumentPromise = injector
.get(IssuedDocumentsProvider)
.getIssuedDocumentsByIdLoader.load(document.id);
const vatTypePromise = deduceVatTypeFromBusiness(injector, locality, mainBusinessId);
const [client, openIssuedDocument, vatType] = await Promise.all([
clientPromise,
openIssuedDocumentPromise,
vatTypePromise,
]);
if (!openIssuedDocument) {
throw new GraphQLError(
`Document with ID "${document.id}" doesn't seem like a document we issued`,
);
}
if (openIssuedDocument.status !== 'OPEN') {
throw new GraphQLError(`Document with ID "${document.id}" is closed`);
}
const greenInvoiceDocumentPromise: Promise<_DOLLAR_defs_Document | null> = injector
.get(GreenInvoiceClientProvider)
.documentLoader.load(openIssuedDocument.external_id)
.then(res => {
if (!res) {
console.error(
'Failed to fetch document from Green Invoice',
openIssuedDocument.external_id,
);
return null;
}
return res;
});
const paymentPromise = getPaymentsFromTransactions(injector, transactions ?? []);
const greenInvoiceClientId = validateClientIntegrations(
client?.integrations ?? {},
).greenInvoiceId;
if (!greenInvoiceClientId) {
throw new GraphQLError(
`Green invoice integration missing for business ID="${mainBusinessId}"`,
);
}
const [greenInvoiceDocument, payment, { documentsCurrency }] = await Promise.all([
greenInvoiceDocumentPromise,
paymentPromise,
getChargeDocumentsMeta(charge.id, injector),
]);
if (!greenInvoiceDocument) {
throw new GraphQLError(
`Document with ID "${document.id}" doesn't have a Green Invoice matching document`,
);
}
const income = getIncomeRecordsFromDocuments([greenInvoiceDocument]);
const type = getTypeFromDocumentsAndTransactions(
[{ type: normalizeDocumentType(document.type) }],
transactions ?? [],
);
const linkedDocsAttributes = getLinkedDocumentsAttributes(
[openIssuedDocument],
type === DocumentType.CreditInvoice,
);
let remarks = greenInvoiceDocument.remarks;
switch (type) {
case DocumentType.Receipt:
case DocumentType.InvoiceReceipt:
remarks = `${getDocumentNameFromType(type)} for ${getDocumentNameFromGreenInvoiceType(greenInvoiceDocument.type)} ${greenInvoiceDocument.number}`;
break;
}
if (!remarks && client?.remark) {
remarks = client.remark;
}
const documentDate = getDocumentDateOutOfTransactions(transactions ?? []);
const draft: ResolversTypes['DocumentDraft'] = {
remarks,
// description: ____,
// footer: ____,
type,
date: documentDate,
dueDate: dateToTimelessDateString(endOfMonth(new Date())),
language: 'ENGLISH',
currency: (documentsCurrency || defaultCryptoConversionFiatCurrency) as Currency,
vatType,
rounding: false,
signed: true,
client,
income,
payment,
// linkedPaymentId: ____,
// maxPayments: _____,
// discount: _____,
...linkedDocsAttributes,
};
return draft;
},
periodicalDocumentDrafts: async (_, { issueMonth }, { injector }) => {
const { locality } = await injector.get(AdminContextProvider).getVerifiedAdminContext();
const openContracts = await injector.get(ContractsProvider).getAllOpenContracts();
const monthlyBillingCycle: BillingCycle = 'MONTHLY';
const monthlyContracts = openContracts.filter(
contract => contract.billing_cycle.toLocaleUpperCase() === monthlyBillingCycle,
);
const drafts = await Promise.all(
monthlyContracts.map(async contract =>
convertContractToDraft(injector, contract, locality, issueMonth),
),
);
return drafts;
},
periodicalDocumentDraftsByContracts: async (_, { issueMonth, contractIds }, { injector }) => {
const { locality } = await injector.get(AdminContextProvider).getVerifiedAdminContext();
const contracts = await injector
.get(ContractsProvider)
.getContractsByIdLoader.loadMany(contractIds)
.then(res => res.filter(c => !!c && !(c instanceof Error)) as IGetContractsByIdsResult[]);
const drafts = await Promise.all(
contracts.map(async contract =>
convertContractToDraft(injector, contract, locality, issueMonth),
),
);
return drafts;
},
clientMonthlyChargeDraft: async (_, { clientId, issueMonth }, { injector }) => {
const clientContracts = await injector
.get(ContractsProvider)
.getContractsByClientIdLoader.load(clientId);
const contracts = clientContracts.filter(clientContract => clientContract.is_active);
if (contracts.length === 0) {
throw new GraphQLError(`No active contracts found for client ID="${clientId}"`);
}
if (contracts.length > 1) {
throw new GraphQLError(
`Multiple active contracts found for client ID="${clientId}", cannot deduce which one to use`,
);
}
const contract = contracts[0];
if (!contract) {
throw new GraphQLError(`Contract not found for client ID="${clientId}"`);
}
const businessPromise = injector
.get(BusinessesProvider)
.getBusinessByIdLoader.load(contract.client_id);
const clientPromise = injector
.get(ClientsProvider)
.getClientByIdLoader.load(contract.client_id);
const [business, client] = await Promise.all([businessPromise, clientPromise]);
if (!business) {
throw new GraphQLError(`Business ID="${contract.client_id}" not found`);
}
if (!client) {
throw new GraphQLError(`Client not found for business ID="${contract.client_id}"`);
}
const greenInvoiceId = validateClientIntegrations(client.integrations)?.greenInvoiceId;
if (!greenInvoiceId) {
throw new GraphQLError(
`Green invoice match not found for business ID="${contract.client_id}"`,
);
}
const today = issueMonth ? addMonths(new Date(issueMonth), 1) : new Date();
const monthStart = dateToTimelessDateString(startOfMonth(today));
const monthEnd = dateToTimelessDateString(endOfMonth(today));
const year = today.getFullYear() + (today.getMonth() === 0 ? -1 : 0);
const month = format(subMonths(today, 1), 'MMMM');
const vatType = await deduceVatTypeFromBusiness(
injector,
business.country,
contract.client_id,
);
const draft: ResolversTypes['DocumentDraft'] = {
remarks: createRemarks(contract),
description: `${getProductName(normalizeProduct(contract.product ?? '')!)} ${getSubscriptionPlanName(normalizeSubscriptionPlan(contract.plan ?? '')!)} - ${month} ${year}`,
type: normalizeDocumentType(contract.document_type),
date: monthStart,
dueDate: monthEnd,
language: 'ENGLISH',
currency: contract.currency as Currency,
vatType,
rounding: false,
signed: true,
client,
income: [
{
description: `${getProductName(normalizeProduct(contract.product ?? '')!)} ${getSubscriptionPlanName(normalizeSubscriptionPlan(contract.plan ?? '')!)} - ${month} ${year}`,
quantity: 1,
price: contract.amount,
currency: contract.currency as Currency,
vatType,
},
],
};
return draft;
},
},
Mutation: {
issueGreenInvoiceDocuments: async (_, { generateDocumentsInfo }, { injector }) => {
const errors: string[] = [];
const { ownerId } = await injector.get(AdminContextProvider).getVerifiedAdminContext();
await Promise.all(
generateDocumentsInfo.map(async document =>
executeDocumentIssue(injector, ownerId, document, undefined, true, undefined, true).catch(
e => {
console.error(e);
errors.push(`${document.client?.name ?? document.client?.id}: ${e.message}`);
},
),
),
);
return {
success: true,
errors,
};
},
issueGreenInvoiceDocument: async (
_,
{ input: initialInput, emailContent, attachment, chargeId, sendEmail = false },
{ injector },
) => {
const { ownerId } = await injector.get(AdminContextProvider).getVerifiedAdminContext();
return executeDocumentIssue(
injector,
ownerId,
initialInput,
emailContent ?? undefined,
attachment ?? undefined,
chargeId ?? undefined,
sendEmail ?? false,
).then(charge => {
if (!charge) {
throw new GraphQLError('Failed to issue document');
}
return charge;
});
},
previewDocument: async (_, { input: initialInput }, { injector }) => {
try {
const input = await convertDocumentInputIntoGreenInvoiceInput(initialInput, injector);
const document = await injector
.get(GreenInvoiceClientProvider)
.previewDocuments({ input })
.catch(e => {
if (e instanceof GraphQLError) {
const response = e.extensions?.response as {
status?: number;
body?: { errorMessage?: string };
};
if (response && response.status === 400 && response.body?.errorMessage) {
throw new GraphQLError(
`Failed to generate document preview, Green Invoice returned: ${response.body!.errorMessage}`,
);
}
}
throw e;
});
if (!document) {
throw new GraphQLError('Failed to generate document preview');
}
if ('errorMessage' in document) {
console.error('Failed to generate document preview', document);
throw new GraphQLError(
`Failed to generate document preview, Green Invoice returned: ${document.errorMessage}`,
);
}
if ('file' in document && document.file) {
return document.file;
}
console.error('Document preview does not contain a file', document);
throw new GraphQLError('Document preview does not contain a file');
} catch (e) {
throw errorSimplifier('Error generating document preview', e);
}
},
},
};