From b9798f2c8c4fae3ef2e8aa0c8e5431e6cf4843a3 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 6 Apr 2026 12:00:34 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20accounting=20module=20=E2=80=94=20schem?= =?UTF-8?q?a,=20migration,=20Zod=20schemas,=20AR=20balance=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 foundation: - Migration 0047: all accounting tables (invoice, payment_application, account_balance, account_code, journal_entry, billing_run), chart of accounts seed, nextBillingDate on enrollment, accounting module config - Drizzle schema file with all table definitions and type exports - Zod validation schemas (invoice, payment, billing, reports) - AccountBalanceService: materialized AR balance per account Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/backend/src/db/index.ts | 1 + .../src/db/migrations/0047_accounting.sql | 189 ++++++++++++++++++ .../src/db/migrations/meta/_journal.json | 7 + packages/backend/src/db/schema/accounting.ts | 147 ++++++++++++++ .../src/services/account-balance.service.ts | 93 +++++++++ .../shared/src/schemas/accounting.schema.ts | 83 ++++++++ packages/shared/src/schemas/index.ts | 28 +++ 7 files changed, 548 insertions(+) create mode 100644 packages/backend/src/db/migrations/0047_accounting.sql create mode 100644 packages/backend/src/db/schema/accounting.ts create mode 100644 packages/backend/src/services/account-balance.service.ts create mode 100644 packages/shared/src/schemas/accounting.schema.ts diff --git a/packages/backend/src/db/index.ts b/packages/backend/src/db/index.ts index 6517e32..ec01afe 100644 --- a/packages/backend/src/db/index.ts +++ b/packages/backend/src/db/index.ts @@ -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' diff --git a/packages/backend/src/db/migrations/0047_accounting.sql b/packages/backend/src/db/migrations/0047_accounting.sql new file mode 100644 index 0000000..8617c83 --- /dev/null +++ b/packages/backend/src/db/migrations/0047_accounting.sql @@ -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; diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index 569a4b7..c63513d 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -330,6 +330,13 @@ "when": 1775860000000, "tag": "0046_auto-employee-number", "breakpoints": true + }, + { + "idx": 47, + "version": "7", + "when": 1775950000000, + "tag": "0047_accounting", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/accounting.ts b/packages/backend/src/db/schema/accounting.ts new file mode 100644 index 0000000..f772580 --- /dev/null +++ b/packages/backend/src/db/schema/accounting.ts @@ -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 diff --git a/packages/backend/src/services/account-balance.service.ts b/packages/backend/src/services/account-balance.service.ts new file mode 100644 index 0000000..2168ee1 --- /dev/null +++ b/packages/backend/src/services/account-balance.service.ts @@ -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, 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, + 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 = { + 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, 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, 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)) + }, +} diff --git a/packages/shared/src/schemas/accounting.schema.ts b/packages/shared/src/schemas/accounting.schema.ts new file mode 100644 index 0000000..d82858e --- /dev/null +++ b/packages/shared/src/schemas/accounting.schema.ts @@ -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 + +export const InvoiceStatus = z.enum(['draft', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off']) +export type InvoiceStatus = z.infer + +export const JournalLineType = z.enum(['debit', 'credit']) +export type JournalLineType = z.infer + +export const BillingRunStatus = z.enum(['pending', 'running', 'completed', 'failed']) +export type BillingRunStatus = z.infer + +// 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 + +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 + +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 + +export const InvoiceVoidSchema = z.object({ + reason: z.string().min(1), +}) +export type InvoiceVoidInput = z.infer + +export const InvoiceWriteOffSchema = z.object({ + reason: z.string().min(1), +}) +export type InvoiceWriteOffInput = z.infer + +// Journal Entry +export const JournalEntryVoidSchema = z.object({ + reason: z.string().min(1), +}) +export type JournalEntryVoidInput = z.infer + +// Billing +export const BillingRunSchema = z.object({ + runDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), +}) +export type BillingRunInput = z.infer + +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 + +// 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 + +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 diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index c904d4f..8b7a110 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -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'