213 lines
7.1 KiB
Markdown
213 lines
7.1 KiB
Markdown
|
|
# 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
|