From 6b91e67bdc14cb96f4623540339cd2bb0623e0af Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Wed, 4 Mar 2026 23:47:58 +0800 Subject: [PATCH] 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 --- .../migration.sql | 73 +++ prisma/schema.prisma | 80 ++- src/lib/prisma-tenant.ts | 90 +++- src/lib/services/payment-service.ts | 509 ++++++++++++++++++ 4 files changed, 750 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260304154606_add_payment_model/migration.sql create mode 100644 src/lib/services/payment-service.ts diff --git a/prisma/migrations/20260304154606_add_payment_model/migration.sql b/prisma/migrations/20260304154606_add_payment_model/migration.sql new file mode 100644 index 0000000..9cf2451 --- /dev/null +++ b/prisma/migrations/20260304154606_add_payment_model/migration.sql @@ -0,0 +1,73 @@ +-- CreateEnum +CREATE TYPE "PaymentMethod" AS ENUM ('CASH', 'BANK_TRANSFER'); + +-- CreateEnum +CREATE TYPE "PaymentStatus" AS ENUM ('COMPLETED', 'VOIDED'); + +-- CreateTable +CREATE TABLE "Payment" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "subscriberId" TEXT NOT NULL, + "amount" DECIMAL(10,2) NOT NULL, + "paymentMethod" "PaymentMethod" NOT NULL, + "referenceNumber" TEXT, + "paymentDate" TIMESTAMP(3) NOT NULL, + "notes" TEXT, + "status" "PaymentStatus" NOT NULL DEFAULT 'COMPLETED', + "idempotencyKey" TEXT NOT NULL, + "journalEntryId" TEXT, + "voidedAt" TIMESTAMP(3), + "voidedById" TEXT, + "voidJournalEntryId" TEXT, + "recordedById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Payment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PaymentAllocation" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "paymentId" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "amount" DECIMAL(10,2) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PaymentAllocation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Payment_tenantId_idx" ON "Payment"("tenantId"); + +-- CreateIndex +CREATE INDEX "Payment_tenantId_subscriberId_idx" ON "Payment"("tenantId", "subscriberId"); + +-- CreateIndex +CREATE INDEX "Payment_tenantId_paymentDate_idx" ON "Payment"("tenantId", "paymentDate"); + +-- CreateIndex +CREATE UNIQUE INDEX "Payment_tenantId_idempotencyKey_key" ON "Payment"("tenantId", "idempotencyKey"); + +-- CreateIndex +CREATE INDEX "PaymentAllocation_paymentId_idx" ON "PaymentAllocation"("paymentId"); + +-- CreateIndex +CREATE INDEX "PaymentAllocation_invoiceId_idx" ON "PaymentAllocation"("invoiceId"); + +-- CreateIndex +CREATE INDEX "PaymentAllocation_tenantId_idx" ON "PaymentAllocation"("tenantId"); + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_subscriberId_fkey" FOREIGN KEY ("subscriberId") REFERENCES "Subscriber"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_recordedById_fkey" FOREIGN KEY ("recordedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PaymentAllocation" ADD CONSTRAINT "PaymentAllocation_paymentId_fkey" FOREIGN KEY ("paymentId") REFERENCES "Payment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PaymentAllocation" ADD CONSTRAINT "PaymentAllocation_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 02d369f..cc360d0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -90,6 +90,16 @@ enum InvoiceStatus { VOID } +enum PaymentMethod { + CASH + BANK_TRANSFER +} + +enum PaymentStatus { + COMPLETED + VOIDED +} + // ============================================================================= // MODELS // ============================================================================= @@ -246,6 +256,7 @@ model Subscriber { updatedAt DateTime @updatedAt invoices Invoice[] + payments Payment[] /// Account numbers must be unique within a tenant @@unique([tenantId, accountNumber]) @@ -282,6 +293,8 @@ model User { createdJournalEntries JournalEntry[] @relation("JournalEntryCreatedBy") /// Journal entries this user approved (checker) approvedJournalEntries JournalEntry[] @relation("JournalEntryApprovedBy") + /// Payments this user recorded + recordedPayments Payment[] @relation("PaymentRecordedBy") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -393,7 +406,8 @@ model Invoice { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - lines InvoiceLine[] + lines InvoiceLine[] + paymentAllocations PaymentAllocation[] /// Invoice numbers must be unique within a tenant @@unique([tenantId, invoiceNumber]) @@ -406,6 +420,70 @@ model Invoice { @@index([tenantId, dueDate]) } +/// A Payment records a cash or bank transfer received from a subscriber. +/// Payments are allocated FIFO to oldest unpaid invoices. +/// Every payment creates a balanced journal entry (DR Cash/Bank, CR AR). +/// Voids use reversing entries — records are never deleted. +/// idempotencyKey prevents double-recording; @@unique([tenantId, idempotencyKey]). +model Payment { + id String @id @default(uuid()) + tenantId String + subscriberId String + subscriber Subscriber @relation(fields: [subscriberId], references: [id]) + /// Total amount received + amount Decimal @db.Decimal(10, 2) + paymentMethod PaymentMethod + /// Optional external reference (e.g., bank reference number, receipt number) + referenceNumber String? + /// When the payment was received (economic date, not necessarily createdAt) + paymentDate DateTime + notes String? + status PaymentStatus @default(COMPLETED) + /// Client-supplied key to prevent double-recording on retries + idempotencyKey String + /// Journal entry created when payment was recorded (DR Cash/Bank, CR AR) + journalEntryId String? + /// Timestamp when this payment was voided + voidedAt DateTime? + /// User who voided this payment + voidedById String? + /// Reversing journal entry created when payment was voided + voidJournalEntryId String? + /// User who recorded this payment + recordedById String + recordedBy User @relation("PaymentRecordedBy", fields: [recordedById], references: [id]) + + allocations PaymentAllocation[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + /// Idempotency: one payment per key per tenant + @@unique([tenantId, idempotencyKey]) + /// RLS-ready index — always present on tenant-scoped models + @@index([tenantId]) + @@index([tenantId, subscriberId]) + @@index([tenantId, paymentDate]) +} + +/// A PaymentAllocation links a Payment to an Invoice for the allocated amount. +/// Supports partial allocations and FIFO ordering. +model PaymentAllocation { + id String @id @default(uuid()) + tenantId String + paymentId String + payment Payment @relation(fields: [paymentId], references: [id]) + invoiceId String + invoice Invoice @relation(fields: [invoiceId], references: [id]) + /// Amount of the payment allocated to this invoice + amount Decimal @db.Decimal(10, 2) + createdAt DateTime @default(now()) + + @@index([paymentId]) + @@index([invoiceId]) + @@index([tenantId]) +} + /// A single line item on an Invoice (e.g., "50 Mbps Monthly Service — $49.99"). model InvoiceLine { id String @id @default(uuid()) diff --git a/src/lib/prisma-tenant.ts b/src/lib/prisma-tenant.ts index 1251097..0774f8f 100644 --- a/src/lib/prisma-tenant.ts +++ b/src/lib/prisma-tenant.ts @@ -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); + }, + }, }, }); } diff --git a/src/lib/services/payment-service.ts b/src/lib/services/payment-service.ts new file mode 100644 index 0000000..f7e71c6 --- /dev/null +++ b/src/lib/services/payment-service.ts @@ -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 { + 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 { + // 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 { + 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 }; +}