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
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user