berrypod/docs/plans/provider-sync-enhancements.md
jamey d3fe6f4b56 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 <noreply@anthropic.com>
2026-03-29 18:49:55 +01:00

7.1 KiB

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:

# 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:

# 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:

# variants schema
field :availability, :string, default: "available"
# "available", "out_of_stock", "discontinued"

Sync handling:

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:

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