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:
@@ -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);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
509
src/lib/services/payment-service.ts
Normal file
509
src/lib/services/payment-service.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user