Product Sales Over Time
The sales equation at line grain - day x store x order x line - read from the agreement event log, so it reconciles to the sales report. Carries COGS and gross profit from Shopify's own cost history.
- Grain
- See the notes
- Built from
- 10Raw tables
- 10Staging
- 2Core
- 1Analytics
- Reads from Shopify
- inventory_iteminventory_item__historylocationorderorder_agreementorder_agreement_saleorder_lineorder_line_refund+2 more
- Ships as
- Weld and dbt SQL, open source on GitHub
1-- shopify_product_sales_over_time
2--
3-- The sales report at line grain: day x store x order x line. This is the model
4-- merchandising lives in, and the one that makes SKU-level margin possible.
5--
6-- It reconciles to sales_over_time. That is the whole point, and it is why the
7-- non-product rows are here: shipping, fees, gift cards and unattributable refund
8-- adjustments are emitted as rows with a NULL sku. Drop them and this model sits
9-- below the sales report by exactly the value of shipping and fees, and someone
10-- spends a week finding out why.
11--
12-- The hard part is attribution. An agreement records that money moved on an order,
13-- not always which line it belongs to, so product money is distributed across the
14-- order's lines by weight - units follow quantity share, money follows revenue
15-- share. Using one weight for both distorts mixed-price baskets.
16--
17-- Returns arrive from two places: agreement RETURN events and order_line_refund
18-- rows. Same money, two sources. Naively unioning both doubles returns, so the
19-- refund rows attribute per line and only the unexplained residual is spread.
20--
21-- Depends on: staging.shopify.{order, order_line, order_agreement,
22-- order_agreement_sale, order_refund, order_line_refund, location,
23-- shop} and core.shopify.sku_cost_per_day
24
25WITH shop AS (
26 SELECT
27 shopify_store,
28 ANY_VALUE(currency) AS shop_currency,
29 COALESCE(ANY_VALUE(iana_timezone), 'UTC') AS report_timezone
30 FROM {{staging.shopify.shop}}
31 GROUP BY shopify_store
32),
33
34locations AS (
35 SELECT shopify_store, location_id, location_name, location_country_code
36 FROM {{staging.shopify.location}}
37),
38
39orders AS (
40 SELECTThis model and the 12 it depends on. Deploy them together; refs like {{staging.…}} resolve to the matching folders in Weld, and the dbt view uses source() and ref().
How it counts. And where it stops.
Recreates Shopify's "Total sales by product", but on the event grain rather than by summing order_line, so returns land on the refund date and the numbers actually tie to your sales-over-time report. Three things make that reconciliation work. Shipping, duties, fees and gift cards belong to the order rather than to any line, so they are carried as rows with a NULL sku - drop them and the product report sits below the sales report by exactly their value. Order-level money is distributed across lines by two separate weights, with units following quantity share and money following revenue share, because using one weight for both distorts mixed-price baskets. And returns arrive from both agreement RETURN events and per-line refund rows - the same money, twice - so the refund rows attribute per line and only the unexplained residual is spread across the order. COGS comes from Shopify itself: inventory_item for the current cost and its history table for every change, so a January order is valued at January's cost rather than today's. Cost is left NULL where unknown rather than zero, because a zero cost reads as 100% margin.
Raw tables it reads
- raw.shopify.inventory_item
- raw.shopify.inventory_item__history
- raw.shopify.location
- raw.shopify.order
- raw.shopify.order_agreement
- raw.shopify.order_agreement_sale
- raw.shopify.order_line
- raw.shopify.order_line_refund
- raw.shopify.order_refund
- raw.shopify.shop
More Shopify models
Put Shopify in front
of your agents.
Connect Shopify, deploy the models, and ask questions in Claude, Cursor or ChatGPT. Free for 14 days, no credit card.