# Shopify ↔ Digistore24 Integration

Django application that connects a Shopify store with Digistore24 and automates SEO blog publishing to Shopify.

| Area | What it does |
|------|----------------|
| **Products** | Sync Shopify products → Digistore24; store ID mappings and metafields |
| **Orders** | Digistore24 IPN → create matching Shopify orders |
| **Blog** | Plan topics weekly; generate and publish articles on schedule |

---

## Quick start

```bash
cd shopify_to_digistore
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt

cp .env.example .env   # edit credentials
python manage.py migrate
python manage.py runserver
```

### Required environment

| Variable | Purpose |
|----------|---------|
| `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS`, `HOST_URL` | Django |
| `DB_*` | PostgreSQL |
| `SHOPIFY_API_KEY`, `SHOPIFY_API_SECRET`, `SHOPIFY_API_VERSION`, `SHOPIFY_STORE` | Shopify app |
| `STORE_URL` | Public storefront URL (blog links, previews) |
| `DIGISTORE_API_URL`, `DIGISTORE_API_KEY` | Digistore24 API |
| `DIGISTORE_ORDERFORM_ID` | Order form attached to synced Digistore products (default `232971`) |
| `DIGISTORE_TRACKING_NOTIFY_VIA_EMAIL` | Email the buyer when Shopify tracking is synced to Digistore (default `False`) |
| `DIGISTORE_TRACKING_SYNC_AFTER` | Automatic webhook sync only for orders created on/after this date, UTC (default `2026-09-08`) |
| `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL` | Blog text (Claude) |
| `OPENAI_API_KEY`, `OPENAI_IMAGE_MODEL` | Blog images (default `gpt-image-2`) |

`DIGISTORE_API_URL` must be exactly `https://www.digistore24.com/api/call` (no extra path).

---

## Shopify OAuth

Install the app on your store so the access token is saved in `webhooks.ShopifyStore`.

**Install URL**

```text
https://<HOST_URL>/webhooks/shopify/install/?shop=<SHOP_DOMAIN>
```

**Callback URL** (whitelist in Shopify Partners)

```text
https://<HOST_URL>/webhooks/shopify/callback/
```

### API scopes

Enable these in **Shopify Partners** and in `webhooks/views.py`, then **reinstall** OAuth so the token is refreshed.

| Scope | Used for |
|-------|----------|
| `read_products`, `write_products` | Product sync, metafields, webhooks |
| `read_orders`, `write_orders` | Digistore IPN → Shopify `orderCreate` |
| `read_customers`, `write_customers` | Bulk customer tag updates |
| `read_fulfillments` | `fulfillments/create` and `fulfillments/update` webhooks |
| `read_content`, `write_content` | Blog articles |
| `read_files`, `write_files` | Blog images on Shopify CDN |

Without `write_files`, featured images still publish via REST attachment; inline images may use temporary preview URLs on `HOST_URL` until scopes are granted.

Register webhooks after install:

```bash
python manage.py setup_shopify_webhooks
```

---

## Products (Shopify → Digistore24)

```bash
python manage.py sync_product <SHOPIFY_PRODUCT_ID>
python manage.py sync_products [--limit=100]
python manage.py list_products [--sort-by=name --limit=50]
python manage.py delete_products <DIGISTORE_PRODUCT_ID> [...]
python manage.py get_digistore_product <DIGISTORE_PRODUCT_ID>
python manage.py setup_digistore_ipn https://<HOST_URL>/webhooks/digistore/ipn/
```

High-level flow:

1. Fetch product from Shopify (GraphQL).
2. Push to Digistore24 (`createProduct` / `updateProduct`, payment plan, legal requirements).
3. Save mapping in `products.Product` (`shopify_product_id`, `shopify_variant_id`, `digistore_product_id`).
4. Write Digistore product id to a Shopify product metafield.

---

## Orders (Digistore24 IPN → Shopify)

**IPN URL:** `https://<HOST_URL>/webhooks/digistore/ipn/`

