# Profit-aware pricing, tax & sales

> Status: Planned
> Tasks: #63–71 in PROGRESS.md
> Tier: 3.5 (Business tools)

## Goal

Shop owners always know exactly what they're making on every sale. No hidden costs, no surprises. The system shows the full cost breakdown (provider cost, shipping, Stripe fees, tax) and prevents selling at a loss.

Sales replace discount codes — transparent, visible, same price for everyone. No dark patterns.

## Design principles

- **Default is simple.** Most small POD sellers aren't VAT registered. By default there's no tax — prices are prices and all revenue is profit.
- **No surprises at checkout.** Tax-inclusive pricing is the default (covers UK, EU, AU, NZ, Japan — most of the world). US/CA sellers can opt into tax-exclusive display.
- **Margin guard.** The system prevents shop owners from accidentally selling at a loss — whether through pricing or discounts.
- **No discount codes.** The empty "enter code" box at checkout is a dark pattern. Sales are visible, transparent, and the same for everyone.

## Stripe does the heavy lifting

Many features in this plan can be handled by Stripe rather than reimplemented in Berrypod. The rule: let Stripe do what Stripe is good at, keep in Berrypod what requires knowledge of our costs.

| Feature | Who handles it | Why |
|---------|---------------|-----|
| Tax calculation at checkout | **Stripe Tax** | Automatic correct rates for 50+ countries. Rules change constantly — Stripe keeps them current. One line of config: `automatic_tax: %{enabled: true}` on the Checkout session. Costs 0.5% per transaction (~12.5p on a £25 sale). Far cheaper than building and maintaining tax tables ourselves. |
| Tax display at checkout | **Stripe Tax** | Stripe Checkout shows the tax line automatically when Stripe Tax is enabled. |
| Tax registration management | **Stripe Dashboard** | Shop owner registers their VAT/GST number in Stripe, not in Berrypod. Stripe handles the jurisdiction logic. |
| Exact Stripe fees per order | **Balance Transaction API** | After payment, fetch the real fee from `stripe_charge.balance_transaction`. No estimation needed — get the actual number. |
| "inc. VAT" on shop pages | **Berrypod** | Stripe only acts at checkout. We still need to show tax-inclusive labelling on product/cart pages before checkout. |
| Profit calculation | **Berrypod** | Stripe doesn't know our provider costs. |
| Margin warnings & guards | **Berrypod** | Stripe can't help here. |
| Sales & promotions | **Berrypod** | Stripe has coupons/promo codes but those are the dark pattern we're avoiding. Our sales are transparent price changes, not codes. |
| Price editor | **Berrypod** | Internal business intelligence — Stripe can't help. |

**Net effect on the plan:**
- **#65 (Stripe fees)** — simplified. Fetch real fee from Balance Transaction API after payment. No estimation, no configurable rate tables.
- **#66 (Tax)** — simplified. Configure tax registration in Stripe Dashboard. Add `automatic_tax` to Checkout session. We only need a "registered" toggle in Berrypod settings to drive the shop-page "inc. VAT" display and the profit calculation.
- **No country-specific tax rate tables** needed in Berrypod at all.

## What we already have

| Data point | Source | Status |
|-----------|--------|--------|
| Variant selling price | `product_variants.price` | Synced from both providers |
| Variant cost (Printify) | `product_variants.cost` | Synced directly from API (`var["cost"]`) |
| Variant cost (Printful) | `product_variants.cost` | Always `nil` — sync API doesn't include cost |
| Price snapshot at order | `order_items.unit_price` | Captured at order creation |
| Shipping rates | `shipping_rates` table | Provider shipping costs per country/blueprint |
| `ProductVariant.profit/1` | Helper function | Exists but unused anywhere |
| `compare_at_price` | `product_variants.compare_at_price` | Synced from providers (strikethrough price) |

## What's missing

