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>
94 lines
3.3 KiB
TypeScript
94 lines
3.3 KiB
TypeScript
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))
|
|
},
|
|
}
|