Solavel Solavel Docs

Public Quote Links

docs/customer-portal/public-quote-links.md

Audience: end users, customer support Difficulty: new user

What this covers

The dedicated public link for quotes (/q/{token}), distinct from the generic secure document link. Designed so a customer can review a quote, accept it, or decline it — without an account.


URL pattern

https://solavel.com/finance/q/{token}

Three endpoints under this prefix (defined in solavel-finance/routes/web.php, named quote.customer.*):

URL Method Purpose
/q/{token} GET View the quote (HTML render with the org's PDF-template styling)
/q/{token}/accept POST Customer accepts the quote
/q/{token}/decline POST Customer declines the quote

Controller: App\Http\Controllers\Solabooks\Portal\PublicQuoteController. Throttle bucket: quotes-public. Token middleware: quote.token (InitializeTenantFromQuoteToken).


How it differs from /i/{token}

/i/{token} (secure document link) /q/{token} (public quote link)
Document types Invoices, quotes, sales orders, etc. Quotes only
Token middleware portal.token (ResolvePortalToken) quote.token (InitializeTenantFromQuoteToken)
Throttle portal-public / portal-recovery quotes-public
Recovery (request-access / send-again) Yes No — token is single-link
Account upgrade prompt Yes (offer to set up customer-portal account) No
Accept/Decline Available via quotes/{quote}/accept sub-route Top-level /accept and /decline endpoints
Expiry 24 hours Quote-specific (see below)

The public quote link is the simplest path: it does one thing (show a quote) and hands control to the buyer. The generic /i/{token} is richer but requires a tenant-side send-again to recover an expired link.


Issuing a quote link

When a staff user clicks Send Quote on a quote (controllers under Solabooks/AR/QuoteController), the system issues a q/-style token. The email contains a single URL that takes the customer to the quote view.

Token format follows the same encrypted-tenant-hint + signature + secret pattern as the secure-link service, but is generated by a quote-specific token service. Storage is hashed (SHA-256). The full behaviour of the token service was not fully audited — confirm by reading App\Services\Portal\ for the quote-specific token service.


Customer flow

  1. Customer receives the email with the quote link.
  2. Clicks. Page loads showing line items, tax, total, validity dates, and any attached terms.
  3. AcceptPOST /q/{token}/accept. Quote status moves from sentaccepted. The accept action is recorded with the customer's IP and timestamp; staff sees this in Quote > Activity.
  4. DeclinePOST /q/{token}/decline. Quote moves to declined. Optionally accepts a reason (controller-side; see below).

Both endpoints are CSRF-tokened (the form posts from the same page) and rate-limited.


After accept

  • The quote is locked from further edits.
  • Staff can Convert to Invoice (quotes.convert) which creates a draft invoice using the same line items and customer. The invoice is separate — it is not auto-posted; staff must finalise.
  • The link continues to render the accepted quote until the quote is converted, at which point follow-up emails should reference the invoice link.

Expiry

Quote links use an expiry derived from the quote's own expires_on date plus a hard-cap window. Specific behaviour needs confirmation in InitializeTenantFromQuoteToken middleware — the audit did not fully read the controller. Two possibilities:

  • The token expires when the quote does (recommended).
  • The token has a fixed window (e.g. 30 days) regardless of quote expiry.

If the link is past its window the page renders an expired view; the customer cannot accept or decline. Staff can resend by issuing a new link from the quote page.


Related

Source: docs/customer-portal/public-quote-links.md ← All documentation