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
|
VOID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PaymentMethod {
|
||||||
|
CASH
|
||||||
|
BANK_TRANSFER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PaymentStatus {
|
||||||
|
COMPLETED
|
||||||
|
VOIDED
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MODELS
|
// MODELS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -246,6 +256,7 @@ model Subscriber {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
invoices Invoice[]
|
invoices Invoice[]
|
||||||
|
payments Payment[]
|
||||||
|
|
||||||
/// Account numbers must be unique within a tenant
|
/// Account numbers must be unique within a tenant
|
||||||
@@unique([tenantId, accountNumber])
|
@@unique([tenantId, accountNumber])
|
||||||
@@ -282,6 +293,8 @@ model User {
|
|||||||
createdJournalEntries JournalEntry[] @relation("JournalEntryCreatedBy")
|
createdJournalEntries JournalEntry[] @relation("JournalEntryCreatedBy")
|
||||||
/// Journal entries this user approved (checker)
|
/// Journal entries this user approved (checker)
|
||||||
approvedJournalEntries JournalEntry[] @relation("JournalEntryApprovedBy")
|
approvedJournalEntries JournalEntry[] @relation("JournalEntryApprovedBy")
|
||||||
|
/// Payments this user recorded
|
||||||
|
recordedPayments Payment[] @relation("PaymentRecordedBy")
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -394,6 +407,7 @@ model Invoice {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
lines InvoiceLine[]
|
lines InvoiceLine[]
|
||||||
|
paymentAllocations PaymentAllocation[]
|
||||||
|
|
||||||
/// Invoice numbers must be unique within a tenant
|
/// Invoice numbers must be unique within a tenant
|
||||||
@@unique([tenantId, invoiceNumber])
|
@@unique([tenantId, invoiceNumber])
|
||||||
@@ -406,6 +420,70 @@ model Invoice {
|
|||||||
@@index([tenantId, dueDate])
|
@@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").
|
/// A single line item on an Invoice (e.g., "50 Mbps Monthly Service — $49.99").
|
||||||
model InvoiceLine {
|
model InvoiceLine {
|
||||||
id String @id @default(uuid())
|
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:
|
* Extend this list as new models are added in later phases:
|
||||||
* e.g., "subscriber", "invoice", "servicePlan", "payment"
|
* 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];
|
export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
|
||||||
|
|
||||||
@@ -848,6 +848,94 @@ export function withTenantContext(tenantId: string) {
|
|||||||
return query(args);
|
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