Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Application
APP_ENV=development
APP_NAME=ecom-dynamic-pricing
APP_HOST=0.0.0.0
APP_PORT=8000
LOG_LEVEL=INFO

# Postgres
POSTGRES_USER=ecom
POSTGRES_PASSWORD=change-me
POSTGRES_DB=ecom
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
DATABASE_URL=postgresql+psycopg://ecom:change-me@postgres:5432/ecom

# Redis / Celery broker
REDIS_URL=redis://redis:6379/0
CELERY_BROKER_URL=redis://redis:6379/1
CELERY_RESULT_BACKEND=redis://redis:6379/2

# Shopify OAuth (placeholders only)
SHOPIFY_API_KEY=your-shopify-api-key-here
SHOPIFY_API_SECRET=your-shopify-api-secret-here
SHOPIFY_SCOPES=read_products,write_products,read_inventory
SHOPIFY_REDIRECT_URI=http://localhost:8000/auth/shopify/callback
SHOPIFY_WEBHOOK_SECRET=your-shopify-webhook-secret-here

# Observability
SENTRY_DSN=
SENTRY_ENVIRONMENT=development

# Pricing engine defaults (overridable per merchant)
PRICING_DEFAULT_MIN_MARGIN=0.10
PRICING_DEFAULT_MAX_CHANGE_PCT=0.25
PRICING_NEW_PRODUCT_PROTECTION_DAYS=14
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: ci

on:
push:
branches: [main]
pull_request:

jobs:
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: backend/requirements-dev.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Lint
run: ruff check .
- name: Test
env:
DATABASE_URL: sqlite:///./test.db
REDIS_URL: redis://localhost:6379/0
APP_ENV: test
run: pytest -q

frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package.json
- name: Install dependencies
run: npm install --no-audit --no-fund
- name: Build
run: npm run build
54 changes: 54 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Environment
.env
.env.local
.env.*.local
*.env

# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.venv/
venv/
env/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
*.egg-info/
build/
dist/

# Databases
*.db
*.sqlite
*.sqlite3

# Node / frontend
node_modules/
frontend/dist/
frontend/build/
frontend/.vite/
bun.lockb
.pnp.*

# Editors / OS
.idea/
.vscode/
.DS_Store
Thumbs.db
*.swp
*.swo

# Logs
*.log
logs/

# Alembic local artifacts (keep versions/, ignore caches)
backend/alembic/__pycache__/

# Docker
.docker/
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2025 Mac McFall
Copyright (c) 2026 Mac McFall / M87 Studio

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
135 changes: 135 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# ecom-dynamic-pricing

FastAPI + Celery + Postgres + Redis SaaS skeleton for Shopify dynamic pricing with guardrails, rollback, and audit trail.

## What this is

A working skeleton for a dynamic-pricing service that can run against a Shopify catalog without exposing the merchant to the usual blast radius. The interesting problem is not the price suggestion itself — it's keeping a catalog-scale pricing engine from doing damage. This repo splits authority across four layers: the pricing engine proposes a price, the merchant policy gates it, the Shopify client executes the approved change, and the audit trail records every event so any change is reversible. The operational shape is Docker Compose, Celery workers, structured logs, health and readiness probes, and an Alembic migration path.

## Stack

| Layer | Technologies |
|---|---|
| API | FastAPI, Pydantic v2, uvicorn |
| Workers | Celery, Redis broker |
| Data | Postgres 16, SQLAlchemy 2.x, Alembic |
| Integration | Shopify Admin API (stub), HMAC webhook verification |
| Observability | Structured JSON logs, X-Process-Time middleware, Sentry (optional) |
| Frontend | React 18, TypeScript, Vite |
| Infra | Docker Compose, GitHub Actions |

## Design notes

- **Authority separation.** The engine proposes, the policy gates, Shopify executes, the audit records. No layer holds two responsibilities. This is what makes the system safe to run unattended on a real catalog.
- **Guardrails are inputs, not exceptions.** `PricingEngine(min_margin, max_change_pct, new_product_protection_days)` accepts its constraints in its constructor. The engine cannot produce a suggestion that violates them. There is no `try/except` path that "handles" a guardrail breach because no such breach is reachable.
- **Audit trail is the rollback substrate.** Every applied price change writes an append-only `price_events` row. Rolling back N steps reads N events back and writes a new event with `source=rollback`, so the rollback itself is also auditable.
- **Observability is first-class.** Structured logs are JSON. Every response carries `X-Process-Time`. `/health/ready` probes Postgres and Redis individually so a degraded dependency is visible at the load-balancer layer.

