Orders & pricing
An order has many items, an optional coupon, a generated number and its own currency. Its price accessor returns a Price DTO computing discount, shipping, tax and the final total from the items, the snapshotted shipping (shipping_cost) and the placed discount:
$price = $order->price; // RoundlyConsulting\Shops\Orders\DataTransferObjects\Price
$price->getSubtotal(); // sum of line totals (quantity-aware), before discount
$price->getDiscountValue(); // amount saved by the placed discount
$price->getPriceAfterDiscount(); // subtotal minus the discount
$price->getNetPrice(); // tax-exclusive value of the goods
$price->getTaxPrice(); // tax across the goods (after discount)
$price->shippingCost(); // the shipping charged: shipping_cost, or zero with free shipping
$price->getFinalPrice(); // what the customer pays (goods + shipping, tax-correct)
$price->taxSummary(); // money TaxSummary: net / tax / gross per ratePlaced orders never re-price
- Discount — the price reads the discount snapshotted at placement (discount, free_shipping, coupon_code). A coupon that later expires, is revoked or runs out of uses never changes a placed order.
- Tax — each line keeps the rate it was added with (order_items.tax_rate and tax_label), and the order keeps the price_type it was created under. Editing a shop’s rates, changing the shipping address or flipping shops.pricing.price_type never changes what a placed order costs.
- An item written without a snapshot (tax_rate null) is taxed at the live rate for its shop and shipping country.
- Shipping — the price includes the shipping cost snapshotted at checkout (shipping_cost); a free-shipping coupon takes it off. Quoting another destination later never changes a placed order.
Price caching
$order->price is computed once per model instance and cached together with its loaded items. After changing an order in memory (adding items, applying a discount), call $order->refresh() before reading the price again — and before handing it to StoreCreditTender, which debits the price it reads. Shops::order($order)->charge() re-reads the order as stored, under its row lock, before charging. checkout() already returns a refreshed order:
use RoundlyConsulting\Money\Money;
use RoundlyConsulting\Shops\Orders\Item;
$order->price->getFinalPrice(); // computed and cached on this instance
Item::create([
'order_id' => $order->id,
'name' => 'Gift wrap',
'quantity' => 1,
'price' => Money::ofMinor(300, 'EUR'), // must match the order's currency
]);
$order->refresh(); // drop the cached price and items
$order->price->getFinalPrice(); // now includes the new lineTax summary
$summary = $order->price->taxSummary();
$summary->perRate(); // net / tax / gross grouped per rate — an invoice VAT table
$summary->net(); // Money
$summary->tax(); // Money
$summary->gross(); // MoneyHow tax is computed
- An order-level discount is first spread over the lines in proportion to their totals with money’s DiscountAllocator — the shares sum to the discount exactly.
- Each line is then taxed once on its discounted amount with money’s TaxRate — gross: taxable − round(taxable × 100 / (100 + rate)); net: round(taxable × rate).
- Rounding is half away from zero with bcmath — no floats. A gross catalog always satisfies net + tax = price after discount.
- gross (default) extracts tax from the price; net adds it on top. Shipping is never taxed by this engine.
Pricing arbitrary lines
Construct a Price directly to price any lines or to include a shipping cost:
use RoundlyConsulting\Money\Money;
use RoundlyConsulting\Shops\Orders\DataTransferObjects\Price;
use RoundlyConsulting\Shops\Orders\DataTransferObjects\PriceLine;
use RoundlyConsulting\Shops\Orders\Enums\PriceType;
$price = new Price(
lines: [new PriceLine(unitPrice: Money::ofMinor(1000, 'EUR'), quantity: 2, taxClass: 'standard')],
shipping: Money::ofMinor(490, 'EUR'),
priceType: PriceType::Gross, // or PriceType::Net
taxResolver: null, // defaults to app(TaxResolver::class)
discount: Money::ofMinor(300, 'EUR'),
freeShipping: false,
currency: 'EUR', // the currency of an empty price
shop: null,
country: null,
);
$price->getFinalPrice(); // 21.90 EUR — 20.00 - 3.00 discount + 4.90 shipping (gross)Shipping in the total
A placed order’s price already includes its shipping: checkout quotes the shipping address (or takes the customer’s chosen shippingCost) and snapshots it as shipping_cost — see Placing orders. $order->price->shippingCost() is the shipping charged: shipping_cost, or zero when a free-shipping coupon applied. Shops::order($order)->quoteShipping() prices another destination without touching the order.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.