# SKUFuse — Shopify Catalog Update Review (`overbifrost/skufuse-shopify`) Actor

Compare current and proposed Shopify product CSVs before import. SKUFuse flags significant price changes, stock going to zero, unmatched or ambiguous SKUs, and rows requiring manual review. Comparison only — it never writes to your store.

- **URL**: https://apify.com/overbifrost/skufuse-shopify.md
- **Developed by:** [Lasse Bjerkholt Olsen](https://apify.com/overbifrost) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $500.00 / 1,000 comparison completeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## SKUFuse — Shopify Catalog Update Review

SKUFuse compares a **current Shopify product CSV** with a **proposed update
CSV** and reports every change that needs human review **before** you import it
— price drops or increases beyond your thresholds, inventory going to zero,
unmatched SKUs, duplicate SKUs, and rows it could not check.

**What it is not.** SKUFuse never imports anything. It does not connect to a
store, write to a store, repair files, or verify live state. A clean report
(`no_findings`) means *no review items were found* — it is not a guarantee that
an import is safe.

### Quick start (Apify Console)

1. Open the Actor in Apify Console and go to the **Input** tab.
2. Leave **Run synthetic demo** turned **on** and press **Start** — SKUFuse
   compares its built-in DEMO-\* data and you immediately see what a report
   looks like. No files needed.
3. For your real catalog, turn **Run synthetic demo** **off**, then:
   - **Upload** your current products CSV and your proposed update CSV under
     **Files** — straight from your computer, up to 10 MB each.
   - Set your **Currency** and actively check the declarations.
4. Press **Start**, then open **Review report (HTML)** in the run's output —
   a readable page with the status, the counts that matter, and the findings.
   The canonical report and review sheet are also in storage:
   - **Key-value store** — `REVIEW.html` (the readable report), `OUTPUT`
     (canonical JSON report), `FINDINGS.csv` (spreadsheet-ready review sheet),
     `RUN_METADATA` (operational/billing metadata).
   - **Dataset** — exactly one item: the same JSON report.

Pasting CSV text or pointing to a public HTTPS URL works too — those fields
live under **Advanced** and are meant for API use and automation. Each file
still needs exactly one source: upload, paste, or URL.

### Synthetic example input

The demonstration mode exists so you can evaluate the output before preparing
exports — and it is what the platform's automated health check runs. It
compares a fixed, built-in synthetic pair and nothing else:

```json
{ "demoMode": true }
```

An entirely empty input object `{}` selects the same demonstration — that is
the input a bare default-input run produces. Every other option
(`currency`, `rules`, `maxFindings`, the declarations) is honored exactly as in
a real run when supplied.

**The demonstration never mixes with your data.** `demoMode: true` combined
with any source field (`currentCsvFile`, `currentCsv`, `currentCsvUrl`,
`proposedCsvFile`, `proposedCsv`, `proposedCsvUrl`) fails `INPUT_INVALID`.
Reports from it are marked
`syntheticDemo: true` so they can never be mistaken for a real catalog
comparison. A demo run is billed like any other run (3 evaluated rows —
see [Pricing](#pricing-pay-per-event)).

The built-in pair: `DEMO-COF-001` drops `49.90 → 4.99` (~90 %) with inventory
`12 → 0`; `DEMO-MUG-002` is unchanged; `DEMO-TEE-003` rises `25.00 → 27.50`
(+10 %, under the default 30 % increase threshold).

#### Expected demo output

`status: "review_required"`, `syntheticDemo: true`, `evaluatedRows: 3`,
`coverageComplete: true`, and two findings on `DEMO-COF-001`:
`PRICE_DROP_EXCEEDS_THRESHOLD` (`-90.0000 %` vs. threshold `30`) and
`STOCK_BECOMES_ZERO` (`12 → 0`).

### Pricing (pay-per-event)

| Event | Price | Units charged |
|---|---|---|
| `comparison-completed` | **$0.50** | 1 if `evaluatedRows > 0`, else 0 |
| `row-evaluated` | **$0.00025** | `evaluatedRows` |
| `apify-actor-start` | **$0.00005** | 1 per run — platform-managed, always applies |

Charged **only after** all report outputs are delivered. A 3-row demonstration
run costs $0.50080; a 1,000-row comparison costs $0.75005.

`apify-actor-start` is charged automatically once per run at the configured
memory (≤1 GiB) before any Actor code runs. It is a platform charge, not a
SKUFuse custom event: the Actor never calls `charge()` for it, it never appears
in `RUN_METADATA.billing.expected`/`confirmed`, and it applies on **every**
pay-per-event run — including zero-custom-charge outcomes like header-only,
invalid-input, or insufficient-budget runs. `apify-default-dataset-item` and
any other paid synthetic event are not permitted.

Not billable by custom events: unmatched rows, uncheckable rows, ignored
image/metadata-only rows, header-only files, and insufficient-budget runs. An
unchanged evaluated row is billable; a row with several findings is still one
row event.

Before charging, the run checks the **combined** cost (the platform start fee

- base + all row events) against the run's budget cap with exact decimal
  arithmetic — the start fee is inside the same `maxTotalChargeUsd` cap. If the
  whole report does not fit, it fails `BUDGET_INSUFFICIENT` — no report, no
  partial custom charges, no billed prefix. Effective prices come from the live
  run's pricing info, never hardcoded.

Local runs (`billing.mode: local`) and unconfigured private Actors
(`unconfigured`) never charge (`billing.outcome: not_applicable`).

#### Failed runs and confirmed charges

Charging is the last step, so a run can fail *after* one or more charges were
already confirmed. A failed run is **not** automatically non-billable — the
billing outcome is a separate fact recorded in `RUN_METADATA.billing.outcome`:

| Outcome | Meaning |
|---|---|
| `blocked` | Nothing was attempted or confirmed — e.g. the run failed before charging began. Not billable. |
| `completed` | Every expected unit was confirmed — even if the run then failed during final bookkeeping. The charge stands. |
| `partial` | Some units were confirmed but charging did not complete — the confirmed units stand. |
| `unknown` | A charge's result could not be confirmed. Uncertain is **not** zero; the Actor never retries an uncertain charge. |
| `not_required` | Pay-per-event run with zero evaluated rows — no custom units due (the platform's `apify-actor-start` fee still applies). |
| `not_applicable` | Local or unconfigured run — charging never ran. |
| `pending` | Transient — a run killed before final bookkeeping may leave this in the last persisted metadata; it is not a settled outcome. |

Report outputs already delivered (`OUTPUT`, `FINDINGS.csv`, `REVIEW.html`, the dataset item)
stay in the run's storage whatever the outcome. The exact expected and
confirmed unit counts per event are in `RUN_METADATA.billing.expected` and
`RUN_METADATA.billing.confirmed`; the platform-side charge record is in Apify
Console under the run's usage/charges.

There is no all-or-nothing charging, no automatic refund, and no exactly-once
guarantee: a confirmed charge is not undone by a later failure, and each new
run is a new billable comparison.

### Limitations

- **Shopify product CSV, single-location inventory only**, one fixed profile
  (`shopify_product_price_stock_single_location_v1`). Shopify's product CSV
  documents `Inventory quantity` as the stock of a single location — there is
  no multi-location quantity in this format, so multi-location inventory is
  out of scope. Other platforms, product types, and CSV dialects are out of
  scope.
- **Comparison only.** No import, no store connection, no live-state check,
  no repair. `storeStateVerified` and `importSafetyGuaranteed` are always
  `false` in the report — by design.
- Exact, case-sensitive SKU matching; duplicates are never resolved — every
  duplicate makes its SKU uncheckable.
- `currency` is a declared label for report context — the Actor does not
  convert prices or verify it against a store.
- Hard ceilings below; exceeding them fails the run with a stable error code —
  source data is never silently truncated.

| Resource | Ceiling |
|---|---|
| Whole input object | 2 MiB |
| Each inline CSV | 512 KiB |
| Each remote CSV | 10 MiB streamed (Content-Length is not trusted) |
| Data records per file | 100,000 |
| Columns per file | 128 |
| Retained findings | `maxFindings`, hard cap 1000 |
| Source URL | HTTPS only, port 443, ≤ 4096 bytes |
| Remote retrieval | 30 s total (DNS 5 s, connect/TLS 10 s, idle 10 s) |
| Whole run | 100 s application deadline |

### Privacy and retention

- The demonstration mode sends **no catalog data anywhere** — it compares
  built-in DEMO-\* rows only, and `demoMode: true` combined with any source
  field is rejected.
- Apify retains the run's **INPUT** record under the Actor's storage and
  retention settings. Input may contain complete inline catalogs
  (`currentCsv`/`proposedCsv`) or source URLs — including signed URLs with
  embedded credentials. Prefer pre-signed URLs with short expiry, or inline
  CSVs you are comfortable retaining, and clean up run storage when done.
- The report echoes catalog content — that is the product. It stays in your
  run's storage under Apify's normal storage retention rules.
- `ERROR`, `RUN_METADATA`, `RUN_STATE`, and log output contain **no** URLs,
  SKUs, cell values, secrets, or raw exception text. Input validation errors
  point at a sanitized location (a field name for supported fields, a fixed
  marker for unsupported ones) — never the offending property name verbatim.
- No analytics, no telemetry, no cross-run stores, no external services beyond
  your two declared source URLs.
- Re-running the Actor in a used store fails `RESUME_UNSUPPORTED` — a run
  cannot silently append to or re-charge an earlier run's evidence.

### Running a real comparison

#### Prepare the two CSVs

Export products from Shopify admin (**Products → Export**) for both the current
and proposed files. Fixed Shopify profile
`shopify_product_price_stock_single_location_v1`:

- Required columns: **`SKU`, `Price`, `Inventory tracker`,
  `Inventory quantity`** — exact current native names, case-sensitive,
  order-insensitive; extra columns (e.g. `URL handle`, `Title`, `Option1
  name`, image columns) are ignored and reported under `ignoredColumns`.
  Legacy `Variant SKU`/`Variant Price`-style headers are not aliases — a
  legacy-format file fails `CSV_MISSING_COLUMN`.
- **SKU** — exact, case-sensitive matching; up to 256 UTF-8 bytes; boundary
  whitespace is rejected, not trimmed
- **Price** — 1–9 integer digits plus 0–6 decimals (e.g. `49.90`)
- **Inventory tracker** — only `shopify` makes the row's quantity comparable.
  Blank, `deny`, `shipwire`, `amazon_marketplace_web`, or any other value is
  flagged `UNSUPPORTED_INVENTORY_TRACKER` and the row's quantity is not
  evaluated
- **Inventory quantity** — signed 32-bit integer (`-2147483648`–`2147483647`);
  negative values are valid Shopify data, `0` transitions are flagged
- **Image/metadata-only rows are ignored, not diagnosed.** Shopify CSVs
  interleave product, variant, and continuation rows under a repeated
  `URL handle`. A data record that carries no comparison signal — no SKU,
  Price, Title, option, or other product/variant attribute, i.e. the
  handle-plus-image-URL rows Shopify emits for extra product images — is
  skipped entirely: never matched, never billed, never a finding. The counts
  are disclosed in the report's `ignoredRows.current`/`ignoredRows.proposed`.
- Encoding: UTF-8 only; one leading BOM tolerated; NUL rejected; LF/CRLF/CR
  endings all accepted

#### Declarations — actively confirmed, never pre-filled

`confirmComparablePrices` and `confirmSingleLocationInventory` have no default
or prefill anywhere: you must set both to `true` yourself. The first asserts
both files share currency, tax basis, and price meaning. The second asserts
you understand Shopify's product CSV exposes single-location inventory only —
comparing multi-location semantics through this profile is unsupported. Real
comparisons fail `PRICE_CONTEXT_UNCONFIRMED` /
`SINGLE_LOCATION_UNCONFIRMED` without them. Neither is required for
`demoMode` — no caller files exist for them to describe.

#### Input contract

One JSON object. Unknown properties, nulls, wrong types, and unsupported
options are rejected — nothing is coerced or ignored silently.

| Field | Required | Meaning |
|---|---|---|
| `demoMode` | no | `true` (or an empty input object) selects the built-in synthetic demonstration. Forbidden alongside any source field. |
| `schemaVersion` | real comparisons | Must be exactly `shopify_product_price_stock_single_location_v1`; also accepted (and validated) in demo mode |
| `currency` | real comparisons | Three-letter code `^[A-Z]{3}$`, e.g. `USD` — a declared label only; no conversion or live lookup. Demo defaults to `EUR` |
| `confirmComparablePrices` | real comparisons | Must be `true`: you assert both files share currency, tax basis, and price meaning |
| `confirmSingleLocationInventory` | real comparisons | Must be `true`: you assert the comparison is single-location inventory only |
| `currentCsvFile` / `currentCsv` / `currentCsvUrl` | real comparisons, one of them | Current catalog — file upload, inline text, or HTTPS URL — never more than one, never none, never in demo mode |
| `proposedCsvFile` / `proposedCsv` / `proposedCsvUrl` | real comparisons, one of them | Proposed update, same rules |
| `rules.maxPriceDropPercent` | no, default `30` | 0–100, step 0.01. Flagged when the decrease **strictly exceeds** it |
| `rules.maxPriceIncreasePercent` | no, default `30` | 0–10000, step 0.01. Same rule for increases |
| `rules.flagStockToZero` | no, default `true` | Flag `Inventory tracker: shopify` quantity changing positive → 0 |
| `maxFindings` | no, default `1000` | 1–1000. Bounds the returned finding list only — every finding is still counted |

Mixed modes are allowed (e.g. current uploaded + proposed inline, or current
inline + proposed via URL). `currentCsvFile`/`proposedCsvFile` take the
reference the Apify `fileupload` editor produces — an
`https://api.apify.com/v2/key-value-stores/{storeId}/records/{key}` URL.
Uploads are fetched from that key-value store record, capped at 10 MiB, and
validated byte-for-byte like every other source; any other URL shape is
rejected as `INPUT_INVALID`.

#### The 90 % drop example

Current `A100` at `49.90`, proposed at `4.99` — a ~90 % decrease, plus
inventory `12 → 0`. With the default 30 % drop threshold the report returns
`status: "review_required"` with two findings:

- `PRICE_DROP_EXCEEDS_THRESHOLD` — before `49.90`, after `4.99`,
  `changePercent: "-90.0000"`, threshold `30`
- `STOCK_BECOMES_ZERO` — quantity `12 → 0`

### Output

#### `REVIEW.html` (human-readable review report)

A self-contained HTML page generated from the canonical report: status
headline, the counts that drive a decision (evaluated / unmatched /
uncheckable / ignored rows, findings), scope and truncation notices, and the
retained findings in readable form. It carries no scripts or external assets
and is safe to open anywhere. `REVIEW.html` is a convenience presentation —
`OUTPUT` remains the canonical, machine-readable report.

#### `OUTPUT` (JSON report, also the single dataset item)

| Field | Meaning |
|---|---|
| `status` | `no_findings` · `review_required` · `incomplete` · `no_changes` |
| `scope` | Always `comparison_only` |
| `storeStateVerified` / `importSafetyGuaranteed` | Always `false` — by design |
| `syntheticDemo` | `true` only for the built-in synthetic demonstration — never for a real catalog comparison |
| `currency`, `rules`, `maxFindings` | The normalized options the report was produced under |
| `currentRows`, `proposedRows` | Comparison-relevant data-record counts (image/metadata-only rows excluded) |
| `ignoredRows.current` / `ignoredRows.proposed` | Image/metadata-only rows skipped per file |
| `evaluatedRows` | Rows actually compared (exact SKU match + valid values) |
| `unmatchedRows` | Proposed rows without a current match |
| `uncheckableRows` | Matched rows that could not be evaluated |
| `currentUncheckableRows` | Defective current rows excluded from matching |
| `coverageComplete` | True only when proposed has comparison rows, every proposed comparison row was evaluated, and `currentUncheckableRows` is zero |
| `findingsTotal` / `findingsReturned` | All findings vs. the bounded list returned |
| `truncated` | True exactly when `findingsTotal > findingsReturned` |
| `findingsByCode` | Complete counts per code (zero counts omitted) |
| `ignoredColumns` | Ignored extra columns per file |
| `findings` | Bounded list, deterministic source order |

Status priority: **`incomplete`** (any uncheckable/unmatched row or
truncation) → **`no_changes`** (empty proposed file) →
**`review_required`** (findings) → **`no_findings`**.

#### Finding codes

| Code | Severity | Meaning |
|---|---|---|
| `MISSING_SKU`, `INVALID_SKU` | warning | SKU cell empty or invalid |
| `DUPLICATE_SKU` | warning | SKU appears more than once in a file |
| `UNSUPPORTED_INVENTORY_TRACKER` | warning | `Inventory tracker` is not `shopify` — quantity not comparable |
| `INVALID_PRICE`, `INVALID_STOCK` | warning | Value cell fails the grammar |
| `UNMATCHED_SKU` | unmatched | Proposed SKU has no current counterpart |
| `CURRENT_SKU_AMBIGUOUS` | uncheckable | Proposed SKU matches duplicated current SKUs |
| `CURRENT_ROW_UNCHECKABLE` | uncheckable | The matched current row is defective |
| `ZERO_BASE_PRICE` | uncheckable | Current price is 0 — no percentage computable |
| `PRICE_DROP_EXCEEDS_THRESHOLD` | warning | Decrease strictly above the drop threshold |
| `PRICE_INCREASE_EXCEEDS_THRESHOLD` | warning | Increase strictly above the increase threshold |
| `STOCK_BECOMES_ZERO` | warning | Tracked quantity positive → 0 |

Each finding carries `code`, `severity`, `source`, `row`, `line`, `sku`,
`field`, `message` — plus code-specific `before`/`after`/`changePercent`,
`currentRow`/`currentLine`, or a bounded `rawValue` excerpt (max 128 code
points). `field` names are the Shopify column names (`SKU`, `Price`,
`Inventory tracker`, `Inventory quantity`). Findings never contain full rows,
URLs, or raw exception text.

#### `FINDINGS.csv`

Same findings as a review spreadsheet — fixed columns, UTF-8 without BOM,
quoted as needed, formula-injection protected (cells starting with
`= + - @` or tab/CR/LF, ignoring leading spaces, get a `'` prefix).

#### `RUN_METADATA` / `RUN_STATE` / `ERROR`

Operational records: run state, `reportAvailable`, billing
mode/outcome/expected/confirmed counts, `errorCode`, `durationMs`. `ERROR`
records carry only a stable code, a safe message template, and structured
location fields — never URLs, SKUs, cell values, or raw exceptions.

### Remote CSV security

`currentCsvUrl`/`proposedCsvUrl` are deliberately constrained:

- HTTPS only, hostname required, port omitted or 443, no credentials in the
  URL, no fragment, no IP literals, no single-label hosts, no IPv6
- DNS must resolve to **public IPv4** only — every private/reserved/special
  range is blocked, including rebinding tricks and non-canonical IPv4 forms
- The connection is pinned to the approved DNS answer and the connected peer
  is re-verified — DNS pinning, not just DNS checking
- No redirects, no compression (`identity` encoding only)
- Actual streamed bytes capped at 10 MiB regardless of headers
- Deadlines: DNS 5 s, connect 10 s, idle 10 s, total 30 s

If your file is behind auth, download it yourself and use the inline CSV
fields — there are no credential or custom-header options by design.

### Using the API / SDK

#### REST

```bash
## Start a run
curl -X POST \
  "https://api.apify.com/v2/acts/YOUR_ACTOR_ID/runs?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d @input.json

## Poll until SUCCEEDED, then read outputs
curl "https://api.apify.com/v2/key-value-stores/STORE_ID/records/OUTPUT?token=$APIFY_TOKEN"
curl "https://api.apify.com/v2/datasets/DATASET_ID/items?token=$APIFY_TOKEN"
```

#### JavaScript SDK

```js
import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('YOUR_USER/skufuse-shopify').call(input);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
const report = items[0];              // the full report — exactly one item
const csv = await client.keyValueStore(run.defaultKeyValueStoreId)
  .getRecord('FINDINGS.csv');
```

#### Make / n8n (plain HTTP)

1. **HTTP → Make a request**: `POST`
   `https://api.apify.com/v2/acts/YOUR_ACTOR_ID/run-sync-get-dataset-items?token=…`
   with the input JSON — returns the dataset items directly when the run
   finishes (sync endpoint, simplest for automations).
2. Or async: `POST …/runs` → poll `GET …/runs/{runId}` until
   `status: SUCCEEDED` → `GET …/datasets/{defaultDatasetId}/items`.
3. On `FAILED`, fetch `…/key-value-stores/{storeId}/records/ERROR` for the
   stable `error.code`.

### Error codes

Fatal errors fail the run with a stable code (also in the `ERROR` record):

`INPUT_INVALID`, `INPUT_TOO_LARGE`, `UNSUPPORTED_SCHEMA`,
`PRICE_CONTEXT_UNCONFIRMED`, `SINGLE_LOCATION_UNCONFIRMED`,
`SOURCE_URL_INVALID`, `SOURCE_DESTINATION_BLOCKED`, `SOURCE_DNS_FAILED`,
`SOURCE_FETCH_FAILED`, `SOURCE_TIMEOUT`, `SOURCE_REDIRECT_UNSUPPORTED`,
`SOURCE_HTTP_STATUS`, `SOURCE_ENCODING_UNSUPPORTED`, `SOURCE_TOO_LARGE`,
`CSV_INVALID_ENCODING`, `CSV_FORBIDDEN_CHARACTER`, `CSV_SYNTAX_INVALID`,
`CSV_HEADER_MISSING`, `CSV_HEADER_INVALID`, `CSV_DUPLICATE_HEADER`,
`CSV_MISSING_COLUMN`, `CSV_COLUMN_COUNT`, `CSV_LIMIT_EXCEEDED`,
`BUDGET_INSUFFICIENT`, `BILLING_CONFIGURATION_INVALID`,
`BILLING_STATE_CONFLICT`, `BILLING_PARTIAL`, `BILLING_OUTCOME_UNKNOWN`,
`OUTPUT_WRITE_FAILED`, `RESUME_UNSUPPORTED`, `RUN_DEADLINE_EXCEEDED`,
`INTERNAL_ERROR`.

### Development (local, no Apify account)

```bash
npm ci
npm run build

## Seed a local input
mkdir -p storage/key_value_stores/default
cat > storage/key_value_stores/default/INPUT.json <<'JSON'
{
  "schemaVersion": "shopify_product_price_stock_single_location_v1",
  "currency": "USD",
  "confirmComparablePrices": true,
  "confirmSingleLocationInventory": true,
  "currentCsv": "URL handle,SKU,Price,Inventory tracker,Inventory quantity\na100,A100,49.90,shopify,12",
  "proposedCsv": "URL handle,SKU,Price,Inventory tracker,Inventory quantity\na100,A100,4.99,shopify,0"
}
JSON

CRAWLEE_STORAGE_DIR=./storage node dist/main.js
```

An empty `INPUT.json` object (`{}`) runs the synthetic demonstration locally —
same as `demoMode: true`.

Results land in `storage/`:

- `key_value_stores/default/REVIEW.html` — the human-readable review report
- `key_value_stores/default/OUTPUT.json` — the full comparison report
- `key_value_stores/default/FINDINGS.csv` — a spreadsheet-ready review sheet
- `key_value_stores/default/RUN_METADATA.json` — operational/billing metadata
- `datasets/default/*.json` — exactly one dataset item (the same report)

```bash
npm test              # all suites (unit/contract/integration/e2e/performance)
npm run test:unit     # unit tests only
npm run test:contract # schema + packaging contract tests
npm run test:e2e      # compiled-Actor end-to-end
npm run test:coverage # coverage with per-file thresholds
npm run typecheck && npm run lint && npm run format:check
npm run test:cloud    # authorized Apify cloud verification (needs APIFY_TOKEN
                      # + APIFY_ACTOR_ID + APIFY_BUILD; every PPE run — all
                      # scenarios — needs APIFY_AUTHORIZE_CHARGES=1 +
                      # APIFY_MAX_CHARGE_USD since the platform start fee
                      # applies to each run)
```

Container (when Docker is available):

```bash
docker build -f actors/shopify/Dockerfile -t skufuse-shopify .
bash actors/shopify/scripts/container-check.sh skufuse-shopify:latest
```

The deterministic comparison core (`src/compare.ts`, `src/csv.ts`, `src/rows.ts`,
`src/money.ts`, `src/report.ts`, `src/findingsCsv.ts`) has zero Apify imports —
it is fully testable without the platform.

# Actor input Schema

## `demoMode` (type: `boolean`):

Try SKUFuse on built-in synthetic Shopify data — no files needed and nothing about your catalog is sent. Leave ON for a first look at the report; turn OFF to compare your own Shopify product CSVs. When off, real sources and the declarations below are required. A demo run is billed like any other run (one comparison over 3 rows).

## `currentCsvFile` (type: `string`):

Upload the current Shopify products export as a CSV file from your computer (up to 10 MB). Exactly one source per file — either this upload or one of the advanced options below.

## `proposedCsvFile` (type: `string`):

Upload the proposed products CSV from your computer (up to 10 MB) — the file you plan to import. Exactly one source per file.

## `rules` (type: `object`):

Configure the price-change thresholds and whether inventory changes from positive to zero are reported.

## `currency` (type: `string`):

ISO 4217 code for the compared prices, e.g. EUR or USD. Required for a real comparison. Declared context only — the Actor does not convert prices.

## `confirmComparablePrices` (type: `boolean`):

Required declaration: both files use the same currency and the same price basis (e.g. both net or both gross). Intentionally not pre-checked.

## `confirmSingleLocationInventory` (type: `boolean`):

Required declaration: your store uses a single inventory location, so the CSV's Inventory quantity column describes your whole inventory. Stores with multiple inventory locations are NOT supported by this profile — the product CSV cannot express per-location quantities. Intentionally not pre-checked.

## `currentCsv` (type: `string`):

Paste the current Shopify products export as CSV text (max 512 KB). An alternative to the file upload above — use exactly one source per file.

## `proposedCsv` (type: `string`):

Paste the proposed products CSV as text (max 512 KB). Exactly one source per file.

## `currentCsvUrl` (type: `string`):

Public HTTPS URL returning the current CSV (max 10 MB). No redirects, no URL credentials, port 443 only.

## `proposedCsvUrl` (type: `string`):

Public HTTPS URL returning the proposed CSV (max 10 MB). No redirects, no URL credentials, port 443 only.

## `maxFindings` (type: `integer`):

Maximum number of findings retained in the report (1–1000). Totals are always counted completely; only the returned list is bounded.

## `schemaVersion` (type: `string`):

Fixed input contract version. Any other value fails with UNSUPPORTED\_SCHEMA.

## Actor input object example

```json
{
  "demoMode": true,
  "currency": "EUR",
  "currentCsvUrl": "https://example.com/exports/current.csv",
  "proposedCsvUrl": "https://example.com/exports/proposed.csv",
  "maxFindings": 1000,
  "schemaVersion": "shopify_product_price_stock_single_location_v1"
}
```

# Actor output Schema

## `reviewHtml` (type: `string`):

No description

## `findingsCsv` (type: `string`):

No description

## `report` (type: `string`):

No description

## `datasetItem` (type: `string`):

No description

## `runMetadata` (type: `string`):

No description

## `errorRecord` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "demoMode": true,
    "schemaVersion": "shopify_product_price_stock_single_location_v1"
};

// Run the Actor and wait for it to finish
const run = await client.actor("overbifrost/skufuse-shopify").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "demoMode": True,
    "schemaVersion": "shopify_product_price_stock_single_location_v1",
}

# Run the Actor and wait for it to finish
run = client.actor("overbifrost/skufuse-shopify").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "demoMode": true,
  "schemaVersion": "shopify_product_price_stock_single_location_v1"
}' |
apify call overbifrost/skufuse-shopify --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,overbifrost/skufuse-shopify"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/PCM4S0uQblfqClqRI/builds/gQoLIh3BFwrVjHdrv/openapi.json
