Demo Retail — Analyst Knowledge Base

Synthetic client · generated by the toolkit · no real company, schema, or data

Demo Retail — Analyst Knowledge Base

Generated: 2026-04-10 | Stack: PostgreSQL + Metabase (no dbt) | Scope: full platform | Role: analyst

Day-1 orientation: This KB covers all 5 source tables, the 7 core business metrics, known data bugs, and safe SQL patterns. Read sections 5 and 7 before writing any query — they will save you from 3 common mistakes that cost analysts hours the first week.


1. What is Demo Retail?

Demo Retail is an e-commerce store selling consumer goods (Electronics, Clothing, Home, Sports, Books) across 3 regions: EU, US, and APAC. The business has ~500K lifetime orders, ~80K active customers, and 3 years of transaction history. Revenue reporting is in EUR — all amounts are FX-converted at load time, including US and APAC orders.

The data team is 2 analysts working from a shared operational PostgreSQL database, with Metabase connected for nightly-refresh dashboards. There is no dbt, no transformation layer, and no formal metric catalog yet — installing dbt and defining canonical metrics are the two highest-priority improvements (see the discovery report).

Key open business questions driving current analysis:


2. Architecture Overview

Layer Technology Notes
Warehouse PostgreSQL Operational DB. public schema. 5 core tables.
Transformation None (pre-dbt) Queries run directly against source tables. dbt install is P0.
Metrics Informal (schema_notes.md) No YAML catalog. Revenue calculated differently by each analyst — highest risk.
BI Metabase Nightly dashboard refresh. Multiple saved questions use SELECT * — fragile.
Access Shared login 2 analysts share 1 DB credential. No per-analyst audit trail.

No live warehouse connection in this session — generated from static file analysis.


3. Table Inventory

Source Tables (public schema)

Table Grain Approx. rows Primary key Notes
orders 1 row per order ~500K id ~14K new orders/month
customers 1 row per registered customer ~80K id ~12K have NULL plan_tier
products 1 row per SKU ~3,200 id Includes discontinued SKUs
order_items 1 row per line item per order ~1.5–2M (est.) id Critical: discount_pct is a decimal
returns 1 row per returned item Unknown id Jan 2025 has duplicate rows — always filter

orders

Column Type Description
id integer PK. Auto-increment.
customer_id integer FK → customers.id
status varchar pending | processing | shipped | delivered | cancelled | returned
total_amount numeric(10,2) Pre-computed order total in EUR. Do not use for revenue — use order_items instead (see §5).
currency varchar(3) Always 'EUR' — misleading column, amounts are pre-converted. Do not filter on it.
created_at timestamp Order placement time. UTC timezone.
shipped_at timestamp NULL for ~8% of delivered orders — legacy import gap.
delivered_at timestamp NULL until delivered.

customers

Column Type Description
id integer PK
email varchar Unique. PII — do not include in query results shared externally.
country varchar(2) ISO-2 code. Top 5: DE, FR, US, PL, GB
city varchar Free text. Not normalized.
signup_date date NULL for customers migrated before 2023-01-01 (~12K rows)
plan_tier varchar free | standard | premium. NULL for pre-2023 customers.
last_order_date date Denormalized. Updated by nightly job. May lag 1 day.

products

Column Type Description
id integer PK
name varchar Free text.
category varchar Electronics | Clothing | Home | Sports | Books
subcategory varchar ~40 values. Not normalized.
price numeric(10,2) Current selling price — historical prices not tracked.
cost numeric(10,2) COGS. Sensitive — do not expose in client-facing or shared reports.
is_active boolean false = discontinued. Still appears in order/return history.

order_items

Column Type Description
id integer PK
order_id integer FK → orders.id
product_id integer FK → products.id
quantity integer Always ≥ 1
unit_price numeric(10,2) Price at purchase time. May differ from products.price (current).
discount_pct numeric(5,4) Decimal fraction, NOT percentage. 0.15 = 15% discount.

Revenue formula: unit_price * quantity * (1 - discount_pct)


returns

Column Type Description
id integer PK
order_id integer FK → orders.id
order_item_id integer FK → order_items.id
reason varchar defective | wrong_item | not_as_described | changed_mind | damaged_in_transit
refund_amount numeric(10,2) Actual refund. May differ from unit_price (partial refunds).
created_at timestamp Return requested.
processed_at timestamp Refund issued. NULL if pending.

Known bug: Duplicate rows for Jan 2025 return events. Fixed in pipeline Feb 2025, but duplicates remain in the table. See §7 for the safe deduplication filter.


Metric Definitions (Informal)

No formal YAML catalog exists yet. These definitions come from schema_notes.md and represent the canonical formulas — both analysts should use these until the semantic layer is built.

Metric Formula Key exclusions
Gross Revenue SUM(oi.unit_price * oi.quantity * (1 - oi.discount_pct)) status IN ('shipped','delivered') only
Net Revenue Gross Revenue − SUM(returns.refund_amount) join on order_item_id
AOV Gross Revenue / COUNT(DISTINCT order_id) same status filter
Return Rate COUNT(DISTINCT returns.order_item_id) / NULLIF(COUNT(DISTINCT order_items.id), 0) exclude Jan 2025 duplicates
Conversion Rate No reliable session data in DB — use as proxy: completed orders / signups Very rough proxy only
Churn Customers with last_order_date < CURRENT_DATE - 90 / total active "Active" = ordered in last 12 months
Customer LTV SUM(revenue) per customer since signup Exclude NULL signup_date rows

4. Entity Relationship Map

customers (grain: 1 row per customer, ~80K rows)
  ├── id (PK)
  ├── email (PII)
  ├── country (ISO-2), city
  ├── signup_date         ← NULL for ~12K pre-2023 customers
  ├── plan_tier           ← NULL for ~12K pre-2023 customers
  └── last_order_date     ← denormalized, 1-day lag

orders (grain: 1 row per order, ~500K rows)
  ├── id (PK)
  ├── customer_id         → customers.id
  ├── status              (pending|processing|shipped|delivered|cancelled|returned)
  ├── total_amount        ← pre-computed, do not use for analytics revenue
  ├── currency            ← always EUR, ignore for filtering
  ├── created_at (UTC)
  ├── shipped_at          ← NULL for ~8% of delivered orders
  └── delivered_at

order_items (grain: 1 row per line item, ~1.5–2M rows est.)
  ├── id (PK)
  ├── order_id            → orders.id
  ├── product_id          → products.id
  ├── quantity
  ├── unit_price          ← historical price (not products.price)
  └── discount_pct        ← DECIMAL (0.15 = 15%), NOT percentage

returns (grain: 1 row per returned item)
  ├── id (PK)
  ├── order_id            → orders.id
  ├── order_item_id       → order_items.id
  ├── reason
  ├── refund_amount
  ├── created_at          ← DUPLICATES in Jan 2025, always filter
  └── processed_at

products (grain: 1 row per SKU, ~3,200 rows)
  ├── id (PK)
  ├── name, category, subcategory
  ├── price               ← CURRENT price only, historical not stored
  ├── cost                ← COGS, SENSITIVE
  └── is_active           ← false = discontinued, still in order history

5. Key Business Logic

Revenue domain

Customers domain

Returns domain

Products domain

Conversion Rate


6. Quick-Start SQL Patterns

All queries are written for PostgreSQL.

Pattern 1: Monthly gross revenue (correct)

-- Canonical gross revenue — the safe version both analysts should use
SELECT
    DATE_TRUNC('month', o.created_at)                      AS period,
    SUM(oi.unit_price * oi.quantity * (1 - oi.discount_pct)) AS gross_revenue,
    COUNT(DISTINCT o.id)                                   AS order_count,
    SUM(oi.unit_price * oi.quantity * (1 - oi.discount_pct))
        / NULLIF(COUNT(DISTINCT o.id), 0)                  AS aov
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.status IN ('shipped', 'delivered')
GROUP BY 1
ORDER BY 1;

Pattern 2: Net revenue (after refunds)

-- Gross revenue minus actual refunds
SELECT
    DATE_TRUNC('month', o.created_at)                         AS period,
    SUM(oi.unit_price * oi.quantity * (1 - oi.discount_pct))  AS gross_revenue,
    COALESCE(SUM(r.refund_amount), 0)                         AS total_refunds,
    SUM(oi.unit_price * oi.quantity * (1 - oi.discount_pct))
        - COALESCE(SUM(r.refund_amount), 0)                   AS net_revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
LEFT JOIN returns r ON r.order_item_id = oi.id
    AND r.created_at NOT BETWEEN '2025-01-01' AND '2025-01-31'  -- exclude Jan 2025 bug
WHERE o.status IN ('shipped', 'delivered')
GROUP BY 1
ORDER BY 1;

Pattern 3: Return rate by product category

-- Item-level return rate per category (exclude Jan 2025 duplicates)
SELECT
    p.category,
    COUNT(DISTINCT r.order_item_id)::FLOAT
        / NULLIF(COUNT(DISTINCT oi.id), 0)  AS return_rate,
    COUNT(DISTINCT oi.id)                   AS items_sold,
    COUNT(DISTINCT r.order_item_id)         AS items_returned
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
LEFT JOIN returns r ON r.order_item_id = oi.id
    AND r.created_at NOT BETWEEN '2025-01-01' AND '2025-01-31'
WHERE o.status = 'delivered'
GROUP BY 1
ORDER BY 2 DESC;

Pattern 4: Customer count by plan tier (handle NULLs)

-- Always COALESCE plan_tier — bare GROUP BY silently excludes ~12K customers
SELECT
    COALESCE(plan_tier, 'unknown')  AS tier,
    COUNT(*)                        AS total_customers,
    COUNT(CASE WHEN last_order_date >= CURRENT_DATE - INTERVAL '90 days'
               THEN 1 END)          AS active_90d
FROM customers
GROUP BY 1
ORDER BY 2 DESC;

Pattern 5: AOV by plan tier (last 12 months)

-- Segment AOV by plan tier — reveals premium vs standard difference
SELECT
    COALESCE(c.plan_tier, 'unknown')                              AS tier,
    DATE_TRUNC('month', o.created_at)                            AS period,
    SUM(oi.unit_price * oi.quantity * (1 - oi.discount_pct))
        / NULLIF(COUNT(DISTINCT o.id), 0)                         AS aov,
    COUNT(DISTINCT o.id)                                          AS orders
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN customers c ON c.id = o.customer_id
WHERE o.status IN ('shipped', 'delivered')
  AND o.created_at >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY 1, 2
ORDER BY 2, 1;

Pattern 6: Return reasons breakdown

-- What drives returns? (safe: excludes Jan 2025 duplicates)
SELECT
    reason,
    COUNT(*)                                          AS return_count,
    SUM(refund_amount)                                AS total_refunded,
    COUNT(*)::FLOAT / SUM(COUNT(*)) OVER ()           AS share_of_returns
FROM returns
WHERE created_at NOT BETWEEN '2025-01-01' AND '2025-01-31'
  AND processed_at IS NOT NULL  -- completed refunds only
GROUP BY 1
ORDER BY 2 DESC;

Pattern 7: 90-day churn by plan tier

-- Customers who haven't ordered in 90 days (among those active in last 12 months)
SELECT
    COALESCE(plan_tier, 'unknown')                                   AS tier,
    COUNT(*)                                                         AS active_12m,
    COUNT(CASE WHEN last_order_date < CURRENT_DATE - INTERVAL '90 days'
               THEN 1 END)                                           AS churned_90d,
    COUNT(CASE WHEN last_order_date < CURRENT_DATE - INTERVAL '90 days'
               THEN 1 END)::FLOAT
        / NULLIF(COUNT(*), 0)                                        AS churn_rate
FROM customers
WHERE last_order_date >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY 1
ORDER BY 4 DESC;

7. Common Pitfalls


8. Quick-Start Guide

Top 5 Tables to Know

Table What it is Key columns
orders One row per order id, customer_id, status, created_at
order_items One row per line item order_id, unit_price, quantity, discount_pct
customers One row per customer id, plan_tier, country, last_order_date
products Product catalog id, category, is_active (never use price for history)
returns One row per returned item order_item_id, refund_amount, reason, created_at

Top 5 Metrics

Metric What it measures Key table Gotcha
Gross Revenue Revenue from completed orders order_items + orders Status filter + decimal discount
Net Revenue Revenue after refunds + returns Exclude Jan 2025 duplicates
AOV Average order value order_items + orders Segment by plan_tier with COALESCE
Return Rate % of items returned returns / order_items NULLIF denominator, Jan 2025 filter
Churn Rate % of customers lapsed 90d customers COALESCE plan_tier

KB freshness check

# No dbt — check when schema_notes.md was last updated:
git log --follow -p clients/demo-retail/schema_notes.md | head -20

Regenerate

# Full regeneration (static analysis):
/onboarding demo-retail

# Add new tables or refresh after schema changes:
/onboarding demo-retail --update

# With warehouse data (needs .env + API key):
source .venv/bin/activate
python -m agents.onboarding --client demo-retail

Contributing corrections

Add notes to ## Analyst Notes below. They are preserved on every regeneration. Format: - [YYYY-MM-DD] Your note here


9. Metabase Dashboards

Metabase API not queried in this session — static analysis only.

Known dashboard patterns from profile.yaml:

Dashboard area Status Known issues
Revenue tracking In use May use orders.total_amount instead of order_items formula
Customer segmentation In use Likely missing COALESCE(plan_tier, 'unknown')
Returns analysis In use Likely does not filter Jan 2025 duplicates
Product category In use SELECT * patterns confirmed

Populate with actual workbook names after Metabase access is granted.


10. Planned Improvements

Once dbt is installed (P0 action from discovery report), the following will be added to this KB:

Run /onboarding demo-retail --update after dbt is configured to regenerate this KB with model inventory.


Generated by Claude Code (Mode B — static file analysis) Discovery report: clients/demo-retail/reports/2026-04-10_discovery.md (Level 1/5 — Raw)


Analyst Notes

Add corrections and discoveries below. Preserved on every regeneration.