On `on_payment`:

1. Verify SHA signature (optional `IPN_SHA_PASSPHRASE`).
2. Upsert `orders.Order` by `digistore_order_id`.
3. Skip if a Shopify order already exists for that Digistore order.
4. Otherwise create a Shopify order via GraphQL `orderCreate` (mapped variants when possible).
5. Store `shopify_order_id` on the order record.

```bash
python manage.py sync_digistore_orders --date=YYYY-MM-DD [--dry-run]
python manage.py sync_digistore_orders_daily [--days-ago=1] [--dry-run]
```

Daily cron (midnight UTC) is configured in `settings.py` via `django-crontab`.

### Fulfillment tracking (Shopify → Digistore24)

When Shopify fulfills an order (or updates tracking), the app pushes tracking to Digistore `updateDelivery`.

Webhook topics (registered by `setup_shopify_webhooks`):

- `fulfillments/create` → `/webhooks/shopify/fulfillments-create/`
- `fulfillments/update` → `/webhooks/shopify/fulfillments-update/`

These topics need the `read_fulfillments` scope. Add it in Shopify Partners, **reinstall OAuth**, then run `python manage.py setup_shopify_webhooks` again.

Buyer email (`notify_via_email`) is off by default. Set `DIGISTORE_TRACKING_NOTIFY_VIA_EMAIL=True` to email Digistore buyers on automatic webhook syncs.

Webhooks only sync **orders created on or after** `DIGISTORE_TRACKING_SYNC_AFTER` (default `2026-09-08`). Carrier status updates on older already-fulfilled orders are ignored. Use the management command to backfill a specific older order.

Manual / backfill one order:

```bash
python manage.py sync_digistore_tracking --digistore-order-id=XXXX
python manage.py sync_digistore_tracking --shopify-order-id=123 --dry-run
python manage.py sync_digistore_tracking --digistore-order-id=XXXX --notify
python manage.py sync_digistore_tracking --digistore-order-id=XXXX --no-notify
```

`--notify` / `--no-notify` override the env setting for that run.

---

## Blog automation

AI-generated blog posts: weekly planning, content + images on the publish day, live on Shopify with SEO metafields.

### Admin

| Page | Path |
|------|------|
| Settings | `/admin/blogs/blogsettings/1/change/` |
| Actions (plan / test) | `/admin/blogs/blogsettings/actions/` |
| Articles | `/admin/blogs/blogarticle/` |

Configure:

- `posts_per_week`, `blog_list_url`, `description` (niche), `shopify_blog_id`, `blog_author_name` (Shopify byline; else `store_name`)
- `include_images` (on/off)
- Enable automation

Deleting an article in Django admin also deletes it on Shopify when `shopify_article_id` is set.

### Pipeline

```text
Monday (plan)     run_weekly_blog_automation
                  → scrape existing blog + refresh trends + plan topics
                  → status: planned (no content yet)

Scheduled day     publish_due_blog_articles
                  → generate content (Claude)
                  → generate images (OpenAI GPT Image 2)
                  → upload images to Shopify CDN
                  → publish / update article on Shopify
                  → status: published
```

Article statuses: `planned` → `generating` → `ready` → `published` (or `failed`).

### Content and images

| Step | Service | Details |
|------|---------|---------|
| Text | `content_service` | Claude; 1,500–2,500 words; German by default |
| Images | `openai_image_client` | Model from `OPENAI_IMAGE_MODEL` (default `gpt-image-2`) |
| Staging | `blog_image_storage` | PNGs under `media/blog_images/` until publish |
| Publish images | `shopify_file_service` | GraphQL staged upload → `cdn.shopify.com` URLs |
| Publish article | `shopify_blog_service` | REST create/update; featured image separate from body |

**Image count:** Claude is prompted for **3–6** `[IMAGE: ...]` placeholders per article.

- **1st placeholder** → featured/header image only (`featured_image_url` + Shopify article `image`). **Not** placed in `body_html`.
- **Remaining placeholders** → inline images in the article body (`inline-1`, `inline-2`, …).

