UNPKG

@accounter/server

Version:
315 lines (282 loc) 12.1 kB
import { GraphQLError } from 'graphql'; import { Injectable, Scope } from 'graphql-modules'; import { sql } from '@pgtyped/runtime'; import { makeUUID } from '../../../shared/helpers/deterministic-uuid.js'; import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js'; import type { AdminContext } from '../../admin-context/types.js'; import { TenantAwareDBClient } from '../../app-providers/tenant-db-client.js'; import { Auth0ManagementProvider } from '../../auth/providers/auth0-management.provider.js'; import { BusinessUsersProvider } from '../../auth/providers/business-users.provider.js'; import { InvitationsProvider } from '../../auth/providers/invitations.provider.js'; import { SuperAdminProvider } from '../../auth/providers/super-admin.provider.js'; import { AdminBusinessesProvider } from '../../financial-entities/providers/admin-businesses.provider.js'; import { EntityEnsureProvider } from '../../financial-entities/providers/entity-ensure.provider.js'; import type { IExpireActiveInvitationsQuery, IGetBootstrapBusinessQuery, IGetBootstrapBusinessResult, IGetBootstrapContextQuery, ISetRlsContextQuery, ISetSelfOwnerQuery, } from '../types.js'; export interface BootstrapClientInput { businessName: string; countryCode: string; locality?: string | null; dateEstablished?: string | null; initialAccounterYear?: number | null; ownerEmail: string; ownerRole: string; } export interface BootstrapClientResult { adminEntityId: string; invitationToken: string; business: IGetBootstrapBusinessResult; adminContext: AdminContext; } const setRlsContext = sql<ISetRlsContextQuery>` SELECT set_config('app.current_business_id', $businessId, true); `; const setSelfOwner = sql<ISetSelfOwnerQuery>` UPDATE accounter_schema.financial_entities SET owner_id = $id WHERE id = $id; `; const getBootstrapBusiness = sql<IGetBootstrapBusinessQuery>` SELECT fe.*, b.vat_number, b.hebrew_name, b.address, b.address_hebrew, b.email, b.website, b.phone_number, b.country, b.no_invoices_required, b.suggestion_data, b.can_settle_with_receipt, b.exempt_dealer, b.optional_vat, b.pcn874_record_type_override, b.city, b.zip_code FROM accounter_schema.businesses b INNER JOIN accounter_schema.financial_entities fe USING (id) WHERE b.id = $id; `; const getBootstrapContext = sql<IGetBootstrapContextQuery>` SELECT * FROM accounter_schema.user_context WHERE owner_id = $ownerId; `; const expireActiveInvitations = sql<IExpireActiveInvitationsQuery>` UPDATE accounter_schema.invitations SET expires_at = NOW(), accepted_at = NOW() WHERE business_id = $businessId AND lower(email) = lower($email) AND accepted_at IS NULL AND expires_at > NOW(); `; @Injectable({ scope: Scope.Operation }) export class AdminOnboardingProvider { constructor( private db: TenantAwareDBClient, private entityEnsure: EntityEnsureProvider, private adminContextProvider: AdminContextProvider, private businessUsersProvider: BusinessUsersProvider, private adminBusinessesProvider: AdminBusinessesProvider, private auth0Management: Auth0ManagementProvider, private invitationsProvider: InvitationsProvider, private superAdminProvider: SuperAdminProvider, ) {} async bootstrapNewClient(input: BootstrapClientInput): Promise<BootstrapClientResult> { const superAdmin = await this.superAdminProvider.requireSuperAdmin(); return this.db.transaction(async client => { const { businessName, countryCode, locality, dateEstablished, initialAccounterYear, ownerEmail, ownerRole, } = input; // 0. Ensure country reference data (no RLS on countries table) await this.entityEnsure.ensureCountry(countryCode, countryCode, client); // 1. Determine the new admin entity's deterministic ID so we can set RLS context before inserting const adminEntityId = makeUUID('business', businessName); // Set RLS context — needed for all inserts via the tenant_isolation policy await setRlsContext.run({ businessId: adminEntityId }, client); // Insert financial_entity with owner_id=NULL first (allow_bootstrap_root policy allows // id = current_business_id). Then insert businesses, then update owner_id to self. await this.entityEnsure.ensureFinancialEntity( { id: adminEntityId, name: businessName, type: 'business', }, client, ); await this.entityEnsure.ensureBusinessForEntity( adminEntityId, { ownerId: adminEntityId, country: countryCode, }, client, ); // Update owner_id to self (allow_bootstrap_owner_update RLS policy permits this) await setSelfOwner.run({ id: adminEntityId }, client); // Register as an admin-managed business (required for business_users FK) await this.adminBusinessesProvider.insertAdminBusiness(adminEntityId, client); // 2. Authority businesses const authorityBusinessIds: Record<string, string> = {}; for (const name of ['VAT', 'Tax', 'Social Security']) { const { id } = await this.entityEnsure.ensureFinancialEntity( { name, type: 'business', ownerId: adminEntityId, }, client, ); authorityBusinessIds[name] = id; await this.entityEnsure.ensureBusinessForEntity( id, { isDocumentsOptional: true, ownerId: adminEntityId, }, client, ); } // 3. Authority tax categories const authorityTaxCategoryIds: Record<string, string> = {}; for (const name of ['Input Vat', 'Output Vat', 'Property Output Vat', 'Tax Expenses']) { const { id } = await this.entityEnsure.ensureFinancialEntity( { name, type: 'tax_category', ownerId: adminEntityId, }, client, ); authorityTaxCategoryIds[name] = id; await this.entityEnsure.ensureTaxCategoryForEntity(id, { ownerId: adminEntityId }, client); } // 4. General tax categories const generalTaxCategoryIds: Record<string, string> = {}; for (const name of [ 'DEFAULT (missing)', 'Exchange Rates', 'Income Exchange Rates', 'Exchange Revaluation', 'Fee', 'General Fee', 'Fine', 'Untaxable Gifts', 'Balance Cancellation', 'Development Foreign', 'Development Local', 'Salary Excess Expenses', ]) { const { id } = await this.entityEnsure.ensureFinancialEntity( { name, type: 'tax_category', ownerId: adminEntityId, }, client, ); generalTaxCategoryIds[name] = id; await this.entityEnsure.ensureTaxCategoryForEntity(id, { ownerId: adminEntityId }, client); } // 5. Cross-year tax categories const crossYearTaxCategoryIds: Record<string, string> = {}; for (const name of [ 'Expenses to Pay', 'Expenses in Advance', 'Income to Collect', 'Income in Advance', ]) { const { id } = await this.entityEnsure.ensureFinancialEntity( { name, type: 'tax_category', ownerId: adminEntityId, }, client, ); crossYearTaxCategoryIds[name] = id; await this.entityEnsure.ensureTaxCategoryForEntity(id, { ownerId: adminEntityId }, client); } // 6. Insert user_context — dynamic column list, cannot use PgTyped const contextCols = { owner_id: adminEntityId, default_local_currency: 'ILS', default_fiat_currency_for_crypto_conversions: 'USD', locality: locality ?? 'ISR', default_tax_category_id: generalTaxCategoryIds['DEFAULT (missing)'], vat_business_id: authorityBusinessIds['VAT'], input_vat_tax_category_id: authorityTaxCategoryIds['Input Vat'], output_vat_tax_category_id: authorityTaxCategoryIds['Output Vat'], property_output_vat_tax_category_id: authorityTaxCategoryIds['Property Output Vat'], tax_business_id: authorityBusinessIds['Tax'], tax_expenses_tax_category_id: authorityTaxCategoryIds['Tax Expenses'], social_security_business_id: authorityBusinessIds['Social Security'], exchange_rate_tax_category_id: generalTaxCategoryIds['Exchange Rates'], income_exchange_rate_tax_category_id: generalTaxCategoryIds['Income Exchange Rates'], exchange_rate_revaluation_tax_category_id: generalTaxCategoryIds['Exchange Revaluation'], fee_tax_category_id: generalTaxCategoryIds['Fee'], general_fee_tax_category_id: generalTaxCategoryIds['General Fee'], fine_tax_category_id: generalTaxCategoryIds['Fine'], untaxable_gifts_tax_category_id: generalTaxCategoryIds['Untaxable Gifts'], balance_cancellation_tax_category_id: generalTaxCategoryIds['Balance Cancellation'], development_foreign_tax_category_id: generalTaxCategoryIds['Development Foreign'], development_local_tax_category_id: generalTaxCategoryIds['Development Local'], expenses_to_pay_tax_category_id: crossYearTaxCategoryIds['Expenses to Pay'], expenses_in_advance_tax_category_id: crossYearTaxCategoryIds['Expenses in Advance'], income_to_collect_tax_category_id: crossYearTaxCategoryIds['Income to Collect'], income_in_advance_tax_category_id: crossYearTaxCategoryIds['Income in Advance'], salary_excess_expenses_tax_category_id: generalTaxCategoryIds['Salary Excess Expenses'], ...(dateEstablished ? { date_established: dateEstablished } : {}), ...(initialAccounterYear ? { initial_accounter_year: initialAccounterYear } : {}), }; const columns = Object.keys(contextCols).join(', '); const placeholders = Object.keys(contextCols) .map((_, i) => `$${i + 1}`) .join(', '); await client.query( `INSERT INTO accounter_schema.user_context (${columns}) VALUES (${placeholders}) ON CONFLICT (owner_id) DO NOTHING`, Object.values(contextCols), ); // 7. Create Auth0 user (blocked) + invitation // Expire any standing invitations so insertInvitation can create a fresh one await expireActiveInvitations.run({ businessId: adminEntityId, email: ownerEmail }, client); const auth0UserId = await this.auth0Management.createBlockedUser(ownerEmail); await this.businessUsersProvider.insertBusinessUser({ userId: superAdmin.userId, auth0UserId: superAdmin.auth0UserId, roleId: ownerRole, ownerId: adminEntityId, }); const { token: invitationToken } = await this.invitationsProvider.insertInvitation({ email: ownerEmail, roleId: ownerRole, auth0UserId, invitedByUserId: superAdmin.userId, ownerId: adminEntityId, }); // 8. Fetch business and adminContext while RLS context is still set to adminEntityId const [businessResults, contextResults] = await Promise.all([ getBootstrapBusiness.run({ id: adminEntityId }, client), getBootstrapContext.run({ ownerId: adminEntityId }, client), ]); const business = businessResults[0]; if (!business) { throw new GraphQLError('Admin business not found after bootstrap', { extensions: { code: 'INTERNAL_SERVER_ERROR' }, }); } const rawContext = contextResults[0]; if (!rawContext) { throw new GraphQLError('Admin context not found after bootstrap', { extensions: { code: 'INTERNAL_SERVER_ERROR' }, }); } const adminContext = this.adminContextProvider.normalizeContext(rawContext); return { adminEntityId, invitationToken, business, adminContext, }; }); } }