add Printful provider integration with HTTP client and order routing
Printful HTTP client (v2 + v1 for sync products), Provider behaviour implementation with all callbacks (test_connection, fetch_products, submit_order, get_order_status, fetch_shipping_rates), and multi-provider order routing that looks up the provider connection from the order's product instead of hardcoding "printify". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
af207d7a35
commit
3c788bff78
1
.env
1
.env
@ -1 +0,0 @@
|
||||
set PRINTIFY_API_TOKEN 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiIzN2Q0YmQzMDM1ZmUxMWU5YTgwM2FiN2VlYjNjY2M5NyIsImp0aSI6Ijk3ZjY0YzEwZjIyNGRjOGZkYmM1NDgwN2VhNzI2YzY3N2NmYjYzYTZkNmZmMmYzNjExMmVkMzBmYTM1YjdmYTVmZGZiOWRlMDMzMTZiM2I0IiwiaWF0IjoxNzY4NDI4MTk2LjExNjM4OSwibmJmIjoxNzY4NDI4MTk2LjExNjM5MSwiZXhwIjoxNzk5OTY0MTk2LjEwOTY2Mywic3ViIjoiMjU4ODY0NDQiLCJzY29wZXMiOlsic2hvcHMubWFuYWdlIiwic2hvcHMucmVhZCIsImNhdGFsb2cucmVhZCIsIm9yZGVycy5yZWFkIiwib3JkZXJzLndyaXRlIiwicHJvZHVjdHMucmVhZCIsInByb2R1Y3RzLndyaXRlIiwid2ViaG9va3MucmVhZCIsIndlYmhvb2tzLndyaXRlIiwidXBsb2Fkcy5yZWFkIiwidXBsb2Fkcy53cml0ZSIsInByaW50X3Byb3ZpZGVycy5yZWFkIiwidXNlci5pbmZvIl19.CcBAOWMK2xGiTVPaq0B5JP_YBCJQMG9UiwY5hCfQRKBnc33CEyXm8eGJjt5ugy3ENH-0QCiFBtETvZ6f-hc2atVIsAwRPJ2-ey76wTZ1MlBM1LWTMj33jd2aFy3WguI0OMsJil2cEUBCmU47Hhv2Wgsf1Njp6vJEGAuwwfxVk6TArqMad0yHu6ZH4xyKD3F98MDcAk-lroDp8VmkBfGgYJZri7pHKcFZ7BvOGUZUnkO5PiFKZJyFTjIqYXwuCmbzDR1BSnz5LkSBdEe43tYPl_9-gleSWaADOnjQGJYJT0yZRk5oC9N_IgPjZakZcH_XVhvZ1yjRfWMkvmZQArtHrz7FPn1FwM7yDW82x3vHGmZ56l-AvMf85OUWh1pq_J5yvN7aOVkeNi1aDsWZTWkH6xqtWU4vhVjhE_nr5EoqmN5tIj4dy-v6XJ4XpDzOxxmZF01ty57KcmIY0W7uSd2CZGPGjnicWKKOBR7gKChRCBkSawLEEcDrxe5_RJQw9jHxid7Mfwp5MCApmbd_vfQd5NtdpAC5OA_sGAU_tfA9j4qV0PpFW_733lN--CjWjD0lVnGRnU-p2WH-t0qZpjd6UnvJT_C4p2q9vrpd8x29N7UJYfYCwTBLsrYouVu6OILYGV3uMs9oa4U7gay-EM1qBUww8mc_4xhQT3EvtanQrZY'
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -67,6 +67,12 @@ simpleshop_theme-*.tar
|
||||
npm-debug.log
|
||||
/assets/node_modules/
|
||||
|
||||
# Environment variables (API tokens, secrets)
|
||||
.env
|
||||
|
||||
# API reference specs (development only)
|
||||
/docs/api-specs/
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.db-*
|
||||
|
||||
13
PROGRESS.md
13
PROGRESS.md
@ -27,7 +27,7 @@
|
||||
|
||||
Ordered by dependency level — admin shell chain first (unblocks most downstream work).
|
||||
|
||||
Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | [shipping-sync.md](docs/plans/shipping-sync.md)
|
||||
Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | [shipping-sync.md](docs/plans/shipping-sync.md) | [printful-integration.md](docs/plans/printful-integration.md) | [provider-strategy.md](docs/plans/provider-strategy.md)
|
||||
|
||||
| # | Task | Depends on | Est | Status |
|
||||
|---|------|------------|-----|--------|
|
||||
@ -51,6 +51,15 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](doc
|
||||
| | **Next up** | | | |
|
||||
| 16 | Variant refinement with live data | — | 2-3h | |
|
||||
| ~~18~~ | ~~Shipping costs at checkout~~ | 17 | 4h | done |
|
||||
| | **Printful integration** | | | |
|
||||
| 24 | Printful HTTP client | — | 1.5h | |
|
||||
| 25 | Printful provider (sync + orders) | 24 | 3h | |
|
||||
| 26 | Multi-provider order routing | 25 | 1h | |
|
||||
| 27 | Printful shipping rates | 25 | 1.5h | |
|
||||
| 28 | Printful mockup generation worker | 25 | 2h | |
|
||||
| 29 | Printful webhooks | 25 | 1.5h | |
|
||||
| 30 | Admin UI tweaks for Printful | 25 | 1h | |
|
||||
| 31 | Printful tests + integration testing | 24-30 | 4.5h | |
|
||||
| | **CSS migration (after admin stable)** | | | |
|
||||
| 19 | Admin design tokens (`admin-tokens.css`) | 12 | 30m | |
|
||||
| 20 | Admin component styles (`app-admin.css`) | 19 | 3-4h | |
|
||||
@ -58,7 +67,7 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](doc
|
||||
| 22 | Remove DaisyUI | 21 | 1h | |
|
||||
| 23 | CSS migration tests + visual QA | 22 | 1h | |
|
||||
|
||||
**Total remaining: ~23-29 hours across ~10 sessions**
|
||||
**Total remaining: ~39-45 hours across ~18 sessions**
|
||||
|
||||
## Usability fixes (16/18 done)
|
||||
|
||||
|
||||
429
docs/plans/printful-integration.md
Normal file
429
docs/plans/printful-integration.md
Normal file
@ -0,0 +1,429 @@
|
||||
# Printful integration plan
|
||||
|
||||
**Status:** Planning
|
||||
**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/simpleshop_theme/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/simpleshop_theme/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/simpleshop_theme/providers/provider.ex`
|
||||
|
||||
Change line 97:
|
||||
```elixir
|
||||
defp default_for_type("printful"), do: {:ok, SimpleshopTheme.Providers.Printful}
|
||||
```
|
||||
|
||||
### 1.4 Order submission: multi-provider routing
|
||||
|
||||
**File:** `lib/simpleshop_theme/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/simpleshop_theme/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/simpleshop_theme_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/simpleshop_theme/clients/printful.ex` | HTTP client |
|
||||
| `lib/simpleshop_theme/providers/printful.ex` | Provider behaviour implementation |
|
||||
| `lib/simpleshop_theme/sync/mockup_generation_worker.ex` | Async mockup generation |
|
||||
| `lib/simpleshop_theme_web/controllers/printful_webhook_controller.ex` | Webhook handler |
|
||||
| `test/simpleshop_theme/providers/printful_test.exs` | Provider tests |
|
||||
| `test/simpleshop_theme/clients/printful_test.exs` | Client tests (with Req mocking) |
|
||||
|
||||
### Modified files (6)
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `lib/simpleshop_theme/providers/provider.ex` | Wire `"printful"` to Printful module |
|
||||
| `lib/simpleshop_theme/orders.ex` | Route to correct provider per order (not hardcoded "printify") |
|
||||
| `lib/simpleshop_theme_web/router.ex` | Add Printful webhook route |
|
||||
| `config/config.exs` | (optional) Printful-specific config |
|
||||
| `lib/simpleshop_theme/sync/product_sync_worker.ex` | Enqueue mockup generation for Printful products |
|
||||
| `lib/simpleshop_theme_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). SimpleShop 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
|
||||
172
docs/plans/provider-strategy.md
Normal file
172
docs/plans/provider-strategy.md
Normal file
@ -0,0 +1,172 @@
|
||||
# Provider strategy research
|
||||
|
||||
**Status:** Reference (research notes from Feb 2025)
|
||||
|
||||
Research session exploring multi-provider strategy for SimpleShop. Goal: identify the best additional POD providers to complement Printify, with a focus on UK fulfilment coverage.
|
||||
|
||||
---
|
||||
|
||||
## The problem: Printify's UK coverage gap
|
||||
|
||||
Printify's API was queried for all 10 demo product types to check which have UK-based print providers:
|
||||
|
||||
| Product type | UK providers | Notes |
|
||||
|---|---|---|
|
||||
| T-shirt | Print Clever (72), Monster Digital (29) | 2 UK options |
|
||||
| Hoodie | Monster Digital (29), Print Clever (72) | 2 UK options |
|
||||
| Canvas | Print Clever (72) | 1 UK option |
|
||||
| Tote bag | None | Ships from US |
|
||||
| Mug | None | Ships from US |
|
||||
| Cushion | None | Ships from US |
|
||||
| Blanket | None | Ships from US |
|
||||
| Notebook | None | Ships from US |
|
||||
| Phone case | None | Ships from US |
|
||||
| Laptop sleeve | None | Ships from US |
|
||||
|
||||
**Result: 3/10 product types have UK providers.** 7/10 ship transatlantic from the US, adding cost and delivery time for UK customers.
|
||||
|
||||
No single Printify provider covers more than 3 product types within the UK. The Print Bar, Print Clever, and Printify Choice each cover 3 at most.
|
||||
|
||||
---
|
||||
|
||||
## Providers evaluated
|
||||
|
||||
### Prodigi (recommended)
|
||||
|
||||
- **Coverage:** 9/10 product types from UK
|
||||
- **Fulfilment:** Own UK facility (Birmingham) + global network (US, EU, AU)
|
||||
- **API:** REST v4, proper sandbox environment (`api.sandbox.prodigi.com`), catalogue endpoints, shipping rate queries, webhooks
|
||||
- **Auth:** `X-API-Key` header
|
||||
- **Pricing:** ~10-15% more expensive than Printify on apparel (e.g. Bella+Canvas 3001: Printify ~£7.07, Prodigi ~£8.00), but lower shipping costs and no aggregator markup. Gap narrows on paid plans.
|
||||
- **Strengths:** Widest UK product range, mature API with sandbox, direct manufacturer (no middleman), lower shipping from UK
|
||||
- **Weaknesses:** Higher per-item base cost, smaller community than Printify
|
||||
|
||||
**API highlights:**
|
||||
- Sandbox: `https://api.sandbox.prodigi.com/v4.0/`
|
||||
- Production: `https://api.prodigi.com/v4.0/`
|
||||
- Catalogue: `GET /v4.0/products` (with country/currency filtering)
|
||||
- Shipping: `GET /v4.0/shipping/quote` (per-order quotes)
|
||||
- Orders: `POST /v4.0/orders`, `GET /v4.0/orders/{id}`
|
||||
- Webhooks: order status changes, configurable URL
|
||||
|
||||
### Gelato
|
||||
|
||||
- **Coverage:** ~6/10 product types
|
||||
- **Fulfilment:** Via partners in 140+ facilities across 32 countries
|
||||
- **API:** REST, webhooks, catalogue endpoints
|
||||
- **Pricing:** Subscription model (Gelato+), aggregator like Printify
|
||||
- **Strengths:** Massive global network, good for international sellers
|
||||
- **Weaknesses:** Aggregator model (same limitations as Printify), subscription cost, less UK-specific advantage
|
||||
|
||||
### Inkthreadable
|
||||
|
||||
- **Coverage:** 5/10 product types (strong on apparel, eco products, embroidery)
|
||||
- **Fulfilment:** Own UK facility (Lancashire)
|
||||
- **API:** Order-only — `GET/POST/DELETE /orders`. No catalogue endpoints, no shipping rate queries
|
||||
- **Auth:** AppID + SHA1 Signature
|
||||
- **Pricing:** Competitive on apparel
|
||||
- **Strengths:** Eco-focused (organic cotton, water-based inks), embroidery capability, UK-based
|
||||
- **Weaknesses:** Very limited API (can't fetch products or shipping rates programmatically), would need manual catalogue management
|
||||
|
||||
### Two Fifteen
|
||||
|
||||
- **Coverage:** ~4/10 product types (DTG, DTF, embroidery)
|
||||
- **Fulfilment:** Own UK facility
|
||||
- **API:** Primarily Shopify/WooCommerce plugins, sparse custom API documentation
|
||||
- **Strengths:** DTF printing capability, embroidery, UK-based
|
||||
- **Weaknesses:** Heavy overlap with Inkthreadable's coverage, weakest API of the bunch, documentation hard to find
|
||||
|
||||
### Printful
|
||||
|
||||
- **Coverage:** Wide (~8/10 types)
|
||||
- **Fulfilment:** Global (UK via Latvia/Spain, US facilities)
|
||||
- **API:** Mature REST API
|
||||
- **Pricing:** ~£8.50 for Bella+Canvas 3001, most expensive of the lot
|
||||
- **Strengths:** Mature platform, good API
|
||||
- **Weaknesses:** Highest pricing, UK fulfilment is actually from EU (Latvia/Spain), not genuinely UK-based
|
||||
|
||||
---
|
||||
|
||||
## Pricing comparison (Bella+Canvas 3001 t-shirt)
|
||||
|
||||
| Provider | Base cost | UK shipping | Total to UK customer |
|
||||
|---|---|---|---|
|
||||
| Printify (via Monster Digital) | ~£7.07 | ~£4-5 (from UK) | ~£11-12 |
|
||||
| Prodigi | ~£8.00 | ~£3-4 (from UK) | ~£11-12 |
|
||||
| Printful | ~£8.50 | ~£4-5 (from EU) | ~£12.50-13.50 |
|
||||
| Inkthreadable | ~£7.50 | ~£3-4 (from UK) | ~£10.50-11.50 |
|
||||
|
||||
Net cost is comparable across providers for UK delivery. Prodigi's higher base cost is offset by lower shipping.
|
||||
|
||||
---
|
||||
|
||||
## Recommended strategy (revised Feb 2025)
|
||||
|
||||
### Phase 1: Add Printful (revised winner)
|
||||
|
||||
Original analysis recommended Prodigi for UK coverage (9/10 types). But Prodigi has a critical gap: **no mockup generation API**. Sellers would need to manually create and upload product mockups, which is too much friction for SimpleShop's target audience.
|
||||
|
||||
Printful wins because:
|
||||
- **Mockup generation API** — dedicated async endpoint, generates mockups on actual product blanks
|
||||
- **Own UK facility** (Wolverhampton) — ~252 products, DTG/embroidery/DTF/sublimation
|
||||
- **Full catalogue API** — browse, filter, pricing, availability, all via REST
|
||||
- **Live shipping rate calculator** — `POST /shipping/rates` with recipient + items
|
||||
- **Mature API** (v1 stable, v2 beta) — same Bearer token auth as Printify
|
||||
|
||||
Trade-off: ~30% more expensive per item than Printify on base cost (e.g. Bella+Canvas 3001: ~$11.50 vs ~$8.88). Gap narrows with UK shipping savings.
|
||||
|
||||
See: [printful-integration.md](printful-integration.md) for full implementation plan (~16 hours)
|
||||
|
||||
### Phase 2: Prodigi (conditional)
|
||||
|
||||
Still the best option for shops that are price-sensitive and willing to handle mockups manually. Good sandbox environment for development. Worth revisiting if:
|
||||
- Dynamic Mockups API integration is added (third-party mockup service)
|
||||
- Prodigi adds their own mockup API
|
||||
- A seller specifically needs Prodigi's product range
|
||||
|
||||
### Phase 3: Inkthreadable (conditional)
|
||||
|
||||
Only worth adding if there's seller demand for:
|
||||
- Eco-friendly apparel (organic cotton, water-based inks)
|
||||
- Embroidery products
|
||||
- Niche UK-only products not on Prodigi or Printful
|
||||
|
||||
The weak API makes integration harder — no catalogue sync, no shipping rate queries. Would need manual product management.
|
||||
|
||||
### Skip: Two Fifteen
|
||||
|
||||
Too much overlap with Inkthreadable's coverage. Weaker API, harder to find documentation. Not worth the effort unless they offer something unique that Inkthreadable doesn't.
|
||||
|
||||
### Skip: Gelato
|
||||
|
||||
Aggregator model like Printify — same fundamental limitation of routing to whichever facility has capacity, with less control over where items ship from.
|
||||
|
||||
---
|
||||
|
||||
## Smart fulfilment (future vision)
|
||||
|
||||
Once multiple providers are integrated, the system could offer intelligent order routing:
|
||||
|
||||
1. **Catalogue collation** — unified product view across providers, showing which providers can fulfil each product type
|
||||
2. **Smart recommendations** — suggest the best provider per product based on customer location, cost, delivery time, quality ratings
|
||||
3. **Order routing** — split a multi-item order across providers to optimise for cost, speed, or eco impact (e.g. UK items from Prodigi, US items from Printify)
|
||||
|
||||
This is Tier 5 thinking — the architecture supports it via the Provider behaviour, but the UX and business logic are complex. Start with manual provider selection per product, graduate to smart routing once there's data.
|
||||
|
||||
---
|
||||
|
||||
## Architecture notes
|
||||
|
||||
The existing codebase already supports multi-provider through:
|
||||
|
||||
- **`Provider` behaviour** with callbacks for sync, orders, shipping
|
||||
- **`ProviderConnection` schema** linking shops to providers with credentials
|
||||
- **`provider_data` JSON field** on products storing provider-specific metadata
|
||||
- **Shipping context** grouping by `print_provider_id` for combined shipping calculation
|
||||
- **`Provider.for_type/1`** dispatch (currently Printify-only, extensible via app env)
|
||||
|
||||
Adding Prodigi requires:
|
||||
1. `lib/simpleshop_theme/clients/prodigi.ex` — HTTP client
|
||||
2. `lib/simpleshop_theme/providers/prodigi.ex` — Provider behaviour implementation
|
||||
3. Migration: update provider_connections to support "prodigi" type
|
||||
4. Admin UI: provider selection during setup
|
||||
318
lib/simpleshop_theme/clients/printful.ex
Normal file
318
lib/simpleshop_theme/clients/printful.ex
Normal file
@ -0,0 +1,318 @@
|
||||
defmodule SimpleshopTheme.Clients.Printful do
|
||||
@moduledoc """
|
||||
HTTP client for the Printful API.
|
||||
|
||||
Uses v2 endpoints where available, v1 for sync products (store products).
|
||||
Handles authentication via Bearer tokens stored in the process dictionary.
|
||||
"""
|
||||
|
||||
@base_url "https://api.printful.com"
|
||||
|
||||
# =============================================================================
|
||||
# Auth
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Get the API token.
|
||||
|
||||
Checks process dictionary first (for provider connections with stored credentials),
|
||||
then falls back to environment variable (for development/testing).
|
||||
"""
|
||||
def api_token do
|
||||
Process.get(:printful_api_key) ||
|
||||
System.get_env("PRINTFUL_API_TOKEN") ||
|
||||
raise "PRINTFUL_API_TOKEN environment variable is not set"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get the store ID from the process dictionary.
|
||||
"""
|
||||
def store_id do
|
||||
Process.get(:printful_store_id)
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Core HTTP
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Make a GET request to the Printful API.
|
||||
"""
|
||||
def get(path, _opts \\ []) do
|
||||
url = @base_url <> path
|
||||
|
||||
case Req.get(url, headers: auth_headers(), receive_timeout: 30_000) do
|
||||
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
|
||||
{:ok, unwrap_response(path, body)}
|
||||
|
||||
{:ok, %Req.Response{status: status, body: body}} ->
|
||||
{:error, {status, body}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Make a POST request to the Printful API.
|
||||
"""
|
||||
def post(path, body, _opts \\ []) do
|
||||
url = @base_url <> path
|
||||
|
||||
case Req.post(url, json: body, headers: auth_headers(), receive_timeout: 60_000) do
|
||||
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
|
||||
{:ok, unwrap_response(path, body)}
|
||||
|
||||
{:ok, %Req.Response{status: status, body: body}} ->
|
||||
{:error, {status, body}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Make a DELETE request to the Printful API.
|
||||
"""
|
||||
def delete(path, _opts \\ []) do
|
||||
url = @base_url <> path
|
||||
|
||||
case Req.delete(url, headers: auth_headers(), receive_timeout: 30_000) do
|
||||
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
|
||||
{:ok, unwrap_response(path, body)}
|
||||
|
||||
{:ok, %Req.Response{status: 204}} ->
|
||||
{:ok, nil}
|
||||
|
||||
{:ok, %Req.Response{status: status, body: body}} ->
|
||||
{:error, {status, body}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# v1 responses wrap data in "result", v2 wraps in "data"
|
||||
defp unwrap_response("/v2/" <> _, %{"data" => data}), do: data
|
||||
defp unwrap_response(_path, %{"code" => _, "result" => result}), do: result
|
||||
defp unwrap_response(_path, body), do: body
|
||||
|
||||
# =============================================================================
|
||||
# Stores (v2)
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
List all stores for the authenticated account.
|
||||
"""
|
||||
def get_stores do
|
||||
get("/v2/stores")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get the first store's ID.
|
||||
"""
|
||||
def get_store_id do
|
||||
case get_stores() do
|
||||
{:ok, stores} when is_list(stores) and stores != [] ->
|
||||
{:ok, hd(stores)["id"]}
|
||||
|
||||
{:ok, []} ->
|
||||
{:error, :no_stores}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Catalogue (v2)
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Get a catalogue product by ID.
|
||||
"""
|
||||
def get_catalog_product(product_id) do
|
||||
get("/v2/catalog-products/#{product_id}")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get variants for a catalogue product.
|
||||
"""
|
||||
def get_catalog_variants(product_id) do
|
||||
get("/v2/catalog-products/#{product_id}/catalog-variants")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get stock availability for a catalogue product.
|
||||
"""
|
||||
def get_product_availability(product_id) do
|
||||
get("/v2/catalog-products/#{product_id}/availability")
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Sync Products (v1 — no v2 equivalent)
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
List the seller's configured products (sync products).
|
||||
|
||||
These are products the seller has set up in Printful's dashboard with designs.
|
||||
Supports pagination via `offset` and `limit` options.
|
||||
"""
|
||||
def list_sync_products(opts \\ []) do
|
||||
offset = Keyword.get(opts, :offset, 0)
|
||||
limit = Keyword.get(opts, :limit, 20)
|
||||
get("/store/products?offset=#{offset}&limit=#{limit}")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get a single sync product with all its variants and files.
|
||||
"""
|
||||
def get_sync_product(product_id) do
|
||||
get("/store/products/#{product_id}")
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Shipping (v2)
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Calculate shipping rates for a set of items to a recipient.
|
||||
|
||||
## Example
|
||||
|
||||
calculate_shipping(
|
||||
%{country_code: "GB"},
|
||||
[%{source: "catalog", catalog_variant_id: 474, quantity: 1}]
|
||||
)
|
||||
"""
|
||||
def calculate_shipping(recipient, items) do
|
||||
post("/v2/shipping-rates", %{recipient: recipient, items: items})
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Orders (v2)
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Create a new order (draft status).
|
||||
"""
|
||||
def create_order(order_data) do
|
||||
post("/v2/orders", order_data)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirm an order for fulfilment. This triggers charges and production.
|
||||
"""
|
||||
def confirm_order(order_id) do
|
||||
post("/v2/orders/#{order_id}/confirmation", %{})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get an order by ID.
|
||||
"""
|
||||
def get_order(order_id) do
|
||||
get("/v2/orders/#{order_id}")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get shipments for an order.
|
||||
"""
|
||||
def get_order_shipments(order_id) do
|
||||
get("/v2/orders/#{order_id}/shipments")
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Mockups (v2)
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Create a mockup generation task.
|
||||
"""
|
||||
def create_mockup_task(body) do
|
||||
post("/v2/mockup-tasks", body)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get mockup task results. Pass `task_id` to poll a specific task.
|
||||
"""
|
||||
def get_mockup_tasks(params \\ %{}) do
|
||||
query = URI.encode_query(params)
|
||||
path = if query == "", do: "/v2/mockup-tasks", else: "/v2/mockup-tasks?#{query}"
|
||||
get(path)
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Files (v2)
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Upload a file to the Printful file library.
|
||||
"""
|
||||
def upload_file(url) do
|
||||
post("/v2/files", %{url: url})
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Webhooks (v2)
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Set up webhook configuration.
|
||||
"""
|
||||
def setup_webhooks(url, events) do
|
||||
post("/v2/webhooks", %{url: url, events: events})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get current webhook configuration.
|
||||
"""
|
||||
def get_webhooks do
|
||||
get("/v2/webhooks")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Disable webhook support.
|
||||
"""
|
||||
def delete_webhooks do
|
||||
delete("/v2/webhooks")
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# File Downloads
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Download a file from a URL to a local path.
|
||||
"""
|
||||
def download_file(url, output_path) do
|
||||
case Req.get(url, into: File.stream!(output_path), receive_timeout: 60_000) do
|
||||
{:ok, %Req.Response{status: status}} when status in 200..299 ->
|
||||
{:ok, output_path}
|
||||
|
||||
{:ok, %Req.Response{status: status}} ->
|
||||
File.rm(output_path)
|
||||
{:error, {:http_error, status}}
|
||||
|
||||
{:error, reason} ->
|
||||
File.rm(output_path)
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Private
|
||||
# =============================================================================
|
||||
|
||||
defp auth_headers do
|
||||
headers = [
|
||||
{"Authorization", "Bearer #{api_token()}"},
|
||||
{"Content-Type", "application/json"}
|
||||
]
|
||||
|
||||
case store_id() do
|
||||
nil -> headers
|
||||
id -> [{"X-PF-Store-Id", to_string(id)} | headers]
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -200,7 +200,7 @@ defmodule SimpleshopTheme.Orders do
|
||||
def submit_to_provider(%Order{} = order) do
|
||||
order = Repo.preload(order, :items)
|
||||
|
||||
with {:ok, conn} <- get_provider_connection(),
|
||||
with {:ok, conn} <- get_provider_connection_for_order(order),
|
||||
{:ok, provider} <- Provider.for_connection(conn),
|
||||
{:ok, enriched_items} <- enrich_items(order.items),
|
||||
order_data <- build_submission_data(order, enriched_items),
|
||||
@ -231,7 +231,9 @@ defmodule SimpleshopTheme.Orders do
|
||||
def refresh_fulfilment_status(%Order{provider_order_id: nil} = order), do: {:ok, order}
|
||||
|
||||
def refresh_fulfilment_status(%Order{} = order) do
|
||||
with {:ok, conn} <- get_provider_connection(),
|
||||
order = Repo.preload(order, :items)
|
||||
|
||||
with {:ok, conn} <- get_provider_connection_for_order(order),
|
||||
{:ok, provider} <- Provider.for_connection(conn),
|
||||
{:ok, status_data} <- provider.get_order_status(conn, order.provider_order_id) do
|
||||
attrs =
|
||||
@ -285,13 +287,22 @@ defmodule SimpleshopTheme.Orders do
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
defp get_provider_connection do
|
||||
case Products.get_provider_connection_by_type("printify") do
|
||||
defp get_provider_connection_for_order(%Order{items: items}) do
|
||||
first_item = List.first(items)
|
||||
variants_map = Products.get_variants_with_products([first_item.variant_id])
|
||||
|
||||
case Map.get(variants_map, first_item.variant_id) do
|
||||
nil ->
|
||||
{:error, :variant_not_found}
|
||||
|
||||
variant ->
|
||||
case Products.get_provider_connection(variant.product.provider_connection_id) do
|
||||
nil -> {:error, :no_provider_connection}
|
||||
%{enabled: false} -> {:error, :provider_disabled}
|
||||
conn -> {:ok, conn}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp enrich_items(items) do
|
||||
variant_ids = Enum.map(items, & &1.variant_id)
|
||||
@ -331,6 +342,10 @@ defmodule SimpleshopTheme.Orders do
|
||||
"Variant for '#{name}' no longer exists in the product catalog"
|
||||
end
|
||||
|
||||
defp format_submission_error(:variant_not_found) do
|
||||
"Order variant no longer exists in the product catalog"
|
||||
end
|
||||
|
||||
defp format_submission_error(:no_provider_connection) do
|
||||
"No fulfilment provider connected"
|
||||
end
|
||||
|
||||
540
lib/simpleshop_theme/providers/printful.ex
Normal file
540
lib/simpleshop_theme/providers/printful.ex
Normal file
@ -0,0 +1,540 @@
|
||||
defmodule SimpleshopTheme.Providers.Printful do
|
||||
@moduledoc """
|
||||
Printful provider implementation.
|
||||
|
||||
Handles product sync, order submission, and shipping rate lookups for Printful.
|
||||
Uses v2 API endpoints where available, v1 for sync products.
|
||||
"""
|
||||
|
||||
@behaviour SimpleshopTheme.Providers.Provider
|
||||
|
||||
alias SimpleshopTheme.Clients.Printful, as: Client
|
||||
alias SimpleshopTheme.Products.ProviderConnection
|
||||
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
def provider_type, do: "printful"
|
||||
|
||||
# =============================================================================
|
||||
# Connection
|
||||
# =============================================================================
|
||||
|
||||
@impl true
|
||||
def test_connection(%ProviderConnection{} = conn) do
|
||||
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||||
:ok <- set_credentials(api_key, nil),
|
||||
{:ok, stores} <- Client.get_stores() do
|
||||
store = List.first(stores)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
store_id: store["id"],
|
||||
store_name: store["name"]
|
||||
}}
|
||||
else
|
||||
nil -> {:error, :no_api_key}
|
||||
{:error, _} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Products
|
||||
# =============================================================================
|
||||
|
||||
@impl true
|
||||
def fetch_products(%ProviderConnection{config: config} = conn) do
|
||||
store_id = config["store_id"]
|
||||
|
||||
if is_nil(store_id) do
|
||||
{:error, :no_store_id}
|
||||
else
|
||||
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||||
:ok <- set_credentials(api_key, store_id),
|
||||
{:ok, products} <- fetch_all_sync_products() do
|
||||
{:ok, products}
|
||||
else
|
||||
nil -> {:error, :no_api_key}
|
||||
{:error, _} = error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_all_sync_products do
|
||||
fetch_sync_products_page(0, [])
|
||||
end
|
||||
|
||||
defp fetch_sync_products_page(offset, acc) do
|
||||
case Client.list_sync_products(offset: offset) do
|
||||
{:ok, products} when is_list(products) ->
|
||||
# Fetch full details for each product (includes variants + files)
|
||||
detailed =
|
||||
products
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {product, index} ->
|
||||
if index > 0, do: Process.sleep(100)
|
||||
|
||||
case Client.get_sync_product(product["id"]) do
|
||||
{:ok, detail} ->
|
||||
normalize_product(detail["sync_product"], detail["sync_variants"] || [])
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"Failed to fetch Printful product #{product["id"]}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
nil
|
||||
end
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
all_products = acc ++ detailed
|
||||
|
||||
# Printful paginates at 20 per page by default
|
||||
if length(products) >= 20 do
|
||||
Process.sleep(100)
|
||||
fetch_sync_products_page(offset + 20, all_products)
|
||||
else
|
||||
{:ok, all_products}
|
||||
end
|
||||
|
||||
{:ok, _} ->
|
||||
{:ok, acc}
|
||||
|
||||
{:error, _} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Orders
|
||||
# =============================================================================
|
||||
|
||||
@impl true
|
||||
def submit_order(%ProviderConnection{config: config} = conn, order) do
|
||||
store_id = config["store_id"]
|
||||
|
||||
if is_nil(store_id) do
|
||||
{:error, :no_store_id}
|
||||
else
|
||||
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||||
:ok <- set_credentials(api_key, store_id),
|
||||
order_data <- build_order_payload(order),
|
||||
{:ok, response} <- Client.create_order(order_data),
|
||||
order_id <- response["id"],
|
||||
{:ok, _confirmed} <- Client.confirm_order(order_id) do
|
||||
{:ok, %{provider_order_id: to_string(order_id)}}
|
||||
else
|
||||
nil -> {:error, :no_api_key}
|
||||
{:error, _} = error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def get_order_status(%ProviderConnection{config: config} = conn, provider_order_id) do
|
||||
store_id = config["store_id"]
|
||||
|
||||
if is_nil(store_id) do
|
||||
{:error, :no_store_id}
|
||||
else
|
||||
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||||
:ok <- set_credentials(api_key, store_id),
|
||||
{:ok, response} <- Client.get_order(provider_order_id) do
|
||||
shipments = fetch_shipments(provider_order_id)
|
||||
{:ok, normalize_order_status(response, shipments)}
|
||||
else
|
||||
nil -> {:error, :no_api_key}
|
||||
{:error, _} = error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_shipments(order_id) do
|
||||
case Client.get_order_shipments(order_id) do
|
||||
{:ok, shipments} when is_list(shipments) -> shipments
|
||||
_ -> []
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Shipping Rates
|
||||
# =============================================================================
|
||||
|
||||
@impl true
|
||||
def fetch_shipping_rates(%ProviderConnection{config: config} = conn, products)
|
||||
when is_list(products) do
|
||||
store_id = config["store_id"]
|
||||
|
||||
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||||
:ok <- set_credentials(api_key, store_id) do
|
||||
variant_ids = extract_catalog_variant_ids(products)
|
||||
|
||||
Logger.info(
|
||||
"Fetching Printful shipping rates for #{length(variant_ids)} catalogue variants"
|
||||
)
|
||||
|
||||
rates =
|
||||
target_countries()
|
||||
|> Enum.with_index()
|
||||
|> Enum.flat_map(fn {country_code, index} ->
|
||||
if index > 0, do: Process.sleep(100)
|
||||
fetch_rates_for_country(variant_ids, country_code, products)
|
||||
end)
|
||||
|
||||
{:ok, rates}
|
||||
else
|
||||
nil -> {:error, :no_api_key}
|
||||
end
|
||||
end
|
||||
|
||||
# Countries to pre-cache rates for
|
||||
defp target_countries do
|
||||
["GB", "US", "DE", "FR", "CA", "AU", "IE", "NL", "AT", "BE"]
|
||||
end
|
||||
|
||||
defp fetch_rates_for_country(variant_ids, country_code, products) do
|
||||
items =
|
||||
Enum.map(variant_ids, fn vid ->
|
||||
%{source: "catalog", catalog_variant_id: vid, quantity: 1}
|
||||
end)
|
||||
|
||||
case Client.calculate_shipping(%{country_code: country_code}, items) do
|
||||
{:ok, rates} when is_list(rates) ->
|
||||
# Take the STANDARD rate (cheapest)
|
||||
standard = Enum.find(rates, &(&1["id"] == "STANDARD")) || List.first(rates)
|
||||
|
||||
if standard do
|
||||
catalog_product_id = extract_first_catalog_product_id(products)
|
||||
rate_cents = parse_price(standard["rate"])
|
||||
|
||||
[
|
||||
%{
|
||||
blueprint_id: catalog_product_id,
|
||||
print_provider_id: 0,
|
||||
country_code: country_code,
|
||||
first_item_cost: rate_cents,
|
||||
additional_item_cost: 0,
|
||||
currency: String.upcase(standard["currency"] || "USD"),
|
||||
handling_time_days: standard["maxDeliveryDays"]
|
||||
}
|
||||
]
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"Failed to fetch Printful shipping for #{country_code}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_catalog_variant_ids(products) do
|
||||
products
|
||||
|> Enum.flat_map(fn product ->
|
||||
provider_data = product[:provider_data] || %{}
|
||||
|
||||
catalog_variant_ids =
|
||||
provider_data[:catalog_variant_ids] || provider_data["catalog_variant_ids"] || []
|
||||
|
||||
if catalog_variant_ids == [] do
|
||||
# Fall back to extracting from raw data
|
||||
raw = provider_data[:raw] || provider_data["raw"] || %{}
|
||||
sync_variants = raw["sync_variants"] || raw[:sync_variants] || []
|
||||
Enum.map(sync_variants, fn sv -> sv["variant_id"] || sv[:variant_id] end)
|
||||
else
|
||||
catalog_variant_ids
|
||||
end
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
defp extract_first_catalog_product_id(products) do
|
||||
products
|
||||
|> Enum.find_value(fn product ->
|
||||
provider_data = product[:provider_data] || %{}
|
||||
provider_data[:catalog_product_id] || provider_data["catalog_product_id"]
|
||||
end) || 0
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Option Types (for frontend display)
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Extracts option types from Printful provider_data for frontend display.
|
||||
|
||||
Builds option types from the stored options list, which contains
|
||||
distinct colour and size values with optional hex codes.
|
||||
"""
|
||||
def extract_option_types(%{"options" => options}) when is_list(options) do
|
||||
Enum.map(options, fn opt ->
|
||||
%{
|
||||
name: opt["name"],
|
||||
type: option_type_atom(opt["type"]),
|
||||
values:
|
||||
Enum.map(opt["values"] || [], fn val ->
|
||||
base = %{title: val["title"]}
|
||||
if val["hex"], do: Map.put(base, :hex, val["hex"]), else: base
|
||||
end)
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
def extract_option_types(_), do: []
|
||||
|
||||
defp option_type_atom("color"), do: :color
|
||||
defp option_type_atom("size"), do: :size
|
||||
defp option_type_atom(_), do: :other
|
||||
|
||||
# =============================================================================
|
||||
# Data Normalization
|
||||
# =============================================================================
|
||||
|
||||
defp normalize_product(sync_product, sync_variants) do
|
||||
images = extract_preview_images(sync_variants)
|
||||
catalog_product_id = extract_catalog_product_id_from_variants(sync_variants)
|
||||
catalog_variant_ids = Enum.map(sync_variants, & &1["variant_id"]) |> Enum.reject(&is_nil/1)
|
||||
|
||||
%{
|
||||
provider_product_id: to_string(sync_product["id"]),
|
||||
title: sync_product["name"],
|
||||
description: "",
|
||||
category: extract_category(sync_variants),
|
||||
images: images,
|
||||
variants: Enum.map(sync_variants, &normalize_variant/1),
|
||||
provider_data: %{
|
||||
catalog_product_id: catalog_product_id,
|
||||
catalog_variant_ids: catalog_variant_ids,
|
||||
thumbnail_url: sync_product["thumbnail_url"],
|
||||
options: build_option_types(sync_variants),
|
||||
raw: %{sync_product: sync_product}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_variant(sv) do
|
||||
%{
|
||||
provider_variant_id: to_string(sv["id"]),
|
||||
title: build_variant_title(sv),
|
||||
sku: sv["sku"],
|
||||
price: parse_price(sv["retail_price"]),
|
||||
cost: nil,
|
||||
options: build_variant_options(sv),
|
||||
is_enabled: sv["synced"] == true,
|
||||
is_available: sv["availability_status"] == "active"
|
||||
}
|
||||
end
|
||||
|
||||
defp build_variant_title(sv) do
|
||||
parts = [sv["color"], sv["size"]] |> Enum.reject(&is_nil/1)
|
||||
Enum.join(parts, " / ")
|
||||
end
|
||||
|
||||
defp build_variant_options(sv) do
|
||||
opts = %{}
|
||||
opts = if sv["color"], do: Map.put(opts, "Color", sv["color"]), else: opts
|
||||
opts = if sv["size"], do: Map.put(opts, "Size", sv["size"]), else: opts
|
||||
opts
|
||||
end
|
||||
|
||||
# Extract unique preview images from sync variants (one per unique colour)
|
||||
defp extract_preview_images(sync_variants) do
|
||||
sync_variants
|
||||
|> Enum.flat_map(fn sv ->
|
||||
(sv["files"] || [])
|
||||
|> Enum.filter(&(&1["type"] == "preview"))
|
||||
|> Enum.map(fn file ->
|
||||
%{
|
||||
src: file["preview_url"] || file["thumbnail_url"],
|
||||
color: sv["color"]
|
||||
}
|
||||
end)
|
||||
end)
|
||||
|> Enum.uniq_by(& &1.color)
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {img, index} ->
|
||||
%{
|
||||
src: img.src,
|
||||
position: index,
|
||||
alt: img.color
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp extract_catalog_product_id_from_variants(sync_variants) do
|
||||
sync_variants
|
||||
|> Enum.find_value(fn sv ->
|
||||
get_in(sv, ["product", "product_id"])
|
||||
end) || 0
|
||||
end
|
||||
|
||||
# Build option types from variants for frontend display
|
||||
defp build_option_types(sync_variants) do
|
||||
colors =
|
||||
sync_variants
|
||||
|> Enum.map(& &1["color"])
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.uniq()
|
||||
|> Enum.map(fn color -> %{"title" => color} end)
|
||||
|
||||
sizes =
|
||||
sync_variants
|
||||
|> Enum.map(& &1["size"])
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.uniq()
|
||||
|> Enum.map(fn size -> %{"title" => size} end)
|
||||
|
||||
opts = []
|
||||
|
||||
opts =
|
||||
if colors != [],
|
||||
do: opts ++ [%{"name" => "Color", "type" => "color", "values" => colors}],
|
||||
else: opts
|
||||
|
||||
opts =
|
||||
if sizes != [],
|
||||
do: opts ++ [%{"name" => "Size", "type" => "size", "values" => sizes}],
|
||||
else: opts
|
||||
|
||||
opts
|
||||
end
|
||||
|
||||
defp extract_category(sync_variants) do
|
||||
case sync_variants do
|
||||
[sv | _] ->
|
||||
product_name = get_in(sv, ["product", "name"]) || ""
|
||||
categorize_from_name(product_name)
|
||||
|
||||
[] ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp categorize_from_name(name) do
|
||||
name_lower = String.downcase(name)
|
||||
|
||||
cond do
|
||||
has_keyword?(name_lower, ~w[t-shirt tshirt shirt hoodie sweatshirt jogger]) -> "Apparel"
|
||||
has_keyword?(name_lower, ~w[canvas poster print frame]) -> "Canvas Prints"
|
||||
has_keyword?(name_lower, ~w[mug cup blanket pillow cushion]) -> "Homewares"
|
||||
has_keyword?(name_lower, ~w[notebook journal]) -> "Stationery"
|
||||
has_keyword?(name_lower, ~w[phone case bag tote hat cap]) -> "Accessories"
|
||||
true -> "Apparel"
|
||||
end
|
||||
end
|
||||
|
||||
defp has_keyword?(text, keywords) do
|
||||
Enum.any?(keywords, &String.contains?(text, &1))
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Order Normalization
|
||||
# =============================================================================
|
||||
|
||||
defp normalize_order_status(raw, shipments) do
|
||||
%{
|
||||
status: map_order_status(raw["status"]),
|
||||
provider_status: raw["status"],
|
||||
tracking_number: extract_tracking(shipments),
|
||||
tracking_url: extract_tracking_url(shipments),
|
||||
shipments: shipments
|
||||
}
|
||||
end
|
||||
|
||||
defp map_order_status("draft"), do: "submitted"
|
||||
defp map_order_status("pending"), do: "submitted"
|
||||
defp map_order_status("inprocess"), do: "processing"
|
||||
defp map_order_status("fulfilled"), do: "shipped"
|
||||
defp map_order_status("shipped"), do: "shipped"
|
||||
defp map_order_status("delivered"), do: "delivered"
|
||||
defp map_order_status("canceled"), do: "cancelled"
|
||||
defp map_order_status("failed"), do: "submitted"
|
||||
defp map_order_status("onhold"), do: "submitted"
|
||||
defp map_order_status(_), do: "submitted"
|
||||
|
||||
defp extract_tracking([shipment | _]) do
|
||||
shipment["tracking_number"] || get_in(shipment, ["tracking", "number"])
|
||||
end
|
||||
|
||||
defp extract_tracking(_), do: nil
|
||||
|
||||
defp extract_tracking_url([shipment | _]) do
|
||||
shipment["tracking_url"] || get_in(shipment, ["tracking", "url"])
|
||||
end
|
||||
|
||||
defp extract_tracking_url(_), do: nil
|
||||
|
||||
# =============================================================================
|
||||
# Order Building
|
||||
# =============================================================================
|
||||
|
||||
defp build_order_payload(order_data) do
|
||||
%{
|
||||
external_id: order_data.order_number,
|
||||
shipping: "STANDARD",
|
||||
recipient: build_recipient(order_data.shipping_address, 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)
|
||||
}
|
||||
end
|
||||
|
||||
defp build_recipient(address, email) when is_map(address) do
|
||||
%{
|
||||
name: address["name"] || "",
|
||||
address1: address["line1"] || address["address1"] || "",
|
||||
address2: address["line2"] || address["address2"] || "",
|
||||
city: address["city"] || "",
|
||||
country_code: address["country"] || "",
|
||||
state_code: address["state"] || address["region"] || "",
|
||||
zip: address["postal_code"] || address["zip"] || "",
|
||||
email: email
|
||||
}
|
||||
end
|
||||
|
||||
defp build_recipient(_address, email) do
|
||||
%{
|
||||
name: "",
|
||||
address1: "",
|
||||
address2: "",
|
||||
city: "",
|
||||
country_code: "",
|
||||
state_code: "",
|
||||
zip: "",
|
||||
email: email
|
||||
}
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
# Parse a price string like "13.50" into integer pence (1350)
|
||||
defp parse_price(price) when is_binary(price) do
|
||||
case Float.parse(price) do
|
||||
{float, _} -> round(float * 100)
|
||||
:error -> 0
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_price(price) when is_number(price), do: round(price * 100)
|
||||
defp parse_price(_), do: 0
|
||||
|
||||
defp parse_int(value) when is_integer(value), do: value
|
||||
defp parse_int(value) when is_binary(value), do: String.to_integer(value)
|
||||
|
||||
defp set_credentials(api_key, store_id) do
|
||||
Process.put(:printful_api_key, api_key)
|
||||
if store_id, do: Process.put(:printful_store_id, store_id)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
@ -94,7 +94,7 @@ defmodule SimpleshopTheme.Providers.Provider do
|
||||
defp default_for_type("printify"), do: {:ok, SimpleshopTheme.Providers.Printify}
|
||||
defp default_for_type("gelato"), do: {:error, :not_implemented}
|
||||
defp default_for_type("prodigi"), do: {:error, :not_implemented}
|
||||
defp default_for_type("printful"), do: {:error, :not_implemented}
|
||||
defp default_for_type("printful"), do: {:ok, SimpleshopTheme.Providers.Printful}
|
||||
defp default_for_type(type), do: {:error, {:unknown_provider, type}}
|
||||
|
||||
@doc """
|
||||
|
||||
48
test/simpleshop_theme/clients/printful_test.exs
Normal file
48
test/simpleshop_theme/clients/printful_test.exs
Normal file
@ -0,0 +1,48 @@
|
||||
defmodule SimpleshopTheme.Clients.PrintfulTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias SimpleshopTheme.Clients.Printful
|
||||
|
||||
describe "api_token/0" do
|
||||
test "reads from process dictionary when set" do
|
||||
Process.put(:printful_api_key, "test_token_123")
|
||||
|
||||
assert Printful.api_token() == "test_token_123"
|
||||
after
|
||||
Process.delete(:printful_api_key)
|
||||
end
|
||||
|
||||
test "raises when no token available" do
|
||||
Process.delete(:printful_api_key)
|
||||
_original = System.get_env("PRINTFUL_API_TOKEN")
|
||||
System.delete_env("PRINTFUL_API_TOKEN")
|
||||
|
||||
assert_raise RuntimeError, ~r/PRINTFUL_API_TOKEN/, fn ->
|
||||
Printful.api_token()
|
||||
end
|
||||
after
|
||||
Process.delete(:printful_api_key)
|
||||
# Restore env if it was set
|
||||
case System.get_env("PRINTFUL_API_TOKEN") do
|
||||
nil -> :ok
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "store_id/0" do
|
||||
test "reads from process dictionary" do
|
||||
Process.put(:printful_store_id, 12345)
|
||||
|
||||
assert Printful.store_id() == 12345
|
||||
after
|
||||
Process.delete(:printful_store_id)
|
||||
end
|
||||
|
||||
test "returns nil when not set" do
|
||||
Process.delete(:printful_store_id)
|
||||
|
||||
assert Printful.store_id() == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -84,7 +84,9 @@ defmodule SimpleshopTheme.Orders.OrderSubmissionWorkerTest do
|
||||
end
|
||||
|
||||
test "sets failed status when variant not found in local DB" do
|
||||
# Create order with a variant_id that doesn't exist in product variants
|
||||
# Create order with a variant_id that doesn't exist in product variants.
|
||||
# Provider connection lookup now goes through the variant's product,
|
||||
# so a missing variant causes :variant_not_found at routing time.
|
||||
order =
|
||||
order_fixture(%{
|
||||
variant_id: Ecto.UUID.generate(),
|
||||
@ -98,10 +100,7 @@ defmodule SimpleshopTheme.Orders.OrderSubmissionWorkerTest do
|
||||
}
|
||||
})
|
||||
|
||||
# Need a printify connection for the lookup to proceed
|
||||
SimpleshopTheme.ProductsFixtures.provider_connection_fixture(%{provider_type: "printify"})
|
||||
|
||||
assert {:error, {:variant_not_found, _, _}} =
|
||||
assert {:error, :variant_not_found} =
|
||||
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
|
||||
|
||||
updated = Orders.get_order(order.id)
|
||||
|
||||
483
test/simpleshop_theme/providers/printful_test.exs
Normal file
483
test/simpleshop_theme/providers/printful_test.exs
Normal file
@ -0,0 +1,483 @@
|
||||
defmodule SimpleshopTheme.Providers.PrintfulTest do
|
||||
use SimpleshopTheme.DataCase, async: true
|
||||
|
||||
alias SimpleshopTheme.Providers.Printful
|
||||
|
||||
describe "provider_type/0" do
|
||||
test "returns printful" do
|
||||
assert Printful.provider_type() == "printful"
|
||||
end
|
||||
end
|
||||
|
||||
describe "test_connection/1" do
|
||||
test "returns error when no API key" do
|
||||
conn = %SimpleshopTheme.Products.ProviderConnection{
|
||||
provider_type: "printful",
|
||||
api_key_encrypted: nil
|
||||
}
|
||||
|
||||
assert {:error, :no_api_key} = Printful.test_connection(conn)
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_products/1" do
|
||||
test "returns error when no store_id in config" do
|
||||
conn = %SimpleshopTheme.Products.ProviderConnection{
|
||||
provider_type: "printful",
|
||||
api_key_encrypted: nil,
|
||||
config: %{}
|
||||
}
|
||||
|
||||
assert {:error, :no_store_id} = Printful.fetch_products(conn)
|
||||
end
|
||||
|
||||
test "returns error when no API key" do
|
||||
conn = %SimpleshopTheme.Products.ProviderConnection{
|
||||
provider_type: "printful",
|
||||
api_key_encrypted: nil,
|
||||
config: %{"store_id" => "12345"}
|
||||
}
|
||||
|
||||
assert {:error, :no_api_key} = Printful.fetch_products(conn)
|
||||
end
|
||||
end
|
||||
|
||||
describe "submit_order/2" do
|
||||
test "returns error when no store_id in config" do
|
||||
conn = %SimpleshopTheme.Products.ProviderConnection{
|
||||
provider_type: "printful",
|
||||
api_key_encrypted: nil,
|
||||
config: %{}
|
||||
}
|
||||
|
||||
assert {:error, :no_store_id} = Printful.submit_order(conn, %{})
|
||||
end
|
||||
|
||||
test "returns error when no API key" do
|
||||
conn = %SimpleshopTheme.Products.ProviderConnection{
|
||||
provider_type: "printful",
|
||||
api_key_encrypted: nil,
|
||||
config: %{"store_id" => "12345"}
|
||||
}
|
||||
|
||||
assert {:error, :no_api_key} = Printful.submit_order(conn, %{})
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_order_status/2" do
|
||||
test "returns error when no store_id in config" do
|
||||
conn = %SimpleshopTheme.Products.ProviderConnection{
|
||||
provider_type: "printful",
|
||||
api_key_encrypted: nil,
|
||||
config: %{}
|
||||
}
|
||||
|
||||
assert {:error, :no_store_id} = Printful.get_order_status(conn, "12345")
|
||||
end
|
||||
|
||||
test "returns error when no API key" do
|
||||
conn = %SimpleshopTheme.Products.ProviderConnection{
|
||||
provider_type: "printful",
|
||||
api_key_encrypted: nil,
|
||||
config: %{"store_id" => "12345"}
|
||||
}
|
||||
|
||||
assert {:error, :no_api_key} = Printful.get_order_status(conn, "12345")
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_shipping_rates/2" do
|
||||
test "returns error when no API key" do
|
||||
conn = %SimpleshopTheme.Products.ProviderConnection{
|
||||
provider_type: "printful",
|
||||
api_key_encrypted: nil,
|
||||
config: %{"store_id" => "12345"}
|
||||
}
|
||||
|
||||
assert {:error, :no_api_key} = Printful.fetch_shipping_rates(conn, [])
|
||||
end
|
||||
end
|
||||
|
||||
describe "extract_option_types/1" do
|
||||
test "extracts color and size options" do
|
||||
provider_data = %{
|
||||
"options" => [
|
||||
%{
|
||||
"name" => "Color",
|
||||
"type" => "color",
|
||||
"values" => [
|
||||
%{"title" => "Black"},
|
||||
%{"title" => "Natural", "hex" => "#F5F5DC"}
|
||||
]
|
||||
},
|
||||
%{
|
||||
"name" => "Size",
|
||||
"type" => "size",
|
||||
"values" => [
|
||||
%{"title" => "S"},
|
||||
%{"title" => "M"},
|
||||
%{"title" => "L"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = Printful.extract_option_types(provider_data)
|
||||
|
||||
assert length(result) == 2
|
||||
|
||||
[color_opt, size_opt] = result
|
||||
assert color_opt.name == "Color"
|
||||
assert color_opt.type == :color
|
||||
assert length(color_opt.values) == 2
|
||||
assert hd(color_opt.values).title == "Black"
|
||||
assert Enum.at(color_opt.values, 1).hex == "#F5F5DC"
|
||||
|
||||
assert size_opt.name == "Size"
|
||||
assert size_opt.type == :size
|
||||
assert length(size_opt.values) == 3
|
||||
end
|
||||
|
||||
test "returns empty list for nil provider_data" do
|
||||
assert Printful.extract_option_types(nil) == []
|
||||
end
|
||||
|
||||
test "returns empty list for provider_data without options" do
|
||||
assert Printful.extract_option_types(%{}) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "product normalization" do
|
||||
test "normalizes sync product response correctly" do
|
||||
{sync_product, sync_variants} = printful_sync_product_response()
|
||||
normalized = normalize_product(sync_product, sync_variants)
|
||||
|
||||
assert normalized.provider_product_id == "456789"
|
||||
assert normalized.title == "PC Man T-Shirt"
|
||||
assert normalized.description == ""
|
||||
assert normalized.category == "Apparel"
|
||||
|
||||
# Images — one per unique colour from preview files
|
||||
assert length(normalized.images) == 2
|
||||
[img1, img2] = normalized.images
|
||||
assert img1.position == 0
|
||||
assert img2.position == 1
|
||||
assert img1.alt == "Black"
|
||||
assert img2.alt == "Natural"
|
||||
|
||||
# Variants
|
||||
assert length(normalized.variants) == 4
|
||||
[v1 | _] = normalized.variants
|
||||
assert v1.provider_variant_id == "5001"
|
||||
assert v1.title == "Black / S"
|
||||
assert v1.price == 1350
|
||||
assert v1.sku == "PCM-BK-S"
|
||||
assert v1.is_enabled == true
|
||||
assert v1.is_available == true
|
||||
assert v1.options == %{"Color" => "Black", "Size" => "S"}
|
||||
|
||||
# Provider data
|
||||
assert normalized.provider_data.catalog_product_id == 71
|
||||
assert is_list(normalized.provider_data.options)
|
||||
assert length(normalized.provider_data.catalog_variant_ids) == 4
|
||||
end
|
||||
|
||||
test "handles variant with missing colour or size" do
|
||||
sync_product = %{"id" => 1, "name" => "Test", "thumbnail_url" => nil}
|
||||
|
||||
sync_variants = [
|
||||
%{
|
||||
"id" => 100,
|
||||
"color" => nil,
|
||||
"size" => "M",
|
||||
"retail_price" => "10.00",
|
||||
"sku" => "T-M",
|
||||
"synced" => true,
|
||||
"availability_status" => "active",
|
||||
"files" => [],
|
||||
"product" => %{"product_id" => 1, "name" => "Test"},
|
||||
"variant_id" => 200
|
||||
}
|
||||
]
|
||||
|
||||
normalized = normalize_product(sync_product, sync_variants)
|
||||
|
||||
assert length(normalized.variants) == 1
|
||||
[v] = normalized.variants
|
||||
assert v.title == "M"
|
||||
assert v.options == %{"Size" => "M"}
|
||||
end
|
||||
|
||||
test "parses price strings correctly" do
|
||||
assert parse_price("13.50") == 1350
|
||||
assert parse_price("0.99") == 99
|
||||
assert parse_price("100.00") == 10000
|
||||
assert parse_price(nil) == 0
|
||||
assert parse_price(13.5) == 1350
|
||||
end
|
||||
end
|
||||
|
||||
describe "order status mapping" do
|
||||
test "maps Printful statuses to internal statuses" do
|
||||
assert map_order_status("draft") == "submitted"
|
||||
assert map_order_status("pending") == "submitted"
|
||||
assert map_order_status("inprocess") == "processing"
|
||||
assert map_order_status("fulfilled") == "shipped"
|
||||
assert map_order_status("shipped") == "shipped"
|
||||
assert map_order_status("delivered") == "delivered"
|
||||
assert map_order_status("canceled") == "cancelled"
|
||||
assert map_order_status("failed") == "submitted"
|
||||
assert map_order_status("onhold") == "submitted"
|
||||
assert map_order_status("unknown_status") == "submitted"
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Test fixtures — replicate Printful API responses
|
||||
# =============================================================================
|
||||
|
||||
defp printful_sync_product_response do
|
||||
sync_product = %{
|
||||
"id" => 456_789,
|
||||
"name" => "PC Man T-Shirt",
|
||||
"thumbnail_url" => "https://files.cdn.printful.com/thumb.png",
|
||||
"synced" => 4
|
||||
}
|
||||
|
||||
sync_variants = [
|
||||
%{
|
||||
"id" => 5001,
|
||||
"color" => "Black",
|
||||
"size" => "S",
|
||||
"retail_price" => "13.50",
|
||||
"sku" => "PCM-BK-S",
|
||||
"synced" => true,
|
||||
"availability_status" => "active",
|
||||
"variant_id" => 4011,
|
||||
"product" => %{
|
||||
"product_id" => 71,
|
||||
"variant_id" => 4011,
|
||||
"name" => "Bella+Canvas 3001 Unisex Short Sleeve Jersey T-Shirt"
|
||||
},
|
||||
"files" => [
|
||||
%{
|
||||
"type" => "preview",
|
||||
"preview_url" => "https://files.cdn.printful.com/preview-black.png",
|
||||
"thumbnail_url" => "https://files.cdn.printful.com/thumb-black.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
%{
|
||||
"id" => 5002,
|
||||
"color" => "Black",
|
||||
"size" => "M",
|
||||
"retail_price" => "13.50",
|
||||
"sku" => "PCM-BK-M",
|
||||
"synced" => true,
|
||||
"availability_status" => "active",
|
||||
"variant_id" => 4012,
|
||||
"product" => %{
|
||||
"product_id" => 71,
|
||||
"variant_id" => 4012,
|
||||
"name" => "Bella+Canvas 3001 Unisex Short Sleeve Jersey T-Shirt"
|
||||
},
|
||||
"files" => [
|
||||
%{
|
||||
"type" => "preview",
|
||||
"preview_url" => "https://files.cdn.printful.com/preview-black.png",
|
||||
"thumbnail_url" => "https://files.cdn.printful.com/thumb-black.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
%{
|
||||
"id" => 5003,
|
||||
"color" => "Natural",
|
||||
"size" => "S",
|
||||
"retail_price" => "13.50",
|
||||
"sku" => "PCM-NT-S",
|
||||
"synced" => true,
|
||||
"availability_status" => "active",
|
||||
"variant_id" => 4013,
|
||||
"product" => %{
|
||||
"product_id" => 71,
|
||||
"variant_id" => 4013,
|
||||
"name" => "Bella+Canvas 3001 Unisex Short Sleeve Jersey T-Shirt"
|
||||
},
|
||||
"files" => [
|
||||
%{
|
||||
"type" => "preview",
|
||||
"preview_url" => "https://files.cdn.printful.com/preview-natural.png",
|
||||
"thumbnail_url" => "https://files.cdn.printful.com/thumb-natural.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
%{
|
||||
"id" => 5004,
|
||||
"color" => "Natural",
|
||||
"size" => "M",
|
||||
"retail_price" => "13.50",
|
||||
"sku" => "PCM-NT-M",
|
||||
"synced" => true,
|
||||
"availability_status" => "active",
|
||||
"variant_id" => 4014,
|
||||
"product" => %{
|
||||
"product_id" => 71,
|
||||
"variant_id" => 4014,
|
||||
"name" => "Bella+Canvas 3001 Unisex Short Sleeve Jersey T-Shirt"
|
||||
},
|
||||
"files" => [
|
||||
%{
|
||||
"type" => "preview",
|
||||
"preview_url" => "https://files.cdn.printful.com/preview-natural.png",
|
||||
"thumbnail_url" => "https://files.cdn.printful.com/thumb-natural.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
{sync_product, sync_variants}
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Test helpers — replicate private normalization functions
|
||||
# =============================================================================
|
||||
|
||||
defp normalize_product(sync_product, sync_variants) do
|
||||
images = extract_preview_images(sync_variants)
|
||||
catalog_product_id = extract_catalog_product_id(sync_variants)
|
||||
catalog_variant_ids = Enum.map(sync_variants, & &1["variant_id"]) |> Enum.reject(&is_nil/1)
|
||||
|
||||
%{
|
||||
provider_product_id: to_string(sync_product["id"]),
|
||||
title: sync_product["name"],
|
||||
description: "",
|
||||
category: extract_category(sync_variants),
|
||||
images: images,
|
||||
variants: Enum.map(sync_variants, &normalize_variant/1),
|
||||
provider_data: %{
|
||||
catalog_product_id: catalog_product_id,
|
||||
catalog_variant_ids: catalog_variant_ids,
|
||||
thumbnail_url: sync_product["thumbnail_url"],
|
||||
options: build_option_types(sync_variants),
|
||||
raw: %{sync_product: sync_product}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_variant(sv) do
|
||||
%{
|
||||
provider_variant_id: to_string(sv["id"]),
|
||||
title: build_variant_title(sv),
|
||||
sku: sv["sku"],
|
||||
price: parse_price(sv["retail_price"]),
|
||||
cost: nil,
|
||||
options: build_variant_options(sv),
|
||||
is_enabled: sv["synced"] == true,
|
||||
is_available: sv["availability_status"] == "active"
|
||||
}
|
||||
end
|
||||
|
||||
defp build_variant_title(sv) do
|
||||
[sv["color"], sv["size"]] |> Enum.reject(&is_nil/1) |> Enum.join(" / ")
|
||||
end
|
||||
|
||||
defp build_variant_options(sv) do
|
||||
opts = %{}
|
||||
opts = if sv["color"], do: Map.put(opts, "Color", sv["color"]), else: opts
|
||||
if sv["size"], do: Map.put(opts, "Size", sv["size"]), else: opts
|
||||
end
|
||||
|
||||
defp extract_preview_images(sync_variants) do
|
||||
sync_variants
|
||||
|> Enum.flat_map(fn sv ->
|
||||
(sv["files"] || [])
|
||||
|> Enum.filter(&(&1["type"] == "preview"))
|
||||
|> Enum.map(fn file ->
|
||||
%{src: file["preview_url"] || file["thumbnail_url"], color: sv["color"]}
|
||||
end)
|
||||
end)
|
||||
|> Enum.uniq_by(& &1.color)
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {img, index} ->
|
||||
%{src: img.src, position: index, alt: img.color}
|
||||
end)
|
||||
end
|
||||
|
||||
defp extract_catalog_product_id(sync_variants) do
|
||||
Enum.find_value(sync_variants, 0, fn sv -> get_in(sv, ["product", "product_id"]) end)
|
||||
end
|
||||
|
||||
defp build_option_types(sync_variants) do
|
||||
colors =
|
||||
sync_variants
|
||||
|> Enum.map(& &1["color"])
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.uniq()
|
||||
|> Enum.map(fn c -> %{"title" => c} end)
|
||||
|
||||
sizes =
|
||||
sync_variants
|
||||
|> Enum.map(& &1["size"])
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.uniq()
|
||||
|> Enum.map(fn s -> %{"title" => s} end)
|
||||
|
||||
opts = []
|
||||
|
||||
opts =
|
||||
if colors != [],
|
||||
do: opts ++ [%{"name" => "Color", "type" => "color", "values" => colors}],
|
||||
else: opts
|
||||
|
||||
if sizes != [],
|
||||
do: opts ++ [%{"name" => "Size", "type" => "size", "values" => sizes}],
|
||||
else: opts
|
||||
end
|
||||
|
||||
defp extract_category(sync_variants) do
|
||||
case sync_variants do
|
||||
[sv | _] ->
|
||||
product_name = get_in(sv, ["product", "name"]) || ""
|
||||
categorize_from_name(product_name)
|
||||
|
||||
[] ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp categorize_from_name(name) do
|
||||
name_lower = String.downcase(name)
|
||||
|
||||
cond do
|
||||
has_keyword?(name_lower, ~w[t-shirt tshirt shirt hoodie sweatshirt jogger]) -> "Apparel"
|
||||
has_keyword?(name_lower, ~w[canvas poster print frame]) -> "Canvas Prints"
|
||||
has_keyword?(name_lower, ~w[mug cup blanket pillow cushion]) -> "Homewares"
|
||||
has_keyword?(name_lower, ~w[notebook journal]) -> "Stationery"
|
||||
has_keyword?(name_lower, ~w[phone case bag tote hat cap]) -> "Accessories"
|
||||
true -> "Apparel"
|
||||
end
|
||||
end
|
||||
|
||||
defp has_keyword?(text, keywords), do: Enum.any?(keywords, &String.contains?(text, &1))
|
||||
|
||||
defp parse_price(price) when is_binary(price) do
|
||||
case Float.parse(price) do
|
||||
{float, _} -> round(float * 100)
|
||||
:error -> 0
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_price(price) when is_number(price), do: round(price * 100)
|
||||
defp parse_price(_), do: 0
|
||||
|
||||
defp map_order_status("draft"), do: "submitted"
|
||||
defp map_order_status("pending"), do: "submitted"
|
||||
defp map_order_status("inprocess"), do: "processing"
|
||||
defp map_order_status("fulfilled"), do: "shipped"
|
||||
defp map_order_status("shipped"), do: "shipped"
|
||||
defp map_order_status("delivered"), do: "delivered"
|
||||
defp map_order_status("canceled"), do: "cancelled"
|
||||
defp map_order_status("failed"), do: "submitted"
|
||||
defp map_order_status("onhold"), do: "submitted"
|
||||
defp map_order_status(_), do: "submitted"
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user