PYTH-290 · Lesson 6 of 12 · 25 min
Building the core features
▶ Video coming soon A video version of this lesson is on the way.
With the data layer in place, the team turned to the heart of Ridgeline Ops: the subscription rules and the morning renewal job. This is the part of the system that makes Ridgeline different, so it is where the team spent most of its care. It is also where the most surprising requirements appeared, because rules that sound simple in a meeting turn out to have many edge cases once you write them as code.
This lesson builds the core features as the team did: as plain Python in the domain and application layers, driven by the acceptance criteria from lesson 2, tested with the in-memory fakes from lesson 5, and kept free of any web framework or database code. Every exercise is a piece of the real renewal engine.
Start from the acceptance criteria
The team wrote the core features test first, the way PYTH-230 taught. Each acceptance criterion became one or more test cases before the code existed: a fortnightly subscription due on 1 December, when skipped, has its next delivery on 15 December; a skip the day before delivery is refused; a third consecutive skip suggests a pause. Writing the tests first forced the remaining questions into the open. What happens to a subscription due on a Sunday, when the roastery is closed? What if a coffee is discontinued while people subscribe to it? Each question went to Rosa and came back as a rule.
The answers produced three rules the team had not known about at the start. Deliveries are never scheduled on a Sunday; a date that would fall on a Sunday moves to the following Monday, and the schedule continues from the original date, so a subscription does not slowly drift through the week. If a coffee is discontinued, existing subscriptions switch to the coffee Kai names as its replacement and the subscriber gets an email. And subscribers who skip twice in a row are offered a pause on the third attempt, as lesson 2 recorded.
Dates are harder than they look
The next delivery date is the core calculation, and dates are a notorious source of bugs. Adding days with timedelta is safe, but rules about weekdays, holidays and cut-off times are easy to get subtly wrong. The team kept every date rule in small, pure functions: functions that take dates and return dates, with no database, no clock and no side effects. Pure functions are trivial to test, because the same input always gives the same output.
The functions never call date.today() themselves. The current date is passed in as an argument, which PYTH-270 called injecting the clock. That way a test can ask what would happen on 30 November without waiting for 30 November, and the renewal job can be re-run for a past date after an outage.
from datetime import date, timedelta
SUNDAY = 6 # date.weekday(): Monday is 0, Sunday is 6
def scheduled(start, interval_weeks, n):
"""The nth delivery on the original schedule (n = 0 is the start date)."""
return start + timedelta(weeks=interval_weeks * n)
def ship_date(day):
"""Deliveries never go out on a Sunday; move to Monday."""
return day + timedelta(days=1) if day.weekday() == SUNDAY else day
start = date(2026, 11, 30) # a Monday
for n in range(4):
planned = scheduled(start, 2, n)
print(planned, planned.strftime("%a"), "->", ship_date(planned))
print(ship_date(date(2026, 12, 6)), "(a Sunday moves to Monday)")2026-11-30 Mon -> 2026-11-30 2026-12-14 Mon -> 2026-12-14 2026-12-28 Mon -> 2026-12-28 2027-01-11 Mon -> 2027-01-11 2026-12-07 (a Sunday moves to Monday)
Keeping the schedule tied to the start date matters. A naive version that adds two weeks to the last actual ship date would drift by a day every time a Sunday was skipped, and after a few months a Monday subscriber would be receiving coffee on Thursdays. The team caught this with a test that ran a subscription through a whole year of dates and checked every scheduled day.
The skip rule
Skipping looks like the simplest feature in the backlog: move the next delivery one interval later. The acceptance criteria made it richer. A skip is refused if the delivery is tomorrow or today, because the order has already been packed. Skips in a row are counted, and the third one is refused with a suggestion to pause. A successful delivery resets the count. And a skip on a paused or cancelled subscription makes no sense and should be refused.
The team represented refusals as exceptions with clear names, SkipRefused and PauseSuggested, rather than as True and False return values. A caller cannot accidentally ignore an exception, and the exception's message tells the subscriber exactly why. The web layer catches them and turns them into friendly messages; the domain code simply states the rule.
Try it yourself
Implement the skip rule
Write skip(sub, today) for the Subscription dataclass in the setup. If the subscription is not active, raise SkipRefused. If its next_delivery is today or tomorrow (or already past), raise SkipRefused. If sub.skips is already 2, raise PauseSuggested. Otherwise move next_delivery one interval later, add 1 to skips, and return the new date.
Both exception classes are defined in the setup. Do not call date.today(); use the today argument.
def skip(sub, today):
sub.next_delivery = sub.next_delivery + timedelta(weeks=2)
return sub.next_deliveryThe renewal job
Every morning at 6:00, the renewal job looks for subscriptions due that day and turns each into an order for Theo to pack. It sounds like a loop over a list, and the first version was. The edge cases came from stock. If twelve subscribers want Ethiopia Guji and only nine bags are roasted, who gets coffee? Rosa decided: subscriptions are served in order of how long they have been waiting, oldest due date first, then lowest subscription id, and anyone who cannot be served is listed for Theo and Kai and kept due for the next morning, rather than skipped silently.
The job is an application service. It asks the subscription repository for everything due on or before today, asks the stock repository how many bags of each coffee are available, and for each subscription in order either creates an order and reserves the bags, or adds it to the short list. A delivered subscription has its skip count reset and its next delivery moved to the next date on its schedule. The service reports what it did as a small summary object, which the command-line interface prints and the web page displays.
Try it yourself
Run the morning renewals
Write run_renewals(subs, stock, today). subs is a list of dictionaries with keys id, sku, bags, interval_weeks, next_delivery (a date) and status; stock maps each sku to the bags available. Consider active subscriptions whose next_delivery is on or before today, oldest due date first, then lowest id.
For each one, if there is enough stock: reduce the stock, add (id, sku, bags) to the orders list, set skips to 0, and move next_delivery forward by whole intervals until it is after today. Otherwise add its id to the short list and leave it unchanged. Return (orders, short). A sku missing from stock has no stock.
from datetime import timedelta
def run_renewals(subs, stock, today):
orders, short = [], []
for s in subs:
if s["next_delivery"] == today:
orders.append((s["id"], s["sku"], s["bags"]))
return orders, shortMoney and the daily report
Each renewal order needs a price, tax and a total, and those numbers flow into Anita's daily report. The team reused the Money value object and the rounding rule from PYTH-230 and PYTH-270 without changing a line: add the line amounts in integer cents, calculate tax once on the subtotal, round once to the nearest cent. Reusing tested code for money was one of the best decisions of the project; the November rounding bug had already taught everyone how easy it is to get this wrong.
Subscribers also earn loyalty points, using the rule from PYTH-101: one point per whole dollar spent, with tiers called Bean, Roast and Reserve. Rosa added one subscription rule: subscribers earn points on renewal orders at the same rate as everyone else, but the points are awarded when the order ships, not when it is created, so a renewal that is cancelled before shipping never awards points. Small rules like this are why domain logic deserves its own carefully tested layer.
from decimal import Decimal, ROUND_HALF_UP
TAX = {"ca": Decimal("0.05"), "us": Decimal("0.06")}
def order_total(lines, region):
"""lines: (bags, unit_price_cents). Tax on the subtotal, rounded once."""
subtotal = sum(bags * price for bags, price in lines)
tax = (Decimal(subtotal) * TAX[region]).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
return subtotal, int(tax), subtotal + int(tax)
def points(total_cents):
return total_cents // 100
def tier(points_total):
return "Reserve" if points_total >= 300 else "Roast" if points_total >= 100 else "Bean"
for region in ("ca", "us"):
sub, tax, total = order_total([(2, 2200), (1, 1850)], region)
print(region, f"subtotal {sub} tax {tax} total {total} points {points(total)}")
print(tier(95), tier(140), tier(320))ca subtotal 6250 tax 313 total 6563 points 65 us subtotal 6250 tax 375 total 6625 points 66 Bean Roast Reserve
The tax rates in the examples are illustrative: the Canadian figure is the federal GST alone and the US figure is a single state rate. In the real deployment the rates live in configuration, because Anita confirmed that coffee beans are treated differently from cafe drinks in some places. The code only needs to know that there is a rate and that rounding happens once.
Try it yourself
Award points when orders ship
Write award_points(balances, shipped) where balances maps a customer id to their current points and shipped is a list of (customer_id, total_cents) tuples for orders that shipped today. Add one point per whole dollar of each total (cents are dropped per order, not after adding), starting new customers at 0.
Return a dictionary mapping each customer whose tier changed to a tuple (old_tier, new_tier). Tiers are "Bean" below 100 points, "Roast" from 100 to 299 and "Reserve" from 300. Update balances in place.
def award_points(balances, shipped):
for customer, total in shipped:
balances[customer] = balances.get(customer, 0) + total / 100
return {}Discontinued coffees
The third surprise rule concerned coffees that leave the list. Green coffee is seasonal: when Kai's last lot of Colombia Huila runs out, that coffee is gone until next year's harvest, but subscribers may still be signed up for it. Rosa's rule was that each discontinued coffee names a replacement, chosen by Kai to be as close as possible in taste and roast level, and every active subscription for the old coffee switches to the replacement with an email explaining the change. Subscribers can then change to something else if they prefer.
The team implemented the switch as its own small use case, replace_coffee(old_sku, new_sku), rather than as a special case hidden inside the renewal job. Separate use cases are easier to test, easier to explain and easier to run on purpose: Kai triggers the switch from the staff page when he retires a coffee, and the renewal job never needs to know that coffees can disappear. The use case refuses to run if the replacement is itself discontinued or has a different bag size, because both mistakes would silently produce wrong orders.
This is a pattern worth noticing. Each time a rule tempted the team to add another if statement to the renewal job, they asked whether it was really part of renewing or a separate event in the life of a subscription. Pausing, resuming, skipping, changing coffee and replacing a coffee all turned out to be separate events, each with its own small, tested function. The renewal job stayed short enough to read in one sitting.
Keeping the core small
By the end of the second sprint the domain and application layers held about 600 lines of code and 900 lines of tests, and the whole suite ran in under two seconds. That ratio is normal for business logic that matters, and the speed was a direct result of the architecture: none of those tests touched a real database, a web server or the network. When Rosa asked for a rule change on the Thursday of sprint two, allowing pickups on Sundays when the cafe is open, Nia changed one function and three tests in twenty minutes, and the pull request was merged before lunch.
Common mistakes
MistakeCalling date.today() inside business rules.
InsteadPass the current date in as an argument so rules can be tested for any day.
MistakeComputing the next delivery from the last ship date, so Sunday moves accumulate.
InsteadCompute each delivery from the original schedule and adjust only the ship date.
MistakeReturning True or False for refused actions, which callers can ignore.
InsteadRaise clearly named exceptions such as SkipRefused with a helpful message.
MistakeCreating one order per missed day when the job catches up after an outage.
InsteadAdvance the next delivery past today in whole intervals and create a single order.
MistakeServing subscriptions in an arbitrary order when stock is short.
InsteadAgree a fair rule with the product owner and implement it explicitly, such as oldest due first.
MistakeRewriting money and rounding code for the new project.
InsteadReuse the tested Money and rounding code: add in cents, tax the subtotal, round once.
Summary and key terms
- Core features are driven by acceptance criteria and written test first.
- Writing tests early surfaces hidden rules, which go back to the product owner for decisions.
- Date rules live in small pure functions that take the current date as an argument.
- Schedules are computed from the start date so adjustments do not accumulate.
- Refusals are clearly named exceptions that the outer layers turn into friendly messages.
- The renewal job serves the oldest due first, reports shortages and catches up after outages with one order.
- Money is summed in integer cents with tax calculated once on the subtotal.
- A core without databases or web code stays small, fast to test and quick to change.
- pure function
- A function whose result depends only on its arguments and which has no side effects.
- injected clock
- Passing the current date or time into code instead of reading it inside.
- domain rule
- A business rule, such as when a skip is allowed, expressed in the domain layer.
- application service
- Code that carries out one use case by coordinating repositories and domain rules.
- edge case
- An unusual input or situation at the boundary of normal behaviour.
- idempotent
- Safe to run more than once with the same effect as running it once.
- loyalty tier
- A level of a rewards program reached by accumulating points.
Practice problems
Work each problem on paper first, then reveal the answer to check.
Why do the date functions take today as an argument?
Show answer
So tests can check any date without waiting, and the job can be re-run for a past date.
A fortnightly subscription started on Monday 30 November 2026. What is its fourth scheduled delivery (n = 3)?
Show answer
30 November plus 6 weeks, which is Monday 11 January 2027.
Why is a refused skip an exception rather than a False return value?
Show answer
A caller cannot accidentally ignore an exception, and its message explains the refusal.
Nine bags are available and twelve subscribers want them. How does the job decide?
Show answer
Oldest due date first, then lowest id; the rest go on the short list and stay due.
An order has lines of 2 x 2200 cents and 1 x 1850 cents. What is the subtotal?
Show answer
4400 + 1850 = 6250 cents.
Why are points awarded when an order ships rather than when it is created?
Show answer
So a renewal cancelled before shipping never awards points.
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.