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