1-- shopify_sales_by_product - recreates Shopify's "Total sales by product"
2-- Grain: product x variant (SKU) x day, on the ORDER date. Currency: shop money.
3-- Depends on: staging.shopify.order, .order_line, .product
4--
5-- SCOPE: net of discounts, NOT net of returns. Does not reconcile to
6-- shopify_sales_over_time - returns stay on the original order date. Right for
7-- ranking top sellers, wrong for SKU revenue that has to tie out.
8with
9 orders as (
10 select
11 shopify_store
12 , order_id
13 , date(processed_at) as date
14 from
15 {{staging.shopify.order}}
16 )
17select
18 o.date
19 , l.shopify_store
20 , l.product_id
21 , -- Canonical product attributes beat the order_line snapshot, which was taken
22 -- at purchase time and goes stale if a product is renamed.
23 coalesce(p.product_title, l.product_title) as product_title
24 , p.product_type
25 , coalesce(p.vendor, l.vendor) as vendor
26 , l.variant_id
27 , l.variant_title
28 , l.sku
29 , sum(l.quantity) as units
30 , count(distinct o.order_id) as orders
31 , round(sum(l.gross_sales), 2) as gross_sales
32 , round(sum(l.discounts), 2) as discounts
33 , round(sum(l.gross_sales - l.discounts), 2) as net_sales
34from
35 {{staging.shopify.order_line}} l
36 -- shopify_store in every join key: order IDs are only unique within a store.
37 join orders o using (shopify_store, order_id)
38 left join {{staging.shopify.product}} p using (shopify_store, product_id)
39 -- Gift cards are deferred revenue, not product revenue. staging.shopify.order_line
40 -- keeps them, so exclude them here.
41where
42 not l.is_gift_card
43 -- Ordinals, not names: product_title and vendor exist on both joined tables, so
44 -- a bare column name here would be ambiguous.
45group by
46 1
47 , 2
48 , 3
49 , 4
50 , 5
51 , 6
52 , 7
53 , 8
54 , 9
55order by
56 o.date
57 , net_sales descRequired models (4)
This model reads from staging models rather than raw tables. Deploy these first — Weld resolves the order for you.
staging.shopify.orderOrders with test orders removed, types cast, currency precedence resolved.
-- staging.shopify.order
-- Thin wrapper over raw `order`. Casts, renames, drops test orders. No other logic.
--
-- Single store. To add another, UNION ALL a second block below pointing at that
-- store's connector with a different shopify_store label. Keep it in staging so
-- the core models never have to know how many stores there are.
select
'store_1' as shopify_store
, cast(id as int64) as order_id
, cast(name as string) as order_name
, cast(customer_id as int64) as customer_id
, cast(location_id as int64) as location_id
, lower(cast(source_name as string)) as source_name
, cast(processed_at as timestamp) as processed_at
, cast(created_at as timestamp) as created_at
, cast(cancelled_at as timestamp) as cancelled_at
, cast(closed_at as timestamp) as closed_at
, -- COALESCE to '' so downstream NOT IN (...) filters keep NULL-status orders
-- rather than silently dropping them.
lower(coalesce(cast(financial_status as string), '')) as financial_status
, lower(cast(fulfillment_status as string)) as fulfillment_status
, -- The order's own currency, preferred over the shop's CURRENT currency.
-- Stores that changed base currency, or imported history from another
-- platform, hold orders denominated in something else; defaulting to today's
-- shop currency would mislabel them by the full FX factor.
upper(
nullif(
trim(
cast(
current_total_price_set_shop_money_currency_code as string
)
)
, ''
)
) as order_shop_currency
, upper(nullif(trim(cast(currency as string)), '')) as currency
, upper(
nullif(
trim(
cast(
current_total_price_set_presentment_money_currency_code as string
)
)
, ''
)
) as order_presentment_currency
, upper(
nullif(trim(cast(presentment_currency as string)), '')
) as presentment_currency
, upper(
nullif(
trim(cast(shipping_address_country_code as string))
, ''
)
) as shipping_country_code
, upper(
nullif(
trim(cast(billing_address_country_code as string))
, ''
)
) as billing_country_code
from
{{raw.shopify.order}}
where
coalesce(test, false) = falsestaging.shopify.order_lineProduct identity and line money, plus gift-card and requires-shipping flags.
-- staging.shopify.order_line
-- Product identity and line money. Gift cards are NOT filtered here - staging
-- stays neutral and consumers decide. `is_gift_card` and `requires_shipping` are
-- exposed because the sales report needs them to classify orders.
--
-- Single store. To add another, UNION ALL a second block below pointing at that
-- store's connector with a different shopify_store label. Keep it in staging so
-- the core models never have to know how many stores there are.
select
'store_1' as shopify_store
, cast(id as int64) as line_id
, cast(order_id as int64) as order_id
, cast(product_id as int64) as product_id
, cast(variant_id as int64) as variant_id
, cast(sku as string) as sku
, nullif(trim(cast(title as string)), '') as product_title
, cast(variant_title as string) as variant_title
, cast(vendor as string) as vendor
, cast(quantity as int64) as quantity
, coalesce(cast(gift_card as bool), false) as is_gift_card
, coalesce(cast(requires_shipping as bool), true) as requires_shipping
, coalesce(
cast(price_set_shop_money_amount as numeric)
, cast(price as numeric)
) as unit_price
, coalesce(
cast(price_set_shop_money_amount as numeric)
, cast(price as numeric)
) * cast(quantity as int64) as gross_sales
, coalesce(
cast(total_discount_set_shop_money_amount as numeric)
, cast(total_discount as numeric)
, 0
) as discounts
from
{{raw.shopify.order_line}}staging.shopify.productCanonical product title, type and vendor.
-- staging.shopify.product
-- Canonical product attributes. order_line carries a snapshot taken at purchase
-- time; prefer these when a product was later renamed or recategorised.
--
-- Single store. To add another, UNION ALL a second block below pointing at that
-- store's connector with a different shopify_store label. Keep it in staging so
-- the core models never have to know how many stores there are.
select
'store_1' as shopify_store
, cast(id as int64) as product_id
, cast(title as string) as product_title
, cast(product_type as string) as product_type
, cast(vendor as string) as vendor
, lower(cast(status as string)) as status
from
{{raw.shopify.product}}analytics.shopify.sales_by_productThin BI-facing contract over the core model - bind dashboards here, not to core.
-- analytics.shopify.sales_by_product
-- BI-facing contract over the core model. See analytics/sales_over_time.sql for
-- why this layer exists even when it is a passthrough.
select
*
from
{{core.shopify.sales_by_product}}Example output
+ ------------+---------------+--------------+----------+---------------+----------+-------+--------+-------------+-----------+-----------+
| date | product_title | product_type | vendor | variant_title | sku | units | orders | gross_sales | discounts | net_sales | + ------------+---------------+--------------+----------+---------------+----------+-------+--------+-------------+-----------+-----------+
| 2026 -03 -05 | Product 1 | Category A | Vendor A | Variant A | SKU -0001 | 38 | 36 | 6839.62 | -512.00 | 6327.62 | | 2026 -03 -05 | Product 2 | Category B | Vendor A | Variant A | SKU -0002 | 52 | 49 | 4159.48 | -374.35 | 3785.13 | | 2026 -03 -05 | Product 3 | Category C | Vendor B | Variant A | SKU -0003 | 14 | 14 | 3919.86 | -280.00 | 3639.86 | | 2026 -03 -05 | Product 4 | Category D | Vendor B | Variant A | SKU -0004 | 23 | 22 | 2298.77 | -149.90 | 2148.87 | | 2026 -03 -05 | Product 1 | Category A | Vendor A | Variant B | SKU -0005 | 11 | 11 | 1979.89 | -98.00 | 1881.89 | + ------------+---------------+--------------+----------+---------------+----------+-------+--------+-------------+-----------+-----------+Rolls order lines up to product, variant and SKU per day, joined to the product table for canonical titles, product type and vendor. Use it to rank top sellers, chart a single product over time, or compare categories and suppliers. One scope note worth understanding: this model reads order_line on the order date, so it reports gross and net of discounts but not net of returns. That is the right trade-off for ranking - relative ranking barely moves whether or not returns are netted out, and order_line is where product identity actually lives - but it means the numbers will not reconcile day-by-day with the sales report, because returns stay attached to the original order date rather than reducing the SKU that came back. For product-level revenue that has to tie out, you need the agreement event grain at line level.