From 0a2d6e23af3d29ed08ae61bf2df3726a890aa88f Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Fri, 27 Mar 2026 20:53:30 -0500 Subject: [PATCH] Add lookup tables, payment methods, tax exemptions, and processor link APIs Replace unit_status and item_condition pgEnums with company-scoped lookup tables that support custom values. Add account_payment_method table, tax_exemption table with approve/revoke workflow, and CRUD routes for processor links. Validate inventory unit status/condition against lookup tables at service layer. --- .../db/migrations/0007_accounts_lookups.sql | 86 +++ .../src/db/migrations/meta/_journal.json | 7 + packages/backend/src/db/schema/accounts.ts | 50 ++ packages/backend/src/db/schema/inventory.ts | 23 +- packages/backend/src/db/schema/lookups.ts | 67 +++ packages/backend/src/main.ts | 2 + .../src/routes/v1/accounts-extended.test.ts | 491 ++++++++++++++++++ packages/backend/src/routes/v1/accounts.ts | 147 +++++- packages/backend/src/routes/v1/lookups.ts | 66 +++ packages/backend/src/routes/v1/products.ts | 24 +- .../backend/src/services/account.service.ts | 240 ++++++++- .../backend/src/services/lookup.service.ts | 114 ++++ .../backend/src/services/product.service.ts | 19 + packages/backend/src/test/helpers.ts | 5 + packages/shared/src/schemas/account.schema.ts | 79 +++ packages/shared/src/schemas/index.ts | 20 + .../shared/src/schemas/inventory.schema.ts | 19 +- 17 files changed, 1431 insertions(+), 28 deletions(-) create mode 100644 packages/backend/src/db/migrations/0007_accounts_lookups.sql create mode 100644 packages/backend/src/db/schema/lookups.ts create mode 100644 packages/backend/src/routes/v1/accounts-extended.test.ts create mode 100644 packages/backend/src/routes/v1/lookups.ts create mode 100644 packages/backend/src/services/lookup.service.ts diff --git a/packages/backend/src/db/migrations/0007_accounts_lookups.sql b/packages/backend/src/db/migrations/0007_accounts_lookups.sql new file mode 100644 index 0000000..c26cda7 --- /dev/null +++ b/packages/backend/src/db/migrations/0007_accounts_lookups.sql @@ -0,0 +1,86 @@ +-- Migration: Add lookup tables, account payment methods, tax exemptions +-- Replaces unit_status and item_condition pgEnums with lookup tables + +-- 1. Drop defaults that reference old enums +ALTER TABLE "inventory_unit" ALTER COLUMN "status" DROP DEFAULT; +ALTER TABLE "inventory_unit" ALTER COLUMN "condition" DROP DEFAULT; + +-- 2. Migrate inventory_unit columns from enum to varchar +ALTER TABLE "inventory_unit" ALTER COLUMN "status" TYPE varchar(100) USING "status"::text; +ALTER TABLE "inventory_unit" ALTER COLUMN "condition" TYPE varchar(100) USING "condition"::text; + +-- 3. Re-add defaults as varchar values +ALTER TABLE "inventory_unit" ALTER COLUMN "status" SET DEFAULT 'available'; +ALTER TABLE "inventory_unit" ALTER COLUMN "condition" SET DEFAULT 'new'; + +-- 4. Drop old enums (must happen before creating tables with same names) +DROP TYPE IF EXISTS "unit_status"; +DROP TYPE IF EXISTS "item_condition"; + +-- 3. Create new enum for tax exemptions +CREATE TYPE "tax_exempt_status" AS ENUM ('none', 'pending', 'approved'); + +-- 4. Create lookup tables (item_condition name is now free) +CREATE TABLE IF NOT EXISTS "inventory_unit_status" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "company_id" uuid NOT NULL REFERENCES "company"("id"), + "name" varchar(100) NOT NULL, + "slug" varchar(100) NOT NULL, + "description" text, + "is_system" boolean NOT NULL DEFAULT false, + "sort_order" integer NOT NULL DEFAULT 0, + "is_active" boolean NOT NULL DEFAULT true, + "created_at" timestamp with time zone NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS "item_condition" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "company_id" uuid NOT NULL REFERENCES "company"("id"), + "name" varchar(100) NOT NULL, + "slug" varchar(100) NOT NULL, + "description" text, + "is_system" boolean NOT NULL DEFAULT false, + "sort_order" integer NOT NULL DEFAULT 0, + "is_active" boolean NOT NULL DEFAULT true, + "created_at" timestamp with time zone NOT NULL DEFAULT now() +); + +-- 5. Create account_payment_method table +CREATE TABLE IF NOT EXISTS "account_payment_method" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "account_id" uuid NOT NULL REFERENCES "account"("id"), + "company_id" uuid NOT NULL REFERENCES "company"("id"), + "processor" "payment_processor" NOT NULL, + "processor_payment_method_id" varchar(255) NOT NULL, + "card_brand" varchar(50), + "last_four" varchar(4), + "exp_month" integer, + "exp_year" integer, + "is_default" boolean NOT NULL DEFAULT false, + "requires_update" boolean NOT NULL DEFAULT false, + "created_at" timestamp with time zone NOT NULL DEFAULT now() +); + +-- 6. Create tax_exemption table +CREATE TABLE IF NOT EXISTS "tax_exemption" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "account_id" uuid NOT NULL REFERENCES "account"("id"), + "company_id" uuid NOT NULL REFERENCES "company"("id"), + "status" "tax_exempt_status" NOT NULL DEFAULT 'pending', + "certificate_number" varchar(255) NOT NULL, + "certificate_type" varchar(100), + "issuing_state" varchar(2), + "expires_at" date, + "approved_by" uuid, + "approved_at" timestamp with time zone, + "revoked_by" uuid, + "revoked_at" timestamp with time zone, + "revoked_reason" text, + "notes" text, + "created_at" timestamp with time zone NOT NULL DEFAULT now(), + "updated_at" timestamp with time zone NOT NULL DEFAULT now() +); + +-- 7. Add unique constraint on lookup slugs per company +CREATE UNIQUE INDEX "inventory_unit_status_company_slug" ON "inventory_unit_status" ("company_id", "slug"); +CREATE UNIQUE INDEX "item_condition_company_slug" ON "item_condition" ("company_id", "slug"); diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index 3a023ca..1fdf737 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1774653924179, "tag": "0006_add_consignment", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1774662300000, + "tag": "0007_accounts_lookups", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/accounts.ts b/packages/backend/src/db/schema/accounts.ts index b940009..ced693d 100644 --- a/packages/backend/src/db/schema/accounts.ts +++ b/packages/backend/src/db/schema/accounts.ts @@ -6,12 +6,14 @@ import { jsonb, timestamp, boolean, + integer, date, pgEnum, } from 'drizzle-orm/pg-core' import { companies } from './stores.js' export const billingModeEnum = pgEnum('billing_mode', ['consolidated', 'split']) +export const taxExemptStatusEnum = pgEnum('tax_exempt_status', ['none', 'pending', 'approved']) export const accounts = pgTable('account', { id: uuid('id').primaryKey().defaultRandom(), @@ -77,6 +79,54 @@ export const accountProcessorLinks = pgTable('account_processor_link', { export type AccountProcessorLink = typeof accountProcessorLinks.$inferSelect export type AccountProcessorLinkInsert = typeof accountProcessorLinks.$inferInsert +export const accountPaymentMethods = pgTable('account_payment_method', { + 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(), + processorPaymentMethodId: varchar('processor_payment_method_id', { length: 255 }).notNull(), + cardBrand: varchar('card_brand', { length: 50 }), + lastFour: varchar('last_four', { length: 4 }), + expMonth: integer('exp_month'), + expYear: integer('exp_year'), + isDefault: boolean('is_default').notNull().default(false), + requiresUpdate: boolean('requires_update').notNull().default(false), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export type AccountPaymentMethod = typeof accountPaymentMethods.$inferSelect +export type AccountPaymentMethodInsert = typeof accountPaymentMethods.$inferInsert + +export const taxExemptions = pgTable('tax_exemption', { + id: uuid('id').primaryKey().defaultRandom(), + accountId: uuid('account_id') + .notNull() + .references(() => accounts.id), + companyId: uuid('company_id') + .notNull() + .references(() => companies.id), + status: taxExemptStatusEnum('status').notNull().default('pending'), + certificateNumber: varchar('certificate_number', { length: 255 }).notNull(), + certificateType: varchar('certificate_type', { length: 100 }), + issuingState: varchar('issuing_state', { length: 2 }), + expiresAt: date('expires_at'), + approvedBy: uuid('approved_by'), + approvedAt: timestamp('approved_at', { withTimezone: true }), + revokedBy: uuid('revoked_by'), + revokedAt: timestamp('revoked_at', { withTimezone: true }), + revokedReason: text('revoked_reason'), + notes: text('notes'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export type TaxExemption = typeof taxExemptions.$inferSelect +export type TaxExemptionInsert = typeof taxExemptions.$inferInsert + export type Account = typeof accounts.$inferSelect export type AccountInsert = typeof accounts.$inferInsert export type Member = typeof members.$inferSelect diff --git a/packages/backend/src/db/schema/inventory.ts b/packages/backend/src/db/schema/inventory.ts index df6e46a..5ac30c9 100644 --- a/packages/backend/src/db/schema/inventory.ts +++ b/packages/backend/src/db/schema/inventory.ts @@ -8,7 +8,6 @@ import { integer, numeric, date, - pgEnum, } from 'drizzle-orm/pg-core' import { companies, locations } from './stores.js' @@ -44,21 +43,9 @@ export const suppliers = pgTable('supplier', { updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }) -export const conditionEnum = pgEnum('item_condition', [ - 'new', - 'excellent', - 'good', - 'fair', - 'poor', -]) - -export const unitStatusEnum = pgEnum('unit_status', [ - 'available', - 'sold', - 'rented', - 'in_repair', - 'retired', -]) +// NOTE: item_condition and unit_status pgEnums replaced by lookup tables. +// See lookups.ts for inventory_unit_status and item_condition tables. +// Columns below use varchar referencing the lookup slug. export const products = pgTable('product', { id: uuid('id').primaryKey().defaultRandom(), @@ -97,8 +84,8 @@ export const inventoryUnits = pgTable('inventory_unit', { .references(() => companies.id), locationId: uuid('location_id').references(() => locations.id), serialNumber: varchar('serial_number', { length: 255 }), - condition: conditionEnum('condition').notNull().default('new'), - status: unitStatusEnum('status').notNull().default('available'), + condition: varchar('condition', { length: 100 }).notNull().default('new'), + status: varchar('status', { length: 100 }).notNull().default('available'), purchaseDate: date('purchase_date'), purchaseCost: numeric('purchase_cost', { precision: 10, scale: 2 }), notes: text('notes'), diff --git a/packages/backend/src/db/schema/lookups.ts b/packages/backend/src/db/schema/lookups.ts new file mode 100644 index 0000000..d45b5c8 --- /dev/null +++ b/packages/backend/src/db/schema/lookups.ts @@ -0,0 +1,67 @@ +import { pgTable, uuid, varchar, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core' +import { companies } from './stores.js' + +/** + * Lookup tables replace hard-coded pgEnums for values that stores may want to customize. + * System rows (is_system = true) are seeded per company and cannot be deleted or renamed. + * Stores can add custom rows for informational/tracking purposes. + */ + +export const inventoryUnitStatuses = pgTable('inventory_unit_status', { + id: uuid('id').primaryKey().defaultRandom(), + companyId: uuid('company_id') + .notNull() + .references(() => companies.id), + name: varchar('name', { length: 100 }).notNull(), + slug: varchar('slug', { length: 100 }).notNull(), + description: text('description'), + isSystem: boolean('is_system').notNull().default(false), + sortOrder: integer('sort_order').notNull().default(0), + isActive: boolean('is_active').notNull().default(true), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const itemConditions = pgTable('item_condition', { + id: uuid('id').primaryKey().defaultRandom(), + companyId: uuid('company_id') + .notNull() + .references(() => companies.id), + name: varchar('name', { length: 100 }).notNull(), + slug: varchar('slug', { length: 100 }).notNull(), + description: text('description'), + isSystem: boolean('is_system').notNull().default(false), + sortOrder: integer('sort_order').notNull().default(0), + isActive: boolean('is_active').notNull().default(true), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export type InventoryUnitStatus = typeof inventoryUnitStatuses.$inferSelect +export type InventoryUnitStatusInsert = typeof inventoryUnitStatuses.$inferInsert +export type ItemCondition = typeof itemConditions.$inferSelect +export type ItemConditionInsert = typeof itemConditions.$inferInsert + +/** + * System-seeded inventory unit statuses. + * Code references these slugs for business logic. + */ +export const SYSTEM_UNIT_STATUSES = [ + { slug: 'available', name: 'Available', description: 'In stock, ready for sale or rental', sortOrder: 0 }, + { slug: 'sold', name: 'Sold', description: 'Purchased by customer', sortOrder: 1 }, + { slug: 'rented', name: 'Rented', description: 'Out on active rental contract', sortOrder: 2 }, + { slug: 'on_trial', name: 'On Trial', description: 'Out with customer on in-home trial', sortOrder: 3 }, + { slug: 'in_repair', name: 'In Repair', description: 'In repair shop for service', sortOrder: 4 }, + { slug: 'layaway', name: 'Layaway', description: 'Reserved for layaway customer', sortOrder: 5 }, + { slug: 'lost', name: 'Lost', description: 'Unrecovered from trial, rental, or discrepancy', sortOrder: 6 }, + { slug: 'retired', name: 'Retired', description: 'Permanently removed from inventory', sortOrder: 7 }, +] as const + +/** + * System-seeded item conditions. + */ +export const SYSTEM_ITEM_CONDITIONS = [ + { slug: 'new', name: 'New', description: 'Brand new, unopened or unused', sortOrder: 0 }, + { slug: 'excellent', name: 'Excellent', description: 'Like new, minimal signs of use', sortOrder: 1 }, + { slug: 'good', name: 'Good', description: 'Normal wear, fully functional', sortOrder: 2 }, + { slug: 'fair', name: 'Fair', description: 'Noticeable wear, functional with minor issues', sortOrder: 3 }, + { slug: 'poor', name: 'Poor', description: 'Heavy wear, may need repair', sortOrder: 4 }, +] as const diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 5bae77e..d94e76b 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -11,6 +11,7 @@ import { authRoutes } from './routes/v1/auth.js' import { accountRoutes } from './routes/v1/accounts.js' import { inventoryRoutes } from './routes/v1/inventory.js' import { productRoutes } from './routes/v1/products.js' +import { lookupRoutes } from './routes/v1/lookups.js' export async function buildApp() { const app = Fastify({ @@ -44,6 +45,7 @@ export async function buildApp() { await app.register(accountRoutes, { prefix: '/v1' }) await app.register(inventoryRoutes, { prefix: '/v1' }) await app.register(productRoutes, { prefix: '/v1' }) + await app.register(lookupRoutes, { prefix: '/v1' }) return app } diff --git a/packages/backend/src/routes/v1/accounts-extended.test.ts b/packages/backend/src/routes/v1/accounts-extended.test.ts new file mode 100644 index 0000000..e0d269c --- /dev/null +++ b/packages/backend/src/routes/v1/accounts-extended.test.ts @@ -0,0 +1,491 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test' +import type { FastifyInstance } from 'fastify' +import { + createTestApp, + cleanDb, + seedTestCompany, + registerAndLogin, + TEST_COMPANY_ID, +} from '../../test/helpers.js' + +describe('Processor link routes', () => { + let app: FastifyInstance + let token: string + let accountId: string + + beforeAll(async () => { + app = await createTestApp() + }) + + beforeEach(async () => { + await cleanDb(app) + await seedTestCompany(app) + const auth = await registerAndLogin(app, { email: `pl-${Date.now()}@test.com` }) + token = auth.token + + const res = await app.inject({ + method: 'POST', + url: '/v1/accounts', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Link Test Account' }, + }) + accountId = res.json().id + }) + + afterAll(async () => { + await app.close() + }) + + it('creates a processor link', async () => { + const res = await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/processor-links`, + headers: { authorization: `Bearer ${token}` }, + payload: { processor: 'stripe', processorCustomerId: 'cus_test123' }, + }) + expect(res.statusCode).toBe(201) + expect(res.json().processor).toBe('stripe') + expect(res.json().processorCustomerId).toBe('cus_test123') + expect(res.json().accountId).toBe(accountId) + }) + + it('lists processor links for an account', async () => { + await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/processor-links`, + headers: { authorization: `Bearer ${token}` }, + payload: { processor: 'stripe', processorCustomerId: 'cus_1' }, + }) + await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/processor-links`, + headers: { authorization: `Bearer ${token}` }, + payload: { processor: 'global_payments', processorCustomerId: 'gp_1' }, + }) + + const res = await app.inject({ + method: 'GET', + url: `/v1/accounts/${accountId}/processor-links`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + expect(res.json().data.length).toBe(2) + }) + + it('updates a processor link', async () => { + const createRes = await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/processor-links`, + headers: { authorization: `Bearer ${token}` }, + payload: { processor: 'stripe', processorCustomerId: 'cus_1' }, + }) + const id = createRes.json().id + + const res = await app.inject({ + method: 'PATCH', + url: `/v1/processor-links/${id}`, + headers: { authorization: `Bearer ${token}` }, + payload: { isActive: false }, + }) + expect(res.statusCode).toBe(200) + expect(res.json().isActive).toBe(false) + }) + + it('deletes a processor link', async () => { + const createRes = await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/processor-links`, + headers: { authorization: `Bearer ${token}` }, + payload: { processor: 'stripe', processorCustomerId: 'cus_del' }, + }) + const id = createRes.json().id + + const delRes = await app.inject({ + method: 'DELETE', + url: `/v1/processor-links/${id}`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(delRes.statusCode).toBe(200) + }) +}) + +describe('Payment method routes', () => { + let app: FastifyInstance + let token: string + let accountId: string + + beforeAll(async () => { + app = await createTestApp() + }) + + beforeEach(async () => { + await cleanDb(app) + await seedTestCompany(app) + const auth = await registerAndLogin(app, { email: `pm-${Date.now()}@test.com` }) + token = auth.token + + const res = await app.inject({ + method: 'POST', + url: '/v1/accounts', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Payment Test Account' }, + }) + accountId = res.json().id + }) + + afterAll(async () => { + await app.close() + }) + + it('creates a payment method', async () => { + const res = await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/payment-methods`, + headers: { authorization: `Bearer ${token}` }, + payload: { + processor: 'stripe', + processorPaymentMethodId: 'pm_test123', + cardBrand: 'visa', + lastFour: '4242', + expMonth: 12, + expYear: 2027, + isDefault: true, + }, + }) + expect(res.statusCode).toBe(201) + expect(res.json().cardBrand).toBe('visa') + expect(res.json().lastFour).toBe('4242') + expect(res.json().isDefault).toBe(true) + }) + + it('lists payment methods for an account', async () => { + await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/payment-methods`, + headers: { authorization: `Bearer ${token}` }, + payload: { processor: 'stripe', processorPaymentMethodId: 'pm_1', lastFour: '1111' }, + }) + await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/payment-methods`, + headers: { authorization: `Bearer ${token}` }, + payload: { processor: 'stripe', processorPaymentMethodId: 'pm_2', lastFour: '2222' }, + }) + + const res = await app.inject({ + method: 'GET', + url: `/v1/accounts/${accountId}/payment-methods`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + expect(res.json().data.length).toBe(2) + }) + + it('sets new default and unsets old default', async () => { + const first = await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/payment-methods`, + headers: { authorization: `Bearer ${token}` }, + payload: { processor: 'stripe', processorPaymentMethodId: 'pm_1', isDefault: true }, + }) + const firstId = first.json().id + + const second = await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/payment-methods`, + headers: { authorization: `Bearer ${token}` }, + payload: { processor: 'stripe', processorPaymentMethodId: 'pm_2', isDefault: true }, + }) + expect(second.json().isDefault).toBe(true) + + // First should no longer be default + const getFirst = await app.inject({ + method: 'GET', + url: `/v1/payment-methods/${firstId}`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(getFirst.json().isDefault).toBe(false) + }) + + it('deletes a payment method', async () => { + const createRes = await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/payment-methods`, + headers: { authorization: `Bearer ${token}` }, + payload: { processor: 'stripe', processorPaymentMethodId: 'pm_del' }, + }) + + const delRes = await app.inject({ + method: 'DELETE', + url: `/v1/payment-methods/${createRes.json().id}`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(delRes.statusCode).toBe(200) + }) +}) + +describe('Tax exemption routes', () => { + let app: FastifyInstance + let token: string + let accountId: string + + beforeAll(async () => { + app = await createTestApp() + }) + + beforeEach(async () => { + await cleanDb(app) + await seedTestCompany(app) + const auth = await registerAndLogin(app, { email: `tax-${Date.now()}@test.com` }) + token = auth.token + + const res = await app.inject({ + method: 'POST', + url: '/v1/accounts', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Tax Test Account' }, + }) + accountId = res.json().id + }) + + afterAll(async () => { + await app.close() + }) + + it('creates a tax exemption', async () => { + const res = await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/tax-exemptions`, + headers: { authorization: `Bearer ${token}` }, + payload: { + certificateNumber: 'TX-12345', + certificateType: 'resale', + issuingState: 'TX', + expiresAt: '2027-12-31', + }, + }) + expect(res.statusCode).toBe(201) + expect(res.json().certificateNumber).toBe('TX-12345') + expect(res.json().status).toBe('pending') + expect(res.json().issuingState).toBe('TX') + }) + + it('lists tax exemptions for an account', async () => { + await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/tax-exemptions`, + headers: { authorization: `Bearer ${token}` }, + payload: { certificateNumber: 'CERT-1' }, + }) + + const res = await app.inject({ + method: 'GET', + url: `/v1/accounts/${accountId}/tax-exemptions`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + expect(res.json().data.length).toBe(1) + }) + + it('approves a tax exemption', async () => { + const createRes = await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/tax-exemptions`, + headers: { authorization: `Bearer ${token}` }, + payload: { certificateNumber: 'CERT-APPROVE' }, + }) + const id = createRes.json().id + + const res = await app.inject({ + method: 'POST', + url: `/v1/tax-exemptions/${id}/approve`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + expect(res.json().status).toBe('approved') + expect(res.json().approvedBy).toBeTruthy() + expect(res.json().approvedAt).toBeTruthy() + }) + + it('revokes a tax exemption with reason', async () => { + const createRes = await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/tax-exemptions`, + headers: { authorization: `Bearer ${token}` }, + payload: { certificateNumber: 'CERT-REVOKE' }, + }) + const id = createRes.json().id + + // Approve first + await app.inject({ + method: 'POST', + url: `/v1/tax-exemptions/${id}/approve`, + headers: { authorization: `Bearer ${token}` }, + }) + + const res = await app.inject({ + method: 'POST', + url: `/v1/tax-exemptions/${id}/revoke`, + headers: { authorization: `Bearer ${token}` }, + payload: { reason: 'Certificate expired' }, + }) + expect(res.statusCode).toBe(200) + expect(res.json().status).toBe('none') + expect(res.json().revokedReason).toBe('Certificate expired') + }) + + it('rejects revoke without reason', async () => { + const createRes = await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/tax-exemptions`, + headers: { authorization: `Bearer ${token}` }, + payload: { certificateNumber: 'CERT-NO-REASON' }, + }) + + const res = await app.inject({ + method: 'POST', + url: `/v1/tax-exemptions/${createRes.json().id}/revoke`, + headers: { authorization: `Bearer ${token}` }, + payload: {}, + }) + expect(res.statusCode).toBe(400) + }) + + it('updates a tax exemption', async () => { + const createRes = await app.inject({ + method: 'POST', + url: `/v1/accounts/${accountId}/tax-exemptions`, + headers: { authorization: `Bearer ${token}` }, + payload: { certificateNumber: 'OLD-CERT' }, + }) + + const res = await app.inject({ + method: 'PATCH', + url: `/v1/tax-exemptions/${createRes.json().id}`, + headers: { authorization: `Bearer ${token}` }, + payload: { certificateNumber: 'NEW-CERT', issuingState: 'CA' }, + }) + expect(res.statusCode).toBe(200) + expect(res.json().certificateNumber).toBe('NEW-CERT') + expect(res.json().issuingState).toBe('CA') + }) +}) + +describe('Lookup table routes', () => { + let app: FastifyInstance + let token: string + + beforeAll(async () => { + app = await createTestApp() + }) + + beforeEach(async () => { + await cleanDb(app) + await seedTestCompany(app) + const auth = await registerAndLogin(app, { email: `lookup-${Date.now()}@test.com` }) + token = auth.token + }) + + afterAll(async () => { + await app.close() + }) + + describe('Unit statuses', () => { + it('lists system-seeded unit statuses', async () => { + const res = await app.inject({ + method: 'GET', + url: '/v1/unit-statuses', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + const slugs = res.json().data.map((s: { slug: string }) => s.slug) + expect(slugs).toContain('available') + expect(slugs).toContain('sold') + expect(slugs).toContain('on_trial') + expect(slugs).toContain('layaway') + expect(slugs).toContain('lost') + }) + + it('creates a custom status', async () => { + const res = await app.inject({ + method: 'POST', + url: '/v1/unit-statuses', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'On Display', slug: 'on_display', description: 'Showroom floor' }, + }) + expect(res.statusCode).toBe(201) + expect(res.json().slug).toBe('on_display') + expect(res.json().isSystem).toBe(false) + }) + + it('rejects duplicate slug', async () => { + const res = await app.inject({ + method: 'POST', + url: '/v1/unit-statuses', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Available Copy', slug: 'available' }, + }) + expect(res.statusCode).toBe(409) + }) + + it('blocks deleting a system status', async () => { + const list = await app.inject({ + method: 'GET', + url: '/v1/unit-statuses', + headers: { authorization: `Bearer ${token}` }, + }) + const systemStatus = list.json().data.find((s: { isSystem: boolean }) => s.isSystem) + + const res = await app.inject({ + method: 'DELETE', + url: `/v1/unit-statuses/${systemStatus.id}`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(403) + }) + + it('allows deleting a custom status', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/v1/unit-statuses', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Temp', slug: 'temp_status' }, + }) + + const res = await app.inject({ + method: 'DELETE', + url: `/v1/unit-statuses/${createRes.json().id}`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + }) + }) + + describe('Item conditions', () => { + it('lists system-seeded item conditions', async () => { + const res = await app.inject({ + method: 'GET', + url: '/v1/item-conditions', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + const slugs = res.json().data.map((c: { slug: string }) => c.slug) + expect(slugs).toContain('new') + expect(slugs).toContain('excellent') + expect(slugs).toContain('good') + expect(slugs).toContain('fair') + expect(slugs).toContain('poor') + }) + + it('creates a custom condition', async () => { + const res = await app.inject({ + method: 'POST', + url: '/v1/item-conditions', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Refurbished', slug: 'refurbished', description: 'Professionally restored' }, + }) + expect(res.statusCode).toBe(201) + expect(res.json().slug).toBe('refurbished') + }) + }) +}) diff --git a/packages/backend/src/routes/v1/accounts.ts b/packages/backend/src/routes/v1/accounts.ts index 1919fb0..c5fd811 100644 --- a/packages/backend/src/routes/v1/accounts.ts +++ b/packages/backend/src/routes/v1/accounts.ts @@ -5,8 +5,20 @@ import { MemberCreateSchema, MemberUpdateSchema, PaginationSchema, + ProcessorLinkCreateSchema, + ProcessorLinkUpdateSchema, + PaymentMethodCreateSchema, + PaymentMethodUpdateSchema, + TaxExemptionCreateSchema, + TaxExemptionUpdateSchema, } from '@forte/shared/schemas' -import { AccountService, MemberService } from '../../services/account.service.js' +import { + AccountService, + MemberService, + ProcessorLinkService, + PaymentMethodService, + TaxExemptionService, +} from '../../services/account.service.js' export const accountRoutes: FastifyPluginAsync = async (app) => { // --- Accounts --- @@ -94,4 +106,137 @@ export const accountRoutes: FastifyPluginAsync = async (app) => { if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } }) return reply.send(member) }) + + // --- Processor Links --- + + app.post('/accounts/:accountId/processor-links', { preHandler: [app.authenticate] }, async (request, reply) => { + const { accountId } = request.params as { accountId: string } + const parsed = ProcessorLinkCreateSchema.safeParse({ ...(request.body as object), accountId }) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const link = await ProcessorLinkService.create(app.db, request.companyId, parsed.data) + return reply.status(201).send(link) + }) + + app.get('/accounts/:accountId/processor-links', { preHandler: [app.authenticate] }, async (request, reply) => { + const { accountId } = request.params as { accountId: string } + const links = await ProcessorLinkService.listByAccount(app.db, request.companyId, accountId) + return reply.send({ data: links }) + }) + + app.patch('/processor-links/:id', { preHandler: [app.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = ProcessorLinkUpdateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const link = await ProcessorLinkService.update(app.db, request.companyId, id, parsed.data) + if (!link) return reply.status(404).send({ error: { message: 'Processor link not found', statusCode: 404 } }) + return reply.send(link) + }) + + app.delete('/processor-links/:id', { preHandler: [app.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string } + const link = await ProcessorLinkService.delete(app.db, request.companyId, id) + if (!link) return reply.status(404).send({ error: { message: 'Processor link not found', statusCode: 404 } }) + return reply.send(link) + }) + + // --- Payment Methods --- + + app.post('/accounts/:accountId/payment-methods', { preHandler: [app.authenticate] }, async (request, reply) => { + const { accountId } = request.params as { accountId: string } + const parsed = PaymentMethodCreateSchema.safeParse({ ...(request.body as object), accountId }) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const method = await PaymentMethodService.create(app.db, request.companyId, parsed.data) + return reply.status(201).send(method) + }) + + app.get('/accounts/:accountId/payment-methods', { preHandler: [app.authenticate] }, async (request, reply) => { + const { accountId } = request.params as { accountId: string } + const methods = await PaymentMethodService.listByAccount(app.db, request.companyId, accountId) + return reply.send({ data: methods }) + }) + + app.get('/payment-methods/:id', { preHandler: [app.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string } + const method = await PaymentMethodService.getById(app.db, request.companyId, id) + if (!method) return reply.status(404).send({ error: { message: 'Payment method not found', statusCode: 404 } }) + return reply.send(method) + }) + + app.patch('/payment-methods/:id', { preHandler: [app.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = PaymentMethodUpdateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const method = await PaymentMethodService.update(app.db, request.companyId, id, parsed.data) + if (!method) return reply.status(404).send({ error: { message: 'Payment method not found', statusCode: 404 } }) + return reply.send(method) + }) + + app.delete('/payment-methods/:id', { preHandler: [app.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string } + const method = await PaymentMethodService.delete(app.db, request.companyId, id) + if (!method) return reply.status(404).send({ error: { message: 'Payment method not found', statusCode: 404 } }) + return reply.send(method) + }) + + // --- Tax Exemptions --- + + app.post('/accounts/:accountId/tax-exemptions', { preHandler: [app.authenticate] }, async (request, reply) => { + const { accountId } = request.params as { accountId: string } + const parsed = TaxExemptionCreateSchema.safeParse({ ...(request.body as object), accountId }) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const exemption = await TaxExemptionService.create(app.db, request.companyId, parsed.data) + return reply.status(201).send(exemption) + }) + + app.get('/accounts/:accountId/tax-exemptions', { preHandler: [app.authenticate] }, async (request, reply) => { + const { accountId } = request.params as { accountId: string } + const exemptions = await TaxExemptionService.listByAccount(app.db, request.companyId, accountId) + return reply.send({ data: exemptions }) + }) + + app.get('/tax-exemptions/:id', { preHandler: [app.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string } + const exemption = await TaxExemptionService.getById(app.db, request.companyId, id) + if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } }) + return reply.send(exemption) + }) + + app.patch('/tax-exemptions/:id', { preHandler: [app.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = TaxExemptionUpdateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const exemption = await TaxExemptionService.update(app.db, request.companyId, id, parsed.data) + if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } }) + return reply.send(exemption) + }) + + app.post('/tax-exemptions/:id/approve', { preHandler: [app.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string } + const exemption = await TaxExemptionService.approve(app.db, request.companyId, id, request.user.id) + if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } }) + return reply.send(exemption) + }) + + app.post('/tax-exemptions/:id/revoke', { preHandler: [app.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string } + const { reason } = (request.body as { reason?: string }) ?? {} + if (!reason) { + return reply.status(400).send({ error: { message: 'Reason is required to revoke a tax exemption', statusCode: 400 } }) + } + const exemption = await TaxExemptionService.revoke(app.db, request.companyId, id, request.user.id, reason) + if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } }) + return reply.send(exemption) + }) } diff --git a/packages/backend/src/routes/v1/lookups.ts b/packages/backend/src/routes/v1/lookups.ts new file mode 100644 index 0000000..c0400e0 --- /dev/null +++ b/packages/backend/src/routes/v1/lookups.ts @@ -0,0 +1,66 @@ +import type { FastifyPluginAsync } from 'fastify' +import { LookupCreateSchema, LookupUpdateSchema } from '@forte/shared/schemas' +import { UnitStatusService, ItemConditionService } from '../../services/lookup.service.js' + +function createLookupRoutes(prefix: string, service: typeof UnitStatusService) { + const routes: FastifyPluginAsync = async (app) => { + app.get(`/${prefix}`, { preHandler: [app.authenticate] }, async (request, reply) => { + const data = await service.list(app.db, request.companyId) + return reply.send({ data }) + }) + + app.post(`/${prefix}`, { preHandler: [app.authenticate] }, async (request, reply) => { + const parsed = LookupCreateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + + // Check slug uniqueness + const existing = await service.getBySlug(app.db, request.companyId, parsed.data.slug) + if (existing) { + return reply.status(409).send({ error: { message: `Slug "${parsed.data.slug}" already exists`, statusCode: 409 } }) + } + + const row = await service.create(app.db, request.companyId, parsed.data) + return reply.status(201).send(row) + }) + + app.patch(`/${prefix}/:id`, { preHandler: [app.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = LookupUpdateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + try { + const row = await service.update(app.db, request.companyId, id, parsed.data) + if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } }) + return reply.send(row) + } catch (err) { + if (err instanceof Error && err.message.includes('system')) { + return reply.status(403).send({ error: { message: err.message, statusCode: 403 } }) + } + throw err + } + }) + + app.delete(`/${prefix}/:id`, { preHandler: [app.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string } + try { + const row = await service.delete(app.db, request.companyId, id) + if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } }) + return reply.send(row) + } catch (err) { + if (err instanceof Error && err.message.includes('system')) { + return reply.status(403).send({ error: { message: err.message, statusCode: 403 } }) + } + throw err + } + }) + } + return routes +} + +export const lookupRoutes: FastifyPluginAsync = async (app) => { + await app.register(createLookupRoutes('unit-statuses', UnitStatusService)) + await app.register(createLookupRoutes('item-conditions', ItemConditionService)) +} diff --git a/packages/backend/src/routes/v1/products.ts b/packages/backend/src/routes/v1/products.ts index 4757fc9..93398ff 100644 --- a/packages/backend/src/routes/v1/products.ts +++ b/packages/backend/src/routes/v1/products.ts @@ -59,8 +59,15 @@ export const productRoutes: FastifyPluginAsync = async (app) => { if (!parsed.success) { return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) } - const unit = await InventoryUnitService.create(app.db, request.companyId, parsed.data) - return reply.status(201).send(unit) + try { + const unit = await InventoryUnitService.create(app.db, request.companyId, parsed.data) + return reply.status(201).send(unit) + } catch (err) { + if (err instanceof Error && (err.message.includes('Invalid condition') || err.message.includes('Invalid status'))) { + return reply.status(400).send({ error: { message: err.message, statusCode: 400 } }) + } + throw err + } }) app.get('/products/:productId/units', { preHandler: [app.authenticate] }, async (request, reply) => { @@ -83,8 +90,15 @@ export const productRoutes: FastifyPluginAsync = async (app) => { if (!parsed.success) { return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) } - const unit = await InventoryUnitService.update(app.db, request.companyId, id, parsed.data) - if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } }) - return reply.send(unit) + try { + const unit = await InventoryUnitService.update(app.db, request.companyId, id, parsed.data) + if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } }) + return reply.send(unit) + } catch (err) { + if (err instanceof Error && (err.message.includes('Invalid condition') || err.message.includes('Invalid status'))) { + return reply.status(400).send({ error: { message: err.message, statusCode: 400 } }) + } + throw err + } }) } diff --git a/packages/backend/src/services/account.service.ts b/packages/backend/src/services/account.service.ts index 278344e..bdf721b 100644 --- a/packages/backend/src/services/account.service.ts +++ b/packages/backend/src/services/account.service.ts @@ -1,7 +1,23 @@ import { eq, and, sql, count } from 'drizzle-orm' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' -import { accounts, members } from '../db/schema/accounts.js' -import type { AccountCreateInput, AccountUpdateInput, PaginationInput } from '@forte/shared/schemas' +import { + accounts, + members, + accountProcessorLinks, + accountPaymentMethods, + taxExemptions, +} from '../db/schema/accounts.js' +import type { + AccountCreateInput, + AccountUpdateInput, + ProcessorLinkCreateInput, + ProcessorLinkUpdateInput, + PaymentMethodCreateInput, + PaymentMethodUpdateInput, + TaxExemptionCreateInput, + TaxExemptionUpdateInput, + PaginationInput, +} from '@forte/shared/schemas' import { isMinor } from '@forte/shared/utils' import { withPagination, @@ -193,3 +209,223 @@ export const MemberService = { 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 + }, +} diff --git a/packages/backend/src/services/lookup.service.ts b/packages/backend/src/services/lookup.service.ts new file mode 100644 index 0000000..38a4d00 --- /dev/null +++ b/packages/backend/src/services/lookup.service.ts @@ -0,0 +1,114 @@ +import { eq, and } from 'drizzle-orm' +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { + inventoryUnitStatuses, + itemConditions, + SYSTEM_UNIT_STATUSES, + SYSTEM_ITEM_CONDITIONS, +} from '../db/schema/lookups.js' +import type { LookupCreateInput, LookupUpdateInput } from '@forte/shared/schemas' + +function createLookupService( + table: typeof inventoryUnitStatuses | typeof itemConditions, + systemSeeds: ReadonlyArray<{ slug: string; name: string; description: string; sortOrder: number }>, +) { + return { + async seedForCompany(db: PostgresJsDatabase, companyId: string) { + const existing = await db + .select() + .from(table) + .where(and(eq(table.companyId, companyId), eq(table.isSystem, true))) + .limit(1) + + if (existing.length > 0) return // already seeded + + await db.insert(table).values( + systemSeeds.map((seed) => ({ + companyId, + ...seed, + isSystem: true, + })), + ) + }, + + async list(db: PostgresJsDatabase, companyId: string) { + return db + .select() + .from(table) + .where(and(eq(table.companyId, companyId), eq(table.isActive, true))) + .orderBy(table.sortOrder) + }, + + async getBySlug(db: PostgresJsDatabase, companyId: string, slug: string) { + const [row] = await db + .select() + .from(table) + .where(and(eq(table.companyId, companyId), eq(table.slug, slug))) + .limit(1) + return row ?? null + }, + + async create(db: PostgresJsDatabase, companyId: string, input: LookupCreateInput) { + const [row] = await db + .insert(table) + .values({ + companyId, + name: input.name, + slug: input.slug, + description: input.description, + sortOrder: input.sortOrder, + isSystem: false, + }) + .returning() + return row + }, + + async update(db: PostgresJsDatabase, companyId: string, id: string, input: LookupUpdateInput) { + // Prevent modifying system rows' slug or system flag + const existing = await db + .select() + .from(table) + .where(and(eq(table.id, id), eq(table.companyId, companyId))) + .limit(1) + + if (!existing[0]) return null + if (existing[0].isSystem && input.isActive === false) { + throw new Error('Cannot deactivate a system status') + } + + const [row] = await db + .update(table) + .set(input) + .where(and(eq(table.id, id), eq(table.companyId, companyId))) + .returning() + return row ?? null + }, + + async delete(db: PostgresJsDatabase, companyId: string, id: string) { + const existing = await db + .select() + .from(table) + .where(and(eq(table.id, id), eq(table.companyId, companyId))) + .limit(1) + + if (!existing[0]) return null + if (existing[0].isSystem) { + throw new Error('Cannot delete a system status') + } + + const [row] = await db + .delete(table) + .where(and(eq(table.id, id), eq(table.companyId, companyId))) + .returning() + return row ?? null + }, + + async validateSlug(db: PostgresJsDatabase, companyId: string, slug: string): Promise { + const row = await this.getBySlug(db, companyId, slug) + return row !== null && row.isActive + }, + } +} + +export const UnitStatusService = createLookupService(inventoryUnitStatuses, SYSTEM_UNIT_STATUSES) +export const ItemConditionService = createLookupService(itemConditions, SYSTEM_ITEM_CONDITIONS) diff --git a/packages/backend/src/services/product.service.ts b/packages/backend/src/services/product.service.ts index 8b58452..77401a6 100644 --- a/packages/backend/src/services/product.service.ts +++ b/packages/backend/src/services/product.service.ts @@ -14,6 +14,7 @@ import { buildSearchCondition, paginatedResponse, } from '../utils/pagination.js' +import { UnitStatusService, ItemConditionService } from './lookup.service.js' export const ProductService = { async create(db: PostgresJsDatabase, companyId: string, input: ProductCreateInput) { @@ -116,6 +117,15 @@ export const ProductService = { export const InventoryUnitService = { async create(db: PostgresJsDatabase, companyId: string, input: InventoryUnitCreateInput) { + if (input.condition) { + const valid = await ItemConditionService.validateSlug(db, companyId, input.condition) + if (!valid) throw new Error(`Invalid condition: "${input.condition}"`) + } + if (input.status) { + const valid = await UnitStatusService.validateSlug(db, companyId, input.status) + if (!valid) throw new Error(`Invalid status: "${input.status}"`) + } + const [unit] = await db .insert(inventoryUnits) .values({ @@ -178,6 +188,15 @@ export const InventoryUnitService = { id: string, input: InventoryUnitUpdateInput, ) { + if (input.condition) { + const valid = await ItemConditionService.validateSlug(db, companyId, input.condition) + if (!valid) throw new Error(`Invalid condition: "${input.condition}"`) + } + if (input.status) { + const valid = await UnitStatusService.validateSlug(db, companyId, input.status) + if (!valid) throw new Error(`Invalid status: "${input.status}"`) + } + const updates: Record = { ...input } if (input.purchaseCost !== undefined) updates.purchaseCost = input.purchaseCost.toString() diff --git a/packages/backend/src/test/helpers.ts b/packages/backend/src/test/helpers.ts index fcebb48..beca6c2 100644 --- a/packages/backend/src/test/helpers.ts +++ b/packages/backend/src/test/helpers.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify' import { buildApp } from '../main.js' import { sql } from 'drizzle-orm' import { companies, locations } from '../db/schema/stores.js' +import { UnitStatusService, ItemConditionService } from '../services/lookup.service.js' export const TEST_COMPANY_ID = '00000000-0000-0000-0000-000000000099' export const TEST_LOCATION_ID = '00000000-0000-0000-0000-000000000099' @@ -44,6 +45,10 @@ export async function seedTestCompany(app: FastifyInstance): Promise { companyId: TEST_COMPANY_ID, name: 'Test Location', }) + + // Seed lookup tables for the test company + await UnitStatusService.seedForCompany(app.db, TEST_COMPANY_ID) + await ItemConditionService.seedForCompany(app.db, TEST_COMPANY_ID) } /** diff --git a/packages/shared/src/schemas/account.schema.ts b/packages/shared/src/schemas/account.schema.ts index d89d2d0..b6d7f18 100644 --- a/packages/shared/src/schemas/account.schema.ts +++ b/packages/shared/src/schemas/account.schema.ts @@ -3,6 +3,12 @@ import { z } from 'zod' export const BillingMode = z.enum(['consolidated', 'split']) export type BillingMode = z.infer +export const PaymentProcessor = z.enum(['stripe', 'global_payments']) +export type PaymentProcessor = z.infer + +export const TaxExemptStatus = z.enum(['none', 'pending', 'approved']) +export type TaxExemptStatus = z.infer + export const AccountCreateSchema = z.object({ name: z.string().min(1).max(255), email: z.string().email().optional(), @@ -40,3 +46,76 @@ export type MemberUpdateInput = z.infer export const AccountSearchSchema = z.object({ q: z.string().min(1).max(255), }) + +// --- Account Processor Link --- + +export const ProcessorLinkCreateSchema = z.object({ + accountId: z.string().uuid(), + processor: PaymentProcessor, + processorCustomerId: z.string().min(1).max(255), +}) +export type ProcessorLinkCreateInput = z.infer + +export const ProcessorLinkUpdateSchema = z.object({ + isActive: z.boolean().optional(), +}) +export type ProcessorLinkUpdateInput = z.infer + +// --- Account Payment Method --- + +export const PaymentMethodCreateSchema = z.object({ + accountId: z.string().uuid(), + processor: PaymentProcessor, + processorPaymentMethodId: z.string().min(1).max(255), + cardBrand: z.string().max(50).optional(), + lastFour: z.string().length(4).optional(), + expMonth: z.number().int().min(1).max(12).optional(), + expYear: z.number().int().min(2000).max(2100).optional(), + isDefault: z.boolean().default(false), +}) +export type PaymentMethodCreateInput = z.infer + +export const PaymentMethodUpdateSchema = z.object({ + isDefault: z.boolean().optional(), + requiresUpdate: z.boolean().optional(), +}) +export type PaymentMethodUpdateInput = z.infer + +// --- Tax Exemption --- + +export const TaxExemptionCreateSchema = z.object({ + accountId: z.string().uuid(), + certificateNumber: z.string().min(1).max(255), + certificateType: z.string().max(100).optional(), + issuingState: z.string().length(2).optional(), + expiresAt: z.string().date().optional(), + notes: z.string().optional(), +}) +export type TaxExemptionCreateInput = z.infer + +export const TaxExemptionUpdateSchema = z.object({ + certificateNumber: z.string().min(1).max(255).optional(), + certificateType: z.string().max(100).optional(), + issuingState: z.string().length(2).optional(), + expiresAt: z.string().date().optional(), + notes: z.string().optional(), +}) +export type TaxExemptionUpdateInput = z.infer + +// --- Lookup Tables --- + +export const LookupCreateSchema = z.object({ + name: z.string().min(1).max(100), + slug: z.string().min(1).max(100).regex(/^[a-z0-9_]+$/, 'Slug must be lowercase alphanumeric with underscores'), + description: z.string().optional(), + sortOrder: z.number().int().default(0), +}) +export type LookupCreateInput = z.infer + +export const LookupUpdateSchema = z.object({ + name: z.string().min(1).max(100).optional(), + description: z.string().optional(), + sortOrder: z.number().int().optional(), + isActive: z.boolean().optional(), +}) +export type LookupUpdateInput = z.infer diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 0d5f2ee..fd21844 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -6,17 +6,35 @@ export type { RegisterInput, LoginInput } from './auth.schema.js' export { BillingMode, + PaymentProcessor, + TaxExemptStatus, AccountCreateSchema, AccountUpdateSchema, MemberCreateSchema, MemberUpdateSchema, AccountSearchSchema, + ProcessorLinkCreateSchema, + ProcessorLinkUpdateSchema, + PaymentMethodCreateSchema, + PaymentMethodUpdateSchema, + TaxExemptionCreateSchema, + TaxExemptionUpdateSchema, + LookupCreateSchema, + LookupUpdateSchema, } from './account.schema.js' export type { AccountCreateInput, AccountUpdateInput, MemberCreateInput, MemberUpdateInput, + ProcessorLinkCreateInput, + ProcessorLinkUpdateInput, + PaymentMethodCreateInput, + PaymentMethodUpdateInput, + TaxExemptionCreateInput, + TaxExemptionUpdateInput, + LookupCreateInput, + LookupUpdateInput, } from './account.schema.js' export { @@ -26,6 +44,8 @@ export { SupplierUpdateSchema, ItemCondition, UnitStatus, + SystemItemCondition, + SystemUnitStatus, ProductCreateSchema, ProductUpdateSchema, ProductSearchSchema, diff --git a/packages/shared/src/schemas/inventory.schema.ts b/packages/shared/src/schemas/inventory.schema.ts index ddffd16..168ed36 100644 --- a/packages/shared/src/schemas/inventory.schema.ts +++ b/packages/shared/src/schemas/inventory.schema.ts @@ -26,8 +26,23 @@ export type SupplierCreateInput = z.infer export const SupplierUpdateSchema = SupplierCreateSchema.partial() export type SupplierUpdateInput = z.infer -export const ItemCondition = z.enum(['new', 'excellent', 'good', 'fair', 'poor']) -export const UnitStatus = z.enum(['available', 'sold', 'rented', 'in_repair', 'retired']) +// System slugs — used for code-level business logic references. +// Actual valid values are stored in lookup tables and are company-configurable. +export const SystemItemCondition = z.enum(['new', 'excellent', 'good', 'fair', 'poor']) +export const SystemUnitStatus = z.enum([ + 'available', + 'sold', + 'rented', + 'on_trial', + 'in_repair', + 'layaway', + 'lost', + 'retired', +]) + +// API validation accepts any string slug (validated against lookup table at service layer) +export const ItemCondition = z.string().min(1).max(100) +export const UnitStatus = z.string().min(1).max(100) export const ProductCreateSchema = z.object({ sku: z.string().max(100).optional(),