feat: accounting module — schema, migration, Zod schemas, AR balance service
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) <noreply@anthropic.com>
This commit is contained in:
@@ -4,3 +4,4 @@ export * from './schema/accounts.js'
|
|||||||
export * from './schema/inventory.js'
|
export * from './schema/inventory.js'
|
||||||
export * from './schema/pos.js'
|
export * from './schema/pos.js'
|
||||||
export * from './schema/settings.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,
|
"when": 1775860000000,
|
||||||
"tag": "0046_auto-employee-number",
|
"tag": "0046_auto-employee-number",
|
||||||
"breakpoints": true
|
"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 { LogLevel, AppConfigUpdateSchema } from './config.schema.js'
|
||||||
export type { AppConfigUpdateInput } 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