feat(billing): payment_terms lookup table + CRUD, invoices reference it by FK #252
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/PMS-333-payment-terms-lookup"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Invoice payment terms were a free-text column, so they could not be a settings-managed dropdown. MAPPS-170 wants tenant-configurable terms surfaced as options on the invoice form. The chosen storage model is a normalized FK (
invoices.payment_term_id -> payment_terms.id) so terms are rename-safe and integrity-enforced.Migration (050)
payment_termslookup, mirroring the project_types lookup (048): explicit RLS policy + FORCE,updated_attrigger, and the PMS-323 single-default partial unique index (idx_payment_terms_one_default).due_on_receipt,net15,net30(the single default),net60.net30matches the legacy column default so the backfill links existingnet30invoices to it.invoices.payment_termsvalue not already in the seed set, per tenant, so no invoice is orphaned.invoices.payment_term_id, backfills it from the legacy string, indexes it.invoices.payment_termsstring column for one release (NOT dropped here); a follow-up drops it once clients readpayment_term_id.API
/api/v1/payment-terms(+/{id}), finance-gated on writes like the sibling tax-rate routes. Single-default-per-tenant enforced in the service (clear prior default in the same tx). Deleting a term still referenced by an invoice returns 409 (23503 -> Conflict), not 500.payment_term_id, validated to belong to the caller's tenant via an RLS-scoped EXISTS (an FK check alone bypasses RLS, so a foreign-tenant id would otherwise link silently; this rejects it with 400).InvoiceResponseexposespayment_term_idand a joinedpayment_term_name, resolved through the existingenrich_invoicespath that already attachescompany_name, so both the list and get paths carry it.copy_default_configreplicatespayment_termsto new tenants.Not changed (follow-up)
The from-time-entries and recurring-billing invoice paths still write only the legacy
payment_termsstring and leavepayment_term_idnull. The AC scopes create/update; these two paths are noted for a follow-up.Tests (green against a throwaway Postgres)
payment_terms_seeded_and_single_default: net30 seeded as the single default; setting a new default clears the prior and leaves exactly one.invoice_payment_term_link_rename_and_delete_guard: an invoice carriespayment_term_id+payment_term_name; renaming the term is reflected on the invoice (rename-safe FK); deleting a referenced term is a 409.invoice_rejects_cross_tenant_payment_term: a foreign-tenantpayment_term_idis rejected with 400.Full billing suite 6/6 (existing invoice/payment tests still pass); tenants suite 10/10 (copy_default_config change is non-breaking). clippy + fmt clean.
#PMS-333
Invoice payment terms were a free-text column, so they could not be a settings-managed dropdown. MAPPS-170 wants tenant-configurable terms with the invoice form offering them as options. The chosen model is a normalized FK (invoices.payment_term_id -> payment_terms.id) so terms are rename-safe. Migration 050 adds a tenant-scoped payment_terms lookup (mirrors the project_types lookup: explicit RLS policy + updated_at trigger + the PMS-323 single-default partial unique index), seeds a sensible per-tenant set (due_on_receipt, net15, net30 as the single default, net60), backfills any distinct existing free-text value so no invoice is orphaned, adds invoices.payment_term_id with a backfill from the legacy string, and keeps the legacy payment_terms string column for one release (not dropped here). CRUD at /api/v1/payment-terms (+ /{id}), finance-gated on writes like the sibling tax-rate routes, with single-default-per-tenant enforced in the service (clear prior default in the same tx) and a 23503 -> 409 guard on deleting a referenced term. Invoice create/update accept payment_term_id, validated to belong to the caller's tenant (RLS-scoped EXISTS, since an FK check alone bypasses RLS and would let a foreign-tenant id through). InvoiceResponse exposes payment_term_id and a joined payment_term_name, resolved through the existing enrich_invoices path that already attaches company_name (so both list and get carry it). copy_default_config replicates payment_terms to new tenants. Not changed (legacy string still written, payment_term_id left null; noted for a follow-up): the from-time-entries and recurring-billing invoice paths. Tests: payment_terms seeded with a single default and the switch clears the prior; an invoice carries payment_term_id + payment_term_name, a term rename is reflected on the invoice (rename-safe FK), and deleting a referenced term is a 409; a cross-tenant payment_term_id is rejected with 400. #PMS-333 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>