diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl
index e042d65..f3c90b6 100644
--- a/assets/i18n/en/main.ftl
+++ b/assets/i18n/en/main.ftl
@@ -213,6 +213,8 @@ variants-options = Variants / options
add-option = Add option
option-label = Option label
optional = optional
+stock-untracked-hint = Leave blank = available without stock tracking
+available = Available
choose-option = Choose an option
from-price = from { $price }
admin-discounts = Discounts
diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl
index b107e58..57ac596 100644
--- a/assets/i18n/sk/main.ftl
+++ b/assets/i18n/sk/main.ftl
@@ -213,6 +213,8 @@ variants-options = Varianty / možnosti
add-option = Pridať možnosť
option-label = Označenie možnosti
optional = voliteľné
+stock-untracked-hint = Nechajte prázdne = dostupné bez sledovania zásob
+available = Dostupné
choose-option = Vyberte možnosť
from-price = od { $price }
admin-discounts = Zľavy
diff --git a/assets/views/admin/catalog/product_form.html b/assets/views/admin/catalog/product_form.html
index b72e162..5130279 100644
--- a/assets/views/admin/catalog/product_form.html
+++ b/assets/views/admin/catalog/product_form.html
@@ -37,10 +37,11 @@
{# --- Variants / options editor ------------------------------------------- #}
{# Each product is sold as one or more variants (a free-text label such as #}
- {# "10cm x 13cm" or "5ml" plus its own price/stock, optional sku & business #}
- {# price). Price and stock are required; the browser blocks save if a row is #}
- {# incomplete. Rows are managed client-side; names are indexed (variants[i][…]) #}
- {# and read back by the controller. #}
+ {# "10cm x 13cm" or "5ml" plus its own price). Price is required. Stock is #}
+ {# optional — leave it blank ("∞") to mark the option simply available (not #}
+ {# inventory-tracked). SKU and business price are optional too. Rows are #}
+ {# managed client-side; names are indexed (variants[i][…]) and read back by #}
+ {# the controller. #}
{% set opt = " (" ~ t(key="optional", lang=lang | default(value='sk')) ~ ")" %}
@@ -68,8 +69,8 @@
- {{ t(key="stock", lang=lang | default(value='sk')) }}
-
+ {{ t(key="stock", lang=lang | default(value='sk')) }}{{ opt }}
+
{{ t(key="price", lang=lang | default(value='sk')) }}
diff --git a/assets/views/shop/_card.html b/assets/views/shop/_card.html
index e7b81d4..6decfc9 100644
--- a/assets/views/shop/_card.html
+++ b/assets/views/shop/_card.html
@@ -32,8 +32,8 @@
{% if product.has_options %}
{# Multiple variants: customer must pick on the product page. #}
{{ ui::button(label=t(key="choose-option", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, extra="w-full") }}
- {% elif product.stock > 0 %}
-
{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}
+ {% elif product.in_stock %}
+
{% if product.tracked %}{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}{% else %}{{ t(key="available", lang=lang | default(value='sk')) }}{% endif %}
-
{{ t(key="in-stock", lang=lang | default(value='sk')) }}:
+
+
+ {{ t(key="in-stock", lang=lang | default(value='sk')) }}:
+
+
+ {{ t(key="available", lang=lang | default(value='sk')) }}
+
+
diff --git a/migration/src/lib.rs b/migration/src/lib.rs
index 276f8a4..67e00f4 100644
--- a/migration/src/lib.rs
+++ b/migration/src/lib.rs
@@ -41,6 +41,7 @@ mod m20260621_000003_discount_profiles;
mod m20260621_000004_add_business_sale_price_to_products;
mod m20260622_000001_audience_discount_profiles;
mod m20260622_000002_product_variants;
+mod m20260622_000003_variant_stock_nullable;
pub struct Migrator;
#[async_trait::async_trait]
@@ -86,6 +87,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260621_000004_add_business_sale_price_to_products::Migration),
Box::new(m20260622_000001_audience_discount_profiles::Migration),
Box::new(m20260622_000002_product_variants::Migration),
+ Box::new(m20260622_000003_variant_stock_nullable::Migration),
// inject-above (do not remove this comment)
]
}
diff --git a/migration/src/m20260622_000003_variant_stock_nullable.rs b/migration/src/m20260622_000003_variant_stock_nullable.rs
new file mode 100644
index 0000000..829ba9c
--- /dev/null
+++ b/migration/src/m20260622_000003_variant_stock_nullable.rs
@@ -0,0 +1,36 @@
+//! Make `product_variants.stock` nullable: a NULL stock means the variant is
+//! "available" but not inventory-tracked — always purchasable, no quantity cap,
+//! and never decremented on order. A numeric stock is tracked/capped as before.
+
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
+ m.get_connection()
+ .execute_unprepared(
+ r#"
+ ALTER TABLE product_variants ALTER COLUMN stock DROP DEFAULT;
+ ALTER TABLE product_variants ALTER COLUMN stock DROP NOT NULL;
+ "#,
+ )
+ .await?;
+ Ok(())
+ }
+
+ async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
+ m.get_connection()
+ .execute_unprepared(
+ r#"
+ UPDATE product_variants SET stock = 0 WHERE stock IS NULL;
+ ALTER TABLE product_variants ALTER COLUMN stock SET DEFAULT 0;
+ ALTER TABLE product_variants ALTER COLUMN stock SET NOT NULL;
+ "#,
+ )
+ .await?;
+ Ok(())
+ }
+}
diff --git a/src/controllers/admin_products.rs b/src/controllers/admin_products.rs
index 9022e54..3ff0ee7 100644
--- a/src/controllers/admin_products.rs
+++ b/src/controllers/admin_products.rs
@@ -102,7 +102,8 @@ struct VariantInput {
id: Option,
label: String,
sku: Option,
- stock: i32,
+ /// `None` = available but not inventory-tracked.
+ stock: Option,
price_cents: i64,
business_sale_cents: Option,
position: i32,
@@ -156,11 +157,17 @@ fn parse_variants(form: &MultipartForm) -> Result> {
}
let sku = form.text(&format!("variants[{i}][sku]"));
- let stock = form
- .text(&format!("variants[{i}][stock]"))
- .and_then(|s| s.parse::().ok())
- .filter(|n| *n >= 0)
- .ok_or_else(|| Error::BadRequest("each option needs a stock quantity".to_string()))?;
+ // Stock is optional: blank means "available, not tracked". A value must
+ // be a non-negative integer.
+ let stock = match form.text(&format!("variants[{i}][stock]")) {
+ None => None,
+ Some(raw) => Some(
+ raw.parse::()
+ .ok()
+ .filter(|n| *n >= 0)
+ .ok_or_else(|| Error::BadRequest("stock must be 0 or more".to_string()))?,
+ ),
+ };
let business_sale_cents = parse_optional_sale(form, i, "business_sale", price_cents)?;
let id = form
.text(&format!("variants[{i}][id]"))
@@ -315,12 +322,22 @@ async fn index(
let category_name = product
.category_id
.and_then(|id| category_name.get(&id).cloned());
- let total_stock: i32 = variants.iter().map(|v| v.stock).sum();
+ // Stock column: total across tracked variants, or "∞" when any option is
+ // untracked (always available).
+ let stock_display = if variants.iter().any(|v| !v.tracked()) {
+ "∞".to_string()
+ } else {
+ variants
+ .iter()
+ .filter_map(|v| v.stock)
+ .sum::()
+ .to_string()
+ };
rows.push(product_row(
product,
priced,
variants.len(),
- total_stock,
+ stock_display,
image,
category_name,
));
@@ -349,7 +366,7 @@ fn product_row(
product: &products::Model,
effective: &pricing::PricedProduct,
variant_count: usize,
- total_stock: i32,
+ stock_display: String,
image: Option,
category_name: Option,
) -> serde_json::Value {
@@ -358,7 +375,7 @@ fn product_row(
"name": product.name,
"slug": product.slug,
"currency": product.currency,
- "stock": total_stock,
+ "stock": stock_display,
"variant_count": variant_count,
"has_options": variant_count > 1,
"published": product.published,
diff --git a/src/controllers/cart.rs b/src/controllers/cart.rs
index a693b98..44a215a 100644
--- a/src/controllers/cart.rs
+++ b/src/controllers/cart.rs
@@ -97,9 +97,9 @@ async fn add(
let mut items = parse_cart(&jar);
let add_qty = form.quantity.unwrap_or(1).max(1);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
- entry.1 = (entry.1 + add_qty).min(variant.stock);
+ entry.1 = variant.cap(entry.1 + add_qty);
} else {
- items.push((variant.id, add_qty.min(variant.stock)));
+ items.push((variant.id, variant.cap(add_qty)));
}
items.retain(|(_, qty)| *qty > 0);
@@ -128,13 +128,14 @@ async fn update(
headers: HeaderMap,
Form(form): Form,
) -> Result {
- let stock = published_variant(&ctx, form.variant_id)
- .await?
- .map(|(v, _)| v.stock)
- .unwrap_or(0);
+ // Clamp the requested quantity to what's available (no cap for untracked
+ // variants); a removed variant clamps to 0 and drops out below.
+ let clamped = match published_variant(&ctx, form.variant_id).await? {
+ Some((variant, _)) => variant.cap(form.quantity),
+ None => 0,
+ };
let mut items = parse_cart(&jar);
- let clamped = form.quantity.clamp(0, stock);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) {
entry.1 = clamped;
}
@@ -208,7 +209,7 @@ pub(crate) async fn resolve_cart(
let Some((variant, product)) = published_variant(ctx, id).await? else {
continue;
};
- let qty = qty.clamp(0, variant.stock);
+ let qty = variant.cap(qty);
if qty == 0 {
continue;
}
diff --git a/src/models/_entities/product_variants.rs b/src/models/_entities/product_variants.rs
index 1596b92..1f87e1d 100644
--- a/src/models/_entities/product_variants.rs
+++ b/src/models/_entities/product_variants.rs
@@ -14,7 +14,7 @@ pub struct Model {
pub label: String,
pub position: i32,
pub sku: Option,
- pub stock: i32,
+ pub stock: Option,
pub price_cents: i64,
pub sale_price_cents: Option,
pub business_sale_price_cents: Option,
diff --git a/src/models/orders.rs b/src/models/orders.rs
index 875b33b..044b4c1 100644
--- a/src/models/orders.rs
+++ b/src/models/orders.rs
@@ -65,11 +65,15 @@ pub async fn place(
.one(&txn)
.await?
.ok_or_else(|| Error::BadRequest("a product is no longer available".to_string()))?;
- if variant.stock < *qty {
- return Err(Error::BadRequest(format!(
- "not enough stock for {}",
- product.name
- )));
+ // Tracked variants can't oversell; untracked ones (stock = None) are
+ // always available and never decremented.
+ if let Some(on_hand) = variant.stock {
+ if on_hand < *qty {
+ return Err(Error::BadRequest(format!(
+ "not enough stock for {}",
+ product.name
+ )));
+ }
}
currency = product.currency.clone();
// Snapshot the price the buyer actually pays — public sale or, for a
@@ -78,9 +82,11 @@ pub async fn place(
let unit_price_cents = pricing::price_variant(ctx, &variant, user).await?.price_cents;
subtotal += unit_price_cents * i64::from(*qty);
- let mut active = variant.clone().into_active_model();
- active.stock = Set(variant.stock - *qty);
- active.update(&txn).await?;
+ if let Some(on_hand) = variant.stock {
+ let mut active = variant.clone().into_active_model();
+ active.stock = Set(Some(on_hand - *qty));
+ active.update(&txn).await?;
+ }
snapshots.push((product.id, variant.id, product.name, variant.label, unit_price_cents, *qty));
}
diff --git a/src/models/product_variants.rs b/src/models/product_variants.rs
index 22c8001..9710ad6 100644
--- a/src/models/product_variants.rs
+++ b/src/models/product_variants.rs
@@ -45,6 +45,30 @@ impl Model {
pub fn business_on_sale(&self) -> bool {
matches!(self.business_sale_price_cents, Some(sale) if sale < self.price_cents)
}
+
+ /// Whether the variant's inventory is tracked. A `None` stock means
+ /// "available, not tracked" (always purchasable, unlimited).
+ #[must_use]
+ pub fn tracked(&self) -> bool {
+ self.stock.is_some()
+ }
+
+ /// Whether the variant can currently be bought: untracked variants are always
+ /// available; tracked ones need a positive quantity on hand.
+ #[must_use]
+ pub fn in_stock(&self) -> bool {
+ self.stock.map_or(true, |s| s > 0)
+ }
+
+ /// Clamp a desired quantity to what's available: capped at the tracked stock,
+ /// or left as-is (only floored at 0) when untracked.
+ #[must_use]
+ pub fn cap(&self, qty: i32) -> i32 {
+ match self.stock {
+ Some(s) => qty.clamp(0, s),
+ None => qty.max(0),
+ }
+ }
}
// implement your write-oriented logic here
diff --git a/src/seed.rs b/src/seed.rs
index f622871..0c5f2ed 100644
--- a/src/seed.rs
+++ b/src/seed.rs
@@ -177,7 +177,7 @@ pub async fn seed_catalog(ctx: &AppContext) -> Result<()> {
label: Set(String::new()),
position: Set(0),
sku: Set(item.sku.map(|s| s.to_string())),
- stock: Set(item.stock),
+ stock: Set(Some(item.stock)),
price_cents: Set(item.price_cents),
..Default::default()
}
diff --git a/src/views/shop.rs b/src/views/shop.rs
index bbf09d9..a946ac0 100644
--- a/src/views/shop.rs
+++ b/src/views/shop.rs
@@ -34,6 +34,8 @@ pub fn product_card(
"currency": product.currency,
"sku": representative.sku,
"stock": representative.stock,
+ "tracked": representative.tracked(),
+ "in_stock": representative.in_stock(),
"variant_count": variant_count,
"has_options": variant_count > 1,
"published": product.published,
@@ -49,7 +51,8 @@ pub fn variant_option(variant: &product_variants::Model, priced: &PricedProduct)
"label": variant.label,
"sku": variant.sku,
"stock": variant.stock,
- "in_stock": variant.stock > 0,
+ "tracked": variant.tracked(),
+ "in_stock": variant.in_stock(),
"price": format_price(priced.price_cents),
"on_sale": priced.is_reduced(),
"regular_price": format_price(priced.regular_cents),