Products, variants and options
The model
Section titled “The model”A product has a slug, a name, a description, bullet points, a type (physical
or digital), images, an optional tax code, optional SEO overrides, and a live
flag. A draft is editable but absent from the storefront.
A product has one or more variants. Each variant is a separately priced, separately stocked thing, and becomes a Stripe Price when the product is published. A variant carries:
priceCents, and an optionalcompareAtPriceCentsshown struck through beside it with a Sale badge. Compare-at is display only, never sent to Stripe, and never what checkout charges.inventory:infinite, orfinitewith a quantity.weightGrams, for weight-banded shipping. Zero means “not recorded”, which is fine for a flat-rate store and quietly matches the lightest band otherwise.- An optional
sku, unique across the catalogue when set. Beluga never looks a variant up by it; it exists for a warehouse or accounting system, and appears on order lines, the order CSV, theorder.paidwebhook, and asmetadata.skuon the Stripe Price. optionValues: one value per axis, in order.
Options are the priced axes: Size with Small and Large, Colour with
Blue and Red, up to three. Every variant names exactly one value on every axis,
and the label Large / Blue is generated from them on every save. A product with no
options has one variant with an empty label.
Option groups are the other kind of choice: gift wrap, a monogram. They do not
affect price or stock, and the buyer’s answers are recorded on the order line as
options. They are kept apart from the priced axes on purpose, because the two
are easy to confuse.
Per-variant images
Section titled “Per-variant images”An image can be pinned to one variant instead of the whole product, so picking a colour shows that colour’s picture first. Removing the variant does not delete the image; it falls back to the whole product.
The editor
Section titled “The editor”One autosaving form. The draft is local, it saves itself to Beluga’s database, and Publish is the only thing that ever writes to Stripe, so abandoning an edit halfway leaves nothing orphaned in a live account.
Display order is edited with buttons rather than drag-and-drop, because it is real persisted data and should be editable from a phone or a keyboard.
Publishing
Section titled “Publishing”POST /api/admin/products/:id/publish creates or updates the Stripe Product and one
Price per variant, and records the ids on the rows. Until a product is published, a
live product shows in the storefront but cannot be bought; the checkout refuses it
with a plain message to the shopper and a reason in the log, and the Overview lists
it.
Two rules follow from Stripe:
- Prices are immutable. Changing an amount mints a new Price and archives the old one, which is why historic orders still resolve.
- Test and live keys have separate catalogues. Publishing under a test key puts nothing in the live account. Going live republishes everything.
Tax behaviour is immutable on a Price too, so changing how the store quotes prices reaches Stripe only when each product is published again. Nothing republishes itself; the Overview lists what is stale.
Inventory
Section titled “Inventory”Stock lives in Beluga, not Stripe. Checkout checks it when building the session
and again, authoritatively, when the webhook confirms payment. If stock ran out in
between, the order is recorded and flagged oversold rather than silently dropped,
because the money has been taken. A digital variant is always unlimited and the API
refuses a finite count on one.
inventory.low fires as an outbound webhook when a sale
leaves a finite variant at five or fewer.
Deleting
Section titled “Deleting”Deletes go by id, the API refuses an unknown one, and the dialog names what is about to go. A miss is an error, never a different product.