Canonical: all shop pages now assign og_url (reusing the existing og:url assign), which the layout renders as <link rel="canonical">. Collection pages strip the sort param so ?sort=price_asc doesn't create a duplicate canonical. robots.txt: dynamic controller disallows /admin/, /api/, /users/, /webhooks/, /checkout/. Removed robots.txt from static_paths so it goes through the router instead of Plug.Static. sitemap.xml: auto-generated from all visible products + categories + static pages, served as application/xml. 8 tests. Also updates PROGRESS.md: marks tasks 55, 58, 59, 61, 62 as done. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
19 KiB
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_taxto 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/1currently hardcodescost: nil- Already have
catalog_product_idfrom 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
pricefield (this is what Printful charges the seller)
Tests:
- Update Printful provider test stubs to include catalog variant cost data
- Assert
costis populated on synced Printful variants
#64 — Cost snapshot on orders (1.5h)
Migration:
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 upProductVariant.costfor each item - Snapshot as
unit_coston eachOrderItem - Calculate
total_cost= sum ofunit_cost * quantityacross items - Calculate
gross_profit=subtotal - total_cost - If variant cost is
nil(legacy data or missing), storenil— 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 schemalib/berrypod/orders/order_item.ex— addunit_costfieldlib/berrypod/orders.ex—create_order/1enrichment 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.completedwebhook, the payment intent has a charge - Fetch
stripe_charge.balance_transactionto get the exact fee - Stripity Stripe:
Stripe.BalanceTransaction.retrieve(balance_transaction_id) - Returns
%{fee: integer}in the same currency/minor units
New field:
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 fieldlib/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.sessionobject 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, defaultfalsetax_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_taxNOT 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_amountfromcheckout.session.total_details.amount_taxonto order after payment - Profit calculation:
gross_profit = subtotal - total_cost - shipping_cost - tax_amount - stripe_fee
New field:
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_displaydefault - 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— addautomatic_taxwhen registeredlib/berrypod_web/controllers/stripe_webhook_controller.ex— snapshottax_amount- Migration —
add :tax_amount, :integeron orders
Tests:
- Checkout session includes
automatic_taxwhen 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
nilcost 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 LiveViewlib/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:
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:
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:
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:
nilcost: 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.