UNPKG

@accounter/server

Version:
635 lines (605 loc) 25.7 kB
import DataLoader from 'dataloader'; import { GraphQLError } from 'graphql'; import { Injectable, Scope } from 'graphql-modules'; import { sql } from '@pgtyped/runtime'; import { formatCurrency, optionalDateToTimelessDateString, resolveWriteTargetBusinessId, } from '../../../shared/helpers/index.js'; import type { AuthContext } from '../../../shared/types/auth.js'; import { TenantAwareDBClient } from '../../app-providers/tenant-db-client.js'; import { AuthContextProvider } from '../../auth/providers/auth-context.provider.js'; import type { AdminContext, IGetAdminContextsQuery, IGetAdminContextsResult, IUpdateAdminContextParams, IUpdateAdminContextQuery, } from '../types.js'; // The per-role correlated subqueries below expose each admin_business_role group // (see #3612) as an aggregated uuid[] column on the context row, so the runtime // arrays (bank accounts, credit cards, internal wallets, dividend payments, // VAT-excluded) are data-driven instead of enumerated from named columns/UUIDs. const getAdminContexts = sql<IGetAdminContextsQuery>` SELECT uc.*, COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = uc.owner_id AND r.role = 'BANK_ACCOUNT'), '{}'::uuid[]) AS bank_account_business_ids, COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = uc.owner_id AND r.role = 'CREDIT_CARD'), '{}'::uuid[]) AS credit_card_business_ids, COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = uc.owner_id AND r.role = 'CRYPTO_WALLET'), '{}'::uuid[]) AS crypto_wallet_business_ids, COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = uc.owner_id AND r.role = 'DIVIDEND_PAYMENT'), '{}'::uuid[]) AS dividend_payment_business_ids, COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = uc.owner_id AND r.role = 'VAT_EXCLUDED'), '{}'::uuid[]) AS vat_excluded_business_ids FROM accounter_schema.user_context uc WHERE uc.owner_id IN $$ownerIds; `; const updateAdminContext = sql<IUpdateAdminContextQuery>` UPDATE accounter_schema.user_context SET default_local_currency = COALESCE( $defaultLocalCurrency, default_local_currency ), default_fiat_currency_for_crypto_conversions = COALESCE( $defaultFiatCurrencyForCryptoConversions, default_fiat_currency_for_crypto_conversions ), default_tax_category_id = COALESCE( $defaultTaxCategoryId, default_tax_category_id ), vat_business_id = COALESCE( $vatBusinessId, vat_business_id ), input_vat_tax_category_id = COALESCE( $inputVatTaxCategoryId, input_vat_tax_category_id ), output_vat_tax_category_id = COALESCE( $outputVatTaxCategoryId, output_vat_tax_category_id ), property_output_vat_tax_category_id = COALESCE( $propertyOutputVatTaxCategoryId, property_output_vat_tax_category_id ), tax_business_id = COALESCE( $taxBusinessId, tax_business_id ), tax_expenses_tax_category_id = COALESCE( $taxExpensesTaxCategoryId, tax_expenses_tax_category_id ), social_security_business_id = COALESCE( $socialSecurityBusinessId, social_security_business_id ), exchange_rate_tax_category_id = COALESCE( $exchangeRateTaxCategoryId, exchange_rate_tax_category_id ), income_exchange_rate_tax_category_id = COALESCE( $incomeExchangeRateTaxCategoryId, income_exchange_rate_tax_category_id ), exchange_rate_revaluation_tax_category_id = COALESCE( $exchangeRateRevaluationTaxCategoryId, exchange_rate_revaluation_tax_category_id ), fee_tax_category_id = COALESCE( $feeTaxCategoryId, fee_tax_category_id ), general_fee_tax_category_id = COALESCE( $generalFeeTaxCategoryId, general_fee_tax_category_id ), fine_tax_category_id = COALESCE( $fineTaxCategoryId, fine_tax_category_id ), untaxable_gifts_tax_category_id = COALESCE( $untaxableGiftsTaxCategoryId, untaxable_gifts_tax_category_id ), balance_cancellation_tax_category_id = COALESCE( $balanceCancellationTaxCategoryId, balance_cancellation_tax_category_id ), development_foreign_tax_category_id = COALESCE( $developmentForeignTaxCategoryId, development_foreign_tax_category_id ), development_local_tax_category_id = COALESCE( $developmentLocalTaxCategoryId, development_local_tax_category_id ), accumulated_depreciation_tax_category_id = COALESCE( $accumulatedDepreciationTaxCategoryId, accumulated_depreciation_tax_category_id ), rnd_depreciation_expenses_tax_category_id = COALESCE( $rndDepreciationExpensesTaxCategoryId, rnd_depreciation_expenses_tax_category_id ), gnm_depreciation_expenses_tax_category_id = COALESCE( $gnmDepreciationExpensesTaxCategoryId, gnm_depreciation_expenses_tax_category_id ), marketing_depreciation_expenses_tax_category_id = COALESCE( $marketingDepreciationExpensesTaxCategoryId, marketing_depreciation_expenses_tax_category_id ), bank_deposit_interest_income_tax_category_id = COALESCE( $bankDepositInterestIncomeTaxCategoryId, bank_deposit_interest_income_tax_category_id ), business_trip_tax_category_id = COALESCE( $businessTripTaxCategoryId, business_trip_tax_category_id ), business_trip_tag_id = COALESCE( $businessTripTagId, business_trip_tag_id ), expenses_to_pay_tax_category_id = COALESCE( $expensesToPayTaxCategoryId, expenses_to_pay_tax_category_id ), expenses_in_advance_tax_category_id = COALESCE( $expensesInAdvanceTaxCategoryId, expenses_in_advance_tax_category_id ), income_to_collect_tax_category_id = COALESCE( $incomeToCollectTaxCategoryId, income_to_collect_tax_category_id ), income_in_advance_tax_category_id = COALESCE( $incomeInAdvanceTaxCategoryId, income_in_advance_tax_category_id ), zkufot_expenses_tax_category_id = COALESCE( $zkufotExpensesTaxCategoryId, zkufot_expenses_tax_category_id ), zkufot_income_tax_category_id = COALESCE( $zkufotIncomeTaxCategoryId, zkufot_income_tax_category_id ), social_security_expenses_tax_category_id = COALESCE( $socialSecurityExpensesTaxCategoryId, social_security_expenses_tax_category_id ), salary_expenses_tax_category_id = COALESCE( $salaryExpensesTaxCategoryId, salary_expenses_tax_category_id ), training_fund_expenses_tax_category_id = COALESCE( $trainingFundExpensesTaxCategoryId, training_fund_expenses_tax_category_id ), pension_fund_expenses_tax_category_id = COALESCE( $pensionFundExpensesTaxCategoryId, pension_fund_expenses_tax_category_id ), compensation_fund_expenses_tax_category_id = COALESCE( $compensationFundExpensesTaxCategoryId, compensation_fund_expenses_tax_category_id ), batched_employees_business_id = COALESCE( $batchedEmployeesBusinessId, batched_employees_business_id ), batched_funds_business_id = COALESCE( $batchedFundsBusinessId, batched_funds_business_id ), tax_deductions_business_id = COALESCE( $taxDeductionsBusinessId, tax_deductions_business_id ), recovery_reserve_expenses_tax_category_id = COALESCE( $recoveryReserveExpensesTaxCategoryId, recovery_reserve_expenses_tax_category_id ), recovery_reserve_tax_category_id = COALESCE( $recoveryReserveTaxCategoryId, recovery_reserve_tax_category_id ), vacation_reserve_expenses_tax_category_id = COALESCE( $vacationReserveExpensesTaxCategoryId, vacation_reserve_expenses_tax_category_id ), vacation_reserve_tax_category_id = COALESCE( $vacationReserveTaxCategoryId, vacation_reserve_tax_category_id ), poalim_business_id = COALESCE( $poalimBusinessId, poalim_business_id ), discount_business_id = COALESCE( $discountBusinessId, discount_business_id ), otsar_hahayal_business_id = COALESCE( $otsarHahayalBusinessId, otsar_hahayal_business_id ), isracard_business_id = COALESCE( $isracardBusinessId, isracard_business_id ), amex_business_id = COALESCE( $amexBusinessId, amex_business_id ), cal_business_id = COALESCE( $calBusinessId, cal_business_id ), otsar_hahayal_credit_card_business_id = COALESCE( $otsarHahayalCreditCardBusinessId, otsar_hahayal_credit_card_business_id ), etana_business_id = COALESCE( $etanaBusinessId, etana_business_id ), kraken_business_id = COALESCE( $krakenBusinessId, kraken_business_id ), etherscan_business_id = COALESCE( $etherscanBusinessId, etherscan_business_id ), swift_business_id = COALESCE( $swiftBusinessId, swift_business_id ), bank_deposit_business_id = COALESCE( $bankDepositBusinessId, bank_deposit_business_id ), dividend_withholding_tax_business_id = COALESCE( $dividendWithholdingTaxBusinessId, dividend_withholding_tax_business_id ), dividend_tax_category_id = COALESCE( $dividendTaxCategoryId, dividend_tax_category_id ), salary_excess_expenses_tax_category_id = COALESCE( $salaryExcessExpensesTaxCategoryId, salary_excess_expenses_tax_category_id ), ledger_lock = COALESCE( $ledgerLock, ledger_lock ), foreign_securities_business_id = COALESCE( $foreignSecuritiesBusinessId, foreign_securities_business_id ), foreign_securities_fees_category_id = COALESCE( $foreignSecuritiesFeesCategoryId, foreign_securities_fees_category_id ), date_established = COALESCE( $dateEstablished, date_established ), initial_accounter_year = COALESCE( $initialAccounterYear, initial_accounter_year ), locality = COALESCE( $locality, locality ) WHERE owner_id = $ownerId RETURNING user_context.*, COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = user_context.owner_id AND r.role = 'BANK_ACCOUNT'), '{}'::uuid[]) AS bank_account_business_ids, COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = user_context.owner_id AND r.role = 'CREDIT_CARD'), '{}'::uuid[]) AS credit_card_business_ids, COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = user_context.owner_id AND r.role = 'CRYPTO_WALLET'), '{}'::uuid[]) AS crypto_wallet_business_ids, COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = user_context.owner_id AND r.role = 'DIVIDEND_PAYMENT'), '{}'::uuid[]) AS dividend_payment_business_ids, COALESCE((SELECT array_agg(r.business_id ORDER BY r.business_id) FROM accounter_schema.admin_business_roles r WHERE r.owner_id = user_context.owner_id AND r.role = 'VAT_EXCLUDED'), '{}'::uuid[]) AS vat_excluded_business_ids; `; /** * Legacy per-institution `updateAdminContext` inputs mapped to their abstract * {@link https://github.com/Urigo/accounter-fullstack/issues/3612 admin_business_role}. * This lets the deprecated typed mutation inputs (poalimBusinessId, …) keep * admin_business_roles in sync so the role-derived arrays reflect them. Phase 2 * replaces these inputs with an abstract data-source API and removes this adapter. */ const PROVIDER_INPUT_ROLES: ReadonlyArray<[keyof IUpdateAdminContextParams, string]> = [ ['poalimBusinessId', 'BANK_ACCOUNT'], ['discountBusinessId', 'BANK_ACCOUNT'], ['otsarHahayalBusinessId', 'BANK_ACCOUNT'], ['isracardBusinessId', 'CREDIT_CARD'], ['amexBusinessId', 'CREDIT_CARD'], ['calBusinessId', 'CREDIT_CARD'], ['otsarHahayalCreditCardBusinessId', 'CREDIT_CARD'], ['etanaBusinessId', 'CRYPTO_WALLET'], ['krakenBusinessId', 'CRYPTO_WALLET'], ['etherscanBusinessId', 'CRYPTO_WALLET'], ['vatBusinessId', 'VAT_EXCLUDED'], ['taxBusinessId', 'VAT_EXCLUDED'], ['socialSecurityBusinessId', 'VAT_EXCLUDED'], ]; @Injectable({ scope: Scope.Operation, global: true, }) export class AdminContextProvider { private authContext: AuthContext | null = null; private authContextInitialized = false; private cachedContext: AdminContext | null = null; private cachedContextInitializing: Promise<AdminContext | null> | null = null; constructor( private authContextProvider: AuthContextProvider, private db: TenantAwareDBClient, ) {} public normalizeContext(rawContext: IGetAdminContextsResult): AdminContext { // Data-driven data-source / classification arrays, sourced from // admin_business_roles (see #3612) instead of hardcoded per-institution // columns or literal UUIDs. `toIdArray` is defensive against pgTyped's array // nullability and against callers (e.g. unit tests) that supply a row without // the aggregated columns. const toIdArray = (value: readonly (string | null)[] | null | undefined): string[] => (value ?? []).filter((id): id is string => id != null); const bankAccountIds = toIdArray(rawContext.bank_account_business_ids); const creditCardIds = toIdArray(rawContext.credit_card_business_ids); const cryptoWalletIds = toIdArray(rawContext.crypto_wallet_business_ids); const dividendPaymentBusinessIds = toIdArray(rawContext.dividend_payment_business_ids); const vatReportExcludedBusinessNames = toIdArray(rawContext.vat_excluded_business_ids); const salaryBatchedBusinessIds = [ rawContext.batched_employees_business_id, rawContext.batched_funds_business_id, ].filter(Boolean) as string[]; // Internal wallets = every self-owned data source (banks + cards + crypto) // plus the foreign-securities business, de-duplicated. const internalWalletsIds = Array.from( new Set( [ ...bankAccountIds, ...creditCardIds, ...cryptoWalletIds, rawContext.foreign_securities_business_id, ].filter(Boolean) as string[], ), ); return { defaultLocalCurrency: formatCurrency(rawContext.default_local_currency), defaultCryptoConversionFiatCurrency: formatCurrency( rawContext.default_fiat_currency_for_crypto_conversions, ), ownerId: rawContext.owner_id, defaultTaxCategoryId: rawContext.default_tax_category_id, locality: rawContext.locality, ledgerLock: optionalDateToTimelessDateString(rawContext.ledger_lock) ?? undefined, dateEstablished: optionalDateToTimelessDateString(rawContext.date_established) ?? undefined, initialAccounterYear: rawContext.initial_accounter_year ?? undefined, authorities: { vatBusinessId: rawContext.vat_business_id, inputVatTaxCategoryId: rawContext.input_vat_tax_category_id, outputVatTaxCategoryId: rawContext.output_vat_tax_category_id, propertyOutputVatTaxCategoryId: rawContext.property_output_vat_tax_category_id, taxBusinessId: rawContext.tax_business_id, taxExpensesTaxCategoryId: rawContext.tax_expenses_tax_category_id, socialSecurityBusinessId: rawContext.social_security_business_id, vatReportExcludedBusinessNames, }, depreciation: { accumulatedDepreciationTaxCategoryId: rawContext.accumulated_depreciation_tax_category_id, rndDepreciationExpensesTaxCategoryId: rawContext.rnd_depreciation_expenses_tax_category_id, gnmDepreciationExpensesTaxCategoryId: rawContext.gnm_depreciation_expenses_tax_category_id, marketingDepreciationExpensesTaxCategoryId: rawContext.marketing_depreciation_expenses_tax_category_id, }, bankDeposits: { bankDepositBusinessId: rawContext.bank_deposit_business_id, bankDepositInterestIncomeTaxCategoryId: rawContext.bank_deposit_interest_income_tax_category_id, }, foreignSecurities: { foreignSecuritiesBusinessId: rawContext.foreign_securities_business_id, foreignSecuritiesFeesCategoryId: rawContext.foreign_securities_fees_category_id, }, dividends: { dividendWithholdingTaxBusinessId: rawContext.dividend_withholding_tax_business_id, dividendTaxCategoryId: rawContext.dividend_tax_category_id, dividendPaymentBusinessIds, dividendBusinessIds: [ ...(rawContext.dividend_withholding_tax_business_id ? [rawContext.dividend_withholding_tax_business_id] : []), ...dividendPaymentBusinessIds, ], }, businessTrips: { businessTripTaxCategoryId: rawContext.business_trip_tax_category_id, businessTripTagId: rawContext.business_trip_tag_id, }, financialAccounts: { poalimBusinessId: rawContext.poalim_business_id, discountBusinessId: rawContext.discount_business_id, otsarHahayalBusinessId: rawContext.otsar_hahayal_business_id, swiftBusinessId: rawContext.swift_business_id, isracardBusinessId: rawContext.isracard_business_id, amexBusinessId: rawContext.amex_business_id, calBusinessId: rawContext.cal_business_id, otsarHahayalCreditCardBusinessId: rawContext.otsar_hahayal_credit_card_business_id, etanaBusinessId: rawContext.etana_business_id, krakenBusinessId: rawContext.kraken_business_id, etherScanBusinessId: rawContext.etherscan_business_id, foreignSecuritiesBusinessId: rawContext.foreign_securities_business_id, bankAccountIds, creditCardIds, internalWalletsIds, }, salaries: { zkufotExpensesTaxCategoryId: rawContext.zkufot_expenses_tax_category_id, zkufotIncomeTaxCategoryId: rawContext.zkufot_income_tax_category_id, socialSecurityExpensesTaxCategoryId: rawContext.social_security_expenses_tax_category_id, salaryExpensesTaxCategoryId: rawContext.salary_expenses_tax_category_id, trainingFundExpensesTaxCategoryId: rawContext.training_fund_expenses_tax_category_id, compensationFundExpensesTaxCategoryId: rawContext.compensation_fund_expenses_tax_category_id, pensionExpensesTaxCategoryId: rawContext.pension_fund_expenses_tax_category_id, batchedEmployeesBusinessId: rawContext.batched_employees_business_id, batchedFundsBusinessId: rawContext.batched_funds_business_id, salaryBatchedBusinessIds, taxDeductionsBusinessId: rawContext.tax_deductions_business_id, recoveryReserveExpensesTaxCategoryId: rawContext.recovery_reserve_expenses_tax_category_id, recoveryReserveTaxCategoryId: rawContext.recovery_reserve_tax_category_id, vacationReserveExpensesTaxCategoryId: rawContext.vacation_reserve_expenses_tax_category_id, vacationReserveTaxCategoryId: rawContext.vacation_reserve_tax_category_id, }, crossYear: { expensesToPayTaxCategoryId: rawContext.expenses_to_pay_tax_category_id, expensesInAdvanceTaxCategoryId: rawContext.expenses_in_advance_tax_category_id, incomeToCollectTaxCategoryId: rawContext.income_to_collect_tax_category_id, incomeInAdvanceTaxCategoryId: rawContext.income_in_advance_tax_category_id ?? rawContext.default_tax_category_id, }, general: { taxCategories: { exchangeRateTaxCategoryId: rawContext.exchange_rate_tax_category_id, incomeExchangeRateTaxCategoryId: rawContext.income_exchange_rate_tax_category_id, exchangeRevaluationTaxCategoryId: rawContext.exchange_rate_revaluation_tax_category_id, feeTaxCategoryId: rawContext.fee_tax_category_id, generalFeeTaxCategoryId: rawContext.general_fee_tax_category_id, fineTaxCategoryId: rawContext.fine_tax_category_id, untaxableGiftsTaxCategoryId: rawContext.untaxable_gifts_tax_category_id, balanceCancellationTaxCategoryId: rawContext.balance_cancellation_tax_category_id, developmentForeignTaxCategoryId: rawContext.development_foreign_tax_category_id, developmentLocalTaxCategoryId: rawContext.development_local_tax_category_id, salaryExcessExpensesTaxCategoryId: rawContext.salary_excess_expenses_tax_category_id, }, }, }; } public async getAdminContext(): Promise<AdminContext | null> { if (this.cachedContext) { return this.cachedContext; } const ownerId = await this.resolveOwnerIdForRequest(); const contexts = await getAdminContexts.run({ ownerIds: [ownerId] }, this.db); const context = contexts[0] ? this.normalizeContext(contexts[0]) : null; this.cachedContext = context; return context; } public async getVerifiedAdminContext() { if (this.cachedContext) { return this.cachedContext; } this.cachedContextInitializing ??= this.getAdminContext(); const context = await this.cachedContextInitializing; if (!context) { throw new GraphQLError('Admin context not found for the authenticated user.', { extensions: { code: 'FORBIDDEN' }, }); } return context; } public async updateAdminContext(params: IUpdateAdminContextParams): Promise<AdminContext | null> { const ownerId = await this.resolveOwnerIdForRequest(); this.cachedContext = null; // Persist role rows for any legacy per-institution inputs BEFORE the update, // so the RETURNING aggregates below already include the change. await this.syncProviderBusinessRoles(ownerId, params); const updatedContexts = await updateAdminContext.run({ ...params, ownerId }, this.db); if (updatedContexts.length >= 1) { const normalizedContext = this.normalizeContext(updatedContexts[0]); this.cachedContext = normalizedContext; return normalizedContext; } return null; } /** * Write-through adapter for the legacy typed provider/authority business inputs: * upsert the matching admin_business_roles rows so the role-derived arrays stay * consistent when those inputs are used. Additive only (ON CONFLICT DO NOTHING) — * removals are owned by the Phase 2 data-source API. No-op (and no query) when * the update carries none of these inputs. */ private async syncProviderBusinessRoles( ownerId: string, params: IUpdateAdminContextParams, ): Promise<void> { const values: unknown[] = [ownerId]; const rows: string[] = []; for (const [key, role] of PROVIDER_INPUT_ROLES) { const businessId = params[key]; if (typeof businessId === 'string' && businessId.length > 0) { rows.push( `($1, $${values.length + 1}, $${values.length + 2}::accounter_schema.admin_business_role)`, ); values.push(businessId, role); } } if (rows.length === 0) { return; } await this.db.query( `INSERT INTO accounter_schema.admin_business_roles (owner_id, business_id, role) VALUES ${rows.join(', ')} ON CONFLICT DO NOTHING`, values, ); } private async batchAdminContextsByOwnerIds(ownerIds: readonly string[]) { const uniqueOwnerIds = Array.from(new Set(ownerIds)); const contexts = await getAdminContexts.run({ ownerIds: uniqueOwnerIds }, this.db); const contextsByOwnerId = new Map(contexts.map(c => [c.owner_id, c])); return ownerIds.map(id => { const context = contextsByOwnerId.get(id); return context ? this.normalizeContext(context) : null; }); } public adminContextByOwnerIdLoader = new DataLoader((ownerIds: readonly string[]) => this.batchAdminContextsByOwnerIds(ownerIds), ); public clearCache() { this.cachedContext = null; } /** * Resolve the owner business for this request. * - Single-business read scope selects that business. * - Multi-business scope prefers the primary tenant business only when it is * part of the scope; otherwise the first scoped business is used. * - No scope falls back to the primary tenant business. */ private async resolveOwnerIdForRequest(): Promise<string> { await this.ensureAuthContext(); if (!this.authContext) { throw new GraphQLError( 'Auth context not available. AdminContextProvider requires active authentication.', { extensions: { code: 'UNAUTHENTICATED' } }, ); } const ownerId = resolveWriteTargetBusinessId( this.authContext.tenant.businessId, this.authContext.activeReadScope, ); if (!ownerId) { throw new Error('AdminContextProvider: ownerId not found in context (currentUser)'); } return ownerId; } /** * Lazy initialization of auth context on first use. * This ensures the async provider is called only when needed. */ private async ensureAuthContext(): Promise<void> { if (this.authContextInitialized) { return; } this.authContext = await this.authContextProvider.getAuthContext(); this.authContextInitialized = true; } }