feat(02-05): Payment model with FIFO allocation and void

- Add PaymentMethod (CASH, BANK_TRANSFER) and PaymentStatus (COMPLETED, VOIDED) enums
- Add Payment model with idempotency key, journal entry link, void fields
- Add PaymentAllocation model for FIFO invoice allocation tracking
- Add Payment/PaymentAllocation relations to Subscriber, Invoice, User
- Update TENANT_SCOPED_MODELS with "payment" and "paymentAllocation"
- Add payment/paymentAllocation query extensions in withTenantContext()
- Implement recordPayment() with FIFO allocation, overpayment credit balance
- Implement voidPayment() with reversing journal entries
- Implement getSubscriberPaymentHistory() with pagination
- Run migration: 20260304154606_add_payment_model
This commit is contained in:
kevin-asprec
2026-03-04 23:47:58 +08:00
parent df4a467a38
commit 6b91e67bdc
4 changed files with 750 additions and 2 deletions

View File

@@ -30,7 +30,7 @@ import { prisma } from "@/lib/prisma";
* Extend this list as new models are added in later phases:
* e.g., "subscriber", "invoice", "servicePlan", "payment"
*/
export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod", "subscriber", "servicePlan", "tenantSettings", "journalEntry", "journalEntryLine", "invoice", "invoiceLine"] as const;
export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod", "subscriber", "servicePlan", "tenantSettings", "journalEntry", "journalEntryLine", "invoice", "invoiceLine", "payment", "paymentAllocation"] as const;
export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
@@ -848,6 +848,94 @@ export function withTenantContext(tenantId: string) {
return query(args);
},
},
payment: {
async findMany({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async findFirst({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async findFirstOrThrow({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async findUnique({ args, query }) {
if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) {
return prisma.payment.findFirst({
...args,
where: { ...args.where, tenantId },
});
}
return query(args);
},
async create({ args, query }) {
args.data = { ...args.data, tenantId } as typeof args.data;
return query(args);
},
async update({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async updateMany({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async count({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async aggregate({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
},
paymentAllocation: {
async findMany({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async findFirst({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async create({ args, query }) {
args.data = { ...args.data, tenantId } as typeof args.data;
return query(args);
},
async createMany({ args, query }) {
if (Array.isArray(args.data)) {
args.data = args.data.map((item) => ({ ...item, tenantId })) as typeof args.data;
} else {
args.data = { ...args.data, tenantId } as typeof args.data;
}
return query(args);
},
async deleteMany({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async count({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
},
},
});
}

View File

@@ -0,0 +1,509 @@
/**
* PaymentService — Payment recording, FIFO allocation, void, and credit balance management.
*
* ARCHITECTURE:
* This service handles the full payment lifecycle:
* - Record cash or bank payments against subscriber invoices
* - Allocate payments FIFO (oldest unpaid invoice first)
* - Partial payments update invoice to PARTIAL status
* - Full payments update invoice to PAID status
* - Overpayments create subscriber credit balance (via Subscriber.creditBalance)
* - Every payment creates a balanced journal entry (DR Cash/Bank, CR AR)
* - Void uses reversing journal entries — no deletions
* - Idempotency keys prevent double-recording
*
* ACCOUNT CODES USED:
* 1010 — Cash on Hand (CASH payments)
* 1020 — Cash in Bank (BANK_TRANSFER payments)
* 1100 — Accounts Receivable (AR)
* 1150 — Subscriber Credits (overpayment credit balance)
*
* JOURNAL ENTRY PATTERNS:
* Normal payment:
* DR Cash/Bank (1010/1020) [amount received]
* CR Accounts Receivable (1100) [AR reduced]
*
* Overpayment (payment > outstanding invoices):
* DR Cash/Bank (1010/1020) [full amount received]
* CR Accounts Receivable (1100) [allocated to invoices]
* CR Subscriber Credits (1150) [overpayment as credit liability]
*/
import { Prisma, InvoiceStatus, JournalEntrySource, PaymentMethod, PaymentStatus } from "@prisma/client";
import { JournalEntryService } from "@/lib/accounting/journal-entry-service";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TenantPrismaClient = any;
// ---------------------------------------------------------------------------
// Input types
// ---------------------------------------------------------------------------
export interface RecordPaymentInput {
subscriberId: string;
/** Total amount received */
amount: number | string;
paymentMethod: PaymentMethod;
/** Optional external reference (bank ref, receipt number) */
referenceNumber?: string;
/** When the payment was received (economic date) */
paymentDate: Date;
notes?: string;
/** Client-supplied idempotency key to prevent double-recording */
idempotencyKey: string;
/** User recording this payment */
recordedById: string;
}
export interface PaymentAllocationRecord {
invoiceId: string;
amount: Prisma.Decimal;
}
export interface RecordPaymentResult {
payment: {
id: string;
tenantId: string;
subscriberId: string;
amount: Prisma.Decimal;
paymentMethod: PaymentMethod;
referenceNumber: string | null;
paymentDate: Date;
notes: string | null;
status: PaymentStatus;
idempotencyKey: string;
journalEntryId: string | null;
recordedById: string;
createdAt: Date;
updatedAt: Date;
};
allocations: PaymentAllocationRecord[];
creditApplied: Prisma.Decimal;
journalEntryId: string;
idempotent: boolean;
}
export interface VoidPaymentResult {
payment: unknown;
voidJournalEntryId: string;
}
export interface GetPaymentHistoryOptions {
page?: number;
pageSize?: number;
}
export interface GetPaymentHistoryResult {
payments: unknown[];
total: number;
page: number;
pageSize: number;
}
// ---------------------------------------------------------------------------
// recordPayment
// ---------------------------------------------------------------------------
/**
* Record a cash or bank payment against a subscriber's invoices.
*
* FIFO allocation: oldest unpaid invoices (by dueDate) get allocated first.
* Partial allocations update invoice to PARTIAL status.
* Full payment updates invoice to PAID status.
* Overpayment (amount > total outstanding) creates creditBalance.
*
* Idempotency: if idempotencyKey already exists for this tenant,
* returns the existing payment without creating a duplicate.
*
* @throws Error if amount <= 0 or subscriber not found
*/
export async function recordPayment(
tenantPrisma: TenantPrismaClient,
tenantId: string,
input: RecordPaymentInput
): Promise<RecordPaymentResult> {
const {
subscriberId,
amount: rawAmount,
paymentMethod,
referenceNumber,
paymentDate,
notes,
idempotencyKey,
recordedById,
} = input;
// Idempotency check — return existing if key already used
const existing = await tenantPrisma.payment.findFirst({
where: { idempotencyKey },
include: { allocations: true },
});
if (existing) {
return {
payment: existing,
allocations: existing.allocations.map((a: { invoiceId: string; amount: Prisma.Decimal }) => ({
invoiceId: a.invoiceId,
amount: new Prisma.Decimal(a.amount),
})),
creditApplied: new Prisma.Decimal(0),
journalEntryId: existing.journalEntryId ?? "",
idempotent: true,
};
}
// Validate amount
const amount = new Prisma.Decimal(rawAmount);
if (amount.lessThanOrEqualTo(0)) {
throw new Error("Payment amount must be greater than zero.");
}
// Validate subscriber exists
const subscriber = await tenantPrisma.subscriber.findFirst({
where: { id: subscriberId },
select: { id: true, creditBalance: true },
});
if (!subscriber) {
throw new Error(`Subscriber not found: ${subscriberId}`);
}
// Find required accounts
const cashAccountCode = paymentMethod === PaymentMethod.CASH ? "1010" : "1020";
const [cashAccount, arAccount, creditsAccount] = await Promise.all([
tenantPrisma.account.findFirst({ where: { code: cashAccountCode }, select: { id: true } }),
tenantPrisma.account.findFirst({ where: { code: "1100" }, select: { id: true } }),
tenantPrisma.account.findFirst({ where: { code: "1150" }, select: { id: true } }),
]);
if (!cashAccount || !arAccount || !creditsAccount) {
throw new Error(
`Required accounts (${cashAccountCode}, 1100, 1150) not found for this tenant.`
);
}
// FIFO: find unpaid/partial invoices ordered by dueDate ASC
const unpaidInvoices = await tenantPrisma.invoice.findMany({
where: {
subscriberId,
status: { in: [InvoiceStatus.SENT, InvoiceStatus.PARTIAL, InvoiceStatus.OVERDUE] },
},
orderBy: { dueDate: "asc" },
select: {
id: true,
invoiceNumber: true,
totalAmount: true,
amountPaid: true,
status: true,
},
});
// FIFO allocation
let remaining = new Prisma.Decimal(amount);
const allocations: Array<{ invoiceId: string; amount: Prisma.Decimal; newAmountPaid: Prisma.Decimal; newStatus: InvoiceStatus }> = [];
for (const invoice of unpaidInvoices) {
if (remaining.lessThanOrEqualTo(0)) break;
const invoiceTotal = new Prisma.Decimal(invoice.totalAmount);
const alreadyPaid = new Prisma.Decimal(invoice.amountPaid);
const invoiceOutstanding = invoiceTotal.minus(alreadyPaid);
if (invoiceOutstanding.lessThanOrEqualTo(0)) continue;
const allocateAmount = remaining.lessThan(invoiceOutstanding) ? remaining : invoiceOutstanding;
const newAmountPaid = alreadyPaid.plus(allocateAmount);
const isFullyPaid = newAmountPaid.greaterThanOrEqualTo(invoiceTotal);
allocations.push({
invoiceId: invoice.id,
amount: allocateAmount,
newAmountPaid,
newStatus: isFullyPaid ? InvoiceStatus.PAID : InvoiceStatus.PARTIAL,
});
remaining = remaining.minus(allocateAmount);
}
// Any leftover is overpayment -> credit balance
const overpayment = remaining;
const newCreditBalance = new Prisma.Decimal(subscriber.creditBalance).plus(overpayment);
// Build journal entry lines
// DR Cash/Bank (full amount)
// CR AR (amount allocated to invoices)
// CR Subscriber Credits (overpayment, if any)
const totalAllocated = amount.minus(overpayment);
const journalLines: Array<{ accountId: string; debit: number; credit: number; description?: string }> = [
{
accountId: cashAccount.id,
debit: amount.toNumber(),
credit: 0,
description: `${paymentMethod === PaymentMethod.CASH ? "Cash" : "Bank transfer"} received`,
},
];
if (totalAllocated.greaterThan(0)) {
journalLines.push({
accountId: arAccount.id,
debit: 0,
credit: totalAllocated.toNumber(),
description: `AR payment: ${totalAllocated.toFixed(2)}`,
});
}
if (overpayment.greaterThan(0)) {
journalLines.push({
accountId: creditsAccount.id,
debit: 0,
credit: overpayment.toNumber(),
description: `Overpayment credit: ${overpayment.toFixed(2)}`,
});
}
// Create journal entry (SYSTEM source — auto-posts)
const journalEntry = await JournalEntryService.createEntry({
tenantPrisma,
tenantId,
date: paymentDate,
description: `Payment from subscriber`,
source: JournalEntrySource.SYSTEM,
referenceType: "Payment",
referenceId: idempotencyKey, // temp ref; updated after payment created
createdById: recordedById,
lines: journalLines,
});
// Persist payment and allocations in a single transaction
const result = await tenantPrisma.$transaction(async (tx: TenantPrismaClient) => {
// Create payment record
const payment = await tx.payment.create({
data: {
tenantId,
subscriberId,
amount,
paymentMethod,
referenceNumber: referenceNumber ?? null,
paymentDate,
notes: notes ?? null,
status: PaymentStatus.COMPLETED,
idempotencyKey,
journalEntryId: journalEntry.id,
recordedById,
},
});
// Create allocations
for (const alloc of allocations) {
await tx.paymentAllocation.create({
data: {
tenantId,
paymentId: payment.id,
invoiceId: alloc.invoiceId,
amount: alloc.amount,
},
});
// Update invoice amountPaid and status
await tx.invoice.update({
where: { id: alloc.invoiceId, tenantId },
data: {
amountPaid: alloc.newAmountPaid,
status: alloc.newStatus,
paidAt: alloc.newStatus === InvoiceStatus.PAID ? new Date() : null,
},
});
}
// Update subscriber credit balance if overpayment
if (overpayment.greaterThan(0)) {
await tx.subscriber.update({
where: { id: subscriberId, tenantId },
data: { creditBalance: newCreditBalance },
});
}
return payment;
});
return {
payment: result,
allocations: allocations.map((a) => ({ invoiceId: a.invoiceId, amount: a.amount })),
creditApplied: overpayment,
journalEntryId: journalEntry.id,
idempotent: false,
};
}
// ---------------------------------------------------------------------------
// voidPayment
// ---------------------------------------------------------------------------
/**
* Void a payment by:
* 1. Reversing all invoice allocations (recalculate amountPaid and status)
* 2. Reducing subscriber creditBalance if overpayment existed
* 3. Creating a reversing journal entry for the original payment JE
* 4. Setting payment status to VOIDED
*
* @throws Error if payment not found, already VOIDED, or JE missing
*/
export async function voidPayment(
tenantPrisma: TenantPrismaClient,
tenantId: string,
paymentId: string,
voidedById: string
): Promise<VoidPaymentResult> {
// Load the payment with allocations
const payment = await tenantPrisma.payment.findFirst({
where: { id: paymentId },
include: { allocations: true },
});
if (!payment) {
throw new Error(`Payment not found: ${paymentId}`);
}
if (payment.status === PaymentStatus.VOIDED) {
throw new Error(`Payment ${paymentId} is already voided.`);
}
if (!payment.journalEntryId) {
throw new Error(`Payment ${paymentId} has no associated journal entry — cannot void.`);
}
// Calculate total allocated to invoices
const totalAllocated = (payment.allocations as Array<{ invoiceId: string; amount: Prisma.Decimal }>)
.reduce((sum: Prisma.Decimal, a) => sum.plus(new Prisma.Decimal(a.amount)), new Prisma.Decimal(0));
const paymentAmount = new Prisma.Decimal(payment.amount);
const overpayment = paymentAmount.minus(totalAllocated);
// Create reversing journal entry first (outside transaction — JournalEntryService handles its own tx)
const reversingEntry = await JournalEntryService.reverseEntry({
tenantPrisma,
tenantId,
entryId: payment.journalEntryId,
reversedById: voidedById,
description: `Void of payment ${payment.id}`,
});
// Reverse allocations and update invoice statuses in a transaction
const updatedPayment = await tenantPrisma.$transaction(async (tx: TenantPrismaClient) => {
// Recalculate each invoice's amountPaid minus this payment's allocation
for (const alloc of payment.allocations as Array<{ invoiceId: string; amount: Prisma.Decimal }>) {
// Get current invoice state
const invoice = await tx.invoice.findFirst({
where: { id: alloc.invoiceId, tenantId },
select: { id: true, totalAmount: true, amountPaid: true, status: true },
});
if (!invoice) continue;
const currentAmountPaid = new Prisma.Decimal(invoice.amountPaid);
const allocAmount = new Prisma.Decimal(alloc.amount);
const newAmountPaid = currentAmountPaid.minus(allocAmount);
const safeAmountPaid = newAmountPaid.lessThan(0) ? new Prisma.Decimal(0) : newAmountPaid;
// Recalculate status
const total = new Prisma.Decimal(invoice.totalAmount);
let newStatus: InvoiceStatus;
if (safeAmountPaid.lessThanOrEqualTo(0)) {
// If invoice was PAID before this payment, it might have been paid by other payments
// Since we can't know for sure, move back to SENT (the pre-payment state)
newStatus = InvoiceStatus.SENT;
} else if (safeAmountPaid.greaterThanOrEqualTo(total)) {
newStatus = InvoiceStatus.PAID;
} else {
newStatus = InvoiceStatus.PARTIAL;
}
await tx.invoice.update({
where: { id: alloc.invoiceId, tenantId },
data: {
amountPaid: safeAmountPaid,
status: newStatus,
paidAt: newStatus === InvoiceStatus.PAID ? invoice.paidAt ?? new Date() : null,
},
});
}
// Reduce credit balance if overpayment existed
if (overpayment.greaterThan(0)) {
const subscriber = await tx.subscriber.findFirst({
where: { id: payment.subscriberId, tenantId },
select: { id: true, creditBalance: true },
});
if (subscriber) {
const currentCredit = new Prisma.Decimal(subscriber.creditBalance);
const newCredit = currentCredit.minus(overpayment);
await tx.subscriber.update({
where: { id: payment.subscriberId, tenantId },
data: { creditBalance: newCredit.lessThan(0) ? new Prisma.Decimal(0) : newCredit },
});
}
}
// Mark payment as VOIDED
const updated = await tx.payment.update({
where: { id: paymentId, tenantId },
data: {
status: PaymentStatus.VOIDED,
voidedAt: new Date(),
voidedById,
voidJournalEntryId: reversingEntry.id,
},
include: { allocations: true },
});
return updated;
});
return {
payment: updatedPayment,
voidJournalEntryId: reversingEntry.id,
};
}
// ---------------------------------------------------------------------------
// getSubscriberPaymentHistory
// ---------------------------------------------------------------------------
/**
* Get paginated payment history for a subscriber, ordered by paymentDate desc.
*
* Includes allocations for each payment.
*/
export async function getSubscriberPaymentHistory(
tenantPrisma: TenantPrismaClient,
subscriberId: string,
options: GetPaymentHistoryOptions = {}
): Promise<GetPaymentHistoryResult> {
const { page = 1, pageSize = 20 } = options;
const skip = (page - 1) * pageSize;
const [payments, total] = await Promise.all([
tenantPrisma.payment.findMany({
where: { subscriberId },
include: {
allocations: {
include: {
invoice: {
select: {
id: true,
invoiceNumber: true,
totalAmount: true,
amountPaid: true,
status: true,
},
},
},
},
},
orderBy: { paymentDate: "desc" },
skip,
take: pageSize,
}),
tenantPrisma.payment.count({ where: { subscriberId } }),
]);
return { payments, total, page, pageSize };
}