@accounter/server
Version:
Accounter GraphQL server
603 lines (566 loc) • 21.7 kB
text/typescript
import { GraphQLError } from 'graphql';
import { type Injector } from 'graphql-modules';
import { Repeater } from 'graphql-yoga';
import {
ChargeSortByField,
type Resolvers,
type ResolversTypes,
} from '../../../__generated__/types.js';
import { EMPTY_UUID } from '../../../shared/constants.js';
import type { Currency } from '../../../shared/enums.js';
import { formatFinancialAmount } from '../../../shared/helpers/index.js';
import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js';
import { ScopeProvider } from '../../auth/providers/scope.provider.js';
import { ChargesProvider } from '../../charges/providers/charges.provider.js';
import { accountant_statusArray } from '../../charges/types.js';
import { FinancialEntitiesProvider } from '../../financial-entities/providers/financial-entities.provider.js';
import { IGetFinancialEntitiesByIdsResult } from '../../financial-entities/types.js';
import {
ledgerGenerationByCharge,
ledgerUnbalancedBusinessesByCharge,
} from '../helpers/ledger-by-charge-type.helper.js';
import { isChargeLocked } from '../helpers/ledger-lock.js';
import {
convertLedgerRecordToInput,
convertLedgerRecordToProto,
ledgerRecordsGenerationFullMatchComparison,
ledgerRecordsGenerationPartialMatchComparison,
} from '../helpers/ledgrer-storage.helper.js';
import { getLedgerBalanceInfo, updateLedgerBalanceByEntry } from '../helpers/utils.helper.js';
import { LedgerProvider } from '../providers/ledger.provider.js';
import type {
IGetLedgerRecordsByChargesIdsResult,
IInsertLedgerRecordsParams,
LedgerModule,
} from '../types.js';
import { commonChargeLedgerResolver } from './common.resolver.js';
// Resolve a ledger record's local currency from its OWNING business, so
// multi-business reads format each record in the right currency. Falls back to
// the request's primary business when the per-business preference is missing.
async function recordLocalCurrency(
injector: Injector,
ownerId: string | null | undefined,
): Promise<Currency> {
if (ownerId) {
const currency = await injector
.get(ScopeProvider)
.getBusinessPreference(ownerId, 'defaultLocalCurrency');
if (currency) {
return currency;
}
}
const { defaultLocalCurrency } = await injector
.get(AdminContextProvider)
.getVerifiedAdminContext();
return defaultLocalCurrency;
}
export const ledgerResolvers: LedgerModule.Resolvers & Pick<Resolvers, 'GeneratedLedgerRecords'> = {
Query: {
chargesWithLedgerChanges: async (_, { filters, limit }, context, info) => {
const { injector } = context;
const { ledgerLock } = await injector.get(AdminContextProvider).getVerifiedAdminContext();
// handle sort column
let sortColumn: 'event_date' | 'event_amount' | 'abs_event_amount' = 'event_date';
switch (filters?.sortBy?.field) {
case ChargeSortByField.Amount:
sortColumn = 'event_amount';
break;
case ChargeSortByField.AbsAmount:
sortColumn = 'abs_event_amount';
break;
case ChargeSortByField.Date:
sortColumn = 'event_date';
break;
}
// get max date of filter and ledger lock
const fromDate =
ledgerLock || filters?.fromDate
? [ledgerLock, filters?.fromDate].filter(Boolean).sort().reverse()[0]
: undefined;
const charges = await injector
.get(ChargesProvider)
.getChargesByFilters({
ownerIds: filters?.byOwners,
fromDate,
toDate: filters?.toDate,
fromAnyDate: filters?.fromAnyDate,
toAnyDate: filters?.toAnyDate,
sortColumn,
asc: filters?.sortBy?.asc !== false,
chargeType: filters?.chargesType,
businessIds: filters?.byBusinesses,
withoutInvoice: filters?.withoutInvoice,
withoutReceipt: filters?.withoutReceipt,
withoutDocuments: filters?.withoutDocuments,
withoutLedger: filters?.withoutLedger,
tags: filters?.byTags,
accountantStatuses: filters?.accountantStatus as accountant_statusArray | undefined,
})
.catch(e => {
const message = 'Error fetching charges';
console.error(`${message}: ${e}`);
throw new Error(message);
});
const limitedCharges = limit ? charges.slice(0, limit) : charges;
return new Repeater<ResolversTypes['ChargesWithLedgerChangesResult']>(async (push, stop) => {
push({ progress: 20 });
let handledCharges: number = 0;
function calculateProgress() {
return ((handledCharges * 100) / limitedCharges.length) * 0.8 + 20;
}
await Promise.all(
limitedCharges.map(async charge => {
if (await isChargeLocked(charge, injector, ledgerLock)) {
handledCharges++;
if (handledCharges % 50 === 0 || handledCharges === limitedCharges.length) {
push({ progress: calculateProgress() });
}
return;
}
try {
const generatedRecordsPromise = ledgerGenerationByCharge(
charge,
{ insertLedgerRecordsIfNotExists: false },
context,
info,
);
const storageRecordsPromise = injector
.get(LedgerProvider)
.getLedgerRecordsByChargesIdLoader.load(charge.id);
const [generatedRecords, storageRecords] = await Promise.all([
generatedRecordsPromise,
storageRecordsPromise,
]);
if (!generatedRecords || 'message' in generatedRecords) {
handledCharges++;
push({ progress: calculateProgress(), charge });
return;
}
const newRecords = generatedRecords.records as IGetLedgerRecordsByChargesIdsResult[];
const fullMatching = ledgerRecordsGenerationFullMatchComparison(
storageRecords,
newRecords,
);
if (fullMatching.isFullyMatched) {
handledCharges++;
if (handledCharges % 50 === 0 || handledCharges === limitedCharges.length) {
push({ progress: calculateProgress() });
}
return;
}
handledCharges++;
push({ progress: calculateProgress(), charge });
} catch (err) {
console.error(err);
handledCharges++;
push({ progress: calculateProgress(), charge });
}
}),
);
push({ progress: 100 });
stop();
}) as unknown as Promise<readonly ResolversTypes['ChargesWithLedgerChangesResult'][]>;
},
ledgerRecordsByDates: async (_, { fromDate, toDate }, { injector }) => {
if (fromDate > toDate) {
throw new GraphQLError('fromDate must be before or equal to toDate');
}
const { ownerId } = await injector.get(AdminContextProvider).getVerifiedAdminContext();
return await injector
.get(LedgerProvider)
.getLedgerRecordsByDates({
fromDate,
toDate,
ownerId,
})
.then(records =>
records.sort((a, b) => a.invoice_date.getTime() - b.invoice_date.getTime()),
)
.catch(error => {
console.error('Failed to fetch ledger records:', error);
throw new GraphQLError('Failed to fetch ledger records');
});
},
ledgerRecordsByFinancialEntity: async (_, { financialEntityId }, { injector }) => {
return await injector
.get(LedgerProvider)
.getLedgerRecordsByFinancialEntityIdLoader.load(financialEntityId);
},
},
Mutation: {
regenerateLedgerRecords: async (_, { chargeId }, context, info) => {
const { injector } = context;
const { ledgerLock, ownerId } = await injector
.get(AdminContextProvider)
.getVerifiedAdminContext();
const charge = await injector.get(ChargesProvider).getChargeByIdLoader.load(chargeId);
if (!charge) {
throw new GraphQLError(`Charge with id ${chargeId} not found`);
}
if (await isChargeLocked(charge, injector, ledgerLock)) {
return {
__typename: 'CommonError',
message: `Charge with id ${chargeId} is locked`,
};
}
try {
const generated = await ledgerGenerationByCharge(
charge,
{ insertLedgerRecordsIfNotExists: true },
context,
info,
);
if (!generated || 'message' in generated) {
const message = generated?.message ?? 'generation error';
throw new Error(message);
}
const records = generated.records as IGetLedgerRecordsByChargesIdsResult[];
const storageLedgerRecords = await injector
.get(LedgerProvider)
.getLedgerRecordsByChargesIdLoader.load(chargeId);
const fullMatching = ledgerRecordsGenerationFullMatchComparison(
storageLedgerRecords,
records,
);
if (fullMatching.isFullyMatched) {
return {
records: storageLedgerRecords,
charge,
errors: generated.errors,
};
}
const { toUpdate, toRemove } = ledgerRecordsGenerationPartialMatchComparison(
fullMatching.unmatchedStorageRecords,
fullMatching.unmatchedNewRecords,
);
const [newRecords, recordsToUpdate] = toUpdate.reduce(
(acc, record) => {
if (record.id === EMPTY_UUID) {
acc[0].push(record);
} else {
acc[1].push(record);
}
return acc;
},
[[], []] as [
IGetLedgerRecordsByChargesIdsResult[],
IGetLedgerRecordsByChargesIdsResult[],
],
);
const updatePromise = injector
.get(LedgerProvider)
.deleteLedgerRecordsByIdLoader.loadMany(recordsToUpdate.map(r => r.id))
.catch(e => {
const message = `Failed to delete ledger records for charge ID="${chargeId}"`;
console.error(`${message}: ${e}`);
if (e instanceof GraphQLError) {
throw e;
}
throw new Error(message);
})
.then(() =>
injector.get(LedgerProvider).insertLedgerRecords({
ledgerRecords: recordsToUpdate
.map(record => convertLedgerRecordToInput(record, ownerId))
.map(record => {
record.chargeId = chargeId;
return record as IInsertLedgerRecordsParams['ledgerRecords'][number];
}),
}),
)
.catch(e => {
if (e instanceof GraphQLError) {
throw e;
}
const message = `Failed to update ledger records for charge ID="${chargeId}"`;
console.error(`${message}: ${e}`);
throw new Error(message);
});
const insertPromise =
newRecords.length > 0
? injector
.get(LedgerProvider)
.insertLedgerRecords({
ledgerRecords: newRecords.map(record =>
convertLedgerRecordToInput(record, ownerId),
) as IInsertLedgerRecordsParams['ledgerRecords'],
})
.catch(e => {
const message = `Failed to insert new ledger records for charge ID="${chargeId}"`;
console.error(`${message}: ${e}`);
if (e instanceof GraphQLError) {
throw e;
}
throw new Error(message);
})
: Promise.resolve();
const removePromises = toRemove.map(record =>
injector
.get(LedgerProvider)
.deleteLedgerRecordsByIdLoader.load(record.id)
.catch(e => {
const message = `Failed to delete ledger records for charge ID="${chargeId}"`;
console.error(`${message}: ${e}`);
if (e instanceof GraphQLError) {
throw e;
}
throw new Error(message);
}),
);
await Promise.all([updatePromise, insertPromise, ...removePromises]);
return {
records: toUpdate,
charge,
errors: generated.errors,
};
} catch (e) {
if (e instanceof GraphQLError) {
throw e;
}
return {
__typename: 'CommonError',
message: `Failed to generate ledger records for charge ID="${chargeId}"\n${e}`,
};
}
},
lockLedgerRecords: async (_, { date }, { injector }) => {
try {
const { ownerId } = await injector.get(AdminContextProvider).getVerifiedAdminContext();
const lockByRecords = injector
.get(LedgerProvider)
.lockLedgerRecords(date)
.catch(e => {
console.error(`Error locking ledger records for ${date}: ${e}`);
throw new Error(`Error locking ledger records for ${date}`);
});
const lockByDate = injector
.get(AdminContextProvider)
.updateAdminContext({
ownerId,
ledgerLock: date,
})
.catch(e => {
console.error(`Error locking ledger by date ${date}: ${e}`);
throw new Error(`Error locking ledger by date ${date}`);
});
await Promise.all([lockByRecords, lockByDate]);
return true;
} catch (error) {
console.error(`Error locking ledger for ${date}: ${error}`);
return false;
}
},
},
LedgerRecord: {
id: DbLedgerRecord => DbLedgerRecord.id,
debitAmount1: DbLedgerRecord =>
DbLedgerRecord.debit_foreign_amount1 == null
? null
: formatFinancialAmount(DbLedgerRecord.debit_foreign_amount1, DbLedgerRecord.currency),
debitAmount2: DbLedgerRecord =>
DbLedgerRecord.debit_foreign_amount2 == null
? null
: formatFinancialAmount(DbLedgerRecord.debit_foreign_amount2, DbLedgerRecord.currency),
creditAmount1: DbLedgerRecord =>
DbLedgerRecord.credit_foreign_amount1 == null
? null
: formatFinancialAmount(DbLedgerRecord.credit_foreign_amount1, DbLedgerRecord.currency),
creditAmount2: DbLedgerRecord =>
DbLedgerRecord.credit_foreign_amount2 == null
? null
: formatFinancialAmount(DbLedgerRecord.credit_foreign_amount2, DbLedgerRecord.currency),
localCurrencyDebitAmount1: async (DbLedgerRecord, _, { injector }) => {
const defaultLocalCurrency = await recordLocalCurrency(injector, DbLedgerRecord.owner_id);
return formatFinancialAmount(DbLedgerRecord.debit_local_amount1, defaultLocalCurrency);
},
localCurrencyDebitAmount2: async (DbLedgerRecord, _, { injector }) => {
const defaultLocalCurrency = await recordLocalCurrency(injector, DbLedgerRecord.owner_id);
return DbLedgerRecord.debit_local_amount2 == null
? null
: formatFinancialAmount(DbLedgerRecord.debit_local_amount2, defaultLocalCurrency);
},
localCurrencyCreditAmount1: async (DbLedgerRecord, _, { injector }) => {
const defaultLocalCurrency = await recordLocalCurrency(injector, DbLedgerRecord.owner_id);
return formatFinancialAmount(DbLedgerRecord.credit_local_amount1, defaultLocalCurrency);
},
localCurrencyCreditAmount2: async (DbLedgerRecord, _, { injector }) => {
const defaultLocalCurrency = await recordLocalCurrency(injector, DbLedgerRecord.owner_id);
return DbLedgerRecord.credit_local_amount2 == null
? null
: formatFinancialAmount(DbLedgerRecord.credit_local_amount2, defaultLocalCurrency);
},
invoiceDate: DbLedgerRecord => DbLedgerRecord.invoice_date,
valueDate: DbLedgerRecord => DbLedgerRecord.value_date,
description: DbLedgerRecord => DbLedgerRecord.description ?? null,
reference: DbLedgerRecord => DbLedgerRecord.reference1 ?? null,
},
Ledger: {
records: parent => parent.records,
balance: async (parent, _, { injector }) => {
if (parent.balance) {
return parent.balance;
}
const { ownerId, defaultLocalCurrency } = await injector
.get(AdminContextProvider)
.getVerifiedAdminContext();
const financialEntitiesIds = new Set<string>();
parent.records.map(record => {
if (record.debit_entity1) {
financialEntitiesIds.add(record.debit_entity1);
}
if (record.debit_entity2) {
financialEntitiesIds.add(record.debit_entity2);
}
if (record.credit_entity1) {
financialEntitiesIds.add(record.credit_entity1);
}
if (record.credit_entity2) {
financialEntitiesIds.add(record.credit_entity2);
}
});
const financialEntitiesPromise = await injector
.get(FinancialEntitiesProvider)
.getFinancialEntityByIdLoader.loadMany(Array.from(financialEntitiesIds))
.then(
res =>
res.filter(e => !!e && !(e instanceof Error)) as IGetFinancialEntitiesByIdsResult[],
);
const allowedUnbalancedBusinessesPromise = ledgerUnbalancedBusinessesByCharge(
parent.charge,
injector,
);
const [financialEntities, allowedUnbalancedBusinesses] = await Promise.all([
financialEntitiesPromise,
allowedUnbalancedBusinessesPromise,
]);
const ledgerBalance = new Map<string, { amount: number; entityId: string }>();
const ledgerEntries = parent.records.map(record =>
convertLedgerRecordToProto(record, ownerId),
);
for (const ledgerEntry of ledgerEntries) {
updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
}
return getLedgerBalanceInfo(
injector,
ledgerBalance,
undefined,
allowedUnbalancedBusinesses,
financialEntities,
);
},
validate: async ({ charge, records }, _, context, info) => {
const { injector } = context;
const { ledgerLock } = await injector.get(AdminContextProvider).getVerifiedAdminContext();
if (await isChargeLocked(charge, injector, ledgerLock)) {
return {
isValid: true,
differences: [],
matches: records.map(r => r.id),
errors: [],
};
}
try {
const generated = await ledgerGenerationByCharge(
charge,
{ insertLedgerRecordsIfNotExists: records.length === 0 },
context,
info,
);
if (!generated || 'message' in generated) {
return {
isValid: false,
differences: [],
matches: [],
errors: [],
};
}
const newRecords = generated.records as IGetLedgerRecordsByChargesIdsResult[];
const fullMatching = ledgerRecordsGenerationFullMatchComparison(records, newRecords);
if (fullMatching.isFullyMatched) {
return {
isValid: true,
differences: [],
matches: Array.from(fullMatching.fullMatches.values()).filter(Boolean) as string[],
errors: generated.errors,
};
}
const { toUpdate } = ledgerRecordsGenerationPartialMatchComparison(
fullMatching.unmatchedStorageRecords,
fullMatching.unmatchedNewRecords,
);
return {
isValid: fullMatching.isFullyMatched && generated.errors.length === 0,
differences: toUpdate,
matches: Array.from(fullMatching.fullMatches.values()).filter(Boolean) as string[],
errors: generated.errors,
};
} catch (err) {
console.error(err);
return {
isValid: false,
differences: [],
matches: [],
errors: [],
};
}
},
},
LedgerBalanceUnbalancedEntity: {
entity: (parent, _, { injector }) =>
injector
.get(FinancialEntitiesProvider)
.getFinancialEntityByIdLoader.load(parent.entityId)
.then(res => {
if (!res) {
throw new GraphQLError(`Financial entity with id ${parent.entityId} not found`);
}
return res;
}),
balance: parent => parent.balance,
},
CommonCharge: {
...commonChargeLedgerResolver,
},
FinancialCharge: {
...commonChargeLedgerResolver,
},
ConversionCharge: {
...commonChargeLedgerResolver,
},
SalaryCharge: {
...commonChargeLedgerResolver,
},
InternalTransferCharge: {
...commonChargeLedgerResolver,
},
DividendCharge: {
...commonChargeLedgerResolver,
},
BusinessTripCharge: {
...commonChargeLedgerResolver,
},
MonthlyVatCharge: {
...commonChargeLedgerResolver,
},
BankDepositCharge: {
...commonChargeLedgerResolver,
},
ForeignSecuritiesCharge: {
...commonChargeLedgerResolver,
},
CreditcardBankCharge: {
...commonChargeLedgerResolver,
},
GeneratedLedgerRecords: {
__resolveType: (obj, _context, _info) => {
if ('__typename' in obj && obj.__typename === 'CommonError') return 'CommonError';
return 'Ledger';
},
},
ChargeMetadata: {
isLedgerLocked: async (DbCharge, _, { injector }) => {
const { ledgerLock } = await injector.get(AdminContextProvider).getVerifiedAdminContext();
return isChargeLocked(DbCharge, injector, ledgerLock);
},
},
};