@accounter/server
Version:
Accounter GraphQL server
315 lines (282 loc) • 12.1 kB
text/typescript
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();
`;
({ 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,
};
});
}
}