import { eq, and, sql, count, exists } from 'drizzle-orm' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' import { accounts, members, memberIdentifiers, accountProcessorLinks, accountPaymentMethods, taxExemptions, } from '../db/schema/accounts.js' import type { AccountCreateInput, AccountUpdateInput, MemberIdentifierCreateInput, MemberIdentifierUpdateInput, ProcessorLinkCreateInput, ProcessorLinkUpdateInput, PaymentMethodCreateInput, PaymentMethodUpdateInput, TaxExemptionCreateInput, TaxExemptionUpdateInput, PaginationInput, } from '@forte/shared/schemas' import { isMinor, normalizeStateCode } from '@forte/shared/utils' import { withPagination, withSort, buildSearchCondition, paginatedResponse, } from '../utils/pagination.js' async function generateUniqueNumber( db: PostgresJsDatabase, table: typeof accounts | typeof members, column: typeof accounts.accountNumber | typeof members.memberNumber, companyId: string, companyIdColumn: typeof accounts.companyId, ): Promise { for (let attempt = 0; attempt < 10; attempt++) { const num = String(Math.floor(100000 + Math.random() * 900000)) const [existing] = await db .select({ id: table.id }) .from(table) .where(and(eq(companyIdColumn, companyId), eq(column, num))) .limit(1) if (!existing) return num } // Fallback to 8 digits if 6-digit space is crowded return String(Math.floor(10000000 + Math.random() * 90000000)) } function normalizeAddress(address?: { street?: string; city?: string; state?: string; zip?: string } | null) { if (!address) return address return { ...address, state: address.state ? (normalizeStateCode(address.state) ?? address.state) : address.state, } } export const AccountService = { async create(db: PostgresJsDatabase, companyId: string, input: AccountCreateInput) { const accountNumber = await generateUniqueNumber(db, accounts, accounts.accountNumber, companyId, accounts.companyId) const [account] = await db .insert(accounts) .values({ companyId, accountNumber, name: input.name, email: input.email, phone: input.phone, address: normalizeAddress(input.address), billingMode: input.billingMode, notes: input.notes, }) .returning() return account }, async getById(db: PostgresJsDatabase, companyId: string, id: string) { const [account] = await db .select() .from(accounts) .where(and(eq(accounts.id, id), eq(accounts.companyId, companyId))) .limit(1) return account ?? null }, async update(db: PostgresJsDatabase, companyId: string, id: string, input: AccountUpdateInput) { const [account] = await db .update(accounts) .set({ ...input, updatedAt: new Date() }) .where(and(eq(accounts.id, id), eq(accounts.companyId, companyId))) .returning() return account ?? null }, async softDelete(db: PostgresJsDatabase, companyId: string, id: string) { const [account] = await db .update(accounts) .set({ isActive: false, updatedAt: new Date() }) .where(and(eq(accounts.id, id), eq(accounts.companyId, companyId))) .returning() return account ?? null }, async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) { const baseWhere = and(eq(accounts.companyId, companyId), eq(accounts.isActive, true)) const accountSearch = params.q ? buildSearchCondition(params.q, [accounts.name, accounts.email, accounts.phone, accounts.accountNumber]) : undefined // Also search across member names on this account const memberSearch = params.q ? exists( db.select({ id: members.id }) .from(members) .where(and( eq(members.accountId, accounts.id), buildSearchCondition(params.q, [members.firstName, members.lastName, members.email])!, )), ) : undefined const searchCondition = accountSearch && memberSearch ? sql`(${accountSearch} OR ${memberSearch})` : undefined const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere const sortableColumns: Record = { name: accounts.name, email: accounts.email, created_at: accounts.createdAt, account_number: accounts.accountNumber, } let query = db.select().from(accounts).where(where).$dynamic() query = withSort(query, params.sort, params.order, sortableColumns, accounts.name) query = withPagination(query, params.page, params.limit) const [data, [{ total }]] = await Promise.all([ query, db.select({ total: count() }).from(accounts).where(where), ]) return paginatedResponse(data, total, params.page, params.limit) }, } export const MemberService = { async create( db: PostgresJsDatabase, companyId: string, input: { accountId: string firstName: string lastName: string dateOfBirth?: string isMinor?: boolean email?: string phone?: string address?: { street?: string; city?: string; state?: string; zip?: string } notes?: string }, ) { // isMinor: explicit flag wins, else derive from DOB, else false const minor = input.isMinor ?? (input.dateOfBirth ? isMinor(input.dateOfBirth) : false) const memberNumber = await generateUniqueNumber(db, members, members.memberNumber, companyId, members.companyId) // Inherit email, phone, address from account if not provided const [account] = await db .select() .from(accounts) .where(eq(accounts.id, input.accountId)) .limit(1) const email = input.email ?? account?.email ?? undefined const phone = input.phone ?? account?.phone ?? undefined const address = normalizeAddress(input.address ?? account?.address ?? undefined) const [member] = await db .insert(members) .values({ companyId, memberNumber, accountId: input.accountId, firstName: input.firstName, lastName: input.lastName, dateOfBirth: input.dateOfBirth, isMinor: minor, email, phone, address, notes: input.notes, }) .returning() if (account && !account.primaryMemberId) { await db .update(accounts) .set({ primaryMemberId: member.id, updatedAt: new Date() }) .where(eq(accounts.id, input.accountId)) } return member }, async getById(db: PostgresJsDatabase, companyId: string, id: string) { const [member] = await db .select() .from(members) .where(and(eq(members.id, id), eq(members.companyId, companyId))) .limit(1) return member ?? null }, async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) { const baseWhere = eq(members.companyId, companyId) const searchCondition = params.q ? buildSearchCondition(params.q, [members.firstName, members.lastName, members.email, members.phone]) : undefined const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere const sortableColumns: Record = { first_name: members.firstName, last_name: members.lastName, email: members.email, created_at: members.createdAt, } let query = db.select({ id: members.id, accountId: members.accountId, companyId: members.companyId, firstName: members.firstName, lastName: members.lastName, dateOfBirth: members.dateOfBirth, isMinor: members.isMinor, email: members.email, phone: members.phone, notes: members.notes, legacyId: members.legacyId, createdAt: members.createdAt, updatedAt: members.updatedAt, accountName: accounts.name, }) .from(members) .leftJoin(accounts, eq(members.accountId, accounts.id)) .where(where) .$dynamic() query = withSort(query, params.sort, params.order, sortableColumns, members.lastName) query = withPagination(query, params.page, params.limit) const [data, [{ total }]] = await Promise.all([ query, db.select({ total: count() }).from(members).where(where), ]) return paginatedResponse(data, total, params.page, params.limit) }, async listByAccount( db: PostgresJsDatabase, companyId: string, accountId: string, params: PaginationInput, ) { const where = and(eq(members.companyId, companyId), eq(members.accountId, accountId)) const sortableColumns: Record = { first_name: members.firstName, last_name: members.lastName, created_at: members.createdAt, } let query = db.select().from(members).where(where).$dynamic() query = withSort(query, params.sort, params.order, sortableColumns, members.lastName) query = withPagination(query, params.page, params.limit) const [data, [{ total }]] = await Promise.all([ query, db.select({ total: count() }).from(members).where(where), ]) return paginatedResponse(data, total, params.page, params.limit) }, async update( db: PostgresJsDatabase, companyId: string, id: string, input: { firstName?: string lastName?: string dateOfBirth?: string isMinor?: boolean email?: string phone?: string notes?: string }, ) { const updates: Record = { ...input, updatedAt: new Date() } // isMinor: explicit flag wins, else derive from DOB if provided if (input.isMinor !== undefined) { updates.isMinor = input.isMinor } else if (input.dateOfBirth) { updates.isMinor = isMinor(input.dateOfBirth) } const [member] = await db .update(members) .set(updates) .where(and(eq(members.id, id), eq(members.companyId, companyId))) .returning() return member ?? null }, async move(db: PostgresJsDatabase, companyId: string, memberId: string, targetAccountId: string) { const member = await this.getById(db, companyId, memberId) if (!member) return null const [updated] = await db .update(members) .set({ accountId: targetAccountId, updatedAt: new Date() }) .where(and(eq(members.id, memberId), eq(members.companyId, companyId))) .returning() // If target account has no primary, set this member const [targetAccount] = await db .select() .from(accounts) .where(and(eq(accounts.id, targetAccountId), eq(accounts.companyId, companyId))) .limit(1) if (targetAccount && !targetAccount.primaryMemberId) { await db .update(accounts) .set({ primaryMemberId: memberId, updatedAt: new Date() }) .where(eq(accounts.id, targetAccountId)) } return updated }, async delete(db: PostgresJsDatabase, companyId: string, id: string) { const [member] = await db .delete(members) .where(and(eq(members.id, id), eq(members.companyId, companyId))) .returning() return member ?? null }, } export const ProcessorLinkService = { async create(db: PostgresJsDatabase, companyId: string, input: ProcessorLinkCreateInput) { const [link] = await db .insert(accountProcessorLinks) .values({ companyId, accountId: input.accountId, processor: input.processor, processorCustomerId: input.processorCustomerId, }) .returning() return link }, async getById(db: PostgresJsDatabase, companyId: string, id: string) { const [link] = await db .select() .from(accountProcessorLinks) .where(and(eq(accountProcessorLinks.id, id), eq(accountProcessorLinks.companyId, companyId))) .limit(1) return link ?? null }, async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string) { return db .select() .from(accountProcessorLinks) .where( and( eq(accountProcessorLinks.companyId, companyId), eq(accountProcessorLinks.accountId, accountId), ), ) }, async update(db: PostgresJsDatabase, companyId: string, id: string, input: ProcessorLinkUpdateInput) { const [link] = await db .update(accountProcessorLinks) .set(input) .where(and(eq(accountProcessorLinks.id, id), eq(accountProcessorLinks.companyId, companyId))) .returning() return link ?? null }, async delete(db: PostgresJsDatabase, companyId: string, id: string) { const [link] = await db .delete(accountProcessorLinks) .where(and(eq(accountProcessorLinks.id, id), eq(accountProcessorLinks.companyId, companyId))) .returning() return link ?? null }, } export const PaymentMethodService = { async create(db: PostgresJsDatabase, companyId: string, input: PaymentMethodCreateInput) { // If this is the default, unset any existing default for this account if (input.isDefault) { await db .update(accountPaymentMethods) .set({ isDefault: false }) .where( and( eq(accountPaymentMethods.companyId, companyId), eq(accountPaymentMethods.accountId, input.accountId), eq(accountPaymentMethods.isDefault, true), ), ) } const [method] = await db .insert(accountPaymentMethods) .values({ companyId, accountId: input.accountId, processor: input.processor, processorPaymentMethodId: input.processorPaymentMethodId, cardBrand: input.cardBrand, lastFour: input.lastFour, expMonth: input.expMonth, expYear: input.expYear, isDefault: input.isDefault, }) .returning() return method }, async getById(db: PostgresJsDatabase, companyId: string, id: string) { const [method] = await db .select() .from(accountPaymentMethods) .where(and(eq(accountPaymentMethods.id, id), eq(accountPaymentMethods.companyId, companyId))) .limit(1) return method ?? null }, async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string) { return db .select() .from(accountPaymentMethods) .where( and( eq(accountPaymentMethods.companyId, companyId), eq(accountPaymentMethods.accountId, accountId), ), ) }, async update(db: PostgresJsDatabase, companyId: string, id: string, input: PaymentMethodUpdateInput) { // If setting as default, unset existing default if (input.isDefault) { const existing = await this.getById(db, companyId, id) if (existing) { await db .update(accountPaymentMethods) .set({ isDefault: false }) .where( and( eq(accountPaymentMethods.companyId, companyId), eq(accountPaymentMethods.accountId, existing.accountId), eq(accountPaymentMethods.isDefault, true), ), ) } } const [method] = await db .update(accountPaymentMethods) .set(input) .where(and(eq(accountPaymentMethods.id, id), eq(accountPaymentMethods.companyId, companyId))) .returning() return method ?? null }, async delete(db: PostgresJsDatabase, companyId: string, id: string) { const [method] = await db .delete(accountPaymentMethods) .where(and(eq(accountPaymentMethods.id, id), eq(accountPaymentMethods.companyId, companyId))) .returning() return method ?? null }, } export const TaxExemptionService = { async create(db: PostgresJsDatabase, companyId: string, input: TaxExemptionCreateInput) { const [exemption] = await db .insert(taxExemptions) .values({ companyId, accountId: input.accountId, certificateNumber: input.certificateNumber, certificateType: input.certificateType, issuingState: input.issuingState, expiresAt: input.expiresAt, notes: input.notes, status: 'pending', }) .returning() return exemption }, async getById(db: PostgresJsDatabase, companyId: string, id: string) { const [exemption] = await db .select() .from(taxExemptions) .where(and(eq(taxExemptions.id, id), eq(taxExemptions.companyId, companyId))) .limit(1) return exemption ?? null }, async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string) { return db .select() .from(taxExemptions) .where( and( eq(taxExemptions.companyId, companyId), eq(taxExemptions.accountId, accountId), ), ) }, async update(db: PostgresJsDatabase, companyId: string, id: string, input: TaxExemptionUpdateInput) { const [exemption] = await db .update(taxExemptions) .set({ ...input, updatedAt: new Date() }) .where(and(eq(taxExemptions.id, id), eq(taxExemptions.companyId, companyId))) .returning() return exemption ?? null }, async approve(db: PostgresJsDatabase, companyId: string, id: string, approvedBy: string) { const [exemption] = await db .update(taxExemptions) .set({ status: 'approved', approvedBy, approvedAt: new Date(), updatedAt: new Date(), }) .where(and(eq(taxExemptions.id, id), eq(taxExemptions.companyId, companyId))) .returning() return exemption ?? null }, async revoke(db: PostgresJsDatabase, companyId: string, id: string, revokedBy: string, reason: string) { const [exemption] = await db .update(taxExemptions) .set({ status: 'none', revokedBy, revokedAt: new Date(), revokedReason: reason, updatedAt: new Date(), }) .where(and(eq(taxExemptions.id, id), eq(taxExemptions.companyId, companyId))) .returning() return exemption ?? null }, } export const MemberIdentifierService = { async create(db: PostgresJsDatabase, companyId: string, input: MemberIdentifierCreateInput) { // If setting as primary, unset existing primary for this member if (input.isPrimary) { await db .update(memberIdentifiers) .set({ isPrimary: false }) .where( and( eq(memberIdentifiers.memberId, input.memberId), eq(memberIdentifiers.isPrimary, true), ), ) } const [identifier] = await db .insert(memberIdentifiers) .values({ companyId, memberId: input.memberId, type: input.type, label: input.label, value: input.value, issuingAuthority: input.issuingAuthority, issuedDate: input.issuedDate, expiresAt: input.expiresAt, imageFrontFileId: input.imageFrontFileId, imageBackFileId: input.imageBackFileId, notes: input.notes, isPrimary: input.isPrimary, }) .returning() return identifier }, async listByMember(db: PostgresJsDatabase, companyId: string, memberId: string) { return db .select() .from(memberIdentifiers) .where( and( eq(memberIdentifiers.companyId, companyId), eq(memberIdentifiers.memberId, memberId), ), ) }, async getById(db: PostgresJsDatabase, companyId: string, id: string) { const [identifier] = await db .select() .from(memberIdentifiers) .where(and(eq(memberIdentifiers.id, id), eq(memberIdentifiers.companyId, companyId))) .limit(1) return identifier ?? null }, async update(db: PostgresJsDatabase, companyId: string, id: string, input: MemberIdentifierUpdateInput) { if (input.isPrimary) { const existing = await this.getById(db, companyId, id) if (existing) { await db .update(memberIdentifiers) .set({ isPrimary: false }) .where( and( eq(memberIdentifiers.memberId, existing.memberId), eq(memberIdentifiers.isPrimary, true), ), ) } } const [identifier] = await db .update(memberIdentifiers) .set({ ...input, updatedAt: new Date() }) .where(and(eq(memberIdentifiers.id, id), eq(memberIdentifiers.companyId, companyId))) .returning() return identifier ?? null }, async delete(db: PostgresJsDatabase, companyId: string, id: string) { const [identifier] = await db .delete(memberIdentifiers) .where(and(eq(memberIdentifiers.id, id), eq(memberIdentifiers.companyId, companyId))) .returning() return identifier ?? null }, }