Each image is one OpenAI API call (~2–3 minutes). A full set of 6 images can take ~15+ minutes per article.

### Cron

| Schedule | Command |
|----------|---------|
| Mon 09:00 UTC | `run_weekly_blog_automation` |
| Daily 09:30 UTC | `publish_due_blog_articles` |

```bash
python manage.py crontab add
python manage.py crontab show
```

### Commands

```bash
# Plan week (fast; workers not started)
python manage.py run_weekly_blog_automation
python manage.py run_weekly_blog_automation --dry-run

# Publish posts due today
python manage.py publish_due_blog_articles              # background
python manage.py publish_due_blog_articles --sync       # blocking

# One article
python manage.py process_and_publish_blog_article <ARTICLE_ID>

# Plan + immediately generate/publish one (test)
python manage.py run_weekly_blog_automation --sync --limit=1
```

**Already generated (`ready`), publish to Shopify only:**

```bash
python manage.py shell -c "
from blogs.services.shopify_blog_service import ShopifyBlogService
ShopifyBlogService.publish_article(<ARTICLE_ID>, published=True)
"
```

**Background worker for one article:**

```bash
python manage.py shell -c "
from blogs.services.background_jobs import enqueue_process_and_publish_article
enqueue_process_and_publish_article(<ARTICLE_ID>)
"
```

### SEO on publish

Sets Shopify article fields plus `global.title_tag` and `global.description_tag` metafields (Admin “Search engine listing”).

### Blog environment (optional limits)

```env
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-sonnet-4-20250514
OPENAI_API_KEY=
OPENAI_IMAGE_MODEL=gpt-image-2
# BLOG_RESEARCH_MAX_POSTS=15
# BLOG_RESEARCH_MAX_DISCOVER=30
# BLOG_FETCH_TIMEOUT=30
# BLOG_CONTENT_MAX_TOKENS=12000
# BLOG_TRENDING_TOPICS_MAX=15
```

---

## Management commands (reference)

```bash
# Products & Digistore
python manage.py sync_product <SHOPIFY_PRODUCT_ID>
python manage.py sync_products [--limit=100]
python manage.py list_products
python manage.py delete_products <ID> [...]
python manage.py get_digistore_product <ID>
python manage.py list_digistore_payment_plans <ID>
python manage.py fix_digistore_paymentplan_ids [--apply]
python manage.py clean_digistore_payment_plans [--execute]
python manage.py setup_digistore_ipn <IPN_URL>

# Orders
python manage.py sync_digistore_orders --date=YYYY-MM-DD [--dry-run]
python manage.py sync_digistore_orders_daily [--days-ago=1] [--dry-run]
python manage.py sync_digistore_tracking --digistore-order-id=XXXX [--notify|--no-notify] [--dry-run]

# Blog
python manage.py run_weekly_blog_automation [--dry-run] [--sync] [--limit=N]
python manage.py publish_due_blog_articles [--sync]
python manage.py process_and_publish_blog_article <ARTICLE_ID>
python manage.py process_blog_article <ARTICLE_ID>   # generate only, no publish

# Shopify
python manage.py setup_shopify_webhooks
```

---

## Logs

| File | Contents |
|------|----------|
| `app.log` | IPNs, product sync, blog service errors |
| `logs/blog_article_<id>.log` | Per-article generate + publish worker output |

---

## Project layout (blog)

```text
blogs/services/
  automation_service.py    # weekly plan + publish orchestration
  content_service.py       # Claude article text
  openai_image_client.py   # GPT Image 2 generation
  blog_image_storage.py    # local PNG staging
  image_service.py         # generate + inject inline; upload at publish
  shopify_file_service.py  # Shopify Files (CDN URLs)
  shopify_blog_service.py  # article create/update + SEO
  shopify_admin_client.py  # shared Shopify API auth
  background_jobs.py       # subprocess workers
```
