Quotes
Generate quotes from deals, manage quote totals, and let customers view and accept quotes online.
An Odden\Sales\Models\Quote is a proposal attached to a deal. Each quote gets a random public token, and the package serves a public page where the customer can review the quote and accept it by typing their name and email. Acceptance closes the deal as won.
Quote attributes
| Attribute | Type | Notes |
|---|---|---|
deal_id |
int | |
quote_number |
string | Unique. Generated as Q-{year}-{5 random characters} if empty on create. |
title |
string | |
status |
QuoteStatus |
Defaults to draft. |
subtotal, discount_amount, tax_amount, total_amount |
decimal | |
currency |
string(3) | |
terms, notes |
text, nullable | Shown on the public page. |
public_token |
string | Generated with Str::random(40) if empty on create. |
expires_at |
date, nullable | |
accepted_at |
datetime, nullable | |
signed_by_name, signed_by_email |
string, nullable | Set on acceptance. |
user_id |
int, nullable | The rep who prepared the quote. |
Quotes are soft-deletable. Relations: deal(), items() (ordered by sort_order), and user(). $deal->quotes returns a deal's quotes, newest first.
Odden\Sales\Enums\QuoteStatus has Draft, Sent, Approved, Accepted, Declined, and Expired, with label(), color(), isAccepted(), and isTerminal() (true for accepted, declined, and expired). The package sets sent, accepted, and expired itself; approved and declined are only set by your code.
Generating a quote from a deal
Odden\Sales\Actions\GenerateQuoteFromDealAction creates a draft quote and copies the deal's products into quote items:
public function execute(
Deal $deal,
?string $title = null,
?string $terms = null,
?string $notes = null,
?CarbonInterface $expiresAt = null,
int|string|null $userId = null,
): Quoteuse Odden\Sales\Actions\GenerateQuoteFromDealAction;
$quote = app(GenerateQuoteFromDealAction::class)->execute(
deal: $deal,
title: 'Acme platform proposal',
expiresAt: now()->addDays(14),
);Defaults when you omit arguments:
title:Quote for {deal name}.terms:Payment due net 30 days from signature.expires_at: 30 days from now.user_id: the deal'sowner_id, then the authenticated user.currency: the deal's currency.
If the deal has no products but a positive amount, the quote gets a single item named after the deal for that amount. Discount and tax start at zero.
Quote items and totals
Odden\Sales\Models\QuoteItem has the same fields as a deal product: name, sku, description, unit_price, quantity, discount_percent, and sort_order. Its total_price is calculated on save the same way, so pass quantity explicitly here too.
Saving or deleting an item calls $quote->recalculateTotals(), which sets:
subtotalto the sum of item totals, andtotal_amounttomax(0, subtotal − discount_amount) + tax_amount.
discount_amount and tax_amount are amounts you set; the package does not calculate tax. Call recalculateTotals() after changing them:
use Odden\Sales\Models\QuoteItem;
$quote->update(['discount_amount' => 500, 'tax_amount' => 640]);
$quote->recalculateTotals();
QuoteItem::create([
'quote_id' => $quote->id,
'name' => 'Premium support',
'unit_price' => 1200,
'quantity' => 1,
]); // Totals are recalculated automatically.Quote items are a snapshot. Changing the deal's products later does not change existing quotes.
Sharing the quote
Send the customer the URL of the odden.quotes.show route:
$url = route('odden.quotes.show', ['token' => $quote->public_token]);The package does not email quotes; send the link with your own mail or notification.
When the page is opened (GET /quotes/{token}):
- A
draftquote is changed tosent(quietly, without model events). - A note activity titled
Proposal Viewed by Customeris logged on the deal, with the quote ID and visitor IP in its metadata. Only one view note is logged per quote every two hours. - The page shows the line items, totals, terms, and notes. If the quote is accepted it shows the signature. If the quote can't be accepted (see Which quotes can be accepted) it shows a "Proposal Declined" or "Proposal Expired" notice saying the quote is no longer available for acceptance, and no form. Otherwise it shows an acceptance form.
The page is the Blade view odden-sales::quotes.public-portal. Override it by creating resources/views/vendor/odden-sales/quotes/public-portal.blade.php in your app.
Accepting a quote
The form posts to odden.quotes.accept (POST /quotes/{token}/accept), which is rate limited by Core's odden-public limiter. It validates:
| Field | Rules |
|---|---|
signed_name |
required, string, 2–255 characters |
signed_email |
required, email, max 255 |
agree_terms |
accepted |
It then calls Odden\Sales\Actions\AcceptQuoteAction and redirects back to the quote page:
- On success, with a
statusflash message. - If the quote can't be accepted, with an
errorvalidation error holding theQuoteNotAcceptableExceptionmessage, such as "This quote proposal has expired." - On any other failure (for example a stage requirement on the closed won stage), the exception is passed to
report(), so it reaches your log and error tracker, and the visitor sees a genericerror: "We could not complete the acceptance of this proposal. Please contact your sales representative." Internal error messages are never shown on the public page.
You can call the action directly, for example to record a signature captured elsewhere:
use Odden\Sales\Actions\AcceptQuoteAction;
$quote = app(AcceptQuoteAction::class)->execute(
$quote->public_token,
'Alice Johnson',
'alice@acme.com',
);It throws Odden\Sales\Exceptions\QuoteNotAcceptableException (a subclass of InvalidArgumentException) if no quote has the token or the quote can't be accepted. Its messages are safe to show to the customer.
Which quotes can be accepted
A quote can be accepted when $quote->isAcceptable() is true:
- its status is not terminal (
accepted,declined, orexpired; seeQuoteStatus::isTerminal()), and - its expiry date has not passed (
$quote->hasPassedExpiryDate()isfalse).
A quote is valid through the whole of its expires_at day and expires once that date has passed, so a quote with expires_at of 30 June can be accepted until the end of 30 June. The sales:expire-quotes command uses the same rule (see Expiring stale quotes). Dates are compared in your app's timezone.
A quote that is already accepted can't be accepted again, so the original signature is kept. If a draft, sent, or approved quote is past its expiry date when someone tries to accept it, the action also sets its status to expired, as the command would have.
What acceptance does
All of the following runs in one database transaction, with the quote row locked and its status checked again inside the transaction, so two simultaneous submissions can't both accept it:
- Sets the quote to
accepted, withaccepted_at, the trimmed signer name, and the trimmed, lower-cased email. - Moves the deal to the pipeline's closed won stage with
moveToStage(), if the pipeline has one. This runs that stage's stage automations and dispatchesDealMovedStageandDealWonwith anulluser ID. - Sets the deal's
statustowon,closed_atto now, andamountto the quote'stotal_amount. - Logs a note activity on the deal (
Quote #… Accepted & Signed). - If the deal has an owner, logs a pending task for the owner, due now, to start onboarding.
If any step fails, for example a stage requirement on the closed won stage throws StageRequirementException, the whole transaction rolls back: the quote keeps its previous status and no signature, and the deal stays open in its current stage. The public page reports the failure to your logs and shows the customer the generic message above. Avoid requirements on closed won stages that a customer signature can't satisfy, such as RequireDealProducts on deals without products. Because the quote is marked accepted before the deal moves, a RequireAcceptedQuote requirement on the closed won stage is satisfied.
DealMovedStage and DealWon are dispatched inside the transaction. Listeners that run synchronously and fail roll the acceptance back too.
Expiring stale quotes
php artisan sales:expire-quotesThe command sets status to expired for every draft, sent, or approved quote whose expiry date has passed, that is, whose expires_at is before today (the Quote::pastExpiryDate() query scope). A quote expiring today is left alone until tomorrow, matching what acceptance allows. It updates rows in bulk, so no model events fire. The package does not schedule it; see Scheduling.
$quote->isExpired() returns true when the quote is not accepted and either its status is expired or its expiry date has passed (regardless of the stored status).