diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..996db94 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3237e6b --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f5e0a2 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE index 6a5c5a2..a0237bd 100644 --- a/LICENSE +++ b/LICENSE @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c1b8a93 --- /dev/null +++ b/README.md @@ -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) diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..d5075ea --- /dev/null +++ b/ROADMAP.md @@ -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. diff --git a/TECHNICAL_DEBT.md b/TECHNICAL_DEBT.md new file mode 100644 index 0000000..3833eba --- /dev/null +++ b/TECHNICAL_DEBT.md @@ -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. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c4be027 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..48aed4f --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..c57d1e6 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,46 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app import models # noqa: F401 (register models on Base metadata) +from app.config import get_settings +from app.database import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", get_settings().database_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=config.get_main_option("sqlalchemy.url"), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..17dcba0 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/0001_initial_schema.py b/backend/alembic/versions/0001_initial_schema.py new file mode 100644 index 0000000..086dc4a --- /dev/null +++ b/backend/alembic/versions/0001_initial_schema.py @@ -0,0 +1,95 @@ +"""initial schema + +Revision ID: 0001 +Revises: +Create Date: 2026-05-12 00:00:00.000000 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0001" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "merchants", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("shop_domain", sa.String(length=255), nullable=False), + sa.Column("access_token", sa.String(length=255), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.UniqueConstraint("shop_domain", name="uq_merchants_shop_domain"), + ) + op.create_index("ix_merchants_shop_domain", "merchants", ["shop_domain"]) + + op.create_table( + "products", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id"), nullable=False), + sa.Column("shopify_product_id", sa.String(length=64), nullable=False), + sa.Column("sku", sa.String(length=128), nullable=False), + sa.Column("title", sa.String(length=512), nullable=False), + sa.Column("cost", sa.Numeric(12, 2), nullable=False), + sa.Column("current_price", sa.Numeric(12, 2), nullable=False), + sa.Column("min_price", sa.Numeric(12, 2), nullable=True), + sa.Column("max_price", sa.Numeric(12, 2), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + op.create_index("ix_products_merchant_id", "products", ["merchant_id"]) + op.create_index("ix_products_shopify_product_id", "products", ["shopify_product_id"]) + op.create_index("ix_products_sku", "products", ["sku"]) + op.create_index( + "ix_products_merchant_sku", + "products", + ["merchant_id", "sku"], + unique=True, + ) + + op.create_table( + "price_events", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("product_id", sa.Integer(), sa.ForeignKey("products.id"), nullable=False), + sa.Column("previous_price", sa.Numeric(12, 2), nullable=False), + sa.Column("new_price", sa.Numeric(12, 2), nullable=False), + sa.Column("source", sa.String(length=32), nullable=False), + sa.Column("reason", sa.Text(), nullable=False), + sa.Column("suggestion_id", sa.String(length=64), nullable=True), + sa.Column( + "applied_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + op.create_index("ix_price_events_product_id", "price_events", ["product_id"]) + op.create_index("ix_price_events_applied_at", "price_events", ["applied_at"]) + + +def downgrade() -> None: + op.drop_index("ix_price_events_applied_at", table_name="price_events") + op.drop_index("ix_price_events_product_id", table_name="price_events") + op.drop_table("price_events") + + op.drop_index("ix_products_merchant_sku", table_name="products") + op.drop_index("ix_products_sku", table_name="products") + op.drop_index("ix_products_shopify_product_id", table_name="products") + op.drop_index("ix_products_merchant_id", table_name="products") + op.drop_table("products") + + op.drop_index("ix_merchants_shop_domain", table_name="merchants") + op.drop_table("merchants") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..2f2fd32 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,7 @@ +""" +ecom-dynamic-pricing +Author: Mac McFall / M87 Studio +License: MIT +""" + +__version__ = "0.1.0" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/audit.py b/backend/app/api/audit.py new file mode 100644 index 0000000..3aedb00 --- /dev/null +++ b/backend/app/api/audit.py @@ -0,0 +1,34 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from ..audit import AuditTrail +from ..audit.service import RollbackError +from ..database import get_db +from ..schemas import PriceEventOut, RollbackRequest + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/audit", tags=["audit"]) + + +@router.get("/products/{product_id}/history", response_model=list[PriceEventOut]) +def history(product_id: int, limit: int = 50, db: Session = Depends(get_db)) -> list[PriceEventOut]: + events = AuditTrail(db).history(product_id=product_id, limit=limit) + return [PriceEventOut.model_validate(e) for e in events] + + +@router.post("/rollback", response_model=PriceEventOut) +def rollback(req: RollbackRequest, db: Session = Depends(get_db)) -> PriceEventOut: + try: + event = AuditTrail(db).rollback(product_id=req.product_id, steps_back=req.steps_back) + except RollbackError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + db.commit() + logger.info( + "pricing_rollback_committed product_id=%s steps_back=%s new_price=%s", + req.product_id, + req.steps_back, + event.new_price, + ) + return PriceEventOut.model_validate(event) diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..fa6688a --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,43 @@ +import logging + +from fastapi import APIRouter +from sqlalchemy import text + +from ..database import engine +from ..schemas import HealthStatus + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["health"]) + + +@router.get("/health", response_model=HealthStatus) +def health() -> HealthStatus: + return HealthStatus(status="ok", dependencies={}) + + +@router.get("/health/ready", response_model=HealthStatus) +def readiness() -> HealthStatus: + deps: dict[str, str] = {} + + try: + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + deps["postgres"] = "ok" + except Exception as exc: + logger.warning("postgres readiness probe failed: %s", exc) + deps["postgres"] = f"down: {exc.__class__.__name__}" + + try: + import redis + + from ..config import get_settings + + client = redis.Redis.from_url(get_settings().redis_url, socket_connect_timeout=1) + client.ping() + deps["redis"] = "ok" + except Exception as exc: + logger.warning("redis readiness probe failed: %s", exc) + deps["redis"] = f"down: {exc.__class__.__name__}" + + status = "ok" if all(v == "ok" for v in deps.values()) else "degraded" + return HealthStatus(status=status, dependencies=deps) diff --git a/backend/app/api/pricing.py b/backend/app/api/pricing.py new file mode 100644 index 0000000..c15ba16 --- /dev/null +++ b/backend/app/api/pricing.py @@ -0,0 +1,94 @@ +import logging +from decimal import Decimal + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from ..audit import AuditTrail +from ..config import get_settings +from ..database import get_db +from ..models import Product +from ..policy import MerchantPolicy +from ..pricing import PricingEngine, PricingInputs +from ..schemas import ApprovalDecision, PriceSuggestion + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/pricing", tags=["pricing"]) + + +def _engine() -> PricingEngine: + s = get_settings() + return PricingEngine( + min_margin=Decimal(str(s.pricing_default_min_margin)), + max_change_pct=Decimal(str(s.pricing_default_max_change_pct)), + new_product_protection_days=s.pricing_new_product_protection_days, + ) + + +@router.post("/suggest/{product_id}", response_model=PriceSuggestion) +def suggest( + product_id: int, + demand_signal: float = 0.0, + inventory_pressure: float = 0.0, + db: Session = Depends(get_db), +) -> PriceSuggestion: + product = db.get(Product, product_id) + if product is None: + raise HTTPException(status_code=404, detail="product not found") + + engine = _engine() + inputs = PricingInputs( + sku=product.sku, + cost=product.cost, + current_price=product.current_price, + min_price=product.min_price, + max_price=product.max_price, + product_created_at=product.created_at, + demand_signal=Decimal(str(demand_signal)), + inventory_pressure=Decimal(str(inventory_pressure)), + ) + suggestion = engine.suggest(inputs) + logger.info( + "pricing_suggested sku=%s previous=%s suggested=%s guardrails=%s", + suggestion.sku, + suggestion.previous_price, + suggestion.suggested_price, + ",".join(suggestion.guardrails_applied) or "none", + ) + return suggestion + + +@router.post("/apply/{product_id}", response_model=ApprovalDecision) +def apply( + product_id: int, + suggestion: PriceSuggestion, + db: Session = Depends(get_db), +) -> ApprovalDecision: + product = db.get(Product, product_id) + if product is None: + raise HTTPException(status_code=404, detail="product not found") + + decision = MerchantPolicy().evaluate(suggestion) + if not decision.approved or decision.final_price is None: + logger.info( + "pricing_apply_held sku=%s reason=%s", + suggestion.sku, + decision.rejection_reason, + ) + return decision + + audit = AuditTrail(db) + audit.record( + product=product, + new_price=decision.final_price, + source="engine", + reason=suggestion.reason, + suggestion_id=suggestion.suggestion_id, + ) + db.commit() + logger.info( + "pricing_apply_committed sku=%s new_price=%s", + suggestion.sku, + decision.final_price, + ) + return decision diff --git a/backend/app/audit/__init__.py b/backend/app/audit/__init__.py new file mode 100644 index 0000000..63f408d --- /dev/null +++ b/backend/app/audit/__init__.py @@ -0,0 +1,3 @@ +from .service import AuditTrail + +__all__ = ["AuditTrail"] diff --git a/backend/app/audit/service.py b/backend/app/audit/service.py new file mode 100644 index 0000000..596c7bb --- /dev/null +++ b/backend/app/audit/service.py @@ -0,0 +1,76 @@ +from decimal import Decimal + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from ..models import PriceEvent, Product + + +class RollbackError(Exception): + pass + + +class AuditTrail: + """Append-only price-change log. The rollback target reads back through it. + + The audit table is not a log file — it's the source of truth for rollback. + Every applied price change writes one row. Rolling back N steps replays + the previous_price from N events ago and records a new event with + source='rollback' so the rollback itself is also auditable. + """ + + def __init__(self, db: Session) -> None: + self.db = db + + def record( + self, + product: Product, + new_price: Decimal, + source: str, + reason: str, + suggestion_id: str | None = None, + ) -> PriceEvent: + event = PriceEvent( + product_id=product.id, + previous_price=product.current_price, + new_price=new_price, + source=source, + reason=reason, + suggestion_id=suggestion_id, + ) + self.db.add(event) + product.current_price = new_price + self.db.flush() + return event + + def history(self, product_id: int, limit: int = 50) -> list[PriceEvent]: + stmt = ( + select(PriceEvent) + .where(PriceEvent.product_id == product_id) + .order_by(PriceEvent.applied_at.desc()) + .limit(limit) + ) + return list(self.db.execute(stmt).scalars().all()) + + def rollback(self, product_id: int, steps_back: int) -> PriceEvent: + if steps_back < 1: + raise RollbackError("steps_back must be >= 1") + + events = self.history(product_id, limit=steps_back) + if len(events) < steps_back: + raise RollbackError( + f"only {len(events)} events available, cannot roll back {steps_back}" + ) + + target = events[steps_back - 1] + product = self.db.get(Product, product_id) + if product is None: + raise RollbackError(f"product {product_id} not found") + + return self.record( + product=product, + new_price=target.previous_price, + source="rollback", + reason=f"rollback {steps_back} step(s) to event {target.id}", + suggestion_id=None, + ) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..4aaa7af --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,44 @@ +from functools import lru_cache + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + app_env: str = Field(default="development") + app_name: str = Field(default="ecom-dynamic-pricing") + app_host: str = Field(default="0.0.0.0") + app_port: int = Field(default=8000) + log_level: str = Field(default="INFO") + + database_url: str = Field( + default="postgresql+psycopg://ecom:change-me@localhost:5432/ecom" + ) + + redis_url: str = Field(default="redis://localhost:6379/0") + celery_broker_url: str = Field(default="redis://localhost:6379/1") + celery_result_backend: str = Field(default="redis://localhost:6379/2") + + shopify_api_key: str = Field(default="") + shopify_api_secret: str = Field(default="") + shopify_scopes: str = Field(default="read_products,write_products") + shopify_redirect_uri: str = Field(default="") + shopify_webhook_secret: str = Field(default="") + + sentry_dsn: str = Field(default="") + sentry_environment: str = Field(default="development") + + pricing_default_min_margin: float = Field(default=0.10) + pricing_default_max_change_pct: float = Field(default=0.25) + pricing_new_product_protection_days: int = Field(default=14) + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..a39b144 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,29 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from .config import get_settings + + +class Base(DeclarativeBase): + pass + + +_settings = get_settings() + +engine = create_engine( + _settings.database_url, + pool_pre_ping=True, + future=True, +) + +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..78501b0 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,48 @@ +import logging +import time + +from fastapi import FastAPI, Request + +from .api import audit as audit_api +from .api import health as health_api +from .api import pricing as pricing_api +from .config import get_settings +from .observability import configure_logging, configure_sentry + +configure_logging() +configure_sentry() + +logger = logging.getLogger(__name__) +settings = get_settings() + +app = FastAPI( + title=settings.app_name, + version="0.1.0", + description=( + "FastAPI + Celery + Postgres + Redis SaaS skeleton for Shopify dynamic " + "pricing with guardrails, rollback, and audit trail." + ), +) + + +@app.middleware("http") +async def add_process_time_header(request: Request, call_next): + start = time.perf_counter() + response = await call_next(request) + elapsed_ms = (time.perf_counter() - start) * 1000.0 + response.headers["X-Process-Time"] = f"{elapsed_ms:.2f}ms" + return response + + +app.include_router(health_api.router) +app.include_router(pricing_api.router) +app.include_router(audit_api.router) + + +@app.get("/") +def root() -> dict: + return { + "name": settings.app_name, + "version": "0.1.0", + "env": settings.app_env, + } diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..ce49c85 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,63 @@ +from datetime import UTC, datetime +from decimal import Decimal + +from sqlalchemy import DateTime, ForeignKey, Index, Numeric, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .database import Base + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +class Merchant(Base): + __tablename__ = "merchants" + + id: Mapped[int] = mapped_column(primary_key=True) + shop_domain: Mapped[str] = mapped_column(String(255), unique=True, index=True) + access_token: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) + + products: Mapped[list["Product"]] = relationship(back_populates="merchant") + + +class Product(Base): + __tablename__ = "products" + + id: Mapped[int] = mapped_column(primary_key=True) + merchant_id: Mapped[int] = mapped_column(ForeignKey("merchants.id"), index=True) + shopify_product_id: Mapped[str] = mapped_column(String(64), index=True) + sku: Mapped[str] = mapped_column(String(128), index=True) + title: Mapped[str] = mapped_column(String(512)) + cost: Mapped[Decimal] = mapped_column(Numeric(12, 2)) + current_price: Mapped[Decimal] = mapped_column(Numeric(12, 2)) + min_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) + max_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) + + merchant: Mapped[Merchant] = relationship(back_populates="products") + price_events: Mapped[list["PriceEvent"]] = relationship(back_populates="product") + + __table_args__ = ( + Index("ix_products_merchant_sku", "merchant_id", "sku", unique=True), + ) + + +class PriceEvent(Base): + """Append-only audit row. Every price change writes one. Rollback reads these.""" + + __tablename__ = "price_events" + + id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True) + previous_price: Mapped[Decimal] = mapped_column(Numeric(12, 2)) + new_price: Mapped[Decimal] = mapped_column(Numeric(12, 2)) + source: Mapped[str] = mapped_column(String(32)) # engine | merchant | rollback + reason: Mapped[str] = mapped_column(Text) + suggestion_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + applied_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True + ) + + product: Mapped[Product] = relationship(back_populates="price_events") diff --git a/backend/app/observability.py b/backend/app/observability.py new file mode 100644 index 0000000..dacf6c6 --- /dev/null +++ b/backend/app/observability.py @@ -0,0 +1,44 @@ +import logging +import sys +from typing import Any + +from .config import get_settings + +_settings = get_settings() + + +def configure_logging() -> None: + root = logging.getLogger() + root.handlers.clear() + handler = logging.StreamHandler(sys.stdout) + formatter = logging.Formatter( + fmt='{"ts":"%(asctime)s","level":"%(levelname)s","logger":"%(name)s","msg":"%(message)s"}', + datefmt="%Y-%m-%dT%H:%M:%S%z", + ) + handler.setFormatter(formatter) + root.addHandler(handler) + root.setLevel(_settings.log_level.upper()) + + +def configure_sentry() -> None: + if not _settings.sentry_dsn: + return + try: + import sentry_sdk + from sentry_sdk.integrations.fastapi import FastApiIntegration + from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration + except ImportError: + logging.getLogger(__name__).warning("sentry_sdk not installed; skipping Sentry init") + return + + sentry_sdk.init( + dsn=_settings.sentry_dsn, + environment=_settings.sentry_environment, + integrations=[FastApiIntegration(), SqlalchemyIntegration()], + traces_sample_rate=0.1, + ) + + +def log_event(logger: logging.Logger, event: str, **fields: Any) -> None: + payload = " ".join(f"{k}={v}" for k, v in fields.items()) + logger.info("%s %s", event, payload) diff --git a/backend/app/policy.py b/backend/app/policy.py new file mode 100644 index 0000000..bb16ca7 --- /dev/null +++ b/backend/app/policy.py @@ -0,0 +1,55 @@ +from decimal import Decimal + +from .schemas import ApprovalDecision, PriceSuggestion + + +class MerchantPolicy: + """Gates engine suggestions before they reach Shopify. + + The engine produces a guardrailed suggestion. The policy decides whether + to apply it automatically, hold it for manual review, or reject it. Today + the rule set is conservative: auto-approve only inside a tight delta + window. Everything else holds for merchant review. + """ + + def __init__( + self, + auto_apply_max_delta_pct: Decimal = Decimal("0.05"), + manual_review_above_pct: Decimal = Decimal("0.15"), + ) -> None: + self.auto_apply_max_delta_pct = auto_apply_max_delta_pct + self.manual_review_above_pct = manual_review_above_pct + + def evaluate(self, suggestion: PriceSuggestion) -> ApprovalDecision: + if suggestion.previous_price == 0: + return ApprovalDecision( + approved=False, + rejection_reason="cannot evaluate delta against zero previous price", + ) + + delta_pct = abs( + (suggestion.suggested_price - suggestion.previous_price) / suggestion.previous_price + ) + + if delta_pct == 0: + return ApprovalDecision(approved=True, final_price=suggestion.suggested_price) + + if delta_pct > self.manual_review_above_pct: + return ApprovalDecision( + approved=False, + rejection_reason=( + f"delta {delta_pct:.2%} exceeds manual review threshold " + f"{self.manual_review_above_pct:.0%}" + ), + ) + + if delta_pct <= self.auto_apply_max_delta_pct: + return ApprovalDecision(approved=True, final_price=suggestion.suggested_price) + + return ApprovalDecision( + approved=False, + rejection_reason=( + f"delta {delta_pct:.2%} requires merchant confirmation " + f"(auto-apply window {self.auto_apply_max_delta_pct:.0%})" + ), + ) diff --git a/backend/app/pricing/__init__.py b/backend/app/pricing/__init__.py new file mode 100644 index 0000000..09743be --- /dev/null +++ b/backend/app/pricing/__init__.py @@ -0,0 +1,3 @@ +from .engine import PricingEngine, PricingInputs + +__all__ = ["PricingEngine", "PricingInputs"] diff --git a/backend/app/pricing/engine.py b/backend/app/pricing/engine.py new file mode 100644 index 0000000..b90512c --- /dev/null +++ b/backend/app/pricing/engine.py @@ -0,0 +1,111 @@ +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from decimal import ROUND_HALF_UP, Decimal +from uuid import uuid4 + +from ..schemas import PriceSuggestion + +TWOPLACES = Decimal("0.01") + + +@dataclass(frozen=True) +class PricingInputs: + sku: str + cost: Decimal + current_price: Decimal + min_price: Decimal | None = None + max_price: Decimal | None = None + product_created_at: datetime | None = None + demand_signal: Decimal = Decimal("0") # -1.0 .. +1.0, normalized + inventory_pressure: Decimal = Decimal("0") # -1.0 .. +1.0 + + +@dataclass +class PricingEngine: + """Pure-function pricing proposal layer. + + Guardrails are constructor inputs, not exception handlers. The engine + never writes — it returns a PriceSuggestion that the merchant policy + layer gates before any Shopify call. Authority separation is the point. + """ + + min_margin: Decimal = Decimal("0.10") + max_change_pct: Decimal = Decimal("0.25") + new_product_protection_days: int = 14 + + def suggest(self, inputs: PricingInputs) -> PriceSuggestion: + guardrails: list[str] = [] + reasons: list[str] = [] + + raw = self._raw_proposal(inputs) + proposed = raw + + if self._is_new_product(inputs): + proposed = inputs.current_price + guardrails.append("new_product_protection") + reasons.append( + f"product within {self.new_product_protection_days}d of launch; holding price" + ) + + cost_floor = (inputs.cost * (Decimal("1") + self.min_margin)).quantize( + TWOPLACES, rounding=ROUND_HALF_UP + ) + if proposed < cost_floor: + proposed = cost_floor + guardrails.append("cost_floor") + reasons.append(f"raised to cost floor {cost_floor}") + + max_up = (inputs.current_price * (Decimal("1") + self.max_change_pct)).quantize( + TWOPLACES, rounding=ROUND_HALF_UP + ) + max_down = (inputs.current_price * (Decimal("1") - self.max_change_pct)).quantize( + TWOPLACES, rounding=ROUND_HALF_UP + ) + if proposed > max_up: + proposed = max_up + guardrails.append("max_change_pct_up") + reasons.append(f"capped upward at {self.max_change_pct:.0%} delta") + elif proposed < max_down: + proposed = max_down + guardrails.append("max_change_pct_down") + reasons.append(f"capped downward at {self.max_change_pct:.0%} delta") + + if inputs.min_price is not None and proposed < inputs.min_price: + proposed = inputs.min_price + guardrails.append("merchant_min_price") + reasons.append(f"raised to merchant min {inputs.min_price}") + + if inputs.max_price is not None and proposed > inputs.max_price: + proposed = inputs.max_price + guardrails.append("merchant_max_price") + reasons.append(f"capped at merchant max {inputs.max_price}") + + proposed = proposed.quantize(TWOPLACES, rounding=ROUND_HALF_UP) + + if not reasons: + reasons.append("within all guardrails") + + return PriceSuggestion( + sku=inputs.sku, + suggested_price=proposed, + previous_price=inputs.current_price, + reason="; ".join(reasons), + guardrails_applied=guardrails, + suggestion_id=str(uuid4()), + ) + + def _raw_proposal(self, inputs: PricingInputs) -> Decimal: + # Toy proposal: demand pushes price up, inventory pressure pushes it down. + # Scaled by max_change_pct so the unconstrained signal can never exceed the cap by much. + signal = inputs.demand_signal - inputs.inventory_pressure + delta = inputs.current_price * self.max_change_pct * signal + return (inputs.current_price + delta).quantize(TWOPLACES, rounding=ROUND_HALF_UP) + + def _is_new_product(self, inputs: PricingInputs) -> bool: + if inputs.product_created_at is None: + return False + cutoff = datetime.now(UTC) - timedelta(days=self.new_product_protection_days) + created = inputs.product_created_at + if created.tzinfo is None: + created = created.replace(tzinfo=UTC) + return created > cutoff diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..6668998 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,52 @@ +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field + + +class ProductIn(BaseModel): + sku: str + title: str + cost: Decimal + current_price: Decimal + min_price: Decimal | None = None + max_price: Decimal | None = None + created_at: datetime | None = None + + +class PriceSuggestion(BaseModel): + sku: str + suggested_price: Decimal + previous_price: Decimal + reason: str + guardrails_applied: list[str] = Field(default_factory=list) + suggestion_id: str + + +class ApprovalDecision(BaseModel): + approved: bool + final_price: Decimal | None = None + rejection_reason: str | None = None + + +class PriceEventOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + product_id: int + previous_price: Decimal + new_price: Decimal + source: str + reason: str + suggestion_id: str | None + applied_at: datetime + + +class RollbackRequest(BaseModel): + product_id: int + steps_back: int = Field(default=1, ge=1, le=50) + + +class HealthStatus(BaseModel): + status: str + dependencies: dict[str, str] diff --git a/backend/app/shopify/__init__.py b/backend/app/shopify/__init__.py new file mode 100644 index 0000000..7df48da --- /dev/null +++ b/backend/app/shopify/__init__.py @@ -0,0 +1,3 @@ +from .client import ShopifyClient + +__all__ = ["ShopifyClient"] diff --git a/backend/app/shopify/client.py b/backend/app/shopify/client.py new file mode 100644 index 0000000..eb93a91 --- /dev/null +++ b/backend/app/shopify/client.py @@ -0,0 +1,46 @@ +import hashlib +import hmac +import logging +from decimal import Decimal + +logger = logging.getLogger(__name__) + + +class ShopifyClient: + """Stub. The intent is documented; the wire call is intentionally absent. + + Real implementation would POST to /admin/api/2024-10/products/{id}/variants/{vid}.json + with the access_token from the merchant record. Kept as a stub here because + a live integration without a Partner-program app and dev store is theater. + See TECHNICAL_DEBT.md for the cutover plan. + """ + + def __init__(self, shop_domain: str, access_token: str) -> None: + self.shop_domain = shop_domain + self.access_token = access_token + + def update_variant_price(self, variant_id: str, new_price: Decimal) -> dict: + logger.info( + "shopify_price_update_stub shop=%s variant=%s new_price=%s", + self.shop_domain, + variant_id, + new_price, + ) + return { + "shop": self.shop_domain, + "variant_id": variant_id, + "new_price": str(new_price), + "applied": False, + "stub": True, + } + + +def verify_webhook(body: bytes, hmac_header: str, secret: str) -> bool: + """Shopify HMAC-SHA256 webhook verification. Used for inbound product updates.""" + if not secret or not hmac_header: + return False + digest = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).digest() + import base64 + + expected = base64.b64encode(digest).decode("utf-8") + return hmac.compare_digest(expected, hmac_header) diff --git a/backend/app/tasks/__init__.py b/backend/app/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/tasks/celery_app.py b/backend/app/tasks/celery_app.py new file mode 100644 index 0000000..3a977db --- /dev/null +++ b/backend/app/tasks/celery_app.py @@ -0,0 +1,22 @@ +from celery import Celery + +from ..config import get_settings + +_settings = get_settings() + +celery_app = Celery( + "ecom_dynamic_pricing", + broker=_settings.celery_broker_url, + backend=_settings.celery_result_backend, + include=["app.tasks.pricing_tasks"], +) + +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + timezone="UTC", + enable_utc=True, + task_acks_late=True, + worker_prefetch_multiplier=1, +) diff --git a/backend/app/tasks/pricing_tasks.py b/backend/app/tasks/pricing_tasks.py new file mode 100644 index 0000000..de7f391 --- /dev/null +++ b/backend/app/tasks/pricing_tasks.py @@ -0,0 +1,59 @@ +import logging +from decimal import Decimal + +from ..audit import AuditTrail +from ..database import SessionLocal +from ..models import Product +from ..policy import MerchantPolicy +from ..pricing import PricingEngine, PricingInputs +from .celery_app import celery_app + +logger = logging.getLogger(__name__) + + +@celery_app.task(name="pricing.recompute_product") +def recompute_product(product_id: int, demand_signal: float, inventory_pressure: float) -> dict: + db = SessionLocal() + try: + product = db.get(Product, product_id) + if product is None: + logger.warning("recompute_product: product %s not found", product_id) + return {"status": "not_found", "product_id": product_id} + + engine = PricingEngine() + suggestion = engine.suggest( + PricingInputs( + sku=product.sku, + cost=product.cost, + current_price=product.current_price, + min_price=product.min_price, + max_price=product.max_price, + product_created_at=product.created_at, + demand_signal=Decimal(str(demand_signal)), + inventory_pressure=Decimal(str(inventory_pressure)), + ) + ) + + decision = MerchantPolicy().evaluate(suggestion) + if not decision.approved or decision.final_price is None: + return { + "status": "held", + "product_id": product_id, + "reason": decision.rejection_reason, + } + + AuditTrail(db).record( + product=product, + new_price=decision.final_price, + source="engine", + reason=suggestion.reason, + suggestion_id=suggestion.suggestion_id, + ) + db.commit() + return { + "status": "applied", + "product_id": product_id, + "new_price": str(decision.final_price), + } + finally: + db.close() diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..6a12ec2 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +pythonpath = . +filterwarnings = + ignore::DeprecationWarning diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..b970933 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +pytest==8.3.3 +pytest-asyncio==0.24.0 +httpx==0.27.2 +ruff==0.7.4 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..732ad7d --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.115.4 +uvicorn[standard]==0.32.0 +pydantic==2.9.2 +pydantic-settings==2.6.1 +sqlalchemy==2.0.36 +psycopg[binary]==3.2.3 +alembic==1.13.3 +celery==5.4.0 +redis==5.2.0 +sentry-sdk[fastapi]==2.18.0 +httpx==0.27.2 diff --git a/backend/ruff.toml b/backend/ruff.toml new file mode 100644 index 0000000..ec2834f --- /dev/null +++ b/backend/ruff.toml @@ -0,0 +1,14 @@ +target-version = "py311" +line-length = 100 + +[lint] +select = ["E", "F", "I", "B", "UP"] +ignore = ["E501", "B008"] # B008: FastAPI Depends() in defaults is idiomatic + +[lint.per-file-ignores] +"alembic/versions/*" = ["E501"] +"tests/*" = ["B"] + +[lint.isort] +known-first-party = ["app"] +known-third-party = ["alembic"] diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..b5ec5fa --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,5 @@ +import os + +os.environ.setdefault("DATABASE_URL", "sqlite:///./test.db") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") +os.environ.setdefault("APP_ENV", "test") diff --git a/backend/tests/test_policy.py b/backend/tests/test_policy.py new file mode 100644 index 0000000..fcc3a31 --- /dev/null +++ b/backend/tests/test_policy.py @@ -0,0 +1,38 @@ +from decimal import Decimal + +from app.policy import MerchantPolicy +from app.schemas import PriceSuggestion + + +def _suggestion(prev: str, new: str) -> PriceSuggestion: + return PriceSuggestion( + sku="X", + suggested_price=Decimal(new), + previous_price=Decimal(prev), + reason="test", + guardrails_applied=[], + suggestion_id="abc", + ) + + +def test_policy_auto_applies_small_delta() -> None: + decision = MerchantPolicy().evaluate(_suggestion("20.00", "20.40")) + assert decision.approved + assert decision.final_price == Decimal("20.40") + + +def test_policy_holds_medium_delta_for_review() -> None: + decision = MerchantPolicy().evaluate(_suggestion("20.00", "21.50")) + assert not decision.approved + assert decision.rejection_reason is not None + + +def test_policy_rejects_large_delta() -> None: + decision = MerchantPolicy().evaluate(_suggestion("20.00", "26.00")) + assert not decision.approved + assert "exceeds manual review" in (decision.rejection_reason or "") + + +def test_policy_no_change_is_approved() -> None: + decision = MerchantPolicy().evaluate(_suggestion("20.00", "20.00")) + assert decision.approved diff --git a/backend/tests/test_pricing_engine.py b/backend/tests/test_pricing_engine.py new file mode 100644 index 0000000..c472cd0 --- /dev/null +++ b/backend/tests/test_pricing_engine.py @@ -0,0 +1,102 @@ +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +from app.pricing import PricingEngine, PricingInputs + + +def _inputs(**overrides): + base = dict( + sku="TEST-1", + cost=Decimal("10.00"), + current_price=Decimal("20.00"), + min_price=None, + max_price=None, + product_created_at=datetime.now(UTC) - timedelta(days=365), + demand_signal=Decimal("0"), + inventory_pressure=Decimal("0"), + ) + base.update(overrides) + return PricingInputs(**base) + + +def test_engine_respects_cost_floor() -> None: + engine = PricingEngine(min_margin=Decimal("0.50")) # floor = 10 * 1.5 = 15.00 + suggestion = engine.suggest( + _inputs(current_price=Decimal("12.00"), demand_signal=Decimal("-1")) + ) + assert suggestion.suggested_price >= Decimal("15.00") + assert "cost_floor" in suggestion.guardrails_applied + + +def test_engine_respects_merchant_min_price() -> None: + engine = PricingEngine() + suggestion = engine.suggest( + _inputs( + current_price=Decimal("20.00"), + min_price=Decimal("18.00"), + demand_signal=Decimal("-1"), + ) + ) + assert suggestion.suggested_price >= Decimal("18.00") + assert ( + "merchant_min_price" in suggestion.guardrails_applied + or "max_change_pct_down" in suggestion.guardrails_applied + ) + + +def test_engine_respects_merchant_max_price() -> None: + engine = PricingEngine() + suggestion = engine.suggest( + _inputs( + current_price=Decimal("20.00"), + max_price=Decimal("21.00"), + demand_signal=Decimal("1"), + ) + ) + assert suggestion.suggested_price <= Decimal("21.00") + + +def test_engine_caps_change_percentage_upward() -> None: + engine = PricingEngine(max_change_pct=Decimal("0.10")) + suggestion = engine.suggest( + _inputs(current_price=Decimal("20.00"), demand_signal=Decimal("5")) + ) + # Cap at +10% of 20.00 = 22.00 + assert suggestion.suggested_price <= Decimal("22.00") + + +def test_engine_caps_change_percentage_downward() -> None: + engine = PricingEngine(max_change_pct=Decimal("0.10")) + suggestion = engine.suggest( + _inputs(current_price=Decimal("20.00"), demand_signal=Decimal("-5")) + ) + # Cap at -10% of 20.00 = 18.00, then cost floor may push it up + assert suggestion.suggested_price >= Decimal("18.00") + + +def test_engine_new_product_protection_holds_price() -> None: + engine = PricingEngine(new_product_protection_days=14) + just_launched = datetime.now(UTC) - timedelta(days=3) + suggestion = engine.suggest( + _inputs( + current_price=Decimal("20.00"), + product_created_at=just_launched, + demand_signal=Decimal("1"), + ) + ) + assert suggestion.suggested_price == Decimal("20.00") + assert "new_product_protection" in suggestion.guardrails_applied + + +def test_engine_no_change_when_signals_neutral() -> None: + engine = PricingEngine() + suggestion = engine.suggest(_inputs(current_price=Decimal("20.00"))) + assert suggestion.suggested_price == Decimal("20.00") + + +def test_engine_emits_suggestion_id() -> None: + engine = PricingEngine() + s1 = engine.suggest(_inputs()) + s2 = engine.suggest(_inputs()) + assert s1.suggestion_id and s2.suggestion_id + assert s1.suggestion_id != s2.suggestion_id diff --git a/backend/tests/test_smoke.py b/backend/tests/test_smoke.py new file mode 100644 index 0000000..4c004a6 --- /dev/null +++ b/backend/tests/test_smoke.py @@ -0,0 +1,24 @@ +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_health_endpoint_returns_200() -> None: + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +def test_root_app_starts() -> None: + response = client.get("/") + assert response.status_code == 200 + body = response.json() + assert body["name"] + assert body["version"] + + +def test_process_time_header_present() -> None: + response = client.get("/health") + assert "x-process-time" in {k.lower() for k in response.headers} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..262e3fa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-ecom} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me} + POSTGRES_DB: ${POSTGRES_DB:-ecom} + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ecom} -d ${POSTGRES_DB:-ecom}"] + interval: 5s + timeout: 3s + retries: 10 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + api: + build: ./backend + env_file: .env + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + ports: + - "8000:8000" + command: > + sh -c "alembic upgrade head && + uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload" + volumes: + - ./backend:/app + + worker: + build: ./backend + env_file: .env + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + command: celery -A app.tasks.celery_app worker --loglevel=info + volumes: + - ./backend:/app + +volumes: + pgdata: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..66f0e4e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + ecom-dynamic-pricing + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0da89be --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "ecom-dynamic-pricing-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Operator console for ecom-dynamic-pricing. Suggestion review, audit timeline, rollback.", + "author": "Mac McFall (https://m87studio.net)", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MacFall7/E-Commerce" + }, + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..4ac54f3 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,33 @@ +import { useEffect, useState } from "react"; + +type Health = { status: string; dependencies: Record }; + +export function App() { + const [health, setHealth] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + fetch("/api/health/ready") + .then((r) => r.json()) + .then(setHealth) + .catch((e) => setErr(String(e))); + }, []); + + return ( +
+

ecom-dynamic-pricing

+

+ Operator console. Suggestion review, audit timeline, rollback. +

+
+

API health

+ {err &&
{err}
} + {health ? ( +
{JSON.stringify(health, null, 2)}
+ ) : ( + !err &&

loading…

+ )} +
+
+ ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..cadecce --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..0426f7b --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..d97c6ba --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": { + target: "http://localhost:8000", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ""), + }, + }, + }, +});