Skip to content

Products, variants and options

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 optional compareAtPriceCents shown struck through beside it with a Sale badge. Compare-at is display only, never sent to Stripe, and never what checkout charges.
  • inventory: infinite, or finite with 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, the order.paid webhook, and as metadata.sku on 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.

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.

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.

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.

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.

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.

Images, Digital products, Catalogue CSV, Tax.