UNPKG

@accounter/server

Version:
353 lines (308 loc) • 11.7 kB
import DataLoader from 'dataloader'; import { GraphQLError } from 'graphql'; import { Inject, Injectable, Scope } from 'graphql-modules'; import { _DOLLAR_defs_addClientRequest_Input, _DOLLAR_defs_getClientResponse, _DOLLAR_defs_updateClientRequest_Input, init, updateClient_mutationMutationVariables, type Sdk, } from '@accounter/green-invoice-graphql'; import { dateToTimelessDateString } from '../../shared/helpers/index.js'; import { ENVIRONMENT } from '../../shared/tokens.js'; import type { Environment } from '../../shared/types/index.js'; import { AdminContextProvider } from '../admin-context/providers/admin-context.provider.js'; import { ProviderCredentialsProvider } from '../provider-credentials/providers/provider-credentials.provider.js'; export type ExpenseDraft = NonNullable< Extract< Awaited<ReturnType<Sdk['searchExpenseDrafts_query']>>['searchExpenseDrafts'], { items: Array<unknown> } >['items'][number] >; @Injectable({ scope: Scope.Operation, global: true, }) export class GreenInvoiceClientProvider { private initPromise: ReturnType<typeof init> | null = null; constructor( private adminContextProvider: AdminContextProvider, @Inject(ENVIRONMENT) private env: Environment, private credentialsProvider: ProviderCredentialsProvider, ) {} public authToken: string | null = null; private init(): ReturnType<typeof init> { this.initPromise ??= this._doInit(); return this.initPromise; } private async _doInit(): ReturnType<typeof init> { const fromDb = await this.credentialsProvider.getGreenInvoiceCredentials(); const creds = fromDb ?? null; if (!creds) { throw new GraphQLError('Green Invoice credentials not configured for this tenant', { extensions: { code: 'PROVIDER_NOT_CONFIGURED' }, }); } return init(creds.id, creds.secret); } public async getSDK(): Promise<Sdk> { try { const { sdk, authToken } = await this.init(); this.authToken = authToken; return sdk; } catch (e) { const message = 'Green Invoice SDK error'; console.error(`${message}: ${e}`); throw new Error(message, { cause: e }); } } /** * Expenses * */ public async addExpenseDraftByFile(file: File | Blob): Promise<ExpenseDraft> { try { const { defaultLocalCurrency } = await this.adminContextProvider.getVerifiedAdminContext(); // convert what-wg file to nodejs file const newFile = new File( [Buffer.from(await file.arrayBuffer())], 'name' in file ? file.name : 'new-file', { type: file.type, }, ); // get file upload url + params const sdk = await this.getSDK(); const { getFileUploadUrl: fileUploadParams } = await sdk.getFileUploadUrl_query({ context: 'expense', data: { source: 5 }, }); if (!fileUploadParams) { throw new Error('No file upload params returned'); } const form = new FormData(); for (const [field, value] of Object.entries(fileUploadParams.fields)) { if (value) { // replace field names underscores with dashes (originally dashes, Mesh converted to underscores to match GraphQL spec) form.append(field.replaceAll('_', '-'), value as string); } } form.append('file', newFile, newFile.name); const uploadTime = new Date().getTime(); // upload the file await fetch(fileUploadParams.url, { method: 'POST', body: form, }).then(async res => { if (![200, 201, 204].includes(res.status)) { throw new Error('Failed to upload file'); } return; }); // TODO: next section is temporary fix until decent webhook will be set up let retryCounter = 15; // 30 seconds while (retryCounter > 0) { await new Promise(resolve => setTimeout(resolve, 2000)); // 2 second delay to allow Green Invoice to process the file. const res = await sdk.searchExpenseDrafts_query({ input: { fromDate: dateToTimelessDateString(new Date()), toDate: dateToTimelessDateString(new Date()), }, }); if ( res.searchExpenseDrafts && 'items' in res.searchExpenseDrafts && res.searchExpenseDrafts.items.length ) { const items = res.searchExpenseDrafts.items.filter(item => { return !!item && item?.creationDate > uploadTime / 1000; }) as ExpenseDraft[]; if (items.length > 1) { // currently we cannot differentiate between drafts throw new Error('Multiple drafts found'); } if (items.length === 1) { const { expense } = items[0]; // check if any actual OCR fields exists if ( expense.amount !== null || expense.currency !== defaultLocalCurrency || expense.date !== null || expense.documentType !== '_20' || expense.number !== null || expense.vat !== 0 ) { console.log(`Finally, it took only ${(15 - retryCounter) * 2} seconds...`); return items[0]; } } } retryCounter -= 1; } console.log('Failed to fetch OCR data, returning empty draft'); const emptyDraft = { expense: { documentType: '_405', // "receipt on donation" which is not supported, resulting in unprocessed document number: undefined, date: undefined, amount: 0, currency: defaultLocalCurrency, vat: 0, }, } as ExpenseDraft; return emptyDraft; } catch (e) { const message = 'Green Invoice Add Expense Draft error'; console.error(`${message}: ${e}`); throw new Error(message, { cause: e }); } } /** * Documents * */ public async searchDocuments(...params: Parameters<Sdk['searchDocuments_query']>) { const sdk = await this.getSDK(); return sdk.searchDocuments_query(...params).then(res => { for (const doc of res.searchDocuments?.items ?? []) { if (doc) this.documentLoader.prime(doc.id, doc); } return res.searchDocuments; }); } public async addDocuments(...params: Parameters<Sdk['addDocument_mutation']>) { const sdk = await this.getSDK(); return sdk.addDocument_mutation(...params).then(res => res.addDocument); } public async previewDocuments(...params: Parameters<Sdk['previewDocument_query']>) { const sdk = await this.getSDK(); return sdk.previewDocument_query(...params).then(res => res.previewDocument); } public async getLinkedDocuments(documentId: string) { const sdk = await this.getSDK(); return sdk.getLinkedDocuments_query({ id: documentId }).then(res => res.getLinkedDocuments); } public async getDocumentLinks(documentId: string) { const sdk = await this.getSDK(); return sdk .getDocumentsDownloadLinks_query({ id: documentId }) .then(res => res.getDocumentsDownloadLinks); } private async _batchLoadByIds<T extends { id?: string | null }>( ids: readonly string[], fetcher: (sdk: Sdk, id: string) => Promise<T | null | undefined>, primingFunction: (item: T) => void, ): Promise<(T | null)[]> { const sdk = await this.getSDK(); const uniqueIds = Array.from(new Set(ids)); const items: (T | undefined)[] = await Promise.all( uniqueIds.map(async id => fetcher(sdk, id).then(item => { if (item?.id) { primingFunction(item); } return item ?? undefined; }), ), ); const itemsMap = new Map(items.filter((i): i is T => !!i).map(i => [i.id, i])); return ids.map(id => itemsMap.get(id) ?? null); } private async batchDocumentsByIds(ids: readonly string[]) { return this._batchLoadByIds( ids, (sdk, id) => sdk.getDocument_query({ id }).then(res => res.getDocument), item => this.documentLoader.prime(item.id!, item), ); } public documentLoader = new DataLoader((ids: readonly string[]) => this.batchDocumentsByIds(ids)); public async closeDocument(documentId: string) { this.invalidateDocument(documentId); const sdk = await this.getSDK(); return sdk.closeDocument_mutation({ id: documentId }).then(res => res.closeDocument); } public invalidateDocument(documentId: string) { this.documentLoader.clear(documentId); } /** * Clients * */ private async batchClientsByIds(ids: readonly string[]) { return this._batchLoadByIds( ids, (sdk, id) => sdk.getClient_query({ id }).then(res => res.getClient), item => this.clientLoader.prime(item.id!, item), ); } public clientLoader = new DataLoader((ids: readonly string[]) => this.batchClientsByIds(ids)); public async createClient( input: _DOLLAR_defs_addClientRequest_Input, ): Promise<_DOLLAR_defs_getClientResponse> { try { const sdk = await this.getSDK(); const client = await sdk.addClient_mutation({ input }).then(res => res.addClient); if (!client) { throw new Error('Failed to create Green Invoice client'); } if ('errorMessage' in client) { throw new Error(`Failed to create Green Invoice client: ${client.errorMessage}`); } const { id } = client as _DOLLAR_defs_getClientResponse; if (id) { this.clientLoader.prime(id, client as _DOLLAR_defs_getClientResponse); } return client as _DOLLAR_defs_getClientResponse; } catch (error) { const message = 'Green Invoice Create Client error'; console.error(`${message}: ${error}`); throw new Error(message, { cause: error }); } } public async updateClient( args: updateClient_mutationMutationVariables, ): Promise<_DOLLAR_defs_getClientResponse> { try { const sdk = await this.getSDK(); const client = await sdk.updateClient_mutation(args).then(res => res.updateClient); if (!client) { throw new Error('Failed to update Green Invoice client'); } if ('errorMessage' in client) { throw new Error(`Failed to update Green Invoice client: ${client.errorMessage}`); } const { id } = client as _DOLLAR_defs_getClientResponse; if (id) { this.clientLoader.prime(id, client as _DOLLAR_defs_getClientResponse); } return client as _DOLLAR_defs_getClientResponse; } catch (error) { this.invalidateClient(args.id); const message = 'Green Invoice Update Client error'; console.error(`${message}: ${error}`); throw new Error(message, { cause: error }); } } public async deleteClient(clientId: string): Promise<_DOLLAR_defs_getClientResponse> { try { const sdk = await this.getSDK(); const client = await sdk .deleteClient_mutation({ id: clientId }) .then(res => res.deleteClient); if (!client) { throw new Error('Failed to delete Green Invoice client'); } if ('errorMessage' in client) { throw new Error(`Failed to delete Green Invoice client: ${client.errorMessage}`); } this.invalidateClient(clientId); return client as _DOLLAR_defs_getClientResponse; } catch (error) { this.invalidateClient(clientId); const message = 'Green Invoice Delete Client error'; console.error(`${message}: ${error}`); throw new Error(message, { cause: error }); } } public invalidateClient(clientId: string) { this.clientLoader.clear(clientId); } }