Files
kevin-asprec bd8e83bf68 fix(02): revise plans based on checker feedback
- 02-05: Update outstanding report key_link and action to document
  Invoice.amountPaid as transactional convenience field (not standalone
  stored balance), consistent with creditBalance pattern in 02-03
- 02-04: Add applyCredit wiring to generateInvoiceForSubscriber, add
  credit-service.ts extraction to avoid circular imports, add credit
  auto-application tests
- 02-01/02-03: Add parallel Prisma migration serialization notes for
  Wave 1 concurrent execution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:35:59 +08:00

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
02-subscriber-and-billing-core 04 execute 3
02-02
02-03
prisma/schema.prisma
src/lib/services/billing-service.ts
src/lib/services/invoice-service.ts
src/app/api/billing/generate/route.ts
src/app/api/invoices/route.ts
src/app/api/invoices/[id]/route.ts
src/lib/prisma-tenant.ts
prisma/migrations/*_add_invoice_model/migration.sql
src/lib/__tests__/billing.test.ts
true
truths artifacts key_links
System auto-generates invoices for active subscribers on their billing day
Prepaid invoices are generated X days before billing date, postpaid on billing date
Each invoice generation creates a balanced journal entry (debit AR, credit Revenue)
Invoice has status lifecycle: DRAFT -> SENT -> PARTIAL -> PAID -> OVERDUE -> VOID
Duplicate invoices for same subscriber+period are prevented
Overdue detection marks unpaid invoices past due date
Subscriber credit balance is auto-applied to newly generated invoices
path provides contains
prisma/schema.prisma Invoice, InvoiceLine models model Invoice
path provides exports
src/lib/services/billing-service.ts Billing cycle engine — generates invoices for all eligible subscribers
generateMonthlyInvoices
generateInvoiceForSubscriber
path provides exports
src/lib/services/invoice-service.ts Invoice CRUD, status management, overdue detection
getInvoice
listInvoices
markOverdueInvoices
voidInvoice
path provides
src/app/api/billing/generate/route.ts POST endpoint to trigger invoice generation
from to via pattern
src/lib/services/billing-service.ts src/lib/accounting/journal-entry-service.ts Each invoice creates a journal entry via JournalEntryService JournalEntryService.createEntry
from to via pattern
src/lib/services/billing-service.ts src/lib/services/subscriber-service.ts Queries active subscribers with billing day matching subscriber.findMany
from to via pattern
src/lib/services/billing-service.ts src/lib/accounting/chart-of-accounts.ts Uses AR and Revenue account codes for journal entry 1100|4010
from to via pattern
src/lib/services/billing-service.ts src/lib/services/payment-service.ts Calls applyCredit after invoice creation for subscribers with credit balance applyCredit|creditBalance
Build the billing engine that auto-generates invoices for active subscribers. Prepaid and postpaid billing types follow distinct timing logic. Every invoice generation posts a balanced journal entry (debit Accounts Receivable, credit Subscription Revenue). After creating an invoice, if the subscriber has a credit balance, it is automatically applied. Includes overdue detection and invoice status management.

Purpose: The billing engine is the revenue cycle — it turns service plans into invoices. Without invoices, there's nothing to pay against. The payment tracker (02-05) depends on invoices existing. Output: Invoice model, BillingService for invoice generation with credit application, InvoiceService for CRUD/status, overdue detection, API routes, tests.

<execution_context> @C:\Users\KevinAsprec.claude/get-shit-done/workflows/execute-plan.md @C:\Users\KevinAsprec.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-subscriber-and-billing-core/02-CONTEXT.md @.planning/phases/02-subscriber-and-billing-core/02-02-SUMMARY.md @.planning/phases/02-subscriber-and-billing-core/02-03-SUMMARY.md @prisma/schema.prisma @src/lib/accounting/journal-entry-service.ts @src/lib/accounting/chart-of-accounts.ts @src/lib/services/subscriber-service.ts @src/lib/prisma-tenant.ts Task 1: Invoice model + BillingService + InvoiceService prisma/schema.prisma src/lib/services/billing-service.ts src/lib/services/invoice-service.ts src/lib/prisma-tenant.ts prisma/migrations/*_add_invoice_model/migration.sql 1. Add enums to prisma/schema.prisma: - `InvoiceStatus`: DRAFT, SENT, PARTIAL, PAID, OVERDUE, VOID
  1. Add Invoice model:

    • id (uuid), tenantId (String), invoiceNumber (String — auto-generated per tenant, e.g., "INV-2026-0001"), subscriberId (String, relation to Subscriber), periodStart (DateTime — billing period start), periodEnd (DateTime — billing period end), dueDate (DateTime), subtotal (Decimal, precision 10 scale 2), totalAmount (Decimal, precision 10 scale 2), amountPaid (Decimal, precision 10 scale 2, default 0), status (InvoiceStatus, default DRAFT), journalEntryId (String? — links to the JE created on generation), issuedAt (DateTime?), paidAt (DateTime?), voidedAt (DateTime?), createdAt, updatedAt
    • @@unique([tenantId, invoiceNumber])
    • @@unique([tenantId, subscriberId, periodStart]) — prevent duplicate invoices for same subscriber+period
    • @@index([tenantId]), @@index([tenantId, status]), @@index([tenantId, subscriberId]), @@index([tenantId, dueDate])

    IMPORTANT on amountPaid: Like Subscriber.creditBalance (see 02-03), Invoice.amountPaid is a transactional convenience field, NOT a standalone stored accounting balance. It is always updated atomically within the same database transaction as the corresponding journal entry. The "no stored balance fields" principle (ACCT-09) refers to account/ledger balances. Invoice.amountPaid is an operational field on a transactional record (like an order's fulfillment count), not a derived accounting balance.

  2. Add InvoiceLine model:

    • id (uuid), tenantId (String), invoiceId (String, relation to Invoice), description (String), quantity (Int, default 1), unitPrice (Decimal, precision 10 scale 2), lineTotal (Decimal, precision 10 scale 2), createdAt
    • @@index([invoiceId])
  3. Update TENANT_SCOPED_MODELS in prisma-tenant.ts to include "invoice" and "invoiceLine". Add query extensions.

  4. Create src/lib/services/invoice-service.ts:

    • generateInvoiceNumber(tenantPrisma, year) — sequential per tenant+year, format "INV-{YYYY}-{NNNN}"
    • getInvoice(tenantPrisma, invoiceId) — with subscriber, lines, journalEntry included
    • listInvoices(tenantPrisma, { subscriberId?, status?, startDate?, endDate?, page?, pageSize? }) — paginated, ordered by dueDate desc
    • updateInvoiceStatus(tenantPrisma, invoiceId, status) — updates status field. Used internally by billing and payment services.
    • markOverdueInvoices(tenantPrisma) — find all invoices where status is DRAFT or SENT, dueDate < today. Update status to OVERDUE. Return count updated.
    • voidInvoice(tenantPrisma, invoiceId, voidedById) — set status to VOID, set voidedAt. If journalEntryId exists, call JournalEntryService.reverseEntry to reverse the AR journal entry. Return updated invoice.
  5. Create src/lib/services/billing-service.ts:

    • generateInvoiceForSubscriber(tenantPrisma, subscriber, period, createdById):

      • Calculate periodStart and periodEnd based on subscriber.billingDay
      • Check if invoice already exists for this subscriber+periodStart (idempotent — skip if exists)
      • Create Invoice with lines (one line: subscription fee from servicePlan.monthlyPrice)
      • Set dueDate: for POSTPAID, periodEnd. For PREPAID, periodStart.
      • Create journal entry via JournalEntryService.createEntry:
        • Debit: Accounts Receivable (1100) for totalAmount
        • Credit: Subscription Revenue (4010) for totalAmount
        • source: SYSTEM, referenceType: "Invoice", referenceId: invoice.id
      • Link journalEntryId to the invoice
      • After invoice creation, check if subscriber.creditBalance > 0. If yes, auto-apply credit to the new invoice:
        • Import and call applyCredit(tenantPrisma, subscriberId, invoice.id) from payment-service.ts
        • To avoid circular imports: extract applyCredit into a dedicated file src/lib/services/credit-service.ts that both billing-service and payment-service can import. Payment-service's applyCredit function should be moved to (or re-exported from) credit-service.ts.
        • This ensures subscribers with existing credit from overpayments have it automatically applied to new invoices
      • Return created invoice (with updated amountPaid if credit was applied)
    • generateMonthlyInvoices(tenantPrisma, targetDate, createdById):

      • Determine which subscribers need invoices today:
        • POSTPAID subscribers where billingDay === targetDate.getDate() and status === ACTIVE
        • PREPAID subscribers where (billingDay - tenantSettings.prepaidLeadDays) === targetDate.getDate() and status === ACTIVE (accounting for month wrapping)
      • For each eligible subscriber, call generateInvoiceForSubscriber
      • Return { generated: number, skipped: number, errors: string[] }
      • Must be idempotent: running twice on same day generates nothing new
  6. Run npx prisma migrate dev --name add_invoice_model

    • npx prisma migrate status — no pending
    • npx prisma generate succeeds
    • npx tsc --noEmit — clean Invoice and InvoiceLine models exist. BillingService generates invoices with journal entries and auto-applies subscriber credit balances. InvoiceService handles CRUD, overdue detection, and void with journal reversal.
Task 2: Billing API routes + comprehensive tests src/app/api/billing/generate/route.ts src/app/api/invoices/route.ts src/app/api/invoices/[id]/route.ts src/app/api/invoices/[id]/void/route.ts src/lib/__tests__/billing.test.ts 1. Create API routes:

a. POST /api/billing/generate — trigger invoice generation for a target date. withPermission("manage", "Invoice"). Accepts { targetDate?: string } (defaults to today). Calls generateMonthlyInvoices. Returns { generated, skipped, errors }. This is the endpoint that BullMQ or a cron job would call (BullMQ integration is a scheduler concern — the API just needs to work when called).

b. GET /api/invoices — list invoices. withPermission("read", "Invoice"). Accepts ?subscriberId=&status=&startDate=&endDate=&page=&pageSize=. Returns paginated results.

c. GET /api/invoices/[id] — get invoice detail with lines. withPermission("read", "Invoice"). Returns invoice with subscriber, lines, journalEntry.

d. POST /api/invoices/[id]/void — void an invoice. withPermission("manage", "Invoice"). Calls voidInvoice. Returns updated invoice.

  1. Write comprehensive tests in src/lib/tests/billing.test.ts:

    Invoice generation tests:

    • Generate invoice for postpaid subscriber: creates invoice with correct period, dueDate = periodEnd
    • Generate invoice for prepaid subscriber: creates invoice with dueDate = periodStart
    • Invoice has correct amount from servicePlan.monthlyPrice
    • Invoice has one InvoiceLine matching plan price
    • Journal entry created: debit AR, credit Revenue, amounts match invoice
    • Journal entry is balanced (debits = credits)
    • Duplicate generation for same subscriber+period is skipped (idempotent)

    Credit auto-application tests:

    • Generate invoice for subscriber with creditBalance > 0: credit is applied to new invoice
    • If credit covers full invoice amount: invoice status becomes PAID
    • If credit partially covers: invoice status becomes PARTIAL, amountPaid reflects credit applied
    • If no credit: invoice remains DRAFT with amountPaid = 0
    • Credit application creates its own journal entry (debit Subscriber Credits 1150, credit AR 1100)

    Billing cycle tests:

    • generateMonthlyInvoices generates for all eligible subscribers on their billing day
    • Subscribers with different billing days are not included
    • Suspended/cancelled subscribers are not billed
    • Prepaid subscribers get invoiced prepaidLeadDays before billing day

    Invoice status tests:

    • markOverdueInvoices: unpaid invoice past dueDate becomes OVERDUE
    • markOverdueInvoices: paid invoice past dueDate stays PAID
    • voidInvoice: sets status to VOID and reverses journal entry

    Invoice numbering:

    • Sequential per tenant: INV-2026-0001, INV-2026-0002
    • Two tenants have independent numbering

    Tenant isolation:

    • Invoice from Tenant A not visible to Tenant B

Run: npx vitest run src/lib/__tests__/billing.test.ts - npx vitest run src/lib/__tests__/billing.test.ts — all tests pass - npx tsc --noEmit — clean - POST /api/billing/generate creates invoices for eligible subscribers - GET /api/invoices returns filtered, paginated invoices - POST /api/invoices/{id}/void reverses the journal entry Billing engine generates invoices with journal entries for active subscribers. Credit balances auto-applied to new invoices. Prepaid and postpaid timing logic works. Overdue detection and void with journal reversal work. All tests pass.

- `npx vitest run` — all existing + new tests pass - `npx tsc --noEmit` — clean - Create subscriber -> generate billing -> invoice exists with journal entry - Same billing run again -> no duplicate invoices (idempotent) - Subscriber with credit balance -> generate invoice -> credit auto-applied - Void invoice -> journal entry reversed - Overdue detection updates past-due invoices

<success_criteria>

  • Invoice model with invoiceNumber, period dates, dueDate, amountPaid, status
  • amountPaid documented as transactional convenience field (not standalone stored balance)
  • generateInvoiceForSubscriber creates invoice + journal entry atomically
  • generateInvoiceForSubscriber auto-applies subscriber credit balance to new invoice
  • generateMonthlyInvoices handles prepaid/postpaid timing correctly
  • Journal entries: debit AR (1100), credit Revenue (4010)
  • Idempotent: no duplicate invoices for same subscriber+period
  • Overdue detection marks past-due invoices
  • Void reverses the associated journal entry
  • All tests pass </success_criteria>
After completion, create `.planning/phases/02-subscriber-and-billing-core/02-04-SUMMARY.md`