03/Decision Systems

SlowSellerDecisionEngine

A recommendation per style, and a spreadsheet it cannot damage

Year
2025
Status
Internal production
Category
Decision Systems
Stack
TypeScript · Next.js 15 · React 19

01/Context

The spreadsheet was the system of record and was going to stay that way — that constraint was not negotiable, and designing around it rather than against it is most of the work.

The real risk was not a wrong recommendation. It was a script with write access to a live merchandising sheet that 1,900 product rows and several people depend on. Anyone re-running it risked destroying formulas or a colleague's work.

02/At a glance

Problem
Merchandisers decided each season what to keep, sell out, mark down or drop — but the inputs were assembled by hand and by a brittle Apps Script. Four sales columns carried end dates from four different days, status grouping was a hard-coded list that broke whenever someone typed a new wording, and two engine inputs were placeholders rather than real data.
Built
A Next.js app that runs a seven-stage sync — read the sheet, load Shopify products and orders, match style and colour, aggregate two rolling windows, run the engine, write back — streaming progress over Server-Sent Events.
Role
Sole developer. Both integrations, the decision engine, the test suite, and a 1,032-line README written for the operator as much as the developer.
Changed
The same decision, now sourced from live Shopify data, reproducible, and structurally unable to overwrite a formula, a header, or a colleague's edit.

03/Architecture

Read everything before writing anything. Two integration layers feed a pipeline whose stages are all pure until the final write plan, which is guarded by a column allowlist, a source fingerprint and per-cell idempotency.

Slow Seller Decision Engine architecture diagramData flows through 12 components: Shopify Admin GraphQL (External), Shopify client (Ingest), Google Sheets API (External), Write plan + guards (Decision logic), Sheets client (Ingest), Run pipeline (Service), Eligibility + matching (Decision logic), Run store (Store), Sales aggregation (Decision logic), API routes (Service), Decision engine (Decision logic), Dashboard (Interface). Connections: Shopify Admin GraphQL to Shopify client; Google Sheets API to Sheets client; Shopify client to Run pipeline; Sheets client to Run pipeline; Run pipeline to Eligibility + matching; Eligibility + matching to Sales aggregation; Sales aggregation to Decision engine; Decision engine to Write plan + guards; Write plan + guards to Sheets client; Sheets client to Google Sheets API; Run pipeline to Run store; API routes to Run pipeline; Run store to API routes; API routes to Dashboard.Shopify Admin GraphQLProducts, variants, orders,…Shopify clientOAuth refresh · cost thrott…Google Sheets APIService-account batch read/…Write plan + guardsAllowlist · formula guard ·…Sheets clientColumns resolved by header …Run pipeline7 stages; reads all before …Eligibility + matchi…Style + colour index, SKU f…Run storeSingle lock, preview cache,…Sales aggregationNet units, refunds, store-t…API routesZod validation · SSE streamDecision enginePure — no I/O, no clockDashboardLive stages, results, apply…

04/The system

A pure engine with nothing to break

The decision engine is a pure function with no I/O and no clock. Status group, two sales figures, stock, samples, release date, next-season availability and inventory in; an 'ACTION — Reason.' sentence out.

Because it has no dependencies, it is fully testable, and a threshold experiment can run without touching the sheet at all.

Guards between a decision and a cell

Columns are resolved by header text at run time rather than by position, so inserting a column upstream does not silently corrupt the write.

Every run builds a pure write plan first, then asserts that plan touches only an allowlisted set of columns and never the header row. A SHA-256 fingerprint of every source cell, a single in-process run lock, and per-cell idempotency checks sit between the plan and the API call.

A second run over unchanged data writes nothing at all. A Shopify failure mid-run leaves the spreadsheet untouched rather than half-updated. An unmatched row is flagged for human review rather than written as zero sales.

A Shopify client that respects the bucket

A hand-written Admin GraphQL client mints and refreshes its own OAuth client-credentials token — 24-hour lifetime, refreshed ten minutes early, re-minted and retried on a mid-run 401.

It paces itself against Shopify's calculated query cost and leaky bucket with exponential backoff, jitter and Retry-After handling, rather than guessing at a fixed delay.

Experiments separated from the agreed rules

A standard run applies itself. A custom-threshold run stops at a results table and requires an explicit confirmation dialog listing every setting that differs from the agreed rule set — so an experiment can never be mistaken for policy.

05/What was hard

Writing into a spreadsheet people are using

The sheet is live, shared and full of formulas. The answer was to make the write plan a value the code can inspect before it executes — allowlist the columns, forbid the header row, fingerprint the source, and check each cell for idempotency. A destructive write becomes something you can assert against in a test rather than something you hope does not happen.

Replacing two placeholders with real data

The old script used a sample count as a stand-in for 'quantity that can be made', and a proxy date column for 'releases again next season'. Both now read real sources — live inventory, and the next season's tab. Two placeholders quietly wrong for a long time is a good illustration of why a system beats a script.

Documenting the weakness at the top of the README

The app has no sign-in; the deployment boundary is the entire access control. That is stated prominently at the top of the README with a comparison table of four ways to add one, rather than buried. Leading with your system's biggest limitation is what makes the rest of the document trustworthy.

06/Automation

Replaces a Google Apps Script workflow and the manual steps around it: pulling Shopify unit sales for two rolling windows, keying them to the right row by style and colour, hand-typing the date range each sales column covers, and applying the slow-seller rules style by style. A one-off migration endpoint clears the legacy column the old script wrote, showing the affected cell count before confirmation.

08/Direction & delivery

The 1,032-line README documents the platform setup step by step, the service-account sharing step and what to ask a Workspace admin when external sharing is blocked, a troubleshooting matrix mapping every user-facing error to its fix, and a 'where to change X' table pointing every tunable to the file that owns it. Design decisions are recorded as rationale in code — 'do not reorder these thresholds without an explicit instruction — the wording is safe to edit, the numbers are not'.

09/Scale

66
Source files
~9,400
Lines of TypeScript
361
Tests
7
Pipeline stages
1,032
README lines
~1,900
Catalogue rows handled

Objective size signals taken from the repository. No impact metrics are claimed that the source does not prove.

10/What I would tell the next person

  • When a spreadsheet is the system of record, design around that constraint. Resolving columns by header text costs an hour and survives every future column insert.
  • Make the destructive step a value you can inspect. A write plan you can assert against is safer than a write you have to trust.
  • Separate experiments from policy in the UI, not just in the docs.

11/Technologies

  • TypeScript
  • Next.js 15
  • React 19
  • Tailwind CSS 3
  • Radix UI
  • Zod
  • Shopify Admin GraphQL
  • Google Sheets API v4
  • Server-Sent Events
  • Vitest

Integrations

  • Shopify Admin GraphQL (OAuth client-credentials, pinned API version)
  • Google Sheets API v4 (service-account JWT)

Internal tool behind a network boundary. Not publicly reachable.