## Known limitations

| Area | Status | Notes |
|---|---|---|
| Shopify Admin API client | Stub | Method signature and HMAC verification are real; the wire call is a no-op. Cutover plan in TECHNICAL_DEBT.md. |
| Shopify OAuth flow | Not implemented | `access_token` field exists on `merchants`; install/callback routes are not wired. |
| Frontend | Operator console stub | Single page reads `/health/ready`. The suggestion-review and rollback UI are the next milestone. |
| Pricing signal | Toy proposal | The raw signal is `demand_signal - inventory_pressure`. The guardrail layer is the real engineering. Replacing the proposal step does not change the safety surface. |
| Auth on API | None | API routes are unauthenticated. Add merchant-scoped JWT or session middleware before exposing publicly. |

## Architecture

```
+--------------------+
demand / | PricingEngine | pure function
inventory ---> | (guardrails as | --+
signal | constructor args)| |
+--------------------+ | PriceSuggestion
v
+--------------------+
| MerchantPolicy | approve / hold / reject
+--------------------+
|
v approved
+--------------------+ +-------------------+
| AuditTrail.record |------->| price_events |
+--------------------+ | (append-only) |
| +-------------------+
v
+--------------------+
| ShopifyClient | external write
+--------------------+
```

Data flow on a price update:

1. Demand or inventory event triggers a Celery task (or an HTTP `/pricing/suggest` call).
2. Engine returns a `PriceSuggestion` with the guardrails that fired.
3. Policy reads the suggestion and emits an `ApprovalDecision` — auto-apply, hold for review, or reject.
4. On approval, `AuditTrail.record` writes a `price_events` row and updates `products.current_price` in the same flush.
5. `ShopifyClient.update_variant_price` propagates the change to the merchant's storefront (currently stubbed).
6. Rollback reads the last N `price_events` for a product and emits a new event with `source=rollback`.

## Quick start

Requirements: Docker, Docker Compose.

```
cp .env.example .env
# edit .env if you need to override defaults
docker compose up --build
```

The API serves on `http://localhost:8000`. The frontend dev server is `cd frontend && npm install && npm run dev` (port 5173, proxies `/api` to the backend).

Bootstrapping the schema is handled by the `api` service on startup (`alembic upgrade head`). If running the API outside Docker:

```
cd backend
pip install -r requirements-dev.txt
alembic upgrade head
uvicorn app.main:app --reload
```

## Tests

```
cd backend
pip install -r requirements-dev.txt
pytest
```

The suite covers the pricing engine's guardrail surface, the merchant policy's auto-apply window, and the health endpoints. The Shopify client and Celery task layers are not exercised by tests in this repo — see TECHNICAL_DEBT.md.

## Repository structure

```
backend/
app/
main.py FastAPI app, middleware, route wiring
config.py pydantic-settings
database.py SQLAlchemy engine, session, Base
models.py Merchant, Product, PriceEvent
schemas.py Pydantic I/O models
policy.py MerchantPolicy
observability.py logging + Sentry init
pricing/engine.py PricingEngine + PricingInputs
audit/service.py AuditTrail (record, history, rollback)
shopify/client.py Stub + HMAC webhook verification
api/ health, pricing, audit routers
tasks/ Celery app + pricing_tasks
alembic/ migrations
tests/
frontend/ React + TS operator console (stub)
docker-compose.yml
```

## Roadmap

See [ROADMAP.md](ROADMAP.md).

## License

MIT. See [LICENSE](LICENSE).

## Author

