# CSV Import — Design

**Date:** 2026-08-11
**Status:** Draft
**Scope:** A third ingestion path, alongside Shopify OAuth and the HTTP product API.

---

## 1. Purpose

Let a merchant put a catalogue in by uploading a spreadsheet.

Today a merchant needs either a Shopify store or a JSON product API. Plenty have
neither — they have a spreadsheet. This is the lowest-friction way in, and it is
also the fastest way for *us* to load a realistic catalogue for testing.

**Success criteria:**

1. A merchant uploads a CSV and their products appear in the catalogue.
2. A file exported from Shopify admin works unchanged.
3. A bad row is reported by row number; it does not fail the whole file.
4. Re-uploading a corrected file replaces the previous batch rather than
   duplicating it.

## 2. The format

**Shopify's product CSV**, real export headers — `Handle`, `Title`,
`Body (HTML)`, `Vendor`, `Type`, `Tags`, `Published`, `Option1/2 Name+Value`,
`Variant SKU`, `Variant Inventory Qty`, `Variant Price`,
`Variant Compare At Price`, `Image Src`, `Status`.

Chosen over inventing a schema because a merchant on Shopify can export and
upload with no editing, and a merchant not on Shopify gets a format with
documentation already written for it.

**Unknown columns are ignored, not rejected.** A real Shopify export has ~44
columns; we read 17. Rejecting a file for carrying `Variant Weight Unit` would
make the "export and upload" promise false.

**Rows are self-contained in our template.** Shopify writes product fields only
on a product's first row. We accept that, but our sample repeats them on every
row, because the blank-cell convention is unusable for someone filling a
spreadsheet by hand. Both parse identically: fields are taken from the first row
of a handle, and blanks on later rows inherit.

## 3. Grouping

`Handle` groups rows into one product. Each row is a variant.

```
oxford-shirt, M, White, qty 12  ┐
oxford-shirt, L, White, qty 8   ├─ one product, three variants
oxford-shirt, M, Blue,  qty 0   ┘
```

Product-level values come from the **first row** of the handle. Variant rows
contribute price, stock and options.

- `price_cents` — the **lowest** variant price, matching how the Shopify
  connector already treats variants
- `in_stock` — true if **any** variant has stock
- `options` — collected across all rows
- a row with a blank `Handle` and no preceding handle is an error

## 4. Why it is not a syncable source

Shopify and the HTTP API can be re-fetched. **A CSV cannot** — the file exists
only at upload time. So a CSV batch is not registered in `product_sources` and
`POST /catalog/sync` ignores it. Registering it would create a source that
permanently fails to sync.

It still gets a `source_kind` (`csv`) and a `source_ref`, because the whole
catalogue table is scoped by that pair. Without it, a CSV upload and a Shopify
sync would delete each other's products — the exact bug that was caught during
Phase 0 and guarded against in `save_products`.

**`source_ref` is the uploaded file's name**, so:

- re-uploading `spring-2026.csv` **replaces** that batch
- uploading `winter-2026.csv` **adds** a second batch
- neither touches Shopify, HTTP or crawled products

This is the same scoped-replace contract every other source already follows.

## 5. Currency

**Shopify's CSV has no currency column.** The store's currency is implicit.

This system's rule, set after a bug that mislabelled 194 USD products as INR, is
that currency is never guessed. So it is a **required form field on the upload
request**, exactly as the HTTP source requires it at connect time. A file with no
declared currency is rejected before any row is parsed.

Adding a `Currency` column instead was rejected: it would break the promise that
a raw Shopify export works unchanged.

## 6. Validation

Two passes, and the split matters.

**File-level, fatal:** not CSV, unreadable encoding, no `Handle` or `Title`
column, no data rows, missing currency. Nothing is written.

**Row-level, survivable:** a bad row is skipped and reported with its **row
number as it appears in the spreadsheet** (header is row 1, so the first data row
is row 2 — an off-by-one here sends a merchant to the wrong line).

| Row problem | Behaviour |
|---|---|
| no `Handle` | skipped, reported |
| no `Title` on a handle's first row | skipped, reported |
| unparseable `Variant Price` | skipped, reported |
| negative price or quantity | skipped, reported |
| blank `Image Src` | accepted — the product simply has no image |
| unknown extra columns | ignored silently |

**A file where every row fails is still a 200** with zero imported and the full
error list. It is a data problem, not a server error.

## 7. Dry run

`dry_run=true` parses, validates and reports **without writing anything**.

A merchant uploading 5,000 products should be able to find out that column 12 is
wrong before it lands in their live catalogue. This is cheap to build because
validation is already separate from persistence.

## 8. Endpoints

```
POST /catalog/import/csv     multipart: file, tenant_id, currency, dry_run?
GET  /catalog/sample.csv     the template, as a download
```

The import response:

```json
{"source_ref": "spring-2026.csv", "rows": 12, "products": 8,
 "imported": 8, "skipped": 0, "replaced": 3, "dry_run": false,
 "errors": [{"row": 7, "reason": "Variant Price is not a number: 'ask'"}]}
```

`replaced` is how many products from a previous upload of the same file were
removed, so a merchant can tell an update from an addition.

## 9. What happens after import

Nothing automatic. Imported products land in `strategist_products` exactly like
synced ones, and the existing jobs run over them unchanged:

```
POST /catalog/import/csv  ->  POST /catalog/enrich  ->  POST /catalog/pair
```

Deliberately not chained. Enrichment costs money and pairing takes time; a
merchant fixing a typo in one row should not trigger both. This matches how sync
already behaves.

## 10. Known limitations

**No per-variant storage.** Variants collapse into one product row, as they do
for Shopify today. Per-size stock stays the open gap it has been since Phase 1.

**No image validation.** `Image Src` is stored as given; a broken URL shows as a
broken image.

**No product URL.** Shopify's CSV has no product-URL column — it is derived from
the handle and the store domain, which we do not know for a CSV. Products
therefore import with a null URL and are flagged `missing_fields`, meaning they
are visible in the catalogue but **not served in recommendations**. A merchant who
wants them served must give a store domain. This is the same rule already applied
to any product without a URL, and it is the most likely surprise in this feature.

**No upload size limit.** A 200MB CSV would be read into memory. Fine for a
merchant spreadsheet, not for a hostile upload.

**No authentication**, consistent with the rest of the service.

## 11. Out of scope

Per-variant rows in the catalogue; scheduled re-import; image hosting;
Excel (`.xlsx`) files; column mapping UI.
