feat(quotes): send to client + portal accept/decline sign-off (PMS-673) #454
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/PMS-673-quote-send-and-portal-signoff"
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-673, phase 3 of the PMS-670 Quotes epic, and the part that delivers the ticket's stated requirement: quotes can be sent to clients for sign-off before work starts.
Stacked on
feat/PMS-672-quotes-module-crud(PR #453). Review and merge that one first; this branch targetsmainso its diff will shrink to just these five commits once #453 lands.Routes
Staff:
POST /quotes/{id}/sendPortal:
GET /portal/quotes·GET /portal/quotes/{id}·POST /portal/quotes/{id}/accept·POST /portal/quotes/{id}/declineDesign notes worth reviewing
Why client acceptance is not on the approvals table.
ticket_approvalsconstrains approvers to internal staff (approver_user_idXORapprover_role, both staff concepts), so it cannot express "the customer signed off". Internal approval stays on that surface completely untouched and remains the gate for sending; client acceptance is state on the quote itself. Two different actors, two different events.Send is gated on
approvedand mails after commit. A mail failure must not roll back a transition the customer may already have been told about out of band, and the reverse is recoverable by resending, so the transition is the durable half. Delivery failures are logged rather than surfaced: the quote is alreadysent, and a bounced mail must not become a 500 that makes the caller retry a transition that already happened.Nothing pre-
sentreaches the portal.is_client_visibleexcludesdraft/submitted(unfinished),approved(only means staff cleared it to go out), andrejected/cancelled(killed internally, so showing them would leak a negotiation the customer was never part of). A unit test pins the predicate against the SQL bind list so a future status cannot be added to one and forgotten in the other.404, not 403, throughout. A quote belonging to another company, or not yet issued, is 404 on read AND on decide, so a contact cannot learn it exists by watching the error change from 404 to 409.
Expiry is derived, not swept.
effective_statusreinterprets asentquote pastvalid_untilasexpiredat read time. There is then no window in which an expired quote is still acceptable and no background machinery to operate; the cost is that the stored column stayssentuntil something writes the row. Onlysentis reinterpreted, so an accepted or converted quote does not expire retroactively.valid_untilis inclusive, so a customer signing on the deadline is not turned away.Its own rate limiter. Separate from
PortalLoginLimiterbecause the buckets mean different things: login throttles credential guessing by an unauthenticated caller, this throttles actions by an already authenticated contact. Sharing quota would let a burst of decisions lock the contact out of logging back in.Verify
6 integration tests in
tests/quote_signoff.rsplus 3 new unit tests:draft(409), succeeds fromapproved, stampssent_at, is refused a second time, and is audited.valid_until, asserts the stored status is stillsentwhile the read reportsexpiredand accept 409s, then setsvalid_untilto today and accepts successfully, pinning the boundary as inclusive.Full suite passes and clippy is clean at CI strength (
--all-targets -- -D warnings). Same pre-existing unrelatedtests/readiness.rsfailure as #453, confirmed identical on cleanmain.Follow-ups
PMS-674 (convert an accepted quote to a Project), 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>Adds `POST /quotes/{id}/send` on the staff surface, and `/portal/quotes` (list), `/portal/quotes/{id}` (detail), and `/portal/quotes/{id}/accept|decline` on the client surface. The portal reads force the company scope from the authenticated `CurrentContact` rather than a query param, so a contact only ever sees its own company's quotes, and a quote that is not theirs or not yet issued returns 404 rather than 403 so the portal never confirms it exists. Same posture as the existing portal invoice routes. Accept and decline share one handler body, since they differ only in the outcome recorded, and their JSON body is optional: accepting with nothing to say is the common case and requiring `{}` would be a needless 415. `TicketService` and `notifications_routes` now take clones of the mailer and dispatcher they previously consumed, because quotes needs both as well. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>