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:
ryan
2026-04-06 12:00:34 +00:00
parent 5f4a12b9c4
commit b9798f2c8c
7 changed files with 548 additions and 0 deletions

View 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))
},
}