# Printful integration plan

**Status:** Complete
**Depends on:** Shipping sync (done), Provider behaviour (done)

---

## Why Printful

Printify only has UK-based print providers for 3/10 product types. The other 7 ship from the US, adding cost and delivery time for UK customers. Printful runs its own facility in Wolverhampton with ~252 products across DTG, embroidery, DTF, and sublimation. Critically, Printful has a **mockup generation API** — Prodigi doesn't, which was a dealbreaker.

## API overview

- **Base URL:** `https://api.printful.com/` (v1) and `https://api.printful.com/v2/` (v2 beta)
- **Auth:** OAuth 2.0 private tokens (Bearer header). For single-store use (our case), generate a token in the Developer Portal — no OAuth flow needed. Same pattern as Printify's API key, stored encrypted in ProviderConnection
- **Rate limit:** 120 requests/60s (authenticated), leaky bucket in v2
- **No sandbox.** Testing via draft orders (not charged until confirmed) and webhook simulator
- **Store scoping:** `X-PF-Store-Id` header for store-specific endpoints

### Key difference from Printify

Printful IS the manufacturer — no blueprint/print-provider indirection. The data model is simpler:

| Printify | Printful |
|----------|----------|
| Blueprint -> Print Provider -> Variants | Catalog Product -> Variants |
| `blueprint_id` + `print_provider_id` + `variant_id` | `catalog_variant_id` (globally unique) |
| Provider-generated mockups on product creation | Dedicated async mockup generation API |
| Static shipping rate tables per blueprint/provider | `POST /shipping/rates` with recipient + items |

This simplification means `provider_data` stores different fields and shipping rate lookup works differently.

---

## Phase 1: Core integration (product sync + orders)

### 1.1 HTTP client

**New file:** `lib/berrypod/clients/printful.ex`

Same pattern as `clients/printify.ex`. Uses `Req` with Bearer token auth.

```
@base_url "https://api.printful.com"

Core methods:
- get(path) — GET with 30s timeout
- post(path, body) — POST with 60s timeout
- delete(path) — DELETE with 30s timeout

Printful-specific endpoints:
- get_store() — GET /stores (verify connection, get store_id)
- list_catalog_products(opts) — GET /v2/catalog-products (with DSR filter for UK)
- get_catalog_product(id) — GET /v2/catalog-products/{id}
- get_catalog_variants(product_id) — GET /v2/catalog-products/{id}/catalog-variants
- get_product_availability(id) — GET /v2/catalog-products/{id}/availability
- get_product_prices(id) — GET /v2/catalog-products/{id}/prices
- get_mockup_styles(id) — GET /v2/catalog-products/{id}/mockup-styles
- calculate_shipping(recipient, items) — POST /shipping/rates
- create_order(order_data) — POST /orders
- confirm_order(order_id) — POST /orders/{order_id}/confirm
- get_order(order_id) — GET /orders/{order_id}
- create_mockup_task(product_id, body) — POST /mockup-generator/create-task/{product_id}
- get_mockup_task(task_key) — GET /mockup-generator/task?task_key={key}
- setup_webhooks(url, events) — POST /webhooks
- list_webhooks() — GET /webhooks
```

API key stored in process dictionary (`Process.put(:printful_api_key, key)`) same as Printify client.

### 1.2 Provider implementation

**New file:** `lib/berrypod/providers/printful.ex`

Implements the `Provider` behaviour. The big difference from Printify is that Printful products don't need a "shop" — they come from the global catalogue. But orders require a store_id.

