UNPKG

@accounter/server

Version:
365 lines (320 loc) • 12.8 kB
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);`; @Injectable({ 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; } }