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:
@@ -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;
|
||||
@@ -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
|
||||
@@ -394,6 +407,7 @@ model Invoice {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
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())
|
||||
|
||||
@@ -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