Compare commits
2 Commits
feature/st
...
feature/ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9798f2c8c | ||
| 5f4a12b9c4 |
@@ -1,155 +0,0 @@
|
||||
# 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
|
||||
@@ -4,3 +4,4 @@ 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'
|
||||
|
||||
189
packages/backend/src/db/migrations/0047_accounting.sql
Normal file
189
packages/backend/src/db/migrations/0047_accounting.sql
Normal file
@@ -0,0 +1,189 @@
|
||||
-- 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;
|
||||
@@ -330,6 +330,13 @@
|
||||
"when": 1775860000000,
|
||||
"tag": "0046_auto-employee-number",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 47,
|
||||
"version": "7",
|
||||
"when": 1775950000000,
|
||||
"tag": "0047_accounting",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
147
packages/backend/src/db/schema/accounting.ts
Normal file
147
packages/backend/src/db/schema/accounting.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
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
|
||||
93
packages/backend/src/services/account-balance.service.ts
Normal file
93
packages/backend/src/services/account-balance.service.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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))
|
||||
},
|
||||
}
|
||||
83
packages/shared/src/schemas/accounting.schema.ts
Normal file
83
packages/shared/src/schemas/accounting.schema.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
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>
|
||||
@@ -200,3 +200,31 @@ 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'
|
||||
|
||||
Reference in New Issue
Block a user