From d3fe6f4b5627407f8ace92f3f2777cd571d9d6ef Mon Sep 17 00:00:00 2001
From: jamey
Date: Sun, 29 Mar 2026 18:49:55 +0100
Subject: [PATCH] add provider sync enhancements for product lifecycle
- add discontinued status to products (soft-delete when removed from provider)
- add availability helpers to variants (available/out_of_stock/discontinued)
- add detailed sync audit logging (product created/updated/discontinued)
- add cost change detection with threshold alerts (5% warning, 20% critical)
- update cart to show unavailable items with appropriate messaging
- block checkout when cart contains unavailable items
- show discontinued badge on product pages
Co-Authored-By: Claude Opus 4.5
---
assets/css/theme-layer2-attributes.css | 5 +
docs/plans/provider-sync-enhancements.md | 212 ++++++++++++++++++
lib/berrypod/cart.ex | 3 +-
lib/berrypod/products.ex | 149 +++++++++++-
lib/berrypod/products/product.ex | 17 +-
lib/berrypod/products/product_variant.ex | 21 ++
lib/berrypod/sync/product_sync_worker.ex | 34 ++-
.../components/shop_components/cart.ex | 39 +++-
.../components/shop_components/product.ex | 2 +
lib/berrypod_web/live/shop/pages/product.ex | 12 +-
10 files changed, 483 insertions(+), 11 deletions(-)
create mode 100644 docs/plans/provider-sync-enhancements.md
diff --git a/assets/css/theme-layer2-attributes.css b/assets/css/theme-layer2-attributes.css
index 9254010..c87a534 100644
--- a/assets/css/theme-layer2-attributes.css
+++ b/assets/css/theme-layer2-attributes.css
@@ -343,6 +343,11 @@
color: #ffffff;
}
+.badge-discontinued {
+ background-color: var(--t-text-tertiary, #737373);
+ color: #ffffff;
+}
+
/* Product Card Images — mobile: swipe, desktop: hover crossfade */
.product-card-image-wrap {
position: relative;
diff --git a/docs/plans/provider-sync-enhancements.md b/docs/plans/provider-sync-enhancements.md
new file mode 100644
index 0000000..65173be
--- /dev/null
+++ b/docs/plans/provider-sync-enhancements.md
@@ -0,0 +1,212 @@
+# Provider Sync Enhancements
+
+> Status: Planned
+> Tier: 3.5 (Business tools)
+
+Small improvements to provider sync for better visibility and resilience. Not versioning products (overkill), just better audit trail and handling of edge cases.
+
+---
+
+## Overview
+
+Three enhancements to the provider sync system:
+
+1. **Detailed audit logging** — Field-level change tracking, not just "3 updated"
+2. **Soft-delete for discontinued products** — Don't break carts/orders/links
+3. **Variant-level availability** — Handle out of stock / discontinued variants
+4. **Price change alerts** — Warn when margins might be affected
+
+---
+
+## 1. Detailed sync audit logging
+
+Currently `sync.completed` logs "47 products, 3 updated" but doesn't capture *what* changed. Enhance to log field-level changes.
+
+**Implementation:**
+```elixir
+# In product sync, before update
+def sync_product(product, provider_data) do
+ changes = diff_product(product, provider_data)
+
+ if changes != %{} do
+ ActivityLog.log_event("sync.product_updated",
+ "#{product.title}: #{summarize_changes(changes)}",
+ payload: %{product_id: product.id, changes: changes}
+ )
+ end
+
+ update_product(product, provider_data)
+end
+
+defp diff_product(current, new) do
+ fields = [:title, :description, :base_price, :status]
+ Enum.reduce(fields, %{}, fn field, acc ->
+ old_val = Map.get(current, field)
+ new_val = Map.get(new, field)
+ if old_val != new_val, do: Map.put(acc, field, {old_val, new_val}), else: acc
+ end)
+end
+
+defp summarize_changes(changes) do
+ changes
+ |> Map.keys()
+ |> Enum.map(&to_string/1)
+ |> Enum.join(", ")
+ # => "title, base_price"
+end
+```
+
+**Activity log entries:**
+- `sync.product_updated` — "Awesome T-Shirt: title, base_price changed"
+- `sync.product_added` — "New product: Cool Hoodie"
+- `sync.product_removed` — "Product removed: Old Mug" (if provider removes it)
+
+---
+
+## 2. Soft-delete for discontinued products
+
+When a product disappears from the provider catalogue (sync returns 404 or product not in list), don't hard-delete it. Set `status: "discontinued"` instead.
+
+**Why:**
+- Existing carts might have this product
+- Order history references it
+- Links from external sites won't 404 immediately
+
+**Implementation:**
+```elixir
+# products schema
+field :status, :string, default: "active" # "active", "discontinued", "draft"
+
+# In sync
+def handle_missing_product(product) do
+ Products.update_product(product, %{status: "discontinued"})
+ ActivityLog.log_event("sync.product_discontinued",
+ "#{product.title} no longer available from provider",
+ level: "warning",
+ payload: %{product_id: product.id}
+ )
+end
+```
+
+**UI behaviour:**
+- Discontinued products hidden from shop browse/search
+- Product page shows "This product is no longer available"
+- Cart shows warning if item is discontinued
+- Admin can see discontinued products in a filtered view
+
+---
+
+## 2b. Variant-level availability
+
+Variants can go out of stock or be discontinued independently of the product. This matters because:
+- A customer may have "Blue / Large" in their cart when that specific variant becomes unavailable
+- The product is still active, just that combination isn't
+
+**Variant statuses:**
+```elixir
+# variants schema
+field :availability, :string, default: "available"
+# "available", "out_of_stock", "discontinued"
+```
+
+**Sync handling:**
+```elixir
+def sync_variant(variant, provider_data) do
+ new_availability = case provider_data do
+ %{is_available: false, is_enabled: false} -> "discontinued"
+ %{is_available: false} -> "out_of_stock"
+ %{is_available: true} -> "available"
+ end
+
+ if variant.availability != new_availability do
+ Products.update_variant(variant, %{availability: new_availability})
+
+ if new_availability in ["out_of_stock", "discontinued"] do
+ ActivityLog.log_event("sync.variant_unavailable",
+ "#{product.title} (#{variant.title}): now #{new_availability}",
+ level: "warning",
+ payload: %{variant_id: variant.id, product_id: variant.product_id}
+ )
+ end
+ end
+end
+```
+
+**Cart impact:**
+- On cart load: check all variants still available
+- If out of stock: show warning "This size/colour is currently out of stock"
+- If discontinued: show warning "This option is no longer available"
+- Prevent checkout if cart contains unavailable variants (or offer to remove them)
+
+**Product page:**
+- Out of stock variants: show crossed out or greyed, not selectable
+- Discontinued variants: hide entirely from selector
+- If all variants unavailable: show "Currently unavailable" on product
+
+---
+
+## 3. Price change alerts
+
+Log a warning when a product's cost basis changes significantly (>5% or configurable threshold). Helps shop owner know when their margins might be affected.
+
+**Implementation:**
+```elixir
+def check_price_change(product, new_cost) do
+ old_cost = product.base_cost
+ return if old_cost == nil or old_cost == 0
+
+ change_pct = abs((new_cost - old_cost) / old_cost * 100)
+
+ if change_pct > 5 do
+ ActivityLog.log_event("sync.price_change",
+ "#{product.title}: cost changed from £#{old_cost} to £#{new_cost} (#{round(change_pct)}%)",
+ level: if(change_pct > 20, do: "warning", else: "info"),
+ payload: %{product_id: product.id, old_cost: old_cost, new_cost: new_cost, change_pct: change_pct}
+ )
+ end
+end
+```
+
+---
+
+## Files to Modify
+
+| File | Changes |
+|------|---------|
+| `lib/berrypod/products/product.ex` | Add `status` field if not present |
+| `lib/berrypod/products/variant.ex` | Add `availability` field |
+| `lib/berrypod/providers/printify.ex` | Add diff logic, price change check, soft-delete handling, variant availability sync |
+| `lib/berrypod/providers/printful.ex` | Same as above |
+| `lib/berrypod/products.ex` | Query helpers for discontinued products/variants |
+| `lib/berrypod_web/live/shop/pages/product_page.ex` | Handle discontinued status, variant availability display |
+| `lib/berrypod_web/components/shop_components/cart.ex` | Warn on discontinued/unavailable items |
+| `lib/berrypod_web/components/shop_components/product.ex` | Grey out/hide unavailable variants in selector |
+
+---
+
+## Tasks
+
+| # | Task | Est |
+|---|------|-----|
+| 1 | Add `status` field to products, `availability` field to variants | 30m |
+| 2 | Product discontinued handling in sync (soft-delete) | 45m |
+| 3 | Variant availability sync (out of stock / discontinued) | 45m |
+| 4 | Detailed sync audit logging (field-level diff) | 1h |
+| 5 | Price change alerts with threshold | 30m |
+| 6 | Product page UI: discontinued products, variant availability display | 1h |
+| 7 | Cart UI: warn on discontinued/unavailable items, prevent checkout | 1h |
+| **Total** | | **5.5h** |
+
+---
+
+## Verification
+
+1. Manually trigger a sync
+2. Check activity log shows field-level changes
+3. Remove a product from provider (or mock it) — verify soft-delete
+4. Change a product's cost — verify price change alert appears
+5. Try to add discontinued product to cart — verify warning
+6. Mark a variant as out of stock — verify greyed out on product page
+7. Mark a variant as discontinued — verify hidden from selector
+8. Add item to cart, then mark its variant unavailable — verify cart warning
+9. Try to checkout with unavailable variant — verify blocked or prompted to remove
diff --git a/lib/berrypod/cart.ex b/lib/berrypod/cart.ex
index 76a5217..62c1c2c 100644
--- a/lib/berrypod/cart.ex
+++ b/lib/berrypod/cart.ex
@@ -133,7 +133,8 @@ defmodule Berrypod.Cart do
price: variant.price,
quantity: quantity,
image: variant_image_url(variant.product),
- is_available: variant.is_available
+ is_available: variant.is_available,
+ product_discontinued: variant.product.status == "discontinued"
}
end
end)
diff --git a/lib/berrypod/products.ex b/lib/berrypod/products.ex
index b8fea26..2497094 100644
--- a/lib/berrypod/products.ex
+++ b/lib/berrypod/products.ex
@@ -173,6 +173,19 @@ defmodule Berrypod.Products do
|> Repo.one()
end
+ @doc """
+ Returns active (non-discontinued) products for a provider connection.
+
+ Used by product sync to detect products that are no longer returned by the provider.
+ """
+ def list_active_products_for_connection(connection_id) do
+ from(p in Product,
+ where: p.provider_connection_id == ^connection_id and p.status != "discontinued",
+ select: [:id, :provider_product_id, :title, :status]
+ )
+ |> Repo.all()
+ end
+
@doc """
Enqueues a product sync job for the given provider connection.
Returns `{:ok, job}` or `{:error, changeset}`.
@@ -615,6 +628,9 @@ defmodule Berrypod.Products do
product ->
old_slug = product.slug
+ # Compute field-level diff before update
+ changes = compute_product_diff(product, attrs)
+
case update_product(product, attrs) do
{:ok, updated} ->
if old_slug != updated.slug do
@@ -625,6 +641,11 @@ defmodule Berrypod.Products do
})
end
+ # Log field-level changes
+ if changes != %{} do
+ log_product_updated(updated, changes)
+ end
+
{:ok, updated, :updated}
error ->
@@ -633,6 +654,44 @@ defmodule Berrypod.Products do
end
end
+ # Compute diff between existing product and new attributes
+ defp compute_product_diff(product, attrs) do
+ fields = [:title, :description, :category]
+
+ Enum.reduce(fields, %{}, fn field, acc ->
+ old_val = Map.get(product, field)
+ new_val = attrs[field] || attrs[to_string(field)]
+
+ if old_val != new_val and not is_nil(new_val) do
+ Map.put(acc, field, {old_val, new_val})
+ else
+ acc
+ end
+ end)
+ end
+
+ defp log_product_updated(product, changes) do
+ changed_fields =
+ changes
+ |> Map.keys()
+ |> Enum.map(&to_string/1)
+ |> Enum.join(", ")
+
+ Berrypod.ActivityLog.log_event(
+ "sync.product_updated",
+ "#{product.title}: #{changed_fields} changed",
+ payload: %{product_id: product.id, changes: changes}
+ )
+ end
+
+ defp log_product_created(product) do
+ Berrypod.ActivityLog.log_event(
+ "sync.product_added",
+ "New product: #{product.title}",
+ payload: %{product_id: product.id, title: product.title}
+ )
+ end
+
# If product exists with same slug, update it (including new provider_product_id)
# Otherwise insert new product
defp find_by_slug_or_insert(conn_id, slug, attrs, new_checksum) do
@@ -679,6 +738,7 @@ defmodule Berrypod.Products do
defp do_insert_product(attrs) do
case create_product(attrs) do
{:ok, product} ->
+ log_product_created(product)
{:ok, product, :created}
{:error, %Ecto.Changeset{errors: errors} = changeset} ->
@@ -987,7 +1047,7 @@ defmodule Berrypod.Products do
|> Repo.delete_all()
end
- # Upsert incoming variants
+ # Upsert incoming variants, tracking availability changes
Enum.map(variants, fn variant_data ->
provider_variant_id =
variant_data[:provider_variant_id] || variant_data["provider_variant_id"]
@@ -999,11 +1059,96 @@ defmodule Berrypod.Products do
create_product_variant(attrs)
existing ->
- update_product_variant(existing, attrs)
+ # Check for availability changes before update
+ old_availability = ProductVariant.availability(existing)
+
+ new_availability =
+ cond do
+ attrs[:is_available] == true and attrs[:is_enabled] == true -> :available
+ attrs[:is_available] == false and attrs[:is_enabled] == true -> :out_of_stock
+ attrs[:is_enabled] == false -> :discontinued
+ true -> :available
+ end
+
+ # Check for cost changes before update
+ old_cost = existing.cost
+ new_cost = attrs[:cost]
+
+ result = update_product_variant(existing, attrs)
+
+ # Log if availability changed to a worse state
+ if old_availability == :available and new_availability != :available do
+ log_variant_availability_change(existing, product_id, new_availability)
+ end
+
+ # Log significant cost changes (>5%)
+ if old_cost && new_cost && old_cost > 0 do
+ check_cost_change(existing, product_id, old_cost, new_cost)
+ end
+
+ result
end
end)
end
+ # Cost change threshold (5%)
+ @cost_change_threshold 5
+
+ defp check_cost_change(variant, product_id, old_cost, new_cost) do
+ change_pct = abs((new_cost - old_cost) / old_cost * 100)
+
+ if change_pct > @cost_change_threshold do
+ # Load the product title for the log message
+ product = Repo.get(Product, product_id)
+ product_title = if product, do: product.title, else: "Unknown product"
+
+ level = if change_pct > 20, do: "warning", else: "info"
+
+ # Format costs as currency (they're stored in pence)
+ old_formatted = format_cost(old_cost)
+ new_formatted = format_cost(new_cost)
+
+ Berrypod.ActivityLog.log_event(
+ "sync.price_change",
+ "#{product_title} (#{variant.title}): cost #{old_formatted} → #{new_formatted} (#{round(change_pct)}%)",
+ level: level,
+ payload: %{
+ variant_id: variant.id,
+ product_id: product_id,
+ variant_title: variant.title,
+ old_cost: old_cost,
+ new_cost: new_cost,
+ change_pct: Float.round(change_pct, 1)
+ }
+ )
+ end
+ end
+
+ defp format_cost(cost_in_pence) do
+ pounds = cost_in_pence / 100
+ "£#{:erlang.float_to_binary(pounds, decimals: 2)}"
+ end
+
+ defp log_variant_availability_change(variant, product_id, new_availability) do
+ # Load the product title for the log message
+ product = Repo.get(Product, product_id)
+ product_title = if product, do: product.title, else: "Unknown product"
+
+ status_text = if new_availability == :out_of_stock, do: "out of stock", else: "discontinued"
+
+ Berrypod.ActivityLog.log_event(
+ "sync.variant_unavailable",
+ "#{product_title} (#{variant.title}): now #{status_text}",
+ level: "warning",
+ payload: %{
+ variant_id: variant.id,
+ product_id: product_id,
+ variant_title: variant.title,
+ new_availability: new_availability
+ }
+ )
+ end
+
# =============================================================================
# Private Helpers
# =============================================================================
diff --git a/lib/berrypod/products/product.ex b/lib/berrypod/products/product.ex
index 4cdb7df..fc92008 100644
--- a/lib/berrypod/products/product.ex
+++ b/lib/berrypod/products/product.ex
@@ -12,7 +12,7 @@ defmodule Berrypod.Products.Product do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
- @statuses ~w(active draft archived)
+ @statuses ~w(active draft archived discontinued)
schema "products" do
field :provider_product_id, :string
@@ -43,6 +43,21 @@ defmodule Berrypod.Products.Product do
"""
def statuses, do: @statuses
+ @doc """
+ Returns true if the product is discontinued (no longer available from provider).
+ """
+ def discontinued?(%__MODULE__{status: "discontinued"}), do: true
+ def discontinued?(_), do: false
+
+ @doc """
+ Returns true if the product can be purchased.
+ """
+ def purchasable?(%__MODULE__{status: status, visible: visible}) do
+ status == "active" and visible == true
+ end
+
+ def purchasable?(_), do: false
+
@doc """
Changeset for creating or updating a product.
"""
diff --git a/lib/berrypod/products/product_variant.ex b/lib/berrypod/products/product_variant.ex
index e337066..d17f524 100644
--- a/lib/berrypod/products/product_variant.ex
+++ b/lib/berrypod/products/product_variant.ex
@@ -96,4 +96,25 @@ defmodule Berrypod.Products.ProductVariant do
end
def options_title(_), do: nil
+
+ @doc """
+ Returns the availability status of the variant.
+
+ - `:available` — in stock and enabled
+ - `:out_of_stock` — not in stock but still enabled (may come back)
+ - `:discontinued` — disabled by provider (won't come back)
+ """
+ def availability(%__MODULE__{is_available: true, is_enabled: true}), do: :available
+ def availability(%__MODULE__{is_available: false, is_enabled: true}), do: :out_of_stock
+ def availability(%__MODULE__{is_enabled: false}), do: :discontinued
+ def availability(_), do: :available
+
+ @doc """
+ Returns true if the variant can be purchased.
+ """
+ def purchasable?(%__MODULE__{} = variant) do
+ availability(variant) == :available
+ end
+
+ def purchasable?(_), do: false
end
diff --git a/lib/berrypod/sync/product_sync_worker.ex b/lib/berrypod/sync/product_sync_worker.ex
index bda8bc6..7be3f11 100644
--- a/lib/berrypod/sync/product_sync_worker.ex
+++ b/lib/berrypod/sync/product_sync_worker.ex
@@ -92,6 +92,9 @@ defmodule Berrypod.Sync.ProductSyncWorker do
results = sync_all_products(conn, products)
+ # Mark products not returned by provider as discontinued
+ discontinued = mark_discontinued_products(conn, products)
+
created = Enum.count(results, &match?({:ok, _, :created}, &1))
updated = Enum.count(results, &match?({:ok, _, :updated}, &1))
unchanged = Enum.count(results, &match?({:ok, _, :unchanged}, &1))
@@ -99,17 +102,20 @@ defmodule Berrypod.Sync.ProductSyncWorker do
Logger.info(
"Product sync complete for #{conn.provider_type}: " <>
- "#{created} created, #{updated} updated, #{unchanged} unchanged, #{errors} errors"
+ "#{created} created, #{updated} updated, #{unchanged} unchanged, " <>
+ "#{discontinued} discontinued, #{errors} errors"
)
ActivityLog.log_event(
"sync.completed",
- "Product sync complete — #{created + updated + unchanged} products, #{created} created, #{updated} updated",
+ "Product sync complete — #{created + updated + unchanged} products, #{created} created, #{updated} updated" <>
+ if(discontinued > 0, do: ", #{discontinued} discontinued", else: ""),
payload: %{
provider_type: conn.provider_type,
created: created,
updated: updated,
unchanged: unchanged,
+ discontinued: discontinued,
errors: errors
}
)
@@ -168,6 +174,30 @@ defmodule Berrypod.Sync.ProductSyncWorker do
end)
end
+ # Mark products that exist locally but weren't returned by the provider as discontinued.
+ # Returns the count of products that were discontinued.
+ defp mark_discontinued_products(conn, products) do
+ # Get provider_product_ids from the fetched products
+ fetched_ids = MapSet.new(products, & &1[:provider_product_id])
+
+ # Find local active products not in the fetched list
+ Products.list_active_products_for_connection(conn.id)
+ |> Enum.reject(fn product -> MapSet.member?(fetched_ids, product.provider_product_id) end)
+ |> Enum.map(fn product ->
+ Logger.info("Marking product #{product.title} as discontinued (no longer in provider)")
+
+ ActivityLog.log_event(
+ "sync.product_discontinued",
+ "#{product.title} no longer available from provider",
+ level: "warning",
+ payload: %{product_id: product.id, title: product.title}
+ )
+
+ Products.update_product(product, %{status: "discontinued"})
+ end)
+ |> length()
+ end
+
defp sync_single_product(conn, product_data) do
case sync_product(conn, product_data) do
{:ok, product, status} ->
diff --git a/lib/berrypod_web/components/shop_components/cart.ex b/lib/berrypod_web/components/shop_components/cart.ex
index 9f47ecb..37265e5 100644
--- a/lib/berrypod_web/components/shop_components/cart.ex
+++ b/lib/berrypod_web/components/shop_components/cart.ex
@@ -45,10 +45,18 @@ defmodule BerrypodWeb.ShopComponents.Cart do
attr :stripe_connected, :boolean, default: true
def cart_drawer(assigns) do
+ # Check if any cart items are unavailable (out of stock or discontinued)
+ has_unavailable_items =
+ Enum.any?(assigns.cart_items, fn item ->
+ Map.get(item, :product_discontinued) == true or Map.get(item, :is_available) == false
+ end)
+
assigns =
- assign_new(assigns, :display_total, fn ->
+ assigns
+ |> assign_new(:display_total, fn ->
assigns.total || assigns.subtotal || "£0.00"
end)
+ |> assign(:has_unavailable_items, has_unavailable_items)
~H"""
<%!-- Screen reader announcements for cart changes --%>
@@ -142,6 +150,13 @@ defmodule BerrypodWeb.ShopComponents.Cart do
Checkout
Checkout isn't available yet.
+ <% @has_unavailable_items -> %>
+
+
+ Remove unavailable items to checkout.
+
<% true -> %>
<% end %>
-
- This item is currently unavailable
+
+ This product is no longer available
+
+
+ This option is currently out of stock
@@ -457,6 +478,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
attr :available_countries, :list, default: []
attr :mode, :atom, default: :live
attr :stripe_connected, :boolean, default: true
+ attr :has_unavailable_items, :boolean, default: false
def order_summary(assigns) do
assigns =
@@ -516,6 +538,17 @@ defmodule BerrypodWeb.ShopComponents.Cart do
>
Continue shopping
+ <% @has_unavailable_items -> %>
+ <.shop_button disabled class="order-summary-checkout">
+ Checkout
+
+
Remove unavailable items to checkout.
+ <.shop_link_outline
+ href="/collections/all"
+ class="order-summary-continue"
+ >
+ Continue shopping
+
<% true -> %>