Cart and Checkout
How the cart works: ?add= links, the cart components, the checkout page in cart mode, subscriptions in a cart, what each provider shows, and the API.
The cart is part of Licensing and Storefront, so there is no module to turn on.
Since 0.2.0 a buyer can put several products and plans, each with a quantity, into one checkout. They pay once and get one order with an item for each line, plus one license per unit of a licensed product.
A checkout of one product at quantity 1 is still the 0.1.0 checkout. It has the same quote, terms, provider request, order and receipt.
Linking to a cart
Use an ?add= link to open the checkout with items already in the cart:
/checkout/?add=speed-kit:personal
/checkout/?add=speed-kit:personal:2,theme-pack:lifetime
- Each piece is
product:planorproduct:plan:quantity. Products and plans are slugs, and the quantity runs from 1 to 100. - Pieces are separated by commas, and the parameter can repeat. If the same product and plan appear twice, their quantities are added together.
- Malformed pieces are skipped, never guessed. A quantity outside 1 to 100 skips its piece.
- The server renders the cart from the query string, so these links work without JavaScript.
Every plan in the storefront API (storefront/products, storefront/product) now has an add_to_cart_url beside checkout_url. It always points at the native checkout, even for a product whose checkout link goes to a form.
Components
---
import { BuyButton, CartButton, CartDrawer } from "@gatilab/emdash-vendwick/astro";
---
<CartButton /> <!-- in the header: a count badge that opens the drawer -->
<CartDrawer /> <!-- once per layout -->
<BuyButton product="speed-kit" plan="personal" cart /> <!-- "Add to cart" -->
<BuyButton cart>renders an "Add to cart" link to?add=product:plan. With the client script loaded, it adds the plan to the browser cart, announces it in a live region and opens the drawer. If the page has no drawer, it goes to the checkout with the whole cart.<CartButton>is a link to the checkout page. With JavaScript it opens the drawer and shows how many units the cart holds.<CartDrawer>is a modal<dialog>side sheet. It lists the browser cart with quantity steppers (capped by the stock left), a price field for pay-what-you-want lines, and remove with undo; gets a live subtotal fromcheckout/quote; says what is calculated at checkout (shipping, tax); and links to the checkout with the whole cart. A cart that needs a split shows the reason and a "Check out … first" link. Escape or a click outside closes it, focus goes back to the button that opened it, and the page behind does not scroll.- The product page’s plan picker has "Add to cart" too: with JS it adds the chosen plan and quantity to the browser cart and opens the drawer; without JS it opens the checkout with
?cf_product=&cf_plan=&qty=&cart=1, which the checkout reads as a one-line cart (a quantity above 1 does the same). - The
vendwick-cartPortable Text block renders a cart button and its drawer. In the "Buy button" block, the "Add to cart" toggle turns on the cart variant.
The browser keeps the cart in localStorage (vw-cart) as slugs and quantities, plus any pay-what-you-want amounts typed in the drawer (amounts, minor units by product:plan; the checkout page fills its price fields from them). A record written before these amounts existed reads the same. Prices always come from the server. When a direct ?add= link opens the checkout, its lines are added once to whatever was already in the cart. From then on, the checkout page is the cart. After a completed order, the cart is emptied.
The checkout page
In cart mode, a cart section sits at the top of the checkout. It lists every line with its unit price, a quantity field between "One fewer" and "One more" buttons, the line total and a Remove button. All of these are submit buttons tied to the checkout form, so they work without JavaScript: the server prices the page again. The client script changes quantities in place and updates the totals live in the existing aria-live totals region. With JS, "Remove" takes the line out in place and offers "Undo" (the last line still goes to the server, which shows the empty cart). A quantity above the stock left shows as a problem on its line, and a submit is refused with an error that links to that quantity.
Each line’s checkout questions appear in their own fieldset ("About Speed Kit Personal"). Their fields are posted as lq[<line>][<key>].
When the chosen payment method cannot take the cart, the cart section explains why and links to a separate checkout for each group of items. If no available payment method can take the cart, that explanation replaces the payment step.
Subscriptions in a cart
- A cart can hold one subscription line, at quantity 1, with any number of one-time lines.
- Stripe takes one-time lines with a subscription. The subscription renews at its own line’s price, minus its own share of a forever coupon, plus tax. The one-time lines are charged once, on the first invoice.
- PayPal, Razorpay and Polar cannot add one-time lines to a subscription. If the gateway would renew the subscription automatically, the cart is refused with
CART_SPLIT_REQUIRED, and the answer suggests a split. If the gateway would not renew it automatically anyway (renewals off for that gateway, for example), the cart is sold as is and the subscription renews by hand. - A cart with two subscriptions is refused on every gateway with
CART_TWO_SUBSCRIPTIONS. Two units of one subscription plan count as two subscriptions. - The checkout records how its subscription line renews (
renewalMode:auto,manualornone) when it starts. If the checkout promised automatic renewal (auto, orrenewal: "auto"sent by the page) and the provider subscription can no longer be set up, the start fails withAUTO_RENEW_UNAVAILABLEand nothing is charged. The checkout never falls back to a one-off payment. - Only the subscription line’s order item becomes the provider subscription. Term plans on the other lines keep their own local terms and renew by hand.
What the provider shows
- Stripe: one
line_itemsentry per cart line. Each line’s discount and tax are folded into its unit price, so the lines add up to the charged amount exactly. A line whose total does not divide by its quantity becomes one item named "name x n". If the lines do not add up, or Stripe cannot express a unit (three-decimal currencies), the provider gets the single summary line from 0.1.0. A subscription checkout adds the one-time lines asprice_dataitems on the first invoice. - PayPal:
purchase_units[0].items, withamount.breakdown.item_totalequal to the amount and the same folding and fallback. HUF, JPY and TWD units must be whole. - Razorpay: the payment link description lists every line after the order description, and a
vw_itemsnote carries the same list (capped at 256 characters). - Polar: this works as before: one private product priced at the total. For a cart, the label is "Order {amount}", so Polar reuses one product per amount and currency instead of creating one per combination of items. The merchant-of-record block (no tax, no coupon) still applies.
For a cart, the description is "{first item} and N more for {email}".
Emails, invoices and the portal
- The receipt lists every line with its quantity and line total ("2 x Speed Kit Personal: $98.00") and the total paid. When the emails module is on, the branded order table shows the quantity instead ("Speed Kit ยท Personal x 2"). A one-item receipt is unchanged.
- The abandoned-checkout email names the cart’s products, lists every line with its quantity and total, and links back to the whole cart (
?add=). Buying any of the cart’s products after the checkout started stops the email. - The order invoice, the portal order view and the admin order detail list every item with its quantity and amount.
Limits
- At most 20 lines, and a quantity of 1 to 100 per line. Requests outside these limits are refused with
VALIDATION_ERROR, never clamped. - Every line must be priced in the same currency, or the request is refused with
CART_MIXED_CURRENCY. - The rate limits of
checkout/quote(60 a minute per IP) and the checkout start (10 per IP) still apply.
API reference
checkout/quote (public)
Request: product and plan (with an optional quantity), or lines: [{ product, plan, quantity?, answers? }]. The top-level answers belong to the first line. Other fields are unchanged.
The response keeps every 0.1.0 field. product, plan and questions describe the first line. It adds:
items[]:lineId,product { id, slug, name, type },plan { id, slug, name, priceMinor, currency, billingPeriod, billingInterval, activationLimit, recurring },quantity,unitAmountMinor,amountMinor,discountMinor,taxMinor,totalMinor,subscription,questions,extras[], anddisplay { unit, amount, total }.cart:lines,units,block(refused with every gateway:{ code, message, split }),subscriptionLineId, andrenewals: [{ gateway, renewal: "auto" | "manual" | "none", block }]for each available gateway.gatewayErroralso carries the cart block for the chosengateway.
internal/checkout/page (companion)
The request takes the same lines as the quote, and quantity for the shorthand. Each entry in gateways[] adds renewal, and blocked also carries a cart block. The response adds items and cart as in the quote. extras[] entries carry lineId in a cart.
internal/checkout/start (companion)
The request takes the same lines, quantity for the shorthand, and renewal ("auto", "manual" or "none"): what the page told the buyer about renewal. A promised auto is never downgraded. New error codes: CART_MIXED_CURRENCY, CART_SPLIT_REQUIRED, CART_TWO_SUBSCRIPTIONS and AUTO_RENEW_UNAVAILABLE (409).
Records
CheckoutAnswer.lineIdis the line an answer belongs to (set only in a cart).CheckoutRecord.renewalModeis set at start when the checkout has a subscription line.- Order items carry
lineIdas in 0.2.0. Priced answers are stored on their own line’s item (fulfillmentData.extras).