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

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