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
- Customer receives the email with the quote link.
- Clicks. Page loads showing line items, tax, total, validity dates, and any attached terms.
- Accept —
POST /q/{token}/accept. Quote status moves fromsent→accepted. The accept action is recorded with the customer's IP and timestamp; staff sees this in Quote > Activity. - Decline —
POST /q/{token}/decline. Quote moves todeclined. 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.