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

@@ -1,2 +1,3 @@
export * from './schema/stores.js'
export * from './schema/users.js'
export * from './schema/accounts.js'

View File

@@ -0,0 +1,51 @@
CREATE TYPE "public"."billing_mode" AS ENUM('consolidated', 'split');--> statement-breakpoint
CREATE TYPE "public"."payment_processor" AS ENUM('stripe', 'global_payments');--> statement-breakpoint
CREATE TABLE "account_processor_link" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"account_id" uuid NOT NULL,
"company_id" uuid NOT NULL,
"processor" "payment_processor" NOT NULL,
"processor_customer_id" varchar(255) NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "account" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"account_number" varchar(50),
"name" varchar(255) NOT NULL,
"email" varchar(255),
"phone" varchar(50),
"address" jsonb,
"billing_mode" "billing_mode" DEFAULT 'consolidated' NOT NULL,
"notes" text,
"is_active" boolean DEFAULT true NOT NULL,
"legacy_id" varchar(255),
"legacy_source" varchar(50),
"migrated_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "member" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"account_id" uuid NOT NULL,
"company_id" uuid NOT NULL,
"first_name" varchar(100) NOT NULL,
"last_name" varchar(100) NOT NULL,
"date_of_birth" date,
"is_minor" boolean DEFAULT false NOT NULL,
"email" varchar(255),
"phone" varchar(50),
"notes" text,
"legacy_id" varchar(255),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "account_processor_link" ADD CONSTRAINT "account_processor_link_account_id_account_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."account"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "account_processor_link" ADD CONSTRAINT "account_processor_link_company_id_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."company"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_company_id_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."company"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member" ADD CONSTRAINT "member_account_id_account_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."account"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member" ADD CONSTRAINT "member_company_id_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."company"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,628 @@
{
"id": "906895d3-deba-442a-991d-d9245c960d18",
"prevId": "69ee3c88-af63-4f16-88e1-15e4ce355a87",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"first_name": {
"name": "first_name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"last_name": {
"name": "last_name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'staff'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"user_company_id_company_id_fk": {
"name": "user_company_id_company_id_fk",
"tableFrom": "user",
"tableTo": "company",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.company": {
"name": "company",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"phone": {
"name": "phone",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"timezone": {
"name": "timezone",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true,
"default": "'America/Chicago'"
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.location": {
"name": "location",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"address": {
"name": "address",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"timezone": {
"name": "timezone",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"location_company_id_company_id_fk": {
"name": "location_company_id_company_id_fk",
"tableFrom": "location",
"tableTo": "company",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.account_processor_link": {
"name": "account_processor_link",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"account_id": {
"name": "account_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"processor": {
"name": "processor",
"type": "payment_processor",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"processor_customer_id": {
"name": "processor_customer_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"account_processor_link_account_id_account_id_fk": {
"name": "account_processor_link_account_id_account_id_fk",
"tableFrom": "account_processor_link",
"tableTo": "account",
"columnsFrom": [
"account_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"account_processor_link_company_id_company_id_fk": {
"name": "account_processor_link_company_id_company_id_fk",
"tableFrom": "account_processor_link",
"tableTo": "company",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"account_number": {
"name": "account_number",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"address": {
"name": "address",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"billing_mode": {
"name": "billing_mode",
"type": "billing_mode",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'consolidated'"
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"legacy_id": {
"name": "legacy_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"legacy_source": {
"name": "legacy_source",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"migrated_at": {
"name": "migrated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"account_company_id_company_id_fk": {
"name": "account_company_id_company_id_fk",
"tableFrom": "account",
"tableTo": "company",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.member": {
"name": "member",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"account_id": {
"name": "account_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"first_name": {
"name": "first_name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"last_name": {
"name": "last_name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"date_of_birth": {
"name": "date_of_birth",
"type": "date",
"primaryKey": false,
"notNull": false
},
"is_minor": {
"name": "is_minor",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"legacy_id": {
"name": "legacy_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"member_account_id_account_id_fk": {
"name": "member_account_id_account_id_fk",
"tableFrom": "member",
"tableTo": "account",
"columnsFrom": [
"account_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"member_company_id_company_id_fk": {
"name": "member_company_id_company_id_fk",
"tableFrom": "member",
"tableTo": "company",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.user_role": {
"name": "user_role",
"schema": "public",
"values": [
"admin",
"manager",
"staff",
"technician",
"instructor"
]
},
"public.billing_mode": {
"name": "billing_mode",
"schema": "public",
"values": [
"consolidated",
"split"
]
},
"public.payment_processor": {
"name": "payment_processor",
"schema": "public",
"values": [
"stripe",
"global_payments"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -22,6 +22,13 @@
"when": 1774648659531,
"tag": "0002_bumpy_mandarin",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1774651222033,
"tag": "0003_round_captain_midlands",
"breakpoints": true
}
]
}

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