Mac McFall — [m87studio.net](https://m87studio.net) — [github.com/MacFall7](https://github.com/MacFall7)
22 changes: 22 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Roadmap

Order is approximate. Each item is sized so the surrounding system remains shippable after it lands.

## Next

- **Shopify Admin API cutover.** Replace the stubbed `ShopifyClient.update_variant_price` with a real Admin API call. Wire OAuth install / callback routes. Persist `access_token` per merchant.
- **Authn on API routes.** Merchant-scoped JWT issued at install completion. Inject `merchant_id` into request context; scope all queries.
- **Operator console UI.** Suggestion review table, audit timeline per product, one-click rollback. Currently a single health-status page.

## Soon

- **Bulk recompute task.** Celery beat schedule that walks the catalog in shards, emits suggestions, applies the auto-approve set, queues the rest for review.
- **Webhook ingest.** Inbound `products/update` webhook with HMAC verification (already implemented in `shopify/client.py:verify_webhook`) drives a recompute task for the affected SKU.
- **Rate-limit-aware Shopify client.** Backoff on `X-Shopify-Shop-Api-Call-Limit`. Batch variant updates where possible.

## Later

- **Pricing-signal layer.** Replace the toy `demand_signal - inventory_pressure` proposal with something that reads real signals. The guardrail layer is the safety surface — the proposal is swappable.
- **Per-merchant policy.** Today `MerchantPolicy` is constructed with module defaults. Move to a per-merchant config row.
- **A/B price testing.** Treatment / control groups, lift measurement against an audited baseline.
- **Margin-based reporting.** Slice realized margin by SKU, by category, by guardrail-fired rate.
45 changes: 45 additions & 0 deletions TECHNICAL_DEBT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Technical debt

Honest list of what's stubbed, what's deferred, and the cutover plan for each.

## Shopify Admin API client — stubbed

`backend/app/shopify/client.py:ShopifyClient.update_variant_price` logs and returns `{"applied": False, "stub": True}`. No HTTP call is made.

**Cutover:**
1. Register a Partner-program app, obtain API key + secret, set `SHOPIFY_API_KEY` / `SHOPIFY_API_SECRET`.
2. Implement install at `GET /auth/shopify/install` (redirect to Shopify OAuth grant).
3. Implement callback at `GET /auth/shopify/callback` — exchange code for `access_token`, persist on `merchants.access_token`.
4. Replace the stub body with `httpx.post` to `https://{shop_domain}/admin/api/2024-10/variants/{variant_id}.json` with `X-Shopify-Access-Token: {access_token}` and body `{"variant": {"id": variant_id, "price": str(new_price)}}`.
5. Add rate-limit backoff on `X-Shopify-Shop-Api-Call-Limit` header.

## API authentication — none

All routes are unauthenticated. Acceptable for the skeleton; not acceptable for any deployment that touches a real catalog.

**Cutover:**
1. Issue a merchant-scoped JWT at the end of the OAuth callback flow.
2. Add a FastAPI dependency that extracts `merchant_id` from the JWT and injects it into the request context.
3. Scope every query that touches `products` or `price_events` by `merchant_id`.

## Frontend — single page

`frontend/src/App.tsx` reads `/health/ready` and renders the JSON. The actual operator console (suggestion table, audit timeline, rollback button) is not built.

**Cutover:**
1. Add routes: `/products`, `/products/:id/suggestions`, `/products/:id/audit`.
2. Wire to existing API: `GET /audit/products/{id}/history`, `POST /pricing/suggest/{id}`, `POST /audit/rollback`.

## Pricing signal — toy

`PricingEngine._raw_proposal` uses `demand_signal - inventory_pressure` scaled by `max_change_pct`. This is a placeholder. The guardrail layer is the engineering substance; swapping the proposal does not change the safety surface.

**Cutover:** integrate whichever signal source the merchant has (Google Trends, competitor scrape, inventory turnover, etc.). The interface is `PricingInputs` — no other module needs to change.

## Test coverage — partial

Tests cover the pricing engine, the merchant policy, and the smoke surface of the API. The Celery task layer, the Shopify client (once real), and the audit/rollback flow end-to-end are not covered.

## Production migration story

`docker-compose.yml` runs `alembic upgrade head` on the `api` service start. For a real deployment, migrations should run as a separate one-shot job before the API container starts taking traffic, not in the API container's start command.
20 changes: 20 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
FROM python:3.11-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1

WORKDIR /app

RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*

COPY requirements.txt ./
RUN pip install --upgrade pip && pip install -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Loading
Loading