Document Portal
Share a document with a customer for approval, answer their questions in the same thread, and take payment — the branded public portal at /d/{token}.
Overview
The document portal is the customer-facing side of a shared document. Any finalised document can be shared as a tokenised link; when it is shared for approval the customer also gets a question thread, approve/decline controls, and — for invoices — a pay rail, all on one branded page at /d/{token}.
Everything the customer does comes back into Signals: questions and decisions land in a conversation on the parent record, approvals of a quote drive the rental's own version events, and portal payments are recorded against the invoice as ordinary card payments.
Sharing a document
Staff share from three places, all of which open the same Share document modal:
| Where | How |
|---|---|
| Print menu | Print / share menu on a rental, invoice, or other documentable — Share generates the document, then opens the modal |
| Rental Documents tab | Share action on an existing document row |
| Conversation panel | Share one of the parent record's final documents straight into the thread |
The modal requires the documents.share permission plus the share ability on the document itself, and offers:
| Option | Effect |
|---|---|
| Expiry | Optional link expiry; past that the portal returns 410 Gone |
| PIN | Optional PIN (4–32 characters), verified once per session at POST /d/{token}/pin and rate-limited |
| Request approval | Shares the document asking for a decision — see below |
Without Request approval, sharing behaves exactly as the existing public link does: a read-only branded page with a download. Re-opening the modal on an already-shared document updates its expiry and PIN instead of minting a new token.
Sharing for approval
App\Actions\Documents\ShareDocumentForApproval creates or refreshes the public link, flags the document (share_mode = approval, approval_requested_at), and opens a System conversation with External visibility on the parent record, seeded with the share message and the portal URL. The document is linked to that conversation, so staff replies and customer questions share one thread.
Invoice is now a conversable record alongside Rental and Account, so invoices shared for approval get a thread on the invoice itself.
The thread is best-effort. If the parent record cannot carry conversations, or the caller lacks conversations.create / conversations.share-external, the share still succeeds — the document is flagged and the link works, it simply has no question thread (conversation_id stays null) and the portal hides the thread block. Sharing only ever requires documents.share; missing conversation permissions degrade the share rather than failing it half-way.
The degradation is reported back on App\Data\Documents\DocumentApprovalShareData: conversation_started is false and conversation_skipped_reason is insufficient_permissions or not_conversable. POST /api/v1/documents/{document}/share_for_approval returns both fields alongside document and public_url, and the share modal raises a "shared without a conversation thread" notice.
Fires document.approval_requested.
What the customer sees
/d/{token} renders a branded portal page — warehouse logo and contact details where the document has a warehouse, falling back to company settings:
- the document type, number, issue date, link expiry, and status
- an embedded preview of the document plus a Download PDF button (
/d/{token}/filestreams the same bytes behind the same token, expiry, and PIN gates; stored HTML is served sandboxed by CSP) - an action rail carrying the approve/decline block, the pay rail, and the download button
- a question thread when the document was shared for approval
- a trust notice making clear that the link is private and that card details are never requested on the page
The portal is unauthenticated by design. The public token is the only credential: it is #[Locked] on every Livewire component and the document is re-resolved — with expiry and PIN-session re-checks — on every round trip, rather than trusting hydrated state.
Questions
A question posts a comment into the linked conversation attributed to the customer account (falling back to the thread author), marked is_from_portal so staff can see where it came from. Throttled per token by documents.portal_question_max_attempts (default 5) within documents.portal_question_decay_seconds (default 900).
Staff are notified through the document.portal_question notification type — the document's generator and the thread author — and document.portal_question fires once per question.
Approve / decline
The customer types their name to approve, or optionally gives a reason to decline. Both are:
- idempotent — a second response on an answered document is a no-op
- guarded — a document that was not shared for approval, is void, or whose link has expired is rejected
- throttled —
documents.portal_response_max_attempts(default 5) perdocuments.portal_response_decay_seconds(default 900)
What happens next depends on the document:
| Document | Effect |
|---|---|
| Quote documents still linked to a rental version | Fires the VersionAccepted / VersionDeclined Verbs events attributed to the customer account, driving the normal quote lifecycle |
| Everything else | Records the decision on the document (approved_at, declined_at, decline_reason, responded_by_name) |
Where the version can no longer take the transition — already accepted, declined, superseded, or the rental has left quotation — the customer's answer is still recorded on the document and the portal shows a notice that the team will be in touch. Either way a system comment is appended to the thread and staff are notified (document.approved / document.declined notification types), and the matching webhook event fires.
Portal payments
When the shared document belongs to an invoice with an outstanding balance (and a status other than draft, void, or credited), the portal shows a pay rail.
| Setting / key | Purpose |
|---|---|
payments.portal_payments_enabled (config, env PAYMENTS_PORTAL_ENABLED) |
Master switch for the rail |
payments.portal_driver (config, env PAYMENTS_PORTAL_DRIVER) |
Hosted-checkout provider — stripe by default, fake in the testing environment |
payments.portal_deposit_percent (setting) |
When set to 1–99, offers a part-payment button beside "pay the full balance" |
The deposit percentage is a stored setting rather than a config value, so it can be changed at runtime:
settings()->set('payments.portal_deposit_percent', 25, 'integer');
It is applied through RationalMoney, so the intermediate arithmetic is lossless and only the final conversion rounds (half-up) to the currency's minor unit. A deposit that is not strictly smaller than the balance is not offered.
Stripe
The shipped driver creates a one-off mode: payment Stripe Checkout Session for the amount, using the Cashier-configured client. Accounts are deliberately not Cashier billables — there is no per-account Stripe customer.
Configure the keys under Admin → Preferences → Integrations → Payments (requires settings.manage). They are stored encrypted, are write-only in the form (a blank input keeps the stored value), and take precedence over the environment.
| Setting (Integrations → Payments) | Environment fallback | Purpose |
|---|---|---|
| Publishable key | STRIPE_KEY |
Publishable key |
| Secret key | STRIPE_SECRET |
Secret key used to create the Checkout Session |
| Webhook signing secret | STRIPE_WEBHOOK_SECRET |
Signing secret for the inbound webhook — without it the webhook is rejected |
Each credential resolves through App\Services\Payments\StripeCredentials: the stored setting wins, and a blank setting falls back to the Cashier config (cashier.key / cashier.secret / cashier.webhook.secret), so installs configured purely through STRIPE_* environment variables keep working unchanged.
Point a Stripe webhook endpoint for checkout.session.completed at POST /payments/portal/webhook.
How a payment is recorded
The provider webhook, not the browser redirect, is the source of truth. The return trip only drives an optimistic "payment pending" message while the balance is re-read from the invoice.
On a verified webhook the payment is written through the ordinary RecordPayment action — no duplicate payment path — as the staff user who generated the shared document (falling back to the invoice's creator), because a webhook carries no session. Recording is idempotent on the provider session reference, which is stored on invoice_payments.reference, so provider retries are safe, and the amount is clamped to the outstanding balance.
Staff are notified (invoice.portal_payment_received) and the matching webhook event fires.
Other providers
App\Contracts\Payments\PortalPaymentDriver is the seam: key(), label(), createCheckout(), and verifyWebhook(). Register additional drivers in config/payments.php or from a plugin with PluginRegistrar::paymentDriver() — see Registries & resolvers.
Webhook events
| Event | Trigger |
|---|---|
document.approval_requested |
A document is shared with the customer asking for approval |
document.portal_question |
A customer asks a question from the portal |
document.approved |
A customer approves a document |
document.declined |
A customer declines a document |
invoice.portal_payment_received |
A customer pays an invoice from the portal |
See Webhooks for subscription and delivery mechanics.