1-- shopify_sales_over_time - recreates Shopify's "Total sales over time"
2--
3-- Grain: day x store x order x location x channel x row type. Keeping row type
4-- in the grain means a day where an order both sells and refunds produces two
5-- rows, so the SALE and RETURN sides stay independently auditable.
6--
7-- Multi-store safe: order IDs are only unique WITHIN a store, so shopify_store is
8-- part of every join key and of the grain. Drop it from one join and rows fan out
9-- across storefronts silently.
10--
11-- Event-based: reads Shopify's order agreement log, so every financial change
12-- lands on the day it happened - returns on the refund date, order edits on the
13-- edit date. History does not restate itself when an old order is refunded.
14--
15-- Currency: shop money, plus the presentment amounts the customer actually paid.
16-- Timezone: read from the shop record, so nothing needs hardcoding.
17--
18-- Depends on: staging.shopify.order, .order_agreement, .order_agreement_sale
19 , -- .order_line, .order_refund, .order_line_refund, .shop, .location
20with
21 shop as (
22 -- Per store: currency and timezone both vary between storefronts.
23 select
24 shopify_store
25 , any_value(currency) as shop_currency
26 , coalesce(any_value(iana_timezone), 'UTC') as report_timezone
27 from
28 {{staging.shopify.shop}}
29 group by
30 shopify_store
31 )
32 , locations as (
33 select
34 shopify_store
35 , location_id
36 , location_name
37 , location_country_code
38 from
39 {{staging.shopify.location}}
40 )
41 , orders as (
42 select
43 o.shopify_store
44 , o.order_id
45 , o.order_name
46 , o.location_id
47 , o.source_name
48 , o.financial_status
49 , o.fulfillment_status
50 , o.cancelled_at
51 , -- The order's own currency wins over the shop's CURRENT currency, which
52 -- may have changed since. Deliberately NOT falling back to presentment
53 -- currency: that is what the customer was charged in, so using it here
54 -- would label shop-currency money with the buyer's currency. If none of
55 -- these is set, NULL is the honest answer - a guessed currency code
56 -- silently mislabels every amount on the row.
57 coalesce(
58 o.order_shop_currency
59 , o.currency
60 , s.shop_currency
61 ) as shop_currency
62 , -- Shipping country is the right geography, but it is NULL for digital
63 -- goods and POS. Cascade to billing, then the location's country.
64 coalesce(
65 o.shipping_country_code
66 , o.billing_country_code
67 , l.location_country_code
68 ) as country
69 from
70 {{staging.shopify.order}} o
71 join shop s using (shopify_store)
72 left join locations l using (shopify_store, location_id)
73 -- Only voided orders are dropped here: the money never existed. Everything
74 -- else stays, including 'pending' - an authorised-but-uncaptured order
75 -- (bank transfer, cash on delivery, manual payment) is a sale Shopify counts
76 , -- so excluding it here would understate every report downstream. Cancelled
77 -- and refunded orders also stay: their reversals arrive as their own RETURN
78 -- events, so removing the order would lose the sale AND the reversal.
79 -- financial_status and cancelled_at are passed through, so the analytics
80 -- layer can narrow this without core having destroyed the rows.
81 where
82 o.financial_status != 'voided'
83 )
84 , -- Orders carrying only untitled lines are dropped by Shopify's reports: deleted
85 -- products, draft-order custom lines, API artifacts. Flag at order level so a
86 -- partial order does not half-appear. Gift-card lines count as titled.
87 order_line_flags as (
88 select
89 shopify_store
90 , order_id
91 , max(
92 case
93 when is_gift_card
94 or product_title is not null then 1
95 else 0
96 end
97 ) as has_product_title
98 , logical_or(requires_shipping) as any_line_requires_shipping
99 from
100 {{staging.shopify.order_line}}
101 group by
102 shopify_store
103 , order_id
104 )
105 , agreements as (
106 select
107 shopify_store
108 , order_agreement_id
109 , order_id
110 , happened_at
111 , app_handle
112 , reason
113 from
114 {{staging.shopify.order_agreement}}
115 )
116 , sales as (
117 select
118 shopify_store
119 , order_agreement_id
120 , order_id
121 , line_type
122 , action_type
123 , quantity
124 , total_amount
125 , total_tax
126 , discount_before_tax
127 , amount_ex_tax
128 , presentment_total_amount
129 , presentment_total_tax
130 , presentment_discount_before_tax
131 , presentment_amount_ex_tax
132 , presentment_currency
133 from
134 {{staging.shopify.order_agreement_sale}}
135 )
136 , -- Earliest ORDER agreement per order, used to spot checkout-flow edits.
137 order_first_time as (
138 select
139 shopify_store
140 , order_id
141 , min(happened_at) as order_created_at
142 from
143 agreements
144 where
145 reason = 'ORDER'
146 group by
147 shopify_store
148 , order_id
149 )
150 , -- Shopify's own implied FX rate per order: presentment per 1 unit of shop money.
151 -- Using this beats converting shop money at your own daily rate, because Shopify
152 -- used its rate at checkout and yours will not match what the customer paid.
153 order_presentment as (
154 select
155 shopify_store
156 , order_id
157 , any_value(presentment_currency) as presentment_currency
158 , safe_divide(
159 sum(presentment_total_amount)
160 , nullif(sum(total_amount), 0)
161 ) as shopify_fx_rate
162 from
163 sales
164 group by
165 shopify_store
166 , order_id
167 )
168 , joined as (
169 select
170 -- Localise BEFORE truncating to a date. happened_at is UTC while Shopify
171 -- reports in store-local time.
172 date(datetime(a.happened_at, s.report_timezone)) as date
173 , a.shopify_store
174 , a.order_id
175 , a.reason
176 , a.app_handle
177 , o.location_id
178 , o.source_name
179 , o.shop_currency
180 , sa.line_type
181 , sa.action_type
182 , sa.quantity
183 , sa.total_tax
184 , sa.discount_before_tax
185 , sa.amount_ex_tax
186 , sa.presentment_total_tax
187 , sa.presentment_discount_before_tax
188 , sa.presentment_amount_ex_tax
189 , -- An ORDER_EDIT within 90 seconds of creation is part of the original
190 -- purchase (a post-purchase upsell), not a later restatement.
191 case
192 when a.reason = 'ORDER_EDIT'
193 and timestamp_diff(a.happened_at, oft.order_created_at, second) <= 90 then true
194 else false
195 end as is_checkout_edit
196 from
197 agreements a
198 join shop s using (shopify_store)
199 join sales sa using (shopify_store, order_agreement_id, order_id)
200 join orders o using (shopify_store, order_id)
201 join order_line_flags olf using (shopify_store, order_id)
202 left join order_first_time oft using (shopify_store, order_id)
203 where
204 olf.has_product_title = 1
205 )
206 , typed as (
207 select
208 *
209 , case
210 when action_type in ('ORDER', 'UPDATE') then 'SALE'
211 when action_type = 'RETURN' then 'RETURN'
212 end as report_row_type
213 from
214 joined
215 where
216 action_type in ('ORDER', 'UPDATE', 'RETURN')
217 )
218 , -- Shopify lumps cancellations, returns and refunds together; your business does
219 -- not. restock_type on the refunded lines tells them apart. Return rate measured
220 -- on RETURN alone is a product-quality signal; on all three it is noise.
221 refunds as (
222 select
223 r.shopify_store
224 , r.refund_id
225 , r.order_id
226 , date(datetime(r.refund_created_at, s.report_timezone)) as date
227 from
228 {{staging.shopify.order_refund}} r
229 join shop s using (shopify_store)
230 )
231 , refund_detail as (
232 select
233 r.shopify_store
234 , r.date
235 , r.order_id
236 , olr.restock_type
237 , olr.location_id
238 , sum(olr.quantity) as qty
239 from
240 {{staging.shopify.order_line_refund}} olr
241 join refunds r using (shopify_store, refund_id)
242 group by
243 1
244 , 2
245 , 3
246 , 4
247 , 5
248 )
249 , reversal_type as (
250 select
251 shopify_store
252 , date
253 , order_id
254 , case
255 when restock_type = 'cancel' then 'CANCELLATION' -- never shipped
256 when restock_type = 'return' then 'RETURN' -- came back, restocked
257 else 'REFUND' -- money back, no restock
258 end as reversal_type
259 from
260 refund_detail
261 -- The dominant restock type wins when one refund mixes them.
262 qualify
263 row_number() over (
264 partition by
265 shopify_store
266 , date
267 , order_id
268 order by
269 qty desc
270 , restock_type
271 ) = 1
272 )
273 , -- Carry the last known refund location forward so a multi-day return sequence
274 -- does not fragment across locations.
275 refund_location as (
276 select
277 shopify_store
278 , date
279 , order_id
280 , last_value(location_id ignore nulls) over (
281 partition by
282 shopify_store
283 , order_id
284 order by
285 date rows between unbounded preceding
286 and current ROW
287 ) as refund_location_id
288 from
289 (
290 select
291 shopify_store
292 , date
293 , order_id
294 , max(location_id) as location_id
295 from
296 refund_detail
297 group by
298 1
299 , 2
300 , 3
301 )
302 )
303 , agg as (
304 select
305 date
306 , shopify_store
307 , order_id
308 , location_id
309 , source_name
310 , shop_currency
311 , report_row_type
312 , -- MAX not SUM: one order is one order however many lines it has.
313 -- Excluding ORDER_EDIT stops an edited order counting again on the edit date.
314 max(
315 case
316 when report_row_type = 'SALE'
317 and line_type = 'PRODUCT'
318 and action_type = 'ORDER'
319 and reason not in ('RETURN', 'ORDER_EDIT') then 1
320 else 0
321 end
322 ) as orders
323 , -- 1. Net unit movement: sales minus returns.
324 sum(
325 case
326 when report_row_type = 'SALE'
327 and line_type in ('PRODUCT', 'GIFT_CARD') then quantity
328 when report_row_type = 'RETURN'
329 and line_type in ('PRODUCT', 'ADJUSTMENT') then quantity
330 else 0
331 end
332 ) as quantity
333 , -- 2. Strict units ordered: no returns, no edits. Matches the admin UI.
334 sum(
335 case
336 when report_row_type = 'SALE'
337 and line_type = 'PRODUCT'
338 and action_type = 'ORDER'
339 and reason not in ('RETURN', 'ORDER_EDIT') then quantity
340 else 0
341 end
342 ) as quantity_ordered_shopify_compat
343 , -- 3. Keeps checkout-flow edits, drops post-order edits. Matches Shopify's
344 -- CSV export, which differs from the UI.
345 sum(
346 case
347 when report_row_type = 'SALE'
348 and line_type in ('PRODUCT', 'GIFT_CARD')
349 and action_type = 'ORDER'
350 and reason != 'RETURN'
351 and (
352 reason != 'ORDER_EDIT'
353 or is_checkout_edit
354 ) then quantity
355 else 0
356 end
357 ) as quantity_ordered_shopify_export_parity
358 , -- RETURN rows carry negative amounts, so they reduce net sales on their
359 -- own. ADJUSTMENT lines are refund value Shopify cannot tie to a product
360 -- line (partial refunds, goodwill credits) - including them is what makes
361 -- returns tie out.
362 sum(
363 case
364 when report_row_type = 'SALE'
365 and line_type = 'PRODUCT' then amount_ex_tax
366 when report_row_type = 'RETURN'
367 and line_type in ('PRODUCT', 'ADJUSTMENT') then amount_ex_tax
368 else 0
369 end
370 ) as net_sales
371 , -- Same, excluding POST-ORDER edits: reconciles to the admin UI.
372 sum(
373 case
374 when report_row_type = 'SALE'
375 and line_type = 'PRODUCT'
376 and (
377 reason != 'ORDER_EDIT'
378 or is_checkout_edit
379 ) then amount_ex_tax
380 when report_row_type = 'RETURN'
381 and line_type in ('PRODUCT', 'ADJUSTMENT') then amount_ex_tax
382 else 0
383 end
384 ) as net_sales_shopify_parity
385 , -- GREATEST guards negative discounts, which appear on some edit and
386 -- return events and would otherwise inflate the total.
387 (-1) * sum(
388 case
389 when report_row_type = 'SALE'
390 and line_type = 'PRODUCT' then greatest(discount_before_tax, 0)
391 else 0
392 end
393 ) as discounts
394 , sum(
395 case
396 when report_row_type = 'RETURN'
397 and line_type in ('PRODUCT', 'ADJUSTMENT') then amount_ex_tax
398 else 0
399 end
400 ) as returns
401 , sum(
402 case
403 when report_row_type = 'RETURN'
404 and line_type = 'ADJUSTMENT' then amount_ex_tax
405 else 0
406 end
407 ) as return_adjustments
408 , -- No row_type filter: shipping and fees occur on sales AND refunds, and a
409 -- refunded shipping charge should reduce the total.
410 sum(
411 case
412 when line_type = 'SHIPPING' then amount_ex_tax
413 else 0
414 end
415 ) as shipping_charges
416 , sum(
417 case
418 when line_type = 'DUTY' then amount_ex_tax
419 else 0
420 end
421 ) as duties
422 , sum(
423 case
424 when line_type = 'FEE' then amount_ex_tax
425 else 0
426 end
427 ) as return_fees
428 , cast(0 as numeric) as additional_fees
429 , sum(total_tax) as taxes
430 , -- Returns detail block.
431 sum(
432 case
433 when report_row_type = 'RETURN'
434 and line_type in ('PRODUCT', 'ADJUSTMENT') then amount_ex_tax
435 else 0
436 end
437 ) as sales_reversals
438 , (-1) * sum(
439 case
440 when report_row_type = 'RETURN'
441 and line_type = 'PRODUCT' then discount_before_tax
442 else 0
443 end
444 ) as discount_reversals
445 , sum(
446 case
447 when report_row_type = 'RETURN' then total_tax
448 else 0
449 end
450 ) as tax_reversals
451 , sum(
452 case
453 when report_row_type = 'RETURN'
454 and line_type = 'SHIPPING' then amount_ex_tax
455 else 0
456 end
457 ) as shipping_reversals
458 , sum(
459 case
460 when report_row_type = 'RETURN'
461 and line_type in ('PRODUCT', 'ADJUSTMENT') then quantity
462 else 0
463 end
464 ) as reversed_quantity
465 , -- Gift cards are deferred revenue, kept out of the sales equation.
466 sum(
467 case
468 when line_type = 'GIFT_CARD' then amount_ex_tax + greatest(discount_before_tax, 0)
469 else 0
470 end
471 ) as gift_card_gross_sales
472 , sum(
473 case
474 when line_type = 'GIFT_CARD' then amount_ex_tax
475 else 0
476 end
477 ) as gift_card_net_sales
478 , (-1) * sum(
479 case
480 when line_type = 'GIFT_CARD' then greatest(discount_before_tax, 0)
481 else 0
482 end
483 ) as gift_card_discounts
484 , sum(
485 case
486 when line_type = 'GIFT_CARD' then total_tax
487 else 0
488 end
489 ) as gift_card_taxes
490 , -- Presentment money: what the customer actually paid, before any
491 -- conversion of ours.
492 sum(
493 case
494 when report_row_type = 'SALE'
495 and line_type = 'PRODUCT' then presentment_amount_ex_tax
496 when report_row_type = 'RETURN'
497 and line_type in ('PRODUCT', 'ADJUSTMENT') then presentment_amount_ex_tax
498 else 0
499 end
500 ) as presentment_net_sales
501 , sum(presentment_total_tax) as presentment_taxes
502 from
503 typed
504 group by
505 1
506 , 2
507 , 3
508 , 4
509 , 5
510 , 6
511 , 7
512 )
513select
514 a.date
515 , a.shopify_store
516 , a.order_id
517 , o.order_name
518 , a.report_row_type
519 , a.shop_currency
520 , o.country
521 , a.source_name
522 , -- The order's own location, straight from Shopify. NULL means Shopify
523 -- recorded no location - usually an online-store order - and is left NULL
524 -- rather than labelled, because "no physical location" is not the same claim
525 -- as "online". Identical expression in product_sales_over_time, so the two
526 -- models reconcile on this dimension.
527 own_loc.location_name as location_name
528 , -- Where a refund was processed, which is often NOT where the order was
529 -- placed: a POS sale returned through the web admin has a store
530 -- location_name and no refund location. Kept as its own column rather than
531 -- coalesced into location_name - they are two different facts, and merging
532 -- them makes it impossible to ask either question. NULL on SALE rows.
533 rf_loc.location_name as refund_location_name
534 , -- Shopify's own channel value, not an interpretation of it: 'web' for the
535 -- online store, 'pos' for retail, 'shopify_draft_order' for manual orders
536 , -- otherwise the handle of the app or marketplace that created the order.
537 -- Split retail from online with this, or with location_name IS NOT NULL.
538 lower(a.source_name) as sales_channel
539 , -- An order of only digital goods never gets a fulfillment status, which reads
540 -- as "unfulfilled" in every dashboard and panics ops. Distinguish it.
541 case
542 when o.fulfillment_status is null
543 and olf.any_line_requires_shipping = false then 'fulfillment_not_required'
544 else o.fulfillment_status
545 end as fulfillment_status
546 , o.financial_status
547 , rt.reversal_type
548 , o.cancelled_at is not null as is_cancelled
549 , a.orders
550 , -- Exactly ONE row per order, on its first SALE day. Summing over any window
551 -- gives "orders placed", the AOV denominator Shopify uses. Counts edit-only
552 -- orders with no ORDER row, ignores return-only orders, and never
553 -- double-counts across locations or days.
554 case
555 when a.report_row_type = 'SALE'
556 and row_number() over (
557 partition by
558 a.shopify_store
559 , a.order_id
560 order by
561 case
562 when a.report_row_type = 'SALE' then 0
563 else 1
564 end
565 , a.date asc
566 , a.location_id asc
567 , a.source_name asc
568 ) = 1 then 1
569 else 0
570 end as is_order_placed
571 , a.quantity
572 , a.quantity_ordered_shopify_compat
573 , a.quantity_ordered_shopify_export_parity
574 , -- discounts and returns are stored negative, so subtracting adds them back.
575 round(a.net_sales - a.discounts - a.returns, 2) as gross_sales
576 , round(a.discounts, 2) as discounts
577 , round(a.returns, 2) as returns
578 , round(a.return_adjustments, 2) as return_adjustments
579 , round(a.net_sales, 2) as net_sales
580 , round(a.net_sales_shopify_parity, 2) as net_sales_shopify_parity
581 , round(a.shipping_charges, 2) as shipping_charges
582 , round(a.duties, 2) as duties
583 , round(a.return_fees, 2) as return_fees
584 , round(a.additional_fees, 2) as additional_fees
585 , round(a.taxes, 2) as taxes
586 , -- Matches the Shopify admin UI.
587 round(
588 a.net_sales + a.shipping_charges + a.duties + a.return_fees + a.additional_fees + a.taxes
589 , 2
590 ) as total_shopify_sales
591 , -- Excludes tax and duties (pass-through money); what finance usually wants.
592 round(
593 a.net_sales + a.shipping_charges + a.return_fees + a.additional_fees
594 , 2
595 ) as total_sales
596 , round(a.sales_reversals, 2) as net_sales_reversals
597 , round(a.sales_reversals - a.discount_reversals, 2) as gross_sales_reversals
598 , round(
599 a.sales_reversals + a.tax_reversals + a.shipping_reversals + a.return_fees
600 , 2
601 ) as total_sales_reversals
602 , round(a.discount_reversals, 2) as discount_reversals
603 , round(a.tax_reversals, 2) as tax_reversals
604 , round(a.shipping_reversals, 2) as shipping_reversals
605 , a.reversed_quantity
606 , round(a.gift_card_gross_sales, 2) as gift_card_gross_sales
607 , round(a.gift_card_net_sales, 2) as gift_card_net_sales
608 , round(a.gift_card_discounts, 2) as gift_card_discounts
609 , round(a.gift_card_taxes, 2) as gift_card_taxes
610 , round(a.taxes - a.gift_card_taxes, 2) as taxes_excluding_gift_cards
611 , -- What the customer actually paid, and the rate Shopify used at checkout.
612 op.presentment_currency
613 , round(a.presentment_net_sales, 2) as presentment_net_sales
614 , round(a.presentment_taxes, 2) as presentment_taxes
615 , op.shopify_fx_rate
616from
617 agg a
618 join orders o using (shopify_store, order_id)
619 join order_line_flags olf using (shopify_store, order_id)
620 left join order_presentment op using (shopify_store, order_id)
621 left join reversal_type rt on rt.shopify_store = a.shopify_store
622 and rt.order_id = a.order_id
623 and rt.date = a.date
624 left join refund_location rf on rf.shopify_store = a.shopify_store
625 and rf.order_id = a.order_id
626 and rf.date = a.date
627 left join locations rf_loc on rf_loc.shopify_store = a.shopify_store
628 and rf_loc.location_id = rf.refund_location_id
629 left join locations own_loc on own_loc.shopify_store = a.shopify_store
630 and own_loc.location_id = a.location_id
631order by
632 a.date
633 , a.shopify_store
634 , a.order_id
635 , a.report_row_typeRequired models (9)
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_agreementThe financial event log; voided agreements removed.
-- staging.shopify.order_agreement
-- The financial event log, one row per change to an order's value.
-- 'voided' agreements were cancelled before they ever represented money.
--
-- 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 string) as order_agreement_id
, cast(order_id as int64) as order_id
, cast(happened_at as timestamp) as happened_at
, lower(cast(app_handle as string)) as app_handle
, upper(coalesce(cast(reason as string), '')) as reason
from
{{raw.shopify.order_agreement}}
where
lower(coalesce(cast(reason as string), '')) != 'voided'staging.shopify.order_agreement_saleLine-level money per event, shop and presentment, with amount_ex_tax derived.
-- staging.shopify.order_agreement_sale
-- Line-level money per event, in both shop money (the store's base currency) and
-- presentment money (what the customer actually paid in).
--
-- amount_ex_tax is derived here because total_amount is tax-inclusive while every
-- sales component except taxes is not - computing it once avoids repeating the
-- subtraction in every consumer.
--
-- 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(order_agreement_id as string) as order_agreement_id
, cast(order_id as int64) as order_id
, upper(cast(line_type as string)) as line_type
, upper(cast(action_type as string)) as action_type
, coalesce(cast(quantity as int64), 0) as quantity
, coalesce(
cast(total_amount_shop_money_amount as numeric)
, 0
) as total_amount
, coalesce(
cast(total_tax_amount_shop_money_amount as numeric)
, 0
) as total_tax
, coalesce(
cast(
total_discount_amount_before_taxes_shop_money_amount as numeric
)
, 0
) as discount_before_tax
, coalesce(
cast(total_amount_shop_money_amount as numeric)
, 0
) - coalesce(
cast(total_tax_amount_shop_money_amount as numeric)
, 0
) as amount_ex_tax
, coalesce(
cast(total_amount_presentment_money_amount as numeric)
, 0
) as presentment_total_amount
, coalesce(
cast(
total_tax_amount_presentment_money_amount as numeric
)
, 0
) as presentment_total_tax
, coalesce(
cast(
total_discount_amount_before_taxes_presentment_money_amount as numeric
)
, 0
) as presentment_discount_before_tax
, coalesce(
cast(total_amount_presentment_money_amount as numeric)
, 0
) - coalesce(
cast(
total_tax_amount_presentment_money_amount as numeric
)
, 0
) as presentment_amount_ex_tax
, upper(
cast(
total_amount_presentment_money_currency_code as string
)
) as presentment_currency
from
{{raw.shopify.order_agreement_sale}}staging.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.order_refundRefund headers - created_at is the date returns are attributed to.
-- staging.shopify.order_refund
-- Refund headers. created_at is the date the refund was processed, which is the
-- date returns are attributed to.
--
-- 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 refund_id
, cast(order_id as int64) as order_id
, cast(created_at as timestamp) as refund_created_at
from
{{raw.shopify.order_refund}}staging.shopify.order_line_refundPer-line refund detail; restock_type separates cancellation from return.
-- staging.shopify.order_line_refund
-- Per-line refund detail. restock_type is what distinguishes a cancellation from
-- a genuine return from a refund-without-restock.
--
-- 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.
-- order_id is deliberately not selected: it is reached through order_refund
, -- which is how Shopify's own schema links these.
select
'store_1' as shopify_store
, cast(refund_id as int64) as refund_id
, cast(order_line_id as int64) as order_line_id
, cast(location_id as int64) as location_id
, coalesce(cast(quantity as int64), 0) as quantity
, lower(cast(restock_type as string)) as restock_type
from
{{raw.shopify.order_line_refund}}staging.shopify.shopBase currency and the store IANA timezone used to localise dates.
-- staging.shopify.shop
-- One row per store. Supplies the base currency and, usefully, the store's own
-- IANA timezone - so the reports localise correctly without hardcoding one.
--
-- 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 shop_id
, cast(name as string) as shop_name
, upper(nullif(trim(cast(currency as string)), '')) as currency
, nullif(trim(cast(iana_timezone as string)), '') as iana_timezone
from
{{raw.shopify.shop}}staging.shopify.locationLocation names, used to resolve location_name on the reports.
-- staging.shopify.location
-- Physical and virtual locations, used to resolve location_name on the reports.
--
-- 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 location_id
, nullif(trim(cast(name as string)), '') as location_name
, upper(nullif(trim(cast(country_code as string)), '')) as location_country_code
from
{{raw.shopify.location}}analytics.shopify.sales_over_timeThin BI-facing contract over the core model - bind dashboards here, not to core.
-- analytics.shopify.sales_over_time
-- BI-facing contract over the core model. Deliberately thin.
--
-- The point is not transformation, it is indirection: dashboards, scheduled
-- reports and reverse-ETL syncs bind to this name, so core can be refactored -
-- renamed columns, changed grain, split into pieces - without breaking anything
-- downstream. Add the shaping your BI tool wants here rather than in core.
-- Cancelled orders are excluded here rather than in core: core keeps them so
-- their reversal events still land, and a report that shows a cancelled order's
-- sale without context is misleading. Voided orders are already gone in core.
-- Pending payments are deliberately kept - Shopify counts them as sales.
select
*
from
{{core.shopify.sales_over_time}}
where
cancelled_at is nullExample output
+ ------------+----------+-----------------+---------------+----------------------+---------------+--------+----------+-------------+-----------+---------+-----------+
| date | order_id | report_row_type | location_name | refund_location_name | sales_channel | orders | quantity | gross_sales | discounts | returns | net_sales | + ------------+----------+-----------------+---------------+----------------------+---------------+--------+----------+-------------+-----------+---------+-----------+
| 2026 -03 -01 | 5001 | SALE | | | web | 1 | 3 | 249.00 | -24.90 | 0.00 | 224.10 | | 2026 -03 -01 | 5002 | SALE | | | web | 1 | 1 | 89.00 | 0.00 | 0.00 | 89.00 | | 2026 -03 -02 | 5003 | SALE | Store 1 | | pos | 1 | 2 | 178.00 | -17.80 | 0.00 | 160.20 | | 2026 -03 -05 | 5001 |
return | | Store 1 | web | 0 | -1 | 0.00 | 0.00 | -83.00 | -83.00 | | 2026 -03 -06 | 5004 | SALE | Store 1 | | pos | 1 | 4 | 356.00 | -35.60 | 0.00 | 320.40 | + ------------+----------+-----------------+---------------+----------------------+---------------+--------+----------+-------------+-----------+---------+-----------+Rebuilds Shopify's flagship sales report from the order agreement log rather than order_line. order_line is a current-state table: it says what an order looks like now and cannot express when its value changed, so summing it nets returns back to the original order date, hides order edits, and silently restates history. In practice that drifts 2-20% from the admin. Reading the agreement log instead attributes every financial change to the day it actually happened. The model returns one row per order per day per row type, so a day where an order both sells and refunds produces two rows and the SALE and RETURN sides stay independently auditable - aggregate to day in your BI tool. Alongside the sales equation it ships the columns that make reconciliation possible: parity variants for net sales and units, because Shopify's admin UI and its CSV export do not agree; a returns detail block with reversal_type separating cancellations from genuine returns; gift-card columns kept out of the sales equation as deferred revenue; and dimensions that report what Shopify stores rather than interpreting it: location_name is the order's own location and stays NULL when there is none, refund_location_name is kept separate because a refund is often processed somewhere the order was not, sales_channel is Shopify's own source_name, and fulfilment status distinguishes digital-only orders from genuinely unfulfilled ones. Timezone is read from the shop record, so nothing needs hardcoding.