UNPKG

@accounter/server

Version:
272 lines (239 loc) • 9.57 kB
import DataLoader from 'dataloader'; 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 { IDeleteBankDepositChargesByChargeIdsQuery, IGetAllDepositsWithTransactionsQuery, IGetDepositTransactionsByChargeIdQuery, IGetTransactionsByBankDepositsQuery, IInsertOrUpdateBankDepositChargeParams, IInsertOrUpdateBankDepositChargeQuery, } from '../types.js'; const getTransactionsByBankDeposits = sql<IGetTransactionsByBankDepositsQuery>` SELECT cbd.deposit_id, cbd.account_id as deposit_account_id, t.* FROM accounter_schema.charges_bank_deposits cbd INNER JOIN accounter_schema.transactions t ON cbd.id = t.charge_id WHERE deposit_id IN $$depositIds;`; const getDepositTransactionsByChargeId = sql<IGetDepositTransactionsByChargeIdQuery>` SELECT cbd.deposit_id, cbd.account_id as deposit_account_id, t.* FROM accounter_schema.charges_bank_deposits cbd LEFT JOIN accounter_schema.transactions t ON cbd.id = t.charge_id WHERE deposit_id IN ( SELECT cbd2.deposit_id FROM accounter_schema.charges_bank_deposits cbd2 WHERE cbd2.id = $chargeId ) AND ($includeCharge OR t.charge_id <> $chargeId);`; const insertOrUpdateBankDepositCharge = sql<IInsertOrUpdateBankDepositChargeQuery>` INSERT INTO accounter_schema.charges_bank_deposits (id, deposit_id, account_id, owner_id) VALUES ($chargeId, $depositId, $accountId, $ownerId) ON CONFLICT (id) DO UPDATE SET deposit_id = EXCLUDED.deposit_id, account_id = EXCLUDED.account_id, owner_id = EXCLUDED.owner_id; `; const deleteBankDepositChargesByChargeIds = sql<IDeleteBankDepositChargesByChargeIdsQuery>` DELETE FROM accounter_schema.charges_bank_deposits WHERE id IN $$chargeIds; `; const getAllDepositsWithTransactions = sql<IGetAllDepositsWithTransactionsQuery>` SELECT cbd.deposit_id, t.id, t.currency, t.debit_date, t.event_date, t.amount, t.current_balance, t.charge_id FROM accounter_schema.charges_bank_deposits cbd LEFT JOIN accounter_schema.transactions t ON cbd.id = t.charge_id WHERE cbd.deposit_id IS NOT NULL ORDER BY cbd.deposit_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 async batchTransactionsByBankDeposits(depositIds: readonly string[]) { const transactions = await getTransactionsByBankDeposits.run( { depositIds, }, this.db, ); return depositIds.map(id => transactions.filter(t => t.deposit_id === id)); } public getTransactionsByBankDepositLoader = new DataLoader((keys: readonly string[]) => this.batchTransactionsByBankDeposits(keys), ); public async getDepositTransactionsByChargeId(chargeId: string, includeCharge = false) { return getDepositTransactionsByChargeId.run({ chargeId, includeCharge }, this.db); } public async insertOrUpdateBankDepositCharge(params: IInsertOrUpdateBankDepositChargeParams) { const { ownerId } = await this.adminContextProvider.getVerifiedAdminContext(); return insertOrUpdateBankDepositCharge.run(reassureOwnerIdExists(params, ownerId), this.db); } public async getAllDepositsWithMetadata() { const rows = await getAllDepositsWithTransactions.run(undefined, this.db); // Group transactions by deposit_id const depositMap = new Map< string, { id: string; currency: string | null; openDate: Date | null; closeDate: Date | null; currentBalance: number; totalInterest: number; totalDeposit: number; currencyError: string[]; transactionIds: string[]; } >(); // Identify interest transactions using shared helper const interestTransactionIds = identifyInterestTransactionIds(rows, { getId: r => r.id, getChargeId: r => r.charge_id, getAmount: r => Number(r.amount ?? 0), }); for (const row of rows) { if (!row.deposit_id) continue; if (!depositMap.has(row.deposit_id)) { depositMap.set(row.deposit_id, { id: row.deposit_id, currency: row.currency, openDate: row.debit_date ?? row.event_date, closeDate: null, currentBalance: 0, totalInterest: 0, totalDeposit: 0, currencyError: [], transactionIds: [], }); } const deposit = depositMap.get(row.deposit_id)!; deposit.transactionIds.push(row.id); // Validate single currency if (deposit.currency && row.currency && deposit.currency !== row.currency) { const alreadyInErrors = deposit.currencyError.includes(row.id); if (!alreadyInErrors) { deposit.currencyError.push(row.id); } } // Update openDate (earliest transaction) const txDate = row.debit_date ?? row.event_date; if (!deposit.openDate || (txDate && txDate < deposit.openDate)) { deposit.openDate = txDate; } // Track balance and interest separately 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; } } } // If balance reaches zero, update closeDate (interest doesn't affect closure) if (Math.abs(deposit.currentBalance) < 0.005 && txDate) { deposit.closeDate = txDate; } } // Ensure closeDate reflects final state: if not closed, closeDate must be null for (const d of depositMap.values()) { if (Math.abs(d.currentBalance) >= 0.005) { d.closeDate = null; } } return Array.from(depositMap.values()).map(deposit => ({ id: deposit.id, currency: deposit.currency, openDate: deposit.openDate ? dateToTimelessDateString(deposit.openDate) : null, closeDate: deposit.closeDate ? dateToTimelessDateString(deposit.closeDate) : null, currentBalance: deposit.currentBalance, totalInterest: deposit.totalInterest, totalDeposit: deposit.totalDeposit, currencyError: deposit.currencyError, transactionIds: deposit.transactionIds, })); } public async createDeposit(currency: string) { // Generate unique deposit ID using timestamp and random suffix const depositId = `${currency}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; return { id: depositId, currency, openDate: null, closeDate: null, currentBalance: 0, totalInterest: 0, currencyError: [], transactionIds: [], }; } public async deleteChargeDepositsByChargeIds(chargeIds: string[]) { return deleteBankDepositChargesByChargeIds.run({ chargeIds }, this.db); } public async assignChargeToDeposit(chargeId: string, depositId: string) { const [depositTransactions, transactions] = await Promise.all([ // Get target deposit transactions to validate currency this.getTransactionsByBankDepositLoader.load(depositId), this.transactionsProvider.transactionsByChargeIDLoader.load(chargeId), ]); let accountId: string | null = null; // Check currency conflict if (depositTransactions.length > 0) { const depositCurrency = depositTransactions[0].currency; const depositAccountId = depositTransactions[0].deposit_account_id; for (const transaction of transactions) { const transactionCurrency = transaction.currency; if (depositCurrency && transactionCurrency !== depositCurrency) { throw new Error( `Currency conflict: Transaction currency (${transactionCurrency}) does not match deposit currency (${depositCurrency})`, ); } if (depositAccountId !== transaction.account_id) { throw new Error( `Account conflict: Transaction account (${transaction.account_id}) does not match deposit account (${depositAccountId})`, ); } } accountId = depositAccountId; } else { for (const transaction of transactions) { accountId ??= transaction.account_id; if (accountId !== transaction.account_id) { throw new Error(`Account conflict: Transactions accounts are inconsistent`); } } } await this.insertOrUpdateBankDepositCharge({ chargeId, depositId, accountId }); // Return updated deposit metadata const allDeposits = await this.getAllDepositsWithMetadata(); const updatedDeposit = allDeposits.find(d => d.id === depositId); if (!updatedDeposit) { throw new Error('Deposit not found after assignment'); } return updatedDeposit; } }