feat(billing): payment_terms lookup table + CRUD, invoices reference it by FK #252

Merged
nrupard merged 1 commit from feat/PMS-333-payment-terms-lookup into main 2026-06-15 22:06:16 +02:00
Owner

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)

  • New tenant-scoped payment_terms lookup, mirroring the project_types lookup (048): explicit RLS policy + FORCE, updated_at trigger, and the PMS-323 single-default partial unique index (idx_payment_terms_one_default).
  • Seeds a sensible per-tenant set: due_on_receipt, net15, net30 (the single default), net60. net30 matches the legacy column default so the backfill links existing net30 invoices to it.
  • Backfills any DISTINCT existing free-text invoices.payment_terms value not already in the seed set, per tenant, so no invoice is orphaned.
  • Adds invoices.payment_term_id, backfills it from the legacy string, indexes it.
  • Keeps the legacy invoices.payment_terms string column for one release (NOT dropped here); a follow-up drops it once clients read payment_term_id.

API

  • CRUD at /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.
  • Invoice create/update accept 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).
  • 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 the list and get paths carry it.
  • copy_default_config replicates payment_terms to new tenants.

Not changed (follow-up)

The from-time-entries and recurring-billing invoice paths still write only the legacy payment_terms string and leave payment_term_id null. 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 carries payment_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-tenant payment_term_id is 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

## 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) - New tenant-scoped `payment_terms` lookup, mirroring the project_types lookup (048): explicit RLS policy + FORCE, `updated_at` trigger, and the PMS-323 single-default partial unique index (`idx_payment_terms_one_default`). - Seeds a sensible per-tenant set: `due_on_receipt`, `net15`, `net30` (the single default), `net60`. `net30` matches the legacy column default so the backfill links existing `net30` invoices to it. - Backfills any DISTINCT existing free-text `invoices.payment_terms` value not already in the seed set, per tenant, so no invoice is orphaned. - Adds `invoices.payment_term_id`, backfills it from the legacy string, indexes it. - Keeps the legacy `invoices.payment_terms` string column for one release (NOT dropped here); a follow-up drops it once clients read `payment_term_id`. ## API - CRUD at `/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. - Invoice create/update accept `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). - `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 the list and get paths carry it. - `copy_default_config` replicates `payment_terms` to new tenants. ## Not changed (follow-up) The from-time-entries and recurring-billing invoice paths still write only the legacy `payment_terms` string and leave `payment_term_id` null. 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 carries `payment_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-tenant `payment_term_id` is 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
feat(billing): payment_terms lookup table + CRUD, invoices reference it by FK
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 38s
Check / fmt + clippy + compile + unit/doc tests (pull_request) Successful in 1m5s
Integration / integration tests (pull_request) Successful in 2m58s
Create release / Create release from merged PR (pull_request) Has been skipped
8e9d65b2ab
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>
nrupard deleted branch feat/PMS-333-payment-terms-lookup 2026-06-15 22:06:16 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!252
No description provided.