Add accounts, members, and processor-agnostic payment linking

- account table (billing entity, soft-delete, company-scoped)
- member table (people on an account, is_minor from DOB)
- account_processor_link table (maps accounts to any payment
  processor — stripe, global_payments — instead of stripe_customer_id
  directly on account)
- Full CRUD routes + search (name, email, phone, account_number)
- Member routes nested under accounts with isMinor auto-calculation
- Zod validation schemas in @forte/shared
- 19 tests passing
This commit is contained in:
Ryan Moon
2026-03-27 17:41:33 -05:00
parent 979a9a2c00
commit 5ff31ad782
12 changed files with 1429 additions and 1 deletions

View File

@@ -0,0 +1,83 @@
import {
pgTable,
uuid,
varchar,
text,
jsonb,
timestamp,
boolean,
date,
pgEnum,
} from 'drizzle-orm/pg-core'
import { companies } from './stores.js'
export const billingModeEnum = pgEnum('billing_mode', ['consolidated', 'split'])
export const accounts = pgTable('account', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
accountNumber: varchar('account_number', { length: 50 }),
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }),
phone: varchar('phone', { length: 50 }),
address: jsonb('address').$type<{
street?: string
city?: string
state?: string
zip?: string
}>(),
billingMode: billingModeEnum('billing_mode').notNull().default('consolidated'),
notes: text('notes'),
isActive: boolean('is_active').notNull().default(true),
legacyId: varchar('legacy_id', { length: 255 }),
legacySource: varchar('legacy_source', { length: 50 }),
migratedAt: timestamp('migrated_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const members = pgTable('member', {
id: uuid('id').primaryKey().defaultRandom(),
accountId: uuid('account_id')
.notNull()
.references(() => accounts.id),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
firstName: varchar('first_name', { length: 100 }).notNull(),
lastName: varchar('last_name', { length: 100 }).notNull(),
dateOfBirth: date('date_of_birth'),
isMinor: boolean('is_minor').notNull().default(false),
email: varchar('email', { length: 255 }),
phone: varchar('phone', { length: 50 }),
notes: text('notes'),
legacyId: varchar('legacy_id', { length: 255 }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const processorEnum = pgEnum('payment_processor', ['stripe', 'global_payments'])
export const accountProcessorLinks = pgTable('account_processor_link', {
id: uuid('id').primaryKey().defaultRandom(),
accountId: uuid('account_id')
.notNull()
.references(() => accounts.id),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
processor: processorEnum('processor').notNull(),
processorCustomerId: varchar('processor_customer_id', { length: 255 }).notNull(),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export type AccountProcessorLink = typeof accountProcessorLinks.$inferSelect
export type AccountProcessorLinkInsert = typeof accountProcessorLinks.$inferInsert
export type Account = typeof accounts.$inferSelect
export type AccountInsert = typeof accounts.$inferInsert
export type Member = typeof members.$inferSelect
export type MemberInsert = typeof members.$inferInsert