| Data point | Where needed | Notes |
|-----------|-------------|-------|
| Printful variant cost | `product_variants.cost` | Need catalog API cross-reference during sync |
| Cost snapshot on orders | `order_items.unit_cost` | New field — snapshot at order creation |
| Order-level cost totals | `orders.total_cost`, `orders.gross_profit` | New fields — calculated from items |
| Exact Stripe fee per order | `orders.stripe_fee` | Fetch from Balance Transaction API post-payment |
| Tax registration toggle | Settings | Drives "inc. VAT" display and profit calculations |
| Shop country | Settings | Drives tax display mode defaults (inclusive vs exclusive) |
| Sales/promotions | New `sales` table | Scoped discounts with date ranges |
| Minimum margin threshold | Settings | Prevents pricing/discounts below floor |

---

## Task breakdown

### #63 — Fix Printful cost sync (45m)

**Problem:** Printful's `GET /store/products/{id}` response includes `retail_price` (the seller's price) but not the production cost. Printify includes `cost` directly in the variant data.

**Solution:** Printful's catalog API (`GET /products/{catalog_product_id}/variants`) returns the base cost per variant. During sync, cross-reference catalog variant data to populate `cost`.

**Files:**
- `lib/berrypod/providers/printful.ex` — `normalize_variant/1` currently hardcodes `cost: nil`
- Already have `catalog_product_id` from the sync response (`sv["product"]["product_id"]`)
- Already fetch catalog data for colour hex codes — extend this to grab cost too

**Approach:**
- In `sync_product/2`, after fetching the store product, also fetch catalog variants
- Build a lookup map: `catalog_variant_id => cost`
- In `normalize_variant/1`, look up cost from the catalog map
- Parse cost from catalog `price` field (this is what Printful charges the seller)

**Tests:**
- Update Printful provider test stubs to include catalog variant cost data
- Assert `cost` is populated on synced Printful variants

---

### #64 — Cost snapshot on orders (1.5h)

**Migration:**
```elixir
alter table(:order_items) do
  add :unit_cost, :integer  # provider cost snapshot in minor units (pence/cents)
end

alter table(:orders) do
  add :total_cost, :integer     # sum of (unit_cost * qty) for all items
  add :gross_profit, :integer   # subtotal - total_cost - shipping_cost (before fees/tax)
end
```

**Order creation changes:**
- In `Orders.create_order/1`, look up `ProductVariant.cost` for each item
- Snapshot as `unit_cost` on each `OrderItem`
- Calculate `total_cost` = sum of `unit_cost * quantity` across items
- Calculate `gross_profit` = `subtotal - total_cost`
- If variant cost is `nil` (legacy data or missing), store `nil` — don't guess

**Post-payment update:**
- After Stripe webhook updates `shipping_cost`, recalculate: `gross_profit = subtotal - total_cost - shipping_cost`

**Files:**
- New migration
- `lib/berrypod/orders/order.ex` — add fields to schema
- `lib/berrypod/orders/order_item.ex` — add `unit_cost` field
- `lib/berrypod/orders.ex` — `create_order/1` enrichment logic

**Tests:**
- Order creation with cost snapshots
- Order with nil cost (Printful legacy, unknown cost)
- Gross profit calculation with and without shipping

---

### #65 — Exact Stripe fees (45m)

Rather than estimating Stripe fees from configurable rate tables, fetch the real fee from Stripe's Balance Transaction API after payment.

**How:**
- After `checkout.session.completed` webhook, the payment intent has a charge
- Fetch `stripe_charge.balance_transaction` to get the exact fee
- Stripity Stripe: `Stripe.BalanceTransaction.retrieve(balance_transaction_id)`
- Returns `%{fee: integer}` in the same currency/minor units

**New field:**
```elixir
alter table(:orders) do
  add :stripe_fee, :integer  # exact fee in minor units, set after payment
end
```

