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

@@ -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;

View File

@@ -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())

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 };
}