**`test_connection/1`**
- Decrypt API key from ProviderConnection
- Call `Client.get_store()` to verify credentials
- Return `{:ok, %{store_id: id, store_name: name}}`
- Store `store_id` in connection config (same as Printify's `shop_id`)

**`fetch_products/1`**
- No direct equivalent to Printify's "list my products" — Printful's catalogue is global
- Two approaches:
  - **Option A (recommended):** Fetch products the seller has added via Printful's sync products (`GET /sync/products`). This lists products the seller has configured in their Printful store with designs already applied
  - **Option B:** Browse the full catalogue and let the seller pick. More complex UI, better for later
- For Option A: paginate through `GET /sync/products`, then for each, call `GET /sync/products/{id}` to get variants with pricing
- Normalize to the same structure as Printify products

**Normalized product structure:**
```elixir
%{
  provider_product_id: to_string(sync_product["id"]),
  title: sync_product["name"],
  description: "",  # sync products don't have descriptions
  category: extract_category(sync_product),
  images: normalize_images(sync_product),
  variants: normalize_variants(sync_product["sync_variants"]),
  provider_data: %{
    catalog_product_id: variant["product"]["product_id"],
    catalog_variant_ids: [...],  # for mockup generation + shipping
    thumbnail_url: sync_product["thumbnail_url"],
    raw: sync_product
  }
}
```

Key mapping differences:
- Printify `blueprint_id` / `print_provider_id` -> Printful `catalog_product_id` (single ID, no provider layer)
- Printify variant `id` (integer within blueprint scope) -> Printful `variant_id` or `sync_variant_id`
- Printify variant title `"Navy / S"` -> Printful variant has explicit `color`, `size`, `color_code` fields (cleaner!)

**`submit_order/2`**

Printful orders include the artwork in the order payload (unlike Printify where artwork is on the product). This is the biggest structural difference.

```elixir
%{
  external_id: order_data.order_number,
  recipient: %{
    name: address["name"],
    address1: address["line1"],
    address2: address["line2"],
    city: address["city"],
    country_code: address["country"],
    state_code: address["state"],
    zip: address["postal_code"],
    email: order_data.customer_email
  },
  items: Enum.map(order_data.line_items, fn item ->
    %{
      sync_variant_id: parse_int(item.provider_variant_id),
      quantity: item.quantity
    }
  end)
}
```

After creation, immediately call `confirm_order/1` to submit for fulfilment (Printful's two-step flow: create draft -> confirm).

**`get_order_status/2`**

Status mapping:
- `draft` -> `"submitted"` (shouldn't happen post-confirm, but just in case)
- `pending` -> `"submitted"`
- `inprocess` -> `"processing"`
- `fulfilled`, `shipped` -> `"shipped"` (Printful splits these — fulfilled = printed, shipped = dispatched)
- `delivered` -> `"delivered"` (only via webhook, not always available)
- `canceled` -> `"cancelled"`
- `failed`, `onhold` -> `"submitted"` (retriable states)

Extract tracking from `shipments` array (same pattern as Printify).

**`fetch_shipping_rates/2`**

Different approach to Printify. Printful has a live rate calculator rather than static tables:

`POST /shipping/rates` with recipient country + item variant IDs.

For caching in the ShippingRate table, we need to query representative rates at sync time:

1. For each unique `catalog_variant_id` in the synced products, pick one representative variant
2. Query shipping rates to GB and US (the two countries we show at checkout)
3. Store using the existing ShippingRate schema, mapping:
   - `blueprint_id` -> `catalog_product_id` (repurpose the field)
   - `print_provider_id` -> 0 (Printful is always the provider)
   - `country_code`, `first_item_cost`, `additional_item_cost`, `currency`

This is a pragmatic reuse of the existing schema. The field names are Printify-centric but the data works for any provider.

Alternatively, query rates for all countries in `Shipping.@country_names` to populate the country selector. Printful's rate limit (120/min) allows this — one request per country with all items batched.

### 1.3 Wire into Provider dispatch

**File:** `lib/berrypod/providers/provider.ex`

Change line 97:
```elixir
defp default_for_type("printful"), do: {:ok, Berrypod.Providers.Printful}
```

### 1.4 Order submission: multi-provider routing

**File:** `lib/berrypod/orders.ex`

Currently `get_provider_connection/0` is hardcoded to `"printify"`. For multi-provider support, orders need to route to the correct provider based on which connection owns the product.

Change `submit_to_provider/1`:
1. Load the order's items with their product associations
2. Group items by `product.provider_connection_id`
3. For each group, get the provider connection and submit that subset
4. If all succeed, mark the order as submitted. If any fail, mark as failed with details

This is the main architectural change needed in the orders context. For now (single Printful connection), each order's items will all belong to one provider. Multi-provider order splitting (items from Printify AND Printful in one cart) is future work.

Simpler first step: look up the provider connection from the first item's product, rather than hardcoding "printify":

```elixir
defp get_provider_connection_for_order(order) do
  # Get the provider connection from the first item's product
  first_item = List.first(order.items)
  product = Products.get_product(first_item.product_id)
  Products.get_provider_connection(product.provider_connection_id)
end
```

---

## Phase 2: Mockup generation

### 2.1 Mockup generation client methods

Already covered in the client (1.1). The flow:

1. `POST /mockup-generator/create-task/{catalog_product_id}` with:
   - `variant_ids` — which colour/size combos to render
   - `format` — `"jpg"` or `"png"`
   - `files` — artwork URL + placement + positioning
2. Response: `{"task_key": "gt-123456", "status": "pending"}`
3. Poll: `GET /mockup-generator/task?task_key=gt-123456`
4. When `status: "completed"`, response includes `mockups` array with URLs

### 2.2 Mockup worker

**New file:** `lib/berrypod/sync/mockup_generation_worker.ex`

Oban worker that generates mockups for a product after sync:

```elixir
use Oban.Worker, queue: :sync, max_attempts: 3

def perform(%{args: %{"product_id" => product_id}}) do
  product = Products.get_product!(product_id)
  conn = Products.get_provider_connection(product.provider_connection_id)

  if conn.provider_type == "printful" do
    generate_printful_mockups(conn, product)
  else
    :ok  # other providers handle mockups differently
  end
end
```

Flow:
1. Get artwork URL from product's sync data (Printful sync products store the design file)
2. Pick representative variant IDs (one per colour, front placement)
3. Submit mockup task via client
4. Poll with backoff (1s, 2s, 4s, 8s... up to 30s between polls, max 5 minutes)
5. Download generated mockup URLs
6. Process through existing image pipeline (WebP conversion, responsive variants)
7. Update product images in DB

### 2.3 Wire into product sync

In `ProductSyncWorker`, after syncing a Printful product's data, enqueue a `MockupGenerationWorker` job if the product doesn't already have images (or if images are stale).

---

## Phase 3: Shipping rates

### 3.1 Printful shipping in `fetch_shipping_rates/2`

Unlike Printify's static rate tables, Printful calculates shipping live based on recipient + items. For caching:

1. After product sync, collect all unique `sync_variant_id` values
2. For each target country (GB, US, plus REST_OF_THE_WORLD mapped to a representative like DE):
   - Call `POST /shipping/rates` with the country and all variant IDs
   - Extract the STANDARD rate (cheapest)
3. Normalize into the same rate map format as Printify:
   ```elixir
   %{
     blueprint_id: catalog_product_id,  # repurposed field
     print_provider_id: 0,              # Printful is the provider
     country_code: "GB",
     first_item_cost: rate_cents,
     additional_item_cost: 0,           # Printful quotes per-order, not per-item
     currency: "USD",                   # Printful returns USD
     handling_time_days: max_delivery_days
   }
   ```

4. Upsert via existing `Shipping.upsert_rates/3` with exchange rate conversion

### 3.2 Shipping calculation differences

Printful quotes shipping per-order (not per-item like Printify). The existing `Shipping.calculate_for_cart/2` groups by `print_provider_id` and uses first_item + additional_item pricing. For Printful products (all with `print_provider_id: 0`), they'll naturally group together and the first_item_cost will be the full shipping quote.

This works correctly with the existing calculation logic — no changes needed to `Shipping.calculate_for_cart/2`.

---

## Phase 4: Webhooks

### 4.1 Webhook handler

**New file:** `lib/berrypod_web/controllers/printful_webhook_controller.ex`

Printful webhooks POST JSON to a configured URL. Events we care about:

- `order_updated` — order status changed (most important)
- `package_shipped` — shipment dispatched with tracking
- `order_failed` — production issue
- `order_canceled` — order cancelled
- `package_returned` — shipment returned

Handler flow:
1. Parse JSON body
2. Match on `type` field
3. For order events: look up order by `external_id` (our order_number), update fulfilment status
4. For `package_shipped`: extract tracking number/URL, trigger shipping notification email

### 4.2 Webhook registration

During connection setup (after `test_connection` succeeds), register webhooks:

```elixir
Client.setup_webhooks(webhook_url, [
  "order_updated",
  "package_shipped",
  "order_failed",
  "order_canceled",
  "package_returned"
])
```

### 4.3 Route

```elixir
post "/webhooks/printful", PrintfulWebhookController, :handle
```

---

## Phase 5: Admin UI updates

### 5.1 Provider connection form

The existing admin settings page handles provider connections. Currently it's Printify-specific in places (e.g. "Shop ID" in config). Needs:

- When `provider_type == "printful"`: show token input (same as Printify's API key field)
- After test_connection: store `store_id` in config automatically
- Connection test shows store name on success

### 5.2 Sync flow

Identical to Printify — the admin clicks "Sync" and the ProductSyncWorker runs. The provider abstraction handles the differences transparently.

---

## Affected files

### New files (6)
| File | Purpose |
|------|---------|
| `lib/berrypod/clients/printful.ex` | HTTP client |
| `lib/berrypod/providers/printful.ex` | Provider behaviour implementation |
| `lib/berrypod/sync/mockup_generation_worker.ex` | Async mockup generation |
| `lib/berrypod_web/controllers/printful_webhook_controller.ex` | Webhook handler |
| `test/berrypod/providers/printful_test.exs` | Provider tests |
| `test/berrypod/clients/printful_test.exs` | Client tests (with Req mocking) |

### Modified files (6)
| File | Change |
|------|--------|
| `lib/berrypod/providers/provider.ex` | Wire `"printful"` to Printful module |
| `lib/berrypod/orders.ex` | Route to correct provider per order (not hardcoded "printify") |
| `lib/berrypod_web/router.ex` | Add Printful webhook route |
| `config/config.exs` | (optional) Printful-specific config |
| `lib/berrypod/sync/product_sync_worker.ex` | Enqueue mockup generation for Printful products |
| `lib/berrypod_web/live/admin/settings.ex` | Provider form tweaks for Printful |

---

## Implementation order

| # | Task | Est | Notes |
|---|------|-----|-------|
| 1 | HTTP client (`clients/printful.ex`) | 1.5h | All API methods, Req-based, process dict auth |
| 2 | Provider implementation (sync + orders) | 3h | `fetch_products`, `submit_order`, `get_order_status`, normalization |
| 3 | Wire dispatch + order routing | 1h | `provider.ex` dispatch, `orders.ex` provider lookup from product |
| 4 | Shipping rates (`fetch_shipping_rates`) | 1.5h | Live rate queries, cache in ShippingRate schema |
| 5 | Mockup generation worker | 2h | Async task submission, polling, image pipeline |
| 6 | Webhook handler + registration | 1.5h | Controller, route, event handling |
| 7 | Admin UI tweaks | 1h | Provider-specific form fields |
| 8 | Tests | 3h | Mox provider tests, client tests with Req stubs |
| 9 | Manual integration testing | 1.5h | Real Printful account, end-to-end flow |

**Total: ~16 hours** (~8 sessions)

### Session groupings

- **Sessions 1-2:** Client + provider implementation (tasks 1-3)
- **Sessions 3-4:** Shipping rates + mockup generation (tasks 4-5)
- **Sessions 5-6:** Webhooks + admin UI (tasks 6-7)
- **Sessions 7-8:** Tests + integration testing (tasks 8-9)

---

## Decisions and trade-offs

**Using sync products (not catalogue browsing):** Printful's model expects sellers to set up products in Printful's dashboard first (apply designs, choose products, set pricing). Berrypod syncs those configured products. This matches the Printify workflow where products exist in the provider's system first. Full catalogue browsing + product creation via API is possible but significantly more complex (need artwork upload, placement positioning, pricing config) — better suited for a v2.

**Reusing ShippingRate schema fields:** `blueprint_id` and `print_provider_id` are Printify-specific names, but they serve as generic "product type ID" and "provider ID" slots. Renaming them would be a migration + touch every query. Not worth it until a third provider makes the naming confusing.

**Draft + confirm order flow:** Printful charges on confirm, not create. We create as draft, then immediately confirm. No "test mode" needed — if something goes wrong between create and confirm, the draft sits harmlessly.

**Mockups as a separate worker:** Mockup generation is slow (5-30 seconds) and shouldn't block product sync. Running it as a separate Oban job means sync completes fast and mockups trickle in afterwards. Products display with Printful's default thumbnail until mockups are ready.

**V1 vs v2 API:** Use v2 for catalogue endpoints (better filtering, availability data, mockup styles). Use v1 for orders and mockups (stable, well-documented). Both versions use the same auth token.

---

## Prerequisites

- Printful account with API token
- Products set up in Printful's dashboard with designs applied
- No code changes needed to existing Printify integration — they coexist

## Verification

1. `mix test` passes
2. Connect Printful in admin settings, test connection shows store name
3. Sync pulls products with images and variants
4. Mockups generate after sync (check Oban jobs + product images)
5. Cart shipping estimates work for Printful products
6. Stripe checkout shows shipping options
7. Order submits to Printful, status updates via webhook
8. Printify integration unchanged and still works