**Flow:**
```
checkout.session.completed webhook
  → retrieve payment_intent → charge → balance_transaction
  → extract fee
  → update order: stripe_fee, recalculate gross_profit
```

**Files:**
- Migration (add field)
- `lib/berrypod/orders/order.ex` — add field
- `lib/berrypod_web/controllers/stripe_webhook_controller.ex` — fetch and store after payment

**Tests:**
- Webhook handler stores correct fee from mocked Balance Transaction response

---

### #66 — Tax toggle & Stripe Tax (1.5h)

**How tax works globally:**

| Market | Display | Rule |
|--------|---------|------|
| UK, EU, AU, NZ, JP, most of world | Tax-inclusive | Legal requirement for B2C. Price = price. |
| US, CA | Tax-exclusive | Tax added at checkout. Cultural norm. |

**What Stripe Tax handles (no code needed):**
- Correct tax rate per customer location + product type
- Tax line shown in Stripe Checkout
- Tax amount on `checkout.session` object after payment
- Shop owner registers their VAT/GST number in Stripe Dashboard — not in Berrypod

**What Berrypod needs:**

**New settings:**
- `shop_country` — ISO code (e.g. "GB", "US", "DE"). Drives display defaults.
- `tax_registered` — boolean, default `false`
- `tax_display` — `"inclusive"` (default for UK/EU/AU) or `"exclusive"` (default for US/CA). Auto-set from country but editable.

**When `tax_registered` is OFF (default):**
- No "inc. VAT" label anywhere
- Profit = revenue - costs - Stripe fee. Simple.
- `automatic_tax` NOT sent to Stripe Checkout

**When `tax_registered` is ON:**
- Add `automatic_tax: %{enabled: true}` to Stripe Checkout session
- Stripe calculates and displays the correct tax at checkout
- For tax-inclusive shops (UK/EU/AU): show "inc. VAT" on product pages, cart, and invoice
- For tax-exclusive shops (US/CA): show "+ tax" on product pages, cart
- Snapshot `tax_amount` from `checkout.session.total_details.amount_tax` onto order after payment
- Profit calculation: `gross_profit = subtotal - total_cost - shipping_cost - tax_amount - stripe_fee`

**New field:**
```elixir
alter table(:orders) do
  add :tax_amount, :integer  # from Stripe, set after payment (nil if not registered)
end
```

**Profit calculation comparison:**
```
Not VAT registered (default):       VAT registered, tax-inclusive (UK):
£25.00  price                       £25.00  price
-£8.00  provider cost               -£4.17  VAT (from Stripe)
-£0.58  Stripe fee (exact)          -£8.00  provider cost
────────                            -£0.58  Stripe fee (exact)
£16.42  profit                      ────────
                                    £12.25  profit
```

**Admin UI:**
- Settings page section: "Tax"
- Country selector → sets `tax_display` default
- Toggle: "I'm registered for VAT/GST/Sales tax"
- When on: link to Stripe Dashboard to add tax registration + display mode selector
- Preview: "Your customers see: £25.00 inc. VAT" or "$25.00 + tax"

**Files:**
- `lib/berrypod/settings.ex` — new setting keys
- Admin settings LiveView — new section
- Shop layout/components — conditional "inc. VAT" / "+ tax" display
- `lib/berrypod_web/controllers/checkout_controller.ex` — add `automatic_tax` when registered
- `lib/berrypod_web/controllers/stripe_webhook_controller.ex` — snapshot `tax_amount`
- Migration — `add :tax_amount, :integer` on orders

**Tests:**
- Checkout session includes `automatic_tax` when registered, not when unregistered
- Tax amount snapshotted from webhook payload
- "inc. VAT" shown/hidden based on settings

---

### #67 — Admin profit dashboard (3h)

**Route:** `/admin/profit` (or section within existing dashboard)

**Per-product margins table:**
- Product name, variant count
- Selling price range (min–max across variants)
- Cost range (min–max, or "unknown" if nil)
- Margin % range
- Flagged rows for low/negative margins
- Sortable by margin (worst first is useful)

**Per-order profit breakdown:**
- Recent orders table: order #, date, subtotal, cost, shipping, Stripe fee, tax (if applicable), profit, margin %
- Click to expand: per-item breakdown
- Colour-coded: green (healthy), amber (thin), red (loss)

**Overall P&L summary cards:**
- Total revenue (period)
- Total provider costs
- Total Stripe fees
- Total tax liability (if registered)
- **Net profit**
- Average margin %
- Period selector (7d / 30d / 90d / all time)

**Handles unknowns gracefully:**
- Orders with `nil` cost show "unknown" — not wrong numbers, not excluded
- Note: "X orders have unknown cost (pre-cost-tracking, or Printful products before sync)"

**Files:**
- `lib/berrypod_web/live/admin/profit_live.ex` — new LiveView
- `lib/berrypod/orders.ex` — profit query functions
- Router — new admin route
- Admin nav — new sidebar link

---

### #68 — Profit-aware price editor (2h)

**Where:** Admin products page (new or enhanced)

**For each variant, show a live breakdown:**
```
Selling price:      £25.00
Provider cost:      -£8.00
Shipping (avg):     -£3.50
Stripe fee (est):   -£0.58
VAT (if reg'd):     -£4.17
                    ────────
Your profit:        £8.75  (35%)
```

**Live updating:** phx-change on price input — profit recalculates as they type.

**Warnings:**
- Amber if margin < minimum threshold (e.g. 15%)
- Red + message if negative: "You'd lose £X.XX per sale at this price"
- Warning only — doesn't block saving (the margin guard on sales is the hard block)

**Minimum price suggestion:**
- "Minimum price for 20% margin: £X.XX"
- Quick-set button

**Settings:**
- `minimum_margin_percentage` — configurable (default 20%). Used for warnings here and as the hard floor for sales (#70).

**Note on Stripe fee in price editor:** Since we can only get exact fees *after* payment (Balance Transaction API), the price editor uses an estimated fee based on the shop's country (e.g. UK domestic 1.5% + 20p). This is clearly labelled "est." — it's for guidance, not accounting.

**Files:**
- Admin products LiveView — margin breakdown component
- `lib/berrypod/products.ex` — margin calculation helpers

---

### #69 — Sales & promotions (3h)

**Schema:**
```elixir
create table(:sales, primary_key: false) do
  add :id, :binary_id, primary_key: true
  add :name, :string, null: false           # "Summer sale", "Black Friday"
  add :discount_type, :string, null: false  # "percentage" or "fixed"
  add :discount_value, :integer, null: false # 20 (= 20%) or 500 (= £5.00)
  add :scope, :string, null: false          # "all", "category", "products"
  add :scope_value, :string                 # category slug or comma-separated product IDs
  add :starts_at, :utc_datetime, null: false
  add :ends_at, :utc_datetime, null: false
  add :active, :boolean, default: true
  timestamps()
end
```

**Sale price calculation:**
```elixir
def sale_price(variant, sale) do
  case sale.discount_type do
    "percentage" -> variant.price - round(variant.price * sale.discount_value / 100)
    "fixed" -> max(variant.price - sale.discount_value, 0)
  end
end
```

**Active sale resolution:**
```elixir
def active_sale_for(product, now \\ DateTime.utc_now()) do
  # Check product-specific sales first, then category, then catalogue-wide
  # Return the best (highest discount) applicable sale
  # Only return if starts_at <= now < ends_at and active == true
end
```

**Where sales apply:**
- Product listing pages — original price struck through + sale price + "X% off" badge
- Product detail page — same treatment
- Cart — sale prices used for line items
- Checkout — sale prices sent to Stripe as `unit_amount`

**Admin UI:**
- `/admin/sales` — list of sales (active, scheduled, ended)
- Create/edit: name, discount type/value, scope picker, date range
- Preview: "This sale will reduce 'Classic Tee' from £25.00 to £20.00 (20% off)"

**Files:**
- New migration + schema (`lib/berrypod/sales/sale.ex`)
- `lib/berrypod/sales.ex` — context module
- Product display helpers — inject sale pricing
- Cart hydration — apply sale prices
- `lib/berrypod_web/controllers/checkout_controller.ex` — use sale prices for Stripe line items
- Admin LiveView for sale management
- Shop components — sale badges, strikethrough pricing

---

### #70 — Margin guard on sales (1h)

When creating or updating a sale, the system calculates worst-case profit for every affected variant. Hard block if any would breach the minimum threshold.

**Calculation for each affected variant:**
```
sale_price = apply_discount(variant.price, sale)
cost = variant.cost (nil → skip guard, can't verify)
shipping = lowest shipping rate for this product (domestic)
stripe_fee = estimated_fee(sale_price)  # approximate, for guidance
tax = extracted from sale_price if registered
profit = sale_price - cost - shipping - stripe_fee - tax
margin = profit / sale_price * 100
```

**If any variant breaches threshold:**
- Show which variants fail and the numbers
- "Classic Tee (S, Blue): £15.00 sale price → £4.42 profit (29%) — below 30% minimum"
- Suggest: "Maximum discount for 30% margin on all variants: 15%"
- Block save

**Edge cases:**
- `nil` cost: skip guard, show "Cost unknown for X variants — margin can't be verified"
- Fixed discount ≥ price: flagged as "would make product free"

**Files:**
- `lib/berrypod/sales.ex` — validation in changeset or create function
- Admin sales LiveView — error display + suggestions

---

### #71 — Announcement bar (1.5h)

**Settings:**
- `announcement_text` — e.g. "Summer sale — 20% off everything until Friday!"
- `announcement_link` — optional link target (e.g. "/collections/sale")
- `announcement_active` — boolean
- Auto-populated from active sale, but freely editable

**Display:**
- Full-width bar at top of every shop page, above the header
- Dismissable — localStorage remembers dismissal for session
- Theme-aware — uses CSS custom properties from the existing theme system
- Hidden when no active announcement

**Admin UI:**
- Settings section or inline on the sales page
- Live preview

**Files:**
- Shop layout — announcement bar component
- Settings keys
- Admin UI
- CSS — minimal, theme-aware
- JS hook for localStorage dismissal

---

## Data flow

```
Provider API (Printify/Printful)
    ↓
Sync: variant.cost populated
(Printify: direct; Printful: catalog API cross-reference)
    ↓
Admin price editor
  - Shows live margin breakdown
  - Warnings on thin/negative margins
    ↓
Sale created (with margin guard)
  - Hard block if any variant breaches minimum threshold
    ↓
Shop: sale prices displayed (strikethrough + badge)
    ↓
Cart: sale prices applied to line items
    ↓
Checkout:
  - Sale prices sent to Stripe as unit_amount
  - automatic_tax enabled (if registered) → Stripe calculates tax
    ↓
Payment completed — Stripe webhook fires:
  - shipping_cost updated from Stripe session
  - tax_amount snapshotted from session.total_details.amount_tax
  - stripe_fee fetched from Balance Transaction API (exact, not estimated)
  - gross_profit = subtotal - total_cost - shipping_cost - tax_amount - stripe_fee
    ↓
Profit dashboard:
  - Per-product margins
  - Per-order P&L (with exact fees and tax)
  - Overall business health
```

## Migration path

All new fields are additive. Existing orders will have `nil` for `unit_cost`, `total_cost`, `gross_profit`, `stripe_fee`, and `tax_amount`. The profit dashboard handles `nil` gracefully — shows "unknown" not wrong numbers.

Backfilling is possible but not essential. The dashboard is most useful for orders going forward.
