@accounter/server
Version:
Accounter GraphQL server
365 lines (320 loc) • 12.8 kB
text/typescript
import DataLoader from 'dataloader';
import { GraphQLError } from 'graphql';
import { Injectable, Scope } from 'graphql-modules';
import { sql } from '@pgtyped/runtime';
import { dateToTimelessDateString, reassureOwnerIdExists } from '../../../shared/helpers/index.js';
import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js';
import { TenantAwareDBClient } from '../../app-providers/tenant-db-client.js';
import { identifyInterestTransactionIds } from '../../ledger/helpers/bank-deposit-ledger-generation.helper.js';
import { TransactionsProvider } from '../../transactions/providers/transactions.provider.js';
import type {
BankDepositMetadataProto,
IGetAllDepositsWithTransactionsQuery,
IGetBankDepositsByChargeIdsQuery,
IGetBankDepositsByChargeIdsResult,
IGetDepositTransactionsByChargeIdQuery,
IGetTransactionsByBankDepositsQuery,
IUnlinkChargesFromBankDepositsByChargeIdsQuery,
IUpsertBankDepositChargeParams,
IUpsertBankDepositChargeQuery,
} from '../types.js';
import { BankDepositsProvider } from './bank-deposits.provider.js';
const getTransactionsByBankDeposits = sql<IGetTransactionsByBankDepositsQuery>`
SELECT
cbd.new_deposit_id,
t.*
FROM accounter_schema.charges_bank_deposits cbd
INNER JOIN accounter_schema.transactions t
ON cbd.id = t.charge_id
WHERE new_deposit_id IN $$depositIds;`;
const getBankDepositsByChargeIds = sql<IGetBankDepositsByChargeIdsQuery>`
SELECT bd.id, bd.name, bd.currency, bd.account_id, bd.open_date, bd.close_date, cbd.id as charge_id
FROM accounter_schema.charges_bank_deposits cbd
INNER JOIN accounter_schema.bank_deposits bd
ON cbd.new_deposit_id = bd.id
WHERE cbd.id IN $$chargeIds;`;
const getDepositTransactionsByChargeId = sql<IGetDepositTransactionsByChargeIdQuery>`
SELECT
cbd.new_deposit_id,
t.*
FROM accounter_schema.charges_bank_deposits cbd
LEFT JOIN accounter_schema.transactions t
ON cbd.id = t.charge_id
WHERE new_deposit_id IN (
SELECT cbd2.new_deposit_id
FROM accounter_schema.charges_bank_deposits cbd2
WHERE cbd2.id = $chargeId
)
AND ($includeCharge OR t.charge_id <> $chargeId);`;
const upsertBankDepositCharge = sql<IUpsertBankDepositChargeQuery>`
INSERT INTO accounter_schema.charges_bank_deposits (id, new_deposit_id, owner_id, account_id)
VALUES ($chargeId, $depositId, $ownerId, $accountId)
ON CONFLICT (id) DO UPDATE SET new_deposit_id = EXCLUDED.new_deposit_id, owner_id = EXCLUDED.owner_id, account_id = EXCLUDED.account_id;
`;
const unlinkChargesFromBankDepositsByChargeIds = sql<IUnlinkChargesFromBankDepositsByChargeIdsQuery>`
DELETE FROM accounter_schema.charges_bank_deposits
WHERE id IN $$chargeIds;
`;
const getAllDepositsWithTransactions = sql<IGetAllDepositsWithTransactionsQuery>`
SELECT
bd.id as new_deposit_id,
bd.name as deposit_name,
bd.currency as deposit_currency,
bd.open_date,
bd.close_date,
t.*
FROM accounter_schema.bank_deposits bd
LEFT JOIN accounter_schema.charges_bank_deposits cbd
ON cbd.new_deposit_id = bd.id
LEFT JOIN accounter_schema.transactions t
ON cbd.id = t.charge_id
ORDER BY bd.id, COALESCE(t.debit_date, t.event_date);`;
({
scope: Scope.Operation,
global: true,
})
export class BankDepositChargesProvider {
constructor(
private db: TenantAwareDBClient,
private adminContextProvider: AdminContextProvider,
private transactionsProvider: TransactionsProvider,
private bankDepositsProvider: BankDepositsProvider,
) {}
private async batchTransactionsByBankDeposits(depositIds: readonly string[]) {
const transactions = await getTransactionsByBankDeposits.run(
{
depositIds,
},
this.db,
);
return depositIds.map(id => transactions.filter(t => t.new_deposit_id === id));
}
public getTransactionsByBankDepositLoader = new DataLoader((keys: readonly string[]) =>
this.batchTransactionsByBankDeposits(keys),
);
private async batchBankDepositsByChargeIds(chargeIds: readonly string[]) {
const rows = await getBankDepositsByChargeIds.run(
{ chargeIds: Array.from(new Set(chargeIds)) },
this.db,
);
const chargeIdToDepositMap = new Map<string, IGetBankDepositsByChargeIdsResult>();
for (const row of rows) {
if (row.charge_id) {
chargeIdToDepositMap.set(row.charge_id, row);
}
}
return chargeIds.map(chargeId => chargeIdToDepositMap.get(chargeId) ?? null);
}
public getBankDepositByChargeIdLoader = new DataLoader(async (chargeIds: readonly string[]) =>
this.batchBankDepositsByChargeIds(chargeIds),
);
public async getDepositTransactionsByChargeId(chargeId: string, includeCharge = false) {
return getDepositTransactionsByChargeId.run({ chargeId, includeCharge }, this.db);
}
public async upsertBankDepositCharge(params: IUpsertBankDepositChargeParams) {
const { ownerId } = await this.adminContextProvider.getVerifiedAdminContext();
return upsertBankDepositCharge.run(reassureOwnerIdExists(params, ownerId), this.db);
}
private async batchBankDepositsMetadata(
depositIds: readonly string[],
): Promise<(BankDepositMetadataProto | Error)[]> {
const uniqueDepositIds = Array.from(new Set(depositIds));
const depositsTransactions =
await this.getTransactionsByBankDepositLoader.loadMany(uniqueDepositIds);
const depositIdToMetadataMap = new Map<string, BankDepositMetadataProto | Error>();
for (let i = 0; i < uniqueDepositIds.length; i++) {
const depositId = uniqueDepositIds[i];
const transactions = depositsTransactions[i] ?? [];
if (transactions instanceof Error) {
depositIdToMetadataMap.set(depositId, transactions);
continue;
}
const interestTransactionIds = identifyInterestTransactionIds(transactions, {
getId: r => r.id,
getChargeId: r => r.charge_id,
getAmount: r => Number(r.amount ?? 0),
});
let currentBalance = 0;
let totalInterest = 0;
let totalDeposit = 0;
let potentialCloseDate: Date | null = null;
const transactionIds: string[] = [];
for (const tx of transactions) {
if (!tx.id) continue;
transactionIds.push(tx.id);
const amount = Number(tx.amount ?? 0);
if (interestTransactionIds.has(tx.id)) {
totalInterest += amount;
} else {
currentBalance += amount;
if (amount > 0) {
totalDeposit += amount;
}
}
const transactionDate = tx.debit_date ?? tx.event_date;
if (!potentialCloseDate || transactionDate > potentialCloseDate) {
potentialCloseDate = transactionDate;
}
}
depositIdToMetadataMap.set(depositId, {
id: depositId,
potentialCloseDate: potentialCloseDate
? dateToTimelessDateString(potentialCloseDate)
: null,
currentBalance,
totalInterest,
totalDeposit,
transactionIds,
});
}
return depositIds.map(id => {
const metadata = depositIdToMetadataMap.get(id);
if (!metadata) {
return {
id,
potentialCloseDate: null,
currentBalance: 0,
totalInterest: 0,
totalDeposit: 0,
transactionIds: [],
};
}
if (metadata instanceof Error) {
return metadata;
}
return metadata;
});
}
public getBankDepositMetadataLoader = new DataLoader(async (depositIds: readonly string[]) =>
this.batchBankDepositsMetadata(depositIds),
);
public async getAllDepositsWithMetadata(): Promise<Array<BankDepositMetadataProto>> {
const transactionRows = await getAllDepositsWithTransactions.run(undefined, this.db);
const depositMap = new Map<
string,
Omit<BankDepositMetadataProto, 'potentialCloseDate'> & { potentialCloseDate: Date | null }
>();
const interestTransactionIds = identifyInterestTransactionIds(transactionRows, {
getId: r => r.id,
getChargeId: r => r.charge_id,
getAmount: r => Number(r.amount ?? 0),
});
for (const row of transactionRows) {
if (!row.new_deposit_id) continue;
if (!depositMap.has(row.new_deposit_id)) {
depositMap.set(row.new_deposit_id, {
id: row.new_deposit_id,
potentialCloseDate: row.close_date,
currentBalance: 0,
totalInterest: 0,
totalDeposit: 0,
transactionIds: [],
});
}
if (!row.id) continue;
const deposit = depositMap.get(row.new_deposit_id)!;
deposit.transactionIds.push(row.id);
if (row.amount) {
const amount = Number(row.amount);
if (interestTransactionIds.has(row.id)) {
deposit.totalInterest += amount;
} else {
deposit.currentBalance += amount;
if (amount > 0) {
deposit.totalDeposit += amount;
}
}
}
}
return Array.from(depositMap.values()).map(deposit => ({
id: deposit.id,
potentialCloseDate: deposit.potentialCloseDate
? dateToTimelessDateString(deposit.potentialCloseDate)
: null,
currentBalance: deposit.currentBalance,
totalInterest: deposit.totalInterest,
totalDeposit: deposit.totalDeposit,
transactionIds: deposit.transactionIds,
}));
}
public async unlinkChargesFromBankDepositsByChargeIds(chargeIds: string[]) {
return unlinkChargesFromBankDepositsByChargeIds.run({ chargeIds }, this.db);
}
public async assignChargeToDeposit(chargeId: string, depositId: string) {
const [deposit, transactions] = await Promise.all([
// Get target deposit transactions to validate currency
this.bankDepositsProvider.bankDepositByIdLoader.load(depositId),
this.transactionsProvider.transactionsByChargeIDLoader.load(chargeId),
]);
if (!deposit) {
throw new GraphQLError(`Deposit ${depositId} not found`);
}
if (transactions.length < 1) {
throw new GraphQLError(`No transactions found for charge ID="${chargeId}"`);
}
let accountId = deposit.account_id;
let currency = deposit.currency;
// Check currency or account conflict
for (const transaction of transactions) {
if (!accountId || !currency) {
accountId ??= transaction.account_id;
currency ??= transaction.currency;
await this.bankDepositsProvider.updateBankDeposit({
depositId: deposit.id,
accountId,
currency,
});
}
if (accountId !== transaction.account_id) {
throw new Error(`Account conflict: Transactions accounts are inconsistent`);
}
if (currency !== transaction.currency) {
throw new Error(`Currency conflict: Transactions currencies are inconsistent`);
}
}
await this.upsertBankDepositCharge({ chargeId, depositId, accountId });
}
public async createDepositFromCharge(chargeId: string, name: string) {
const transactions =
await this.transactionsProvider.transactionsByChargeIDLoader.load(chargeId);
if (transactions.length === 0) {
throw new GraphQLError(
'createDepositFromCharge can only be called for charges that have at least one transaction',
);
}
// Validate single currency and single account across all transactions
const currencies = new Set(transactions.map(t => t.currency));
if (currencies.size > 1) {
throw new GraphQLError(
`Cannot create deposit: charge has transactions in multiple currencies (${[...currencies].join(', ')})`,
);
}
const accountIds = new Set(transactions.map(t => t.account_id).filter(Boolean));
if (accountIds.size > 1) {
throw new GraphQLError(
`Cannot create deposit: charge has transactions from multiple accounts`,
);
}
const currency = [...currencies][0];
const accountId = [...accountIds][0] ?? null;
// Derive open_date from the earliest debit_date, falling back to event_date
const openDate = transactions.reduce<Date | null>((min, t) => {
const d = t.debit_date ?? t.event_date;
if (!d) return min;
return min === null || d < min ? d : min;
}, null);
const deposit = await this.bankDepositsProvider.insertBankDeposit({
name,
currency,
accountId,
openDate,
closeDate: null,
});
if (!deposit) {
throw new GraphQLError('Failed to create bank deposit');
}
await this.upsertBankDepositCharge({ chargeId, depositId: deposit.id });
this.getBankDepositByChargeIdLoader.clear(chargeId);
return deposit;
}
}