1 Commits

Author SHA1 Message Date
ryan
37e9cc324f docs: frontend testing plan (Playwright + Vitest)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:32:32 +00:00
8 changed files with 155 additions and 548 deletions

View File

@@ -0,0 +1,155 @@
# Frontend Testing Plan
## Overview
The admin frontend has grown significantly (station mode, POS, repairs intake, lessons). We need frontend testing to catch regressions and validate critical user flows.
## Recommended Stack
### Tier 1: Playwright (E2E)
**Priority: High — implement first**
End-to-end browser tests that exercise the full app against a running backend. Catches integration issues, routing problems, and real user flow regressions.
- **Framework:** Playwright (`@playwright/test`)
- **Runner:** `bun run test:e2e` (or `npx playwright test`)
- **Location:** `packages/admin/e2e/`
- **CI:** Runs in the `e2e` job alongside API tests (needs backend + DB + Valkey)
#### Critical flows to test first
1. **Login flow** — email/password login, redirect to dashboard, logout
2. **Forgot password** — submit email, see success message
3. **Station PIN login** — navigate to /station, enter PIN, unlock, see tabs
4. **Station tab switching** — POS → Repairs → Lessons, verify content renders
5. **POS sale** — search product, add to cart, complete cash payment, see receipt
6. **Repair intake** — new intake form, step through all steps, create ticket
7. **Repair queue** — filter by status, select ticket, see detail
8. **Session attendance** — open lessons tab, select session, mark attended
9. **Profile PIN setup** — navigate to profile, security tab, set PIN
10. **Auto-lock** — verify station locks after timeout
#### Setup
```bash
# Install
bun add -d @playwright/test
npx playwright install chromium
# Config: packages/admin/playwright.config.ts
# - baseURL: http://localhost:5173 (dev) or build + serve
# - webServer: start backend + frontend before tests
# - Use test DB with known seed data
```
#### Test structure
```
packages/admin/e2e/
fixtures/
auth.ts -- login helper, PIN login helper
seed.ts -- API calls to seed test data (accounts, products, tickets)
flows/
login.spec.ts
station-pos.spec.ts
station-repairs.spec.ts
station-lessons.spec.ts
profile.spec.ts
forgot-password.spec.ts
```
#### Auth fixtures
```ts
// Reusable login that stores auth state
async function loginAsAdmin(page) {
await page.goto('/login')
await page.fill('[type=email]', 'admin@test.com')
await page.fill('[type=password]', 'TestPassword123!')
await page.click('button[type=submit]')
await page.waitForURL('**/accounts**')
}
async function pinLogin(page, employeeNumber, pin) {
await page.goto('/station')
// Type PIN digits
for (const digit of employeeNumber + pin) {
await page.click(`button:has-text("${digit}")`)
}
await page.waitForSelector('[data-slot="tabs"]')
}
```
### Tier 2: Vitest + Testing Library (Component)
**Priority: Medium — implement after E2E**
Fast unit/integration tests for component logic, state management, and utility functions. Doesn't need a running backend (mocks API calls).
- **Framework:** Vitest + `@testing-library/react`
- **Runner:** `bun run test:unit` (in packages/admin)
- **Location:** `packages/admin/__tests__/`
#### What to test
- **Zustand stores:** `pos.store.ts`, `station.store.ts` — state transitions, lock/unlock, activity tracking
- **Form validation:** intake form step validation, PIN input, password requirements
- **Permission routing:** repairs-station permission check (desk vs tech), lessons-station (desk vs instructor)
- **Utility functions:** time formatting, status color mapping, receipt format toggle
- **Component rendering:** key components render without crashing with mock data
#### What NOT to test at component level
- API integration (covered by E2E + API tests)
- CSS/layout (covered by E2E visual checks)
- Third-party component internals (shadcn, radix)
### Tier 3: Visual Regression (Optional)
**Priority: Low — nice to have**
Screenshot comparison to catch unintended visual changes.
- **Tool:** Playwright's built-in screenshot comparison (`expect(page).toHaveScreenshot()`)
- **Scope:** Key pages only (login, station POS, repair intake, profile)
- **Storage:** Baseline screenshots committed to repo
## CI Integration
```yaml
# Add to .gitea/workflows/ci.yml
frontend-unit:
runs-on: ubuntu-latest
needs: ci
steps:
- uses: actions/checkout@v4
- run: curl -fsSL https://bun.sh/install | bash && echo "$HOME/.bun/bin" >> $GITHUB_PATH
- run: bun install --frozen-lockfile
- working-directory: packages/admin
run: bun run test:unit
frontend-e2e:
runs-on: ubuntu-latest
needs: ci
steps:
- uses: actions/checkout@v4
- run: curl -fsSL https://bun.sh/install | bash && echo "$HOME/.bun/bin" >> $GITHUB_PATH
- run: bun install --frozen-lockfile
- run: npx playwright install chromium --with-deps
# Start services (postgres, valkey)
# Run migrations + seed
# Start backend + frontend
# Run playwright tests
```
## Implementation Order
1. **Set up Playwright** — config, install, first smoke test (login page loads)
2. **Auth fixtures** — reusable login + PIN login helpers
3. **Seed fixtures** — create test data via API (reuse API test patterns)
4. **Login flow tests** — login, forgot password, redirect
5. **Station flow tests** — PIN login, tab switching, POS sale
6. **Repair flow tests** — intake form, queue, detail
7. **Lesson flow tests** — attendance, schedule view
8. **Set up Vitest** — config, first store test
9. **Store tests** — POS store, station store
10. **CI integration** — add to pipeline
## Estimated Effort
- Tier 1 (Playwright setup + 10 critical flows): ~1 session
- Tier 2 (Vitest setup + store/component tests): ~1 session
- CI integration: included in each tier

View File

@@ -4,4 +4,3 @@ export * from './schema/accounts.js'
export * from './schema/inventory.js'
export * from './schema/pos.js'
export * from './schema/settings.js'
export * from './schema/accounting.js'

View File

@@ -1,189 +0,0 @@
-- Accounting module tables
-- Enums
DO $$ BEGIN
CREATE TYPE invoice_status AS ENUM ('draft','sent','paid','partial','overdue','void','written_off');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE account_type AS ENUM ('asset','liability','revenue','contra_revenue','cogs','expense');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE normal_balance AS ENUM ('debit','credit');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE journal_line_type AS ENUM ('debit','credit');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE billing_run_status AS ENUM ('pending','running','completed','failed');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Core: Invoice
CREATE TABLE IF NOT EXISTS "invoice" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"invoice_number" varchar(50) UNIQUE NOT NULL,
"account_id" uuid NOT NULL REFERENCES "account"("id"),
"location_id" uuid REFERENCES "location"("id"),
"status" invoice_status NOT NULL DEFAULT 'draft',
"issue_date" date NOT NULL DEFAULT CURRENT_DATE,
"due_date" date NOT NULL DEFAULT CURRENT_DATE,
"source_type" varchar(50),
"source_id" uuid,
"subtotal" numeric(10,2) NOT NULL DEFAULT 0,
"discount_total" numeric(10,2) NOT NULL DEFAULT 0,
"tax_total" numeric(10,2) NOT NULL DEFAULT 0,
"total" numeric(10,2) NOT NULL DEFAULT 0,
"amount_paid" numeric(10,2) NOT NULL DEFAULT 0,
"balance" numeric(10,2) NOT NULL DEFAULT 0,
"refund_of_invoice_id" uuid REFERENCES "invoice"("id"),
"notes" text,
"created_by" uuid REFERENCES "user"("id"),
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "invoice_account_id" ON "invoice" ("account_id");
CREATE INDEX IF NOT EXISTS "invoice_status" ON "invoice" ("status");
CREATE INDEX IF NOT EXISTS "invoice_source" ON "invoice" ("source_type", "source_id");
-- Core: Invoice Line Item
CREATE TABLE IF NOT EXISTS "invoice_line_item" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"invoice_id" uuid NOT NULL REFERENCES "invoice"("id") ON DELETE CASCADE,
"description" varchar(255) NOT NULL,
"qty" integer NOT NULL DEFAULT 1,
"unit_price" numeric(10,2) NOT NULL,
"discount_amount" numeric(10,2) NOT NULL DEFAULT 0,
"tax_rate" numeric(5,4) NOT NULL DEFAULT 0,
"tax_amount" numeric(10,2) NOT NULL DEFAULT 0,
"line_total" numeric(10,2) NOT NULL DEFAULT 0,
"account_code_id" uuid,
"source_type" varchar(50),
"source_id" uuid,
"created_at" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "invoice_line_item_invoice_id" ON "invoice_line_item" ("invoice_id");
-- Core: Payment Application
CREATE TABLE IF NOT EXISTS "payment_application" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"invoice_id" uuid NOT NULL REFERENCES "invoice"("id"),
"transaction_id" uuid REFERENCES "transaction"("id"),
"amount" numeric(10,2) NOT NULL,
"applied_at" timestamptz NOT NULL DEFAULT now(),
"applied_by" uuid NOT NULL REFERENCES "user"("id"),
"notes" text,
"created_at" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "payment_application_invoice_id" ON "payment_application" ("invoice_id");
-- Core: Account Balance (materialized AR)
CREATE TABLE IF NOT EXISTS "account_balance" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"account_id" uuid NOT NULL UNIQUE REFERENCES "account"("id"),
"current_balance" numeric(10,2) NOT NULL DEFAULT 0,
"last_invoice_date" date,
"last_payment_date" date,
"updated_at" timestamptz NOT NULL DEFAULT now()
);
-- Accounting module: Chart of Accounts
CREATE TABLE IF NOT EXISTS "account_code" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"code" varchar(10) UNIQUE NOT NULL,
"name" varchar(255) NOT NULL,
"account_type" account_type NOT NULL,
"normal_balance" normal_balance NOT NULL,
"is_system" boolean NOT NULL DEFAULT true,
"is_active" boolean NOT NULL DEFAULT true,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);
-- Accounting module: Journal Entry
CREATE TABLE IF NOT EXISTS "journal_entry" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"entry_number" varchar(20) UNIQUE NOT NULL,
"entry_date" date NOT NULL DEFAULT CURRENT_DATE,
"entry_type" varchar(50) NOT NULL,
"source_type" varchar(50),
"source_id" uuid,
"description" text NOT NULL,
"total_debits" numeric(10,2) NOT NULL,
"total_credits" numeric(10,2) NOT NULL,
"is_void" boolean NOT NULL DEFAULT false,
"void_reason" text,
"voided_by" uuid REFERENCES "user"("id"),
"voided_at" timestamptz,
"reversal_of_id" uuid REFERENCES "journal_entry"("id"),
"created_by" uuid NOT NULL REFERENCES "user"("id"),
"created_at" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "journal_entry_date" ON "journal_entry" ("entry_date");
CREATE INDEX IF NOT EXISTS "journal_entry_type" ON "journal_entry" ("entry_type");
-- Accounting module: Journal Entry Line
CREATE TABLE IF NOT EXISTS "journal_entry_line" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"journal_entry_id" uuid NOT NULL REFERENCES "journal_entry"("id") ON DELETE CASCADE,
"account_code_id" uuid NOT NULL REFERENCES "account_code"("id"),
"line_type" journal_line_type NOT NULL,
"amount" numeric(10,2) NOT NULL,
"description" text,
"entity_type" varchar(50),
"entity_id" uuid,
"created_at" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "journal_entry_line_entry_id" ON "journal_entry_line" ("journal_entry_id");
CREATE INDEX IF NOT EXISTS "journal_entry_line_account" ON "journal_entry_line" ("account_code_id");
-- Lessons module: Billing Run
CREATE TABLE IF NOT EXISTS "billing_run" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"run_date" date NOT NULL,
"status" billing_run_status NOT NULL DEFAULT 'pending',
"enrollments_processed" integer NOT NULL DEFAULT 0,
"invoices_generated" integer NOT NULL DEFAULT 0,
"total_amount" numeric(10,2) NOT NULL DEFAULT 0,
"errors" jsonb,
"started_at" timestamptz,
"completed_at" timestamptz,
"created_by" uuid REFERENCES "user"("id"),
"created_at" timestamptz NOT NULL DEFAULT now()
);
-- Add nextBillingDate to enrollment
ALTER TABLE "enrollment" ADD COLUMN IF NOT EXISTS "next_billing_date" date;
-- Add accounting to module_config
INSERT INTO "module_config" ("slug", "name", "description", "licensed", "enabled")
VALUES ('accounting', 'Accounting', 'Chart of accounts, journal entries, and financial reports', true, false)
ON CONFLICT ("slug") DO NOTHING;
-- Seed chart of accounts
INSERT INTO "account_code" ("code", "name", "account_type", "normal_balance", "is_system") VALUES
('1000', 'Cash - Register Drawer', 'asset', 'debit', true),
('1100', 'Accounts Receivable', 'asset', 'debit', true),
('1200', 'Payment Clearing', 'asset', 'debit', true),
('1300', 'Inventory - Sale Stock', 'asset', 'debit', true),
('1320', 'Inventory - Parts & Supplies', 'asset', 'debit', true),
('2000', 'Sales Tax Payable', 'liability', 'credit', true),
('2110', 'Deferred Revenue - Lessons', 'liability', 'credit', true),
('4000', 'Sales Revenue', 'revenue', 'credit', true),
('4200', 'Lesson Revenue', 'revenue', 'credit', true),
('4300', 'Repair Revenue - Labor', 'revenue', 'credit', true),
('4310', 'Repair Revenue - Parts', 'revenue', 'credit', true),
('4900', 'Sales Discounts', 'contra_revenue', 'debit', true),
('4910', 'Sales Returns & Refunds', 'contra_revenue', 'debit', true),
('5000', 'Cost of Goods Sold', 'cogs', 'debit', true),
('5100', 'Repair Parts Cost', 'cogs', 'debit', true),
('6000', 'Cash Over / Short', 'expense', 'debit', true),
('6200', 'Bad Debt Expense', 'expense', 'debit', true)
ON CONFLICT ("code") DO NOTHING;

View File

@@ -330,13 +330,6 @@
"when": 1775860000000,
"tag": "0046_auto-employee-number",
"breakpoints": true
},
{
"idx": 47,
"version": "7",
"when": 1775950000000,
"tag": "0047_accounting",
"breakpoints": true
}
]
}

View File

@@ -1,147 +0,0 @@
import { pgTable, uuid, varchar, text, numeric, integer, boolean, date, timestamp, jsonb, pgEnum } from 'drizzle-orm/pg-core'
import { accounts } from './accounts.js'
import { locations } from './stores.js'
import { users } from './users.js'
import { transactions } from './pos.js'
// Enums
export const invoiceStatusEnum = pgEnum('invoice_status', ['draft', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off'])
export const accountTypeEnum = pgEnum('account_type', ['asset', 'liability', 'revenue', 'contra_revenue', 'cogs', 'expense'])
export const normalBalanceEnum = pgEnum('normal_balance', ['debit', 'credit'])
export const journalLineTypeEnum = pgEnum('journal_line_type', ['debit', 'credit'])
export const billingRunStatusEnum = pgEnum('billing_run_status', ['pending', 'running', 'completed', 'failed'])
// Core: Invoice
export const invoices = pgTable('invoice', {
id: uuid('id').primaryKey().defaultRandom(),
invoiceNumber: varchar('invoice_number', { length: 50 }).notNull().unique(),
accountId: uuid('account_id').notNull().references(() => accounts.id),
locationId: uuid('location_id').references(() => locations.id),
status: invoiceStatusEnum('status').notNull().default('draft'),
issueDate: date('issue_date').notNull().defaultNow(),
dueDate: date('due_date').notNull().defaultNow(),
sourceType: varchar('source_type', { length: 50 }),
sourceId: uuid('source_id'),
subtotal: numeric('subtotal', { precision: 10, scale: 2 }).notNull().default('0'),
discountTotal: numeric('discount_total', { precision: 10, scale: 2 }).notNull().default('0'),
taxTotal: numeric('tax_total', { precision: 10, scale: 2 }).notNull().default('0'),
total: numeric('total', { precision: 10, scale: 2 }).notNull().default('0'),
amountPaid: numeric('amount_paid', { precision: 10, scale: 2 }).notNull().default('0'),
balance: numeric('balance', { precision: 10, scale: 2 }).notNull().default('0'),
refundOfInvoiceId: uuid('refund_of_invoice_id'),
notes: text('notes'),
createdBy: uuid('created_by').references(() => users.id),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
// Core: Invoice Line Item
export const invoiceLineItems = pgTable('invoice_line_item', {
id: uuid('id').primaryKey().defaultRandom(),
invoiceId: uuid('invoice_id').notNull().references(() => invoices.id, { onDelete: 'cascade' }),
description: varchar('description', { length: 255 }).notNull(),
qty: integer('qty').notNull().default(1),
unitPrice: numeric('unit_price', { precision: 10, scale: 2 }).notNull(),
discountAmount: numeric('discount_amount', { precision: 10, scale: 2 }).notNull().default('0'),
taxRate: numeric('tax_rate', { precision: 5, scale: 4 }).notNull().default('0'),
taxAmount: numeric('tax_amount', { precision: 10, scale: 2 }).notNull().default('0'),
lineTotal: numeric('line_total', { precision: 10, scale: 2 }).notNull().default('0'),
accountCodeId: uuid('account_code_id'),
sourceType: varchar('source_type', { length: 50 }),
sourceId: uuid('source_id'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
// Core: Payment Application
export const paymentApplications = pgTable('payment_application', {
id: uuid('id').primaryKey().defaultRandom(),
invoiceId: uuid('invoice_id').notNull().references(() => invoices.id),
transactionId: uuid('transaction_id').references(() => transactions.id),
amount: numeric('amount', { precision: 10, scale: 2 }).notNull(),
appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(),
appliedBy: uuid('applied_by').notNull().references(() => users.id),
notes: text('notes'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
// Core: Account Balance (materialized AR)
export const accountBalances = pgTable('account_balance', {
id: uuid('id').primaryKey().defaultRandom(),
accountId: uuid('account_id').notNull().unique().references(() => accounts.id),
currentBalance: numeric('current_balance', { precision: 10, scale: 2 }).notNull().default('0'),
lastInvoiceDate: date('last_invoice_date'),
lastPaymentDate: date('last_payment_date'),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
// Accounting module: Chart of Accounts
export const accountCodes = pgTable('account_code', {
id: uuid('id').primaryKey().defaultRandom(),
code: varchar('code', { length: 10 }).notNull().unique(),
name: varchar('name', { length: 255 }).notNull(),
accountType: accountTypeEnum('account_type').notNull(),
normalBalance: normalBalanceEnum('normal_balance').notNull(),
isSystem: boolean('is_system').notNull().default(true),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
// Accounting module: Journal Entry
export const journalEntries = pgTable('journal_entry', {
id: uuid('id').primaryKey().defaultRandom(),
entryNumber: varchar('entry_number', { length: 20 }).notNull().unique(),
entryDate: date('entry_date').notNull().defaultNow(),
entryType: varchar('entry_type', { length: 50 }).notNull(),
sourceType: varchar('source_type', { length: 50 }),
sourceId: uuid('source_id'),
description: text('description').notNull(),
totalDebits: numeric('total_debits', { precision: 10, scale: 2 }).notNull(),
totalCredits: numeric('total_credits', { precision: 10, scale: 2 }).notNull(),
isVoid: boolean('is_void').notNull().default(false),
voidReason: text('void_reason'),
voidedBy: uuid('voided_by').references(() => users.id),
voidedAt: timestamp('voided_at', { withTimezone: true }),
reversalOfId: uuid('reversal_of_id'),
createdBy: uuid('created_by').notNull().references(() => users.id),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
// Accounting module: Journal Entry Line
export const journalEntryLines = pgTable('journal_entry_line', {
id: uuid('id').primaryKey().defaultRandom(),
journalEntryId: uuid('journal_entry_id').notNull().references(() => journalEntries.id, { onDelete: 'cascade' }),
accountCodeId: uuid('account_code_id').notNull().references(() => accountCodes.id),
lineType: journalLineTypeEnum('line_type').notNull(),
amount: numeric('amount', { precision: 10, scale: 2 }).notNull(),
description: text('description'),
entityType: varchar('entity_type', { length: 50 }),
entityId: uuid('entity_id'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
// Lessons module: Billing Run
export const billingRuns = pgTable('billing_run', {
id: uuid('id').primaryKey().defaultRandom(),
runDate: date('run_date').notNull(),
status: billingRunStatusEnum('status').notNull().default('pending'),
enrollmentsProcessed: integer('enrollments_processed').notNull().default(0),
invoicesGenerated: integer('invoices_generated').notNull().default(0),
totalAmount: numeric('total_amount', { precision: 10, scale: 2 }).notNull().default('0'),
errors: jsonb('errors'),
startedAt: timestamp('started_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdBy: uuid('created_by').references(() => users.id),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
// Type exports
export type Invoice = typeof invoices.$inferSelect
export type InvoiceInsert = typeof invoices.$inferInsert
export type InvoiceLineItem = typeof invoiceLineItems.$inferSelect
export type PaymentApplication = typeof paymentApplications.$inferSelect
export type AccountBalance = typeof accountBalances.$inferSelect
export type AccountCode = typeof accountCodes.$inferSelect
export type JournalEntry = typeof journalEntries.$inferSelect
export type JournalEntryLine = typeof journalEntryLines.$inferSelect
export type BillingRun = typeof billingRuns.$inferSelect

View File

@@ -1,93 +0,0 @@
import { eq, desc, gt, count } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { accountBalances } from '../db/schema/accounting.js'
import { accounts } from '../db/schema/accounts.js'
import type { PaginationInput } from '@lunarfront/shared/schemas'
export const AccountBalanceService = {
async getBalance(db: PostgresJsDatabase<any>, accountId: string) {
const [existing] = await db.select().from(accountBalances).where(eq(accountBalances.accountId, accountId)).limit(1)
if (existing) return existing
// Create balance record if it doesn't exist
const [created] = await db.insert(accountBalances).values({ accountId }).returning()
return created
},
async adjustBalance(
db: PostgresJsDatabase<any>,
accountId: string,
adjustment: number,
reason: 'invoice' | 'payment' | 'void' | 'write_off',
) {
const balance = await this.getBalance(db, accountId)
const newBalance = parseFloat(balance.currentBalance) + adjustment
const updates: Record<string, unknown> = {
currentBalance: newBalance.toFixed(2),
updatedAt: new Date(),
}
if (reason === 'invoice' && adjustment > 0) {
updates.lastInvoiceDate = new Date().toISOString().slice(0, 10)
}
if (reason === 'payment' && adjustment < 0) {
updates.lastPaymentDate = new Date().toISOString().slice(0, 10)
}
await db.update(accountBalances).set(updates).where(eq(accountBalances.accountId, accountId))
},
async getOutstandingAccounts(db: PostgresJsDatabase<any>, params: PaginationInput) {
const where = gt(accountBalances.currentBalance, '0')
const offset = ((params.page ?? 1) - 1) * (params.limit ?? 25)
const [data, [{ total }]] = await Promise.all([
db.select({
accountId: accountBalances.accountId,
currentBalance: accountBalances.currentBalance,
lastInvoiceDate: accountBalances.lastInvoiceDate,
lastPaymentDate: accountBalances.lastPaymentDate,
accountName: accounts.name,
accountEmail: accounts.email,
})
.from(accountBalances)
.innerJoin(accounts, eq(accountBalances.accountId, accounts.id))
.where(where)
.orderBy(desc(accountBalances.currentBalance))
.limit(params.limit ?? 25)
.offset(offset),
db.select({ total: count() })
.from(accountBalances)
.where(where),
])
return {
data,
pagination: {
page: params.page ?? 1,
limit: params.limit ?? 25,
total,
totalPages: Math.ceil(total / (params.limit ?? 25)),
},
}
},
async recalculateFromInvoices(db: PostgresJsDatabase<any>, accountId: string) {
// Safety valve: recalculate balance from all invoices
const { invoices } = await import('../db/schema/accounting.js')
const rows = await db
.select({ balance: invoices.balance, status: invoices.status })
.from(invoices)
.where(eq(invoices.accountId, accountId))
const totalOutstanding = rows
.filter(r => ['sent', 'partial', 'overdue'].includes(r.status))
.reduce((sum, r) => sum + parseFloat(r.balance), 0)
await db.update(accountBalances).set({
currentBalance: totalOutstanding.toFixed(2),
updatedAt: new Date(),
}).where(eq(accountBalances.accountId, accountId))
},
}

View File

@@ -1,83 +0,0 @@
import { z } from 'zod'
// Enums
export const AccountType = z.enum(['asset', 'liability', 'revenue', 'contra_revenue', 'cogs', 'expense'])
export type AccountType = z.infer<typeof AccountType>
export const InvoiceStatus = z.enum(['draft', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off'])
export type InvoiceStatus = z.infer<typeof InvoiceStatus>
export const JournalLineType = z.enum(['debit', 'credit'])
export type JournalLineType = z.infer<typeof JournalLineType>
export const BillingRunStatus = z.enum(['pending', 'running', 'completed', 'failed'])
export type BillingRunStatus = z.infer<typeof BillingRunStatus>
// Invoice
export const InvoiceLineItemInput = z.object({
description: z.string().min(1).max(255),
qty: z.coerce.number().int().min(1).default(1),
unitPrice: z.coerce.number().min(0),
discountAmount: z.coerce.number().min(0).default(0),
taxRate: z.coerce.number().min(0).default(0),
accountCodeId: z.string().uuid().optional(),
})
export type InvoiceLineItemInput = z.infer<typeof InvoiceLineItemInput>
export const InvoiceCreateSchema = z.object({
accountId: z.string().uuid(),
locationId: z.string().uuid().optional(),
issueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
notes: z.string().optional(),
lineItems: z.array(InvoiceLineItemInput).min(1),
})
export type InvoiceCreateInput = z.infer<typeof InvoiceCreateSchema>
export const PaymentApplicationSchema = z.object({
transactionId: z.string().uuid().optional(),
amount: z.coerce.number().min(0.01),
notes: z.string().optional(),
})
export type PaymentApplicationInput = z.infer<typeof PaymentApplicationSchema>
export const InvoiceVoidSchema = z.object({
reason: z.string().min(1),
})
export type InvoiceVoidInput = z.infer<typeof InvoiceVoidSchema>
export const InvoiceWriteOffSchema = z.object({
reason: z.string().min(1),
})
export type InvoiceWriteOffInput = z.infer<typeof InvoiceWriteOffSchema>
// Journal Entry
export const JournalEntryVoidSchema = z.object({
reason: z.string().min(1),
})
export type JournalEntryVoidInput = z.infer<typeof JournalEntryVoidSchema>
// Billing
export const BillingRunSchema = z.object({
runDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
})
export type BillingRunInput = z.infer<typeof BillingRunSchema>
export const BillEnrollmentSchema = z.object({
periodStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
periodEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
})
export type BillEnrollmentInput = z.infer<typeof BillEnrollmentSchema>
// Report filters
export const DateRangeFilterSchema = z.object({
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
})
export type DateRangeFilter = z.infer<typeof DateRangeFilterSchema>
export const StatementFilterSchema = z.object({
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
})
export type StatementFilter = z.infer<typeof StatementFilterSchema>

View File

@@ -200,31 +200,3 @@ export type {
export { LogLevel, AppConfigUpdateSchema } from './config.schema.js'
export type { AppConfigUpdateInput } from './config.schema.js'
export {
AccountType,
InvoiceStatus,
JournalLineType,
BillingRunStatus,
InvoiceLineItemInput,
InvoiceCreateSchema,
PaymentApplicationSchema,
InvoiceVoidSchema,
InvoiceWriteOffSchema,
JournalEntryVoidSchema,
BillingRunSchema,
BillEnrollmentSchema,
DateRangeFilterSchema,
StatementFilterSchema,
} from './accounting.schema.js'
export type {
InvoiceCreateInput,
PaymentApplicationInput,
InvoiceVoidInput,
InvoiceWriteOffInput,
JournalEntryVoidInput,
BillingRunInput,
BillEnrollmentInput,
DateRangeFilter,
StatementFilter,
} from './accounting.schema.js'