Proposals is the front half of the invoicing funnel: build a quote with rich content and line items, email the client a tokenized review-and-sign link, and let them accept and e-sign online — typed legal name plus an optional drawn signature, with a full IP/timestamp audit trail — or decline with a reason. An accepted proposal converts automatically into a draft invoice in Client Billing, ready to send.
The problem
Winning the work happens before the invoice — but proposal tools are their own SaaS subscription (with per-seat pricing and their own client list), and emailing a PDF quote means no signature, no audit trail, no notification when the client opens it, and re-typing everything into the invoicing tool once they say yes.
The fix
A self-contained feature module under app/Features/Proposals/ that reuses the Client Billing client list, adds a proposal builder (rich intro/scope/terms + line items), serves a tokenized accept-and-sign page from the install's own domain, and hands the win straight to Client Billing as a draft invoice — same client, same lines, fresh invoice number.
Like every addon, it's structured as a feature module gated by feature:proposals middleware. The service provider boots unconditionally; routes 404 and the sidebar entry hides when the feature is off. Toggling it on runs the module's migrations. Disabling preserves all data.
What the dashboard gets
Three pages under Dashboard → Proposals (Proposals and Proposal Templates open to Manager and up and to a salesperson — see Who can do what; Settings is Admin-only):
- Proposals — the pipeline: All / Drafts / Sent / Accepted / Declined tabs (the Sent tab shows an awaiting-signature count) plus search by number, title, or client. Each row shows number, client, title, total, a status badge — Draft, Sent, Viewed (the client has opened it), Accepted, Declined, Expired (derived live from the valid-until date, no sweeper job) — valid-until, and created date. Row actions: edit, open/copy the public link, Download PDF, Send (or Resend), Duplicate (fresh draft with a new number and expiry), Save as template (any status — see Templates), Convert to invoice (accepted, not yet converted), Start a project (accepted, when Project Tracking is on), and Delete draft (only drafts are deletable — sent proposals are records).
- Proposal editor — pick a client from the shared invoicing client list (or quick-add one inline, mirrored into the CRM per the invoicing sync setting), set a title, valid-until date, currency, and tax rate, then build line items: description, quantity (decimals allowed), and unit price with live-updating totals — typed, or picked with Add from price list from the Client Billing price list (a copy: later price-list edits never touch the proposal; see Client Billing → Price list); a negative unit price works as a discount line. Each line's Line options menu makes it required (the default), an optional add-on the client can tick, or one option of a pick-one set — good / better / best (see Options); the totals show the recommended package. A line description is free text with no 255-character cap, so a long price-list description is kept whole. Three rich-text sections (Tiptap — headings, lists, links, bold/italic/underline) shape the document: Introduction (the pitch), Scope of work (deliverables), and Terms (what the signature agrees to). Once the client has accepted or declined, the proposal locks read-only — duplicate it to start a new version.
- Proposal Templates — saved default proposals (a name, an optional default title, tax rate, line items, and the three rich sections) so a seller who quotes the same package repeatedly starts from a filled-in editor and only adjusts headcount and price per client. Template lines can be picked from the price list too. The proposals list gains a Templates button and, once any exist, a New from template menu beside New proposal.
- Settings (admin only) — number prefix (
PRO-; numbers are assigned through Client Billing'sDocumentNumbers, so two proposals created in the same instant get consecutive numbers rather than one failing), default valid-for days, default terms, the auto-convert toggle, PDF email attachments, Email the signer a copy when they accept (on by default), and the email sender / owner-notification overrides (all falling back to the Client Billing settings, which fall back to Business settings).
The dashboard also gets a Proposals card: proposals awaiting signature (sent, not expired) plus the accepted count over the last 30 days.
Who can do what — the Sales grant
A Staff login holding the Sales grant reaches Proposals and Proposal Templates alongside Client Billing's Invoices and Clients — see Client Billing → Who can do what for the grant and its gate. It is the salesperson's landing page.
A salesperson builds, sends, duplicates and converts proposals, saves a proposal as a template, creates, edits and applies templates, deletes a draft, and quick-adds a client. Create job (and the link to a job), Start a project and deleting a template (the team's shared starting points) stay Manager-only — refused server-side and not offered — as does Settings (Admin).
Options (add-ons and good / better / best)
A proposal can let the client shape what they buy before they sign. In the editor, each line's Line options menu sets one of three types:
- Required line — always in. Every line starts here, including a line picked from the price list.
- Optional add-on — the client ticks it or not. Ticked for the client to start with pre-ticks it, which also puts it in the recommended total.
- One option of a set — every line sharing a set name (say "Water heater") is an alternative and the client chooses exactly one. Set names match regardless of case or spacing and are stored with the first line's spelling. Each set needs at least two options and exactly one marked Recommended (marking one clears the rest of its set); the editor refuses to save otherwise. Turning several lines into set options one after another puts them in the set above.
The editor's totals, the stored proposal total, the dashboard list, the proposal email and the PDF all show the recommended package — required lines plus pre-ticked add-ons plus each set's recommended option — until the client answers.
On the public page, add-ons are checkboxes and each set is a radio group under a Choose one: {set} heading, starting on the recommendation. The total updates as the client chooses, and the sign panel repeats it ("You are accepting the options selected above, for a total of …"). Lines not chosen stay visible but dimmed.
What the server checks at signing (ProposalSelection). The choices arrive as plain page state, so they are re-checked and re-priced from the proposal's own rows: an id from another proposal, a set option passed off as an add-on (or the reverse), two picks for one set, or a set name the proposal doesn't have is refused with "Your choices no longer match this proposal" and nothing is recorded. A set left unanswered is refused ("Choose one option for … before signing") rather than priced at nothing, and an all-add-ons proposal can't be accepted with none ticked. Required lines are added on the server whatever the page sends.
What acceptance records (ProposalAcceptance). The answer goes on the lines (proposal_items.is_selected, true or false on every line) and the proposal's subtotal/tax/total become the chosen package's — written in the same transaction as the atomic accept claim, so a second tab accepting with different choices loses the claim and writes nothing. signed_snapshot keeps the offered content plus a selection key: positions into its items list and the chosen totals (see E-signature & audit trail).
Downstream, only the chosen lines travel: conversion to an invoice, the accepted notification and the signer's copy, the PDF and the public page once accepted, and the editor's read-only view (each option line tagged Chosen by the client or Not chosen, totals from the stored choice).
Copies. Duplicate and Save as template keep the option structure (types, set names, recommendations) but never the client's answer — a duplicate of an accepted proposal is a fresh offer at the recommended total, and a template records what the seller recommended, not what one client picked.
If the seller edits the options while the client has the page open, signing is refused as "updated since you opened it" (see below) and the choices reset to the new recommendation, so the client reviews before signing again.
Templates
A template (proposal_templates) holds the reusable half of a proposal — an optional default title, the tax rate, the line items (description, quantity, unit price, and their options), and the Introduction, Scope of work, and Terms sections. It never holds a client, dates, status, or a signature. Each proposal remembers the template it was seeded from (proposals.proposal_template_id); deleting a template unlinks those proposals and leaves their content untouched.
Three ways to use one:
- New from template — on the proposals list (and the New proposal button on each template's row), opens a blank editor already filled with the template's title, items, tax rate, and sections; pick the client, adjust quantities and prices, save.
- Apply template — inside the editor, a Start from a template picker (shown while the proposal is still editable and at least one template exists) replaces the title, line items, tax rate, and the three sections with the chosen template's. The client and valid-until date are left alone, so a template can be swapped in after the client is picked.
- Save as template — in a proposal's row menu on the list, for any status (an accepted proposal is the proven version worth reusing). Prompts for a template name (prefilled with the proposal title) and snapshots the title, items in order, tax rate, and sections.
A template's Terms section is optional: when it is empty, the default terms from Proposal Settings still apply to proposals made from it, exactly as they do for a blank proposal. A template with no default title leaves whatever title is already typed.
What the client sees
The emailed proposal contains the line-item table, totals, an optional PDF attachment, and a Review & sign button pointing at the tokenized public page (/proposal/{token} — a 40-character random token, no login needed):
- A printable document: business identity block (shared with invoicing), number and valid-until, title, the rich intro/scope sections, the investment table with totals, and terms — plus Download PDF and Print buttons.
- Accept & sign: typing the full legal name (previewed in a script-style face) plus a consent checkbox — after the e-sign consent disclosure — is the legal signature; a drawing pad (pointer/touch canvas) is optional. Accepting stamps
accepted_at,signed_at, the signature name/email, the visitor's IP address and user agent, and the drawn polylines — atomically, exactly once (a race between two clicks, or an accept racing a decline, lands only the first). - Decline with an optional reason — same atomic claim, so a proposal can never be both accepted and declined.
- Clear state banners afterwards: a green accepted-and-signed block (typed name, drawn signature, timestamp), a declined notice, or an amber expired notice once the valid-until date passes (expired proposals can no longer be accepted).
- A signed copy by email. Right after accepting, the signer is emailed their signed copy — sent to the email they signed with (falling back to the client's email), in the language they were browsing in: a thank-you with the signing date, the line-item table, the signed PDF attached (when PDF attachments are on — the PDF's signature block carries the audit line), and a View signed proposal link back to the tokenized page, which stays available. Sent once per proposal; turn it off under Settings → Email the signer a copy when they accept.
The first client open records first_viewed_at (shown as Viewed in the dashboard) — managers previewing don't count, and drafts are invisible to the public entirely (managers can preview them while logged in, including the PDF).
A signed-in client doesn't need the emailed link. /members/agreements (the Agreements pill in the account nav, plus a card on the account dashboard) lists every non-draft proposal and contract for clients whose email matches the account, each linking to the tokenized page above. One page for both because both fold into client_invoicing and both hang off the same Client row — a client thinks of them as one pile of paperwork. Reviewing, accepting and signing all stay on the tokenized page. See memberships.md → The account area.
E-signature & audit trail
The signature record stored on the proposal: typed legal name, signer email, IP address, user agent, drawn-signature polylines (rendered as an inline SVG on the page and in the PDF), the signing timestamp, and the e-sign disclosure the signer accepted (version + full text).
The signature is bound to the content: signed_content_hash is the sha256 of Proposal::contentSnapshot() — number, title, currency, tax rate, the offered totals, dates, the three sections, and every line (an option key on option lines only) — and signing is refused if it changed after the client opened the page. The fingerprint is seller content only: the client's choices and the stored (chosen) totals are deliberately outside it, so ticking an add-on never reads as "the proposal changed", and the hash still matches the document after acceptance. A proposal without options hashes exactly as it did before options existed. signed_snapshot stores that snapshot plus selection (items: positions into the snapshot's lines, and the chosen subtotal_cents / tax_cents / total_cents) — so the snapshot's top-level totals are what was offered and selection is what was agreed. The PDF's signature block prints "Signed by {name} ({email}) on {date} — IP {ip}" under the script-styled name and drawing — so the downloadable document carries the evidence with it. The typed name + consent checkbox is the signature; drawing is optional.
E-sign consent disclosure
Before the consent box, the sign panel shows Consent to sign electronically, modelled on what the US ESIGN Act requires before electronic consent counts:
- Scope — the consent covers this document and its records (the signed copy and its confirmation email), nothing else.
- Paper copy — the signer can download or print it any time, or ask the business for a paper copy at no charge.
- Withdrawing — before signing, don't sign and contact the business to sign on paper instead; withdrawing doesn't undo anything already signed electronically.
- Hardware and software — a current web browser, an internet connection, a working email address, and the ability to open, save or print PDFs (opening the PDF on the page shows the signer meets them).
- Updating contact details — contact the business.
Every "contact the business" line names the email and/or phone from the Client Billing business details (invoicing.business_email / invoicing.business_phone, falling back to Business settings). With neither set it reads "the contact details they gave you" — fill them in. The consent box reads "I have read the electronic signature disclosure above and agree to sign electronically…".
The wording lives in App\Support\ESignDisclosure (translatable; shared with Contracts). The page builds it once, when it opens, into a locked component property. The panel renders that array and accept() stores the same array, so the record is exactly what the signer read — wording, language, and the business's contact details at that moment, even if the business edits them while the page is open. A page opened before the disclosure shipped has nothing to record, so it shows the disclosure and asks the client to sign again rather than store a signature without one.
Stored on the proposals row: signature_disclosure_version (ESignDisclosure::VERSION, a date — bump it whenever the wording changes) and signature_disclosure (the full text shown, consent statement included). Both are null on signatures made before the disclosure existed. The PDF's signature block adds "Accepted the electronic signature disclosure (version …) before signing.", and the dashboard editor shows the stored text under the signed callout. ESignDisclosureCoverageTest fails any page that collects a signature (<x-signature-capture> / <x-signature-pad>) without rendering and recording the disclosure; form signature fields show their own variant (see forms-and-submissions.md → Signature fields).
Conversion to invoice
When auto-convert is on (the default), acceptance immediately creates a draft invoice in Client Billing for the same client: same currency, tax rate, and line items — only the lines the client accepted (every required line plus the add-ons and set options they chose) — terms carried over as plain text, fresh INV- number and issue/due dates from the invoicing defaults. The tax rate carries over exactly — the invoice keeps three decimals, so an 8.875% proposal bills 8.875%, not 8.88%. Converting is idempotent — converting again returns the same invoice — and the proposal links to what it became. With auto-convert off, the row menu's Convert to invoice does the same thing manually.
Start a project is the pipeline's third leg (Project Tracking must be on). It opens a tracked job carrying the client, the proposal title, and the scope as its description — the tracking page opens with what was actually agreed rather than an empty box. Deliberately manual, like Convert to invoice: the operator decides when paperwork becomes live work. It's idempotent through the project's source morph, and the menu item disappears once a project exists.
Create job (Jobs & Dispatch must be on) turns an accepted proposal into a job to schedule — the lines the client accepted, the signed tax rate, the scope as the work to do — from the accepted callout in the editor or the row menu (which then says Open job); Jobs → Settings can also make it the moment the client accepts. A proposal that became a job is billed through the job: the job takes the proposal's converted invoice if there is one, and Convert to invoice afterwards returns the job's invoice (or says to invoice the job), so the same work is never billed twice. See Jobs & Dispatch → Where jobs come from.
Emails
All transactional mail goes through the shared Marketing transport (CampaignMailer), with an optional proposals-specific from name/address (falling back to the invoicing sender). The line table follows the options rule: before acceptance it lists every line (sets under a Choose one heading, add-ons tagged included / not included, a note that the total has the recommended options); after, only the chosen lines. The mails: the proposal email to the client (claimed once per proposal so a double-click can't double-send; resending is an explicit dashboard action), an owner notification on acceptance (with the signature audit line and, when PDF attachments are on, the signed PDF), the signed copy to the signer on acceptance (the signed PDF attached, plus a link back to the proposal — claimed once per proposal as signed_copy, and a failed send releases the claim without affecting the acceptance), and an owner notification on decline (with the reason). If the transport isn't configured, sending fails gracefully — the copy-link action, PDF download, and public page work without email entirely.
Integrations
- Client Billing (required) — shares the
invoicing_clientslist, converts intoclient_invoices, and reuses the business identity block and currency settings. - CRM — sent/accepted/declined events log to the matched contact's interaction timeline (type
proposal, matched by client link or email); quick-added clients mirror into the CRM via the invoicing sync setting. - Activity Log — sent, accepted-and-signed, declined, and converted events log under the
proposalscategory.
Statuses
Stored: draft → sent → accepted | declined. Displayed: Viewed and Expired are derived live from first_viewed_at and the valid-until date — no sweeper job when an expiry passes. An expired proposal can't be accepted or declined; clear the date or extend it and resend to revive it.
Limitations
- One signer per proposal — no multi-party or counter-signature flow.
- The drawn signature is supporting evidence only; the typed name + consent is the operative signature (standard click-wrap e-sign, not a qualified/eIDAS-advanced signature).
- One currency per proposal, set at creation.
- Options live on proposals only — a plain Client Billing estimate has no add-ons or sets.
- One tax rate for the whole proposal, add-ons and set options included.
- A set is always "choose exactly one"; there is no "choose one or none" set (use an add-on per option instead).
- Declining is final for that proposal — duplicate it to negotiate a new version.