feat(quotes): quotes module CRUD with server-computed totals (PMS-672) #453
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/PMS-672-quotes-module-crud"
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?
What
PMS-672, phase 2 of the PMS-670 Quotes epic. Adds a real
quotesmodule: CRUD over the quote and its line items, with totals computed server-side.Before this, the only code touching
quoteswasmodules::approvals, which existence-checks the parent row so/quotes/{id}/approvalscan hang off it. The entity had no CRUD of its own.Routes
GET|POST /quotes·GET|PUT|DELETE /quotes/{id}·POST /quotes/{id}/lines·PUT|DELETE /quotes/{id}/lines/{line_id}Design notes worth reviewing
Totals are never taken from the caller.
CreateQuoteRequesthas nosubtotalortotalfield at all, andrecompute_totalsis the single place they are derived. Every mutating path funnels through it, including atax_amount-only edit, which movestotalwithout touching a line.Two freeze rules, not one.
is_frozencoverssentonwards pluscancelled: no staff write at all.allows_content_editis deliberately narrower and confines content changes todraftandrejected. A quote insubmittedorapprovedstill has to advance through the workflow, but letting its figures move underneath the approver looking at them would make the approval meaningless.Staff cannot forge the client's decision.
is_staff_settableexcludes every status owned by another actor or route:sent,accepted/declined,expired, andconverted.Cross-tenant guards on the FKs.
assert_company_in_tenant/assert_contact_in_tenantreject foreign ids. The foreign keys alone are not enough, because FK checks bypass RLS, so a foreigncompany_idwould otherwise produce a quote silently linked across tenants. Same guard and reason asassert_payment_term_in_tenant(PMS-333).DELETEcancels, it does not delete. A quote that reached a customer is a commercial record, and the approvals rows referencing it (target='quote') would be orphaned by a hard delete.RBAC is borrowed, not invented. The
billingmodule gate plusRequireFinance, the same pairinvoices,contracts, andrate-cardsuse./api/v1/quotesis added to the RBAC coverage matrix.Verify
9 integration tests plus 5 unit tests: numbering, computed totals, per-line recompute (including a negative discount line), cross-quote line 404, forged totals ignored on create and update,
sent409ing on all four mutations, content-freeze-at-submittedwithrejectedreopening, all five forged statuses 409, and full cross-tenant isolation (6 routes 404, list total 0, foreign company 400). The pre-existing/quotes/{id}/approvalssurface still works against an API-created quote.Two things worth knowing for anyone writing similar tests: the cross-tenant test enables the
billingmodule for the second tenant deliberately, because otherwise the module gate 404s before the tenant check is reached and the test would pass vacuously; and PMS-138 binds login to(tenant_id, email)with a default-tenant fallback, socommon::logincannot reach a non-default tenant and the test needs its own login helper.Full suite and
just checkclean. One pre-existing unrelated failure:tests/readiness.rs::ready_returns_ok_when_db_reachable_and_infisical_unconfiguredfails locally because the dev.envsupplies Infisical vars while that profile is not running. Confirmed identical on a cleanmaintree with these changes stashed; CI does not set those vars.Follow-ups
PMS-673 (send + portal accept/decline) is already open on top of this branch. Then PMS-674 (convert to Project) and PMS-675 (SPA).
Routes for the quote and its lines, merged rather than nested so the URLs stay flat and sit alongside the `/quotes/{id}/approvals` paths that `modules::approvals` already owns. RBAC mirrors the other money-bearing surfaces exactly rather than inventing a policy: the `billing` module gate plus `RequireFinance`, the same pair `invoices`, `contracts`, and `rate-cards` use. PMS-350 established that financial surfaces are finance-gated on reads as well as writes, and a quote is a priced commercial document, so it belongs in that set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>