Revenue Computation by Day, Category, Region
Estimated read: 10 min
Four silver tables, three of them full SCD Type 2 history. This lecture builds the gold table finance actually asks for – one row per day, category, and region, with gross revenue, discount, and net revenue – and along the way surfaces the two mistakes that quietly wreck a revenue number built on top of Type 2 history: forgetting to filter to the current version, and forgetting that a cancelled order isn’t revenue.
What finance actually wants
Nobody downstream wants silver_order_items – it’s one row per line item, keyed for correctness,
not for a dashboard. Finance wants a daily rollup they can slice by product category and customer
region without writing a four-table join every time: gross_revenue, discount_amount, and
net_revenue, grouped by revenue_date, category, and region. Building that means pulling in
all four silver tables Section 3 produced:
| Silver table | What it contributes |
|---|---|
silver_order_items |
unit_price, quantity, discount_pct – the actual dollars |
silver_orders |
order_date, status, and the join key back to customer_id |
silver_customers |
region – the dimension finance wants to slice by |
silver_products |
category – the other dimension finance wants to slice by |
Gross, discount, and net – and what “proportional allocation” means here
discount_pct already lives on silver_order_items, at the line-item grain – which means the
dollar discount for a given line naturally allocates in proportion to that line’s own gross
revenue, not as some order-level dollar figure split evenly across items. A $150 running shoe and
a $12 pair of socks on the same order, both discounted 20%, each carry a discount proportional to
their own price – $30 and $2.40 – rather than splitting a single order-level discount amount down
the middle and distorting both lines. That’s the calculation, expressed directly:
gross_revenue = unit_price * quantity
discount_amount = gross_revenue * (discount_pct / 100)
net_revenue = gross_revenue - discount_amount
Contrast this with a legacy warehouse pattern that’s easy to reach for out of habit: storing one
discount dollar amount at the order header and dividing it evenly across line items regardless of
each item’s price. That approach was often a compromise forced by an OLTP schema that didn’t carry
a per-line discount column at all. StepRight’s line-item-level discount_pct means the correct,
proportional allocation isn’t extra work here – it’s just multiplication.
Worked example with real numbers
Three line items on the same order, running shoes at $150 and socks at $12, both carrying a 20%
discount_pct, plus a pair of sandals at $60 with no discount at all:
product_id |
unit_price |
quantity |
discount_pct |
gross_revenue |
discount_amount |
net_revenue |
|---|---|---|---|---|---|---|
| PROD-00042 (shoes) | 150.00 | 1 | 20 | 150.00 | 30.00 | 120.00 |
| PROD-00187 (socks) | 12.00 | 2 | 20 | 24.00 | 4.80 | 19.20 |
| PROD-00301 (sandals) | 60.00 | 1 | 0 | 60.00 | 0.00 | 60.00 |
Summed into gold_daily_revenue for that order’s day, category, and region: gross_revenue of
234.00, discount_amount of 34.80, net_revenue of 199.20. Every line kept its own discount dollar
figure, proportional to its own gross – the sandals line, with a 0% discount_pct, contributes
nothing to discount_amount at all, exactly as it should. A flat order-level discount split evenly
three ways would have shaved roughly $11.60 off each line regardless of price, understating the
socks line’s effective discount rate and overstating the shoes line’s – a distortion that
compounds the moment someone tries to explain “average discount rate by category” from a number that
was never really allocated by category to begin with.
Building gold_daily_revenue
# transformations/gold_reporting.py
import dlt as dp
from pyspark.sql.functions import col, sum as spark_sum, count
@dp.materialized_view(
name="gold_daily_revenue",
comment="Daily gross, discount, and net revenue by product category and customer region"
)
def gold_daily_revenue():
order_items = spark.read.table("silver_order_items").filter("__END_AT IS NULL")
orders = (
spark.read.table("silver_orders")
.filter("__END_AT IS NULL")
.filter("status != 'CANCELLED'")
)
customers = spark.read.table("silver_customers").filter("__END_AT IS NULL")
products = spark.read.table("silver_products")
priced = (
order_items
.join(orders, "order_id")
.join(customers, "customer_id")
.join(products, "product_id")
.withColumn("gross_revenue", col("unit_price") * col("quantity"))
.withColumn("discount_amount", col("gross_revenue") * (col("discount_pct") / 100))
.withColumn("net_revenue", col("gross_revenue") - col("discount_amount"))
)
return (
priced
.groupBy(col("order_date").alias("revenue_date"), col("category"), col("region"))
.agg(
spark_sum("gross_revenue").alias("gross_revenue"),
spark_sum("discount_amount").alias("discount_amount"),
spark_sum("net_revenue").alias("net_revenue"),
count("order_item_id").alias("line_item_count"),
)
)
Two filters on orders do real work before a single dollar gets summed: __END_AT IS NULL and
status != 'CANCELLED'.
The same logic, if you’d rather declare it in SQL
Lakeflow Declarative Pipelines lets a materialized view be declared as SQL instead of PySpark – worth knowing if you’re coming from a legacy warehouse background where every transformation was a stored procedure or a scheduled SQL script, not a Python DataFrame chain. The two are equivalent, not a fallback for a “simpler” case:
-- transformations/gold_daily_revenue.sql
CREATE MATERIALIZED VIEW gold_daily_revenue
COMMENT "Daily gross, discount, and net revenue by product category and customer region"
AS
SELECT
o.order_date AS revenue_date,
p.category,
c.region,
SUM(oi.unit_price * oi.quantity) AS gross_revenue,
SUM(oi.unit_price * oi.quantity * (oi.discount_pct / 100)) AS discount_amount,
SUM(oi.unit_price * oi.quantity * (1 - oi.discount_pct / 100)) AS net_revenue,
COUNT(oi.order_item_id) AS line_item_count
FROM silver_order_items oi
JOIN silver_orders o ON oi.order_id = o.order_id AND o.__END_AT IS NULL AND o.status != 'CANCELLED'
JOIN silver_customers c ON o.customer_id = c.customer_id AND c.__END_AT IS NULL
JOIN silver_products p ON oi.product_id = p.product_id
WHERE oi.__END_AT IS NULL
GROUP BY o.order_date, p.category, c.region;
Every filter that mattered in the PySpark version – both __END_AT IS NULL checks, status !=
'CANCELLED' – has a direct SQL equivalent, folded into the JOIN ... ON and WHERE clauses rather
than a chain of .filter() calls. Which form a given pipeline uses is a team preference, not a
capability difference; a project can mix SQL and Python materialized views in the same
transformations/ folder, the same way pipeline.yml’s glob: include pattern already picks up
both .py and .sql files without extra configuration.
Why __END_AT IS NULL isn’t optional
silver_order_items, silver_orders, and silver_customers all hold full SCD Type 2 history
– every prior version of every row, each with its own __START_AT/__END_AT window. Reading one
of these tables without a current-version filter doesn’t just risk stale data; it silently
multiplies revenue by the number of historical versions a row happens to have. An order that
changed status from PLACED to SHIPPED to DELIVERED has three rows in silver_orders – join
that against silver_order_items without __END_AT IS NULL on both sides, and that order’s line
items get summed three times. silver_products is the one exception in this join: Section 3’s
Lecture 5 built it as a plain deduplicated table, one row per product_id, so it needs no
current-version filter at all – there’s no history to filter out.
Why status != 'CANCELLED' matters just as much
A cancelled order still has line items sitting in silver_order_items with real unit_price and
quantity values – nothing about cancellation deletes or zeroes them out, because Section 3’s
design deliberately preserves full history rather than mutating rows. Left unfiltered, a spike in
cancellations shows up as a corresponding spike in reported revenue, which is exactly backwards from
what actually happened. This is a genuine business decision, not a technical default – StepRight
could equally well build a second gold table tracking cancelled-order value for a different
audience (loss analysis, fraud signals), but gold_daily_revenue itself represents revenue finance
can act on, which cancelled orders are not.
Refresh strategy: why this one is a full recompute
Part 1, Section 9
drew the line between full and incremental materialized view refresh: simple, additive
aggregations over append-only sources are the best candidates for incremental refresh; complex joins
and non-append sources tend to force a full recompute. gold_daily_revenue sits firmly in the
second bucket – a four-way join against three SCD Type 2 tables, each of which can rewrite a row’s
__END_AT in place rather than only appending, gives SDP’s refresh planner little room to determine
which output groups a given upstream change could possibly affect. In practice that means every
pipeline run recomputes this materialized view from scratch, which is the correct, expected trade
for correctness here – StepRight’s order volume is small enough that a full recompute costs seconds,
not minutes, and a wrong incremental update that missed a status change would be far more expensive
to debug than the extra compute.
Clustering gold_daily_revenue for how it’s actually queried
Every query in the previous section filters or groups by revenue_date, category, or region –
exactly the columns worth Liquid Clustering on, so Databricks can skip files that don’t match a
given filter instead of scanning the whole table:
ALTER TABLE dev.step_right.gold_daily_revenue
CLUSTER BY (revenue_date, category, region);
This is the same clustering discipline Part 1, Section 5 introduced for silver and bronze tables, applied here to the table BI dashboards actually hit – worth doing on a gold table even more than on the layers beneath it, since a gold table is where query concurrency and dashboard refresh latency are felt directly by the people StepRight built this whole pipeline for.
Verifying the numbers
-- Sanity check: does net revenue reconcile against gross minus discount?
SELECT * FROM dev.step_right.gold_daily_revenue
WHERE ABS(net_revenue - (gross_revenue - discount_amount)) > 0.01;
-- Should return zero rows
-- Top category by net revenue, last 30 days
SELECT category, SUM(net_revenue) AS total_net_revenue
FROM dev.step_right.gold_daily_revenue
WHERE revenue_date >= date_sub(current_date(), 30)
GROUP BY category
ORDER BY total_net_revenue DESC;
-- Revenue by region, sanity-checked against known region values
SELECT region, SUM(net_revenue) AS total_net_revenue, SUM(line_item_count) AS items_sold
FROM dev.step_right.gold_daily_revenue
GROUP BY region
ORDER BY total_net_revenue DESC;
The first query is a structural check that should always return zero rows – if it doesn’t, the aggregation logic itself is wrong, not the underlying data. The other two are the kind of query finance actually runs against this table daily.
Common mistakes
- Filtering
__END_AT IS NULLon only one SCD2 table. A join between an unfilteredsilver_ordersand a filteredsilver_order_itemsstill fans out on the unfiltered side – every table carrying history needs its own filter, not just one of them. - Applying
status != 'CANCELLED'after thegroupByinstead of before it. Filtering the aggregatedgold_daily_revenueoutput for a specific status doesn’t remove cancelled orders from the sums that already happened – the filter has to sit onordersbefore the join, the same lesson Section 3 taught about filteringsilver_orders_cleanbeforeAUTO CDC’s merge rather than after. - Reaching for
spark.readStreamhere out of habit. This materialized view reads all four inputs in batch (spark.read), the same reasoning Section 3, Lecture 5 used forsilver_products– agroupByaggregation across arbitrary historical rows isn’t expressible as a simple streaming append, and SDP’s materialized view refresh handles recomputation efficiently on its own without the pipeline author managing streaming state by hand.
What’s next
gold_daily_revenue answers finance’s question. Lecture 2 turns the same silver layer toward a
completely different consumer – marketing wants to know who each customer is, not just what they
bought on a given day.
| ← Previous: StepRight - Gold Layer Reporting and Analysis | Next: Customer 360 for Marketing → | |:—|—:|