Lesson 1 - Guided Project: Ledgerly End to End

Welcome to the Guided Project

This is the last lesson in the Software Engineering Fundamentals course, and it asks one thing of you: build one real Ledgerly feature, start to finish, using a little of everything the last four modules taught. Module 1 gave you a way to scope and plan work. Module 2 gave you a way to design classes and choose patterns deliberately. Module 3 gave you a way to write clean code and prove it correct. Module 4 gave you a way to ship code safely. None of those skills lives in isolation on a real team, and this project treats them the same way a real team would: as one continuous pipeline, not four separate subjects.

The feature is prorated mid-cycle subscription plan upgrades. Right now, if a Ledgerly customer upgrades from a cheaper plan to a pricier one partway through a billing cycle, nothing charges them correctly for the switch. This project fixes that: a customer who upgrades gets the new plan’s benefits immediately, and Ledgerly charges only the prorated difference for the days remaining in the current cycle, never the full new-plan price and never nothing at all.

By the end of this project, you will be able to:

  • Write user stories and acceptance criteria for a well-scoped billing feature, and pick a methodology that fits it
  • Design a composed collaborator class and justify a design pattern choice for a rounding decision
  • Implement and unit test proration math against real calendar dates, and express one scenario in Gherkin
  • Identify a CI gate and a secure-coding practice a billing feature must respect before it ships

Let’s build the whole thing.


Stage 1: Scope the Feature

Proration sounds complicated, but the requirement itself is narrow and well understood: charge a customer for exactly the plan value they use, no more and no less, whenever they upgrade partway through a cycle. Two user stories capture it.

User story 1: As a Ledgerly customer, I want to upgrade to a pricier plan partway through my billing cycle and pay only for the extra value I use for the days remaining, so that I am never charged the full new-plan price for days my old plan already covered.

Acceptance criteria:

  • Given a customer on an active plan, when they upgrade to a pricier plan before the cycle ends, then Ledgerly charges only the prorated difference for the remaining days.
  • The new plan’s benefits apply immediately at the moment of upgrade, not at the start of the next cycle.
  • The next full billing cycle bills the new plan’s full price, with no further proration.
  • The price used for both the old and the new plan always comes from Ledgerly’s own plan catalog, never from a value supplied by the client.

User story 2: As a Ledgerly customer, I want a notification confirming exactly what I was charged for an upgrade, so that I understand the charge without contacting support.

Acceptance criteria:

  • Given an upgrade has been billed, when the charge completes, then the customer receives a notification stating the old plan, the new plan, and the exact amount charged.
  • The same amount also appears on the customer’s invoice history, so support can reference it later.

Module 1’s methodology guide asked three questions: how stable are the requirements, does the work arrive in a predictable batch, and how much formal sign-off does it need? Proration’s requirements are stable and well understood before any code gets written, the work arrives as one planned feature rather than an unpredictable support ticket, and it needs no external regulatory sign-off. That combination fits neatly inside one of Ledgerly’s normal two-week Scrum sprints, the same framework the team already uses for its product roadmap, rather than needing Kanban’s unpredictable flow or Waterfall’s heavy phase-gated documentation. The team scopes the whole feature into a single sprint precisely because it is well understood enough to plan completely upfront, unlike work such as deciding whether freelancers prefer automatic reminders, where only real usage can answer the question.

Well-scoped is not the same as trivial

A stable, well-understood requirement still needs careful design and real tests. Proration’s rules do not change once written, which is exactly what makes it safe to plan fully upfront in one sprint, but the rounding decision in Stage 2 and the date-boundary tests in Stage 3 show that “well scoped” does not mean “simple to get right on the first try.”


Stage 2: Design the Collaborator and Choose a Pattern

Module 2 warned against cramming a new responsibility into an existing class just because it touches the same data. Subscription already tracks a customer’s plan and billing cycle; adding proration math directly onto it would mix a data record with a pricing calculation, the same coupling problem Module 2’s architecture lessons kept steering away from. Instead, this project introduces a new collaborator, ProrationCalculator, and gives Subscription a reference to it through composition, the same “has-a” relationship Module 2 preferred over inheritance for PaymentGateway’s subclasses.

ProrationCalculator needs one more decision: how to round a raw prorated amount, like $15.483870967741936, into a real, billable number of cents. Different rounding rules give different answers, and Ledgerly may want to change that policy later without touching the proration math itself. That is exactly the problem the Strategy pattern solves, the same pattern Module 2’s behavioral patterns lesson used for PricingEngine’s interchangeable discount rules. Each rounding rule becomes its own class implementing one shared method, and ProrationCalculator holds whichever one it was given, without knowing or caring which rule is running.

from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import date


@dataclass
class Plan:
    """One Ledgerly subscription plan. The price lives only here, never in a request from a client."""
    name: str
    monthly_price: float


PLAN_CATALOG = {
    "basic": Plan("Basic", 19.00),
    "pro": Plan("Pro", 49.00),
    "enterprise": Plan("Enterprise", 99.00),
}


class RoundingStrategy(ABC):
    """Turns a raw prorated dollar amount into a final, billable amount."""

    @abstractmethod
    def round_charge(self, raw_amount: float) -> float:
        raise NotImplementedError


class RoundHalfUpToCent(RoundingStrategy):
    """Standard rounding to the nearest cent. Ledgerly's default policy."""

    def round_charge(self, raw_amount: float) -> float:
        return round(raw_amount, 2)


class CeilingToCent(RoundingStrategy):
    """Always rounds up to the next cent, so Ledgerly never undercharges by a fraction of a cent."""

    def round_charge(self, raw_amount: float) -> float:
        import math
        return math.ceil(round(raw_amount, 10) * 100) / 100


class ProrationCalculator:
    """Computes the extra charge for a mid-cycle plan upgrade.

    Composed with a RoundingStrategy rather than subclassing one, so the
    rounding policy can change without touching the proration math itself.
    """

    def __init__(self, rounding_strategy: RoundingStrategy):
        self._rounding_strategy = rounding_strategy

    def calculate_upgrade_charge(self, old_plan, new_plan, cycle_start, cycle_end, upgrade_date):
        if not (cycle_start <= upgrade_date < cycle_end):
            raise ValueError("upgrade_date must fall inside the billing cycle")
        days_in_cycle = (cycle_end - cycle_start).days
        days_remaining = (cycle_end - upgrade_date).days
        price_difference = new_plan.monthly_price - old_plan.monthly_price
        raw_amount = price_difference * days_remaining / days_in_cycle
        return self._rounding_strategy.round_charge(raw_amount)


standard_calculator = ProrationCalculator(RoundHalfUpToCent())
ceiling_calculator = ProrationCalculator(CeilingToCent())

standard_charge = standard_calculator.calculate_upgrade_charge(
    PLAN_CATALOG["basic"], PLAN_CATALOG["pro"], date(2026, 7, 1), date(2026, 8, 1), date(2026, 7, 16)
)
ceiling_charge = ceiling_calculator.calculate_upgrade_charge(
    PLAN_CATALOG["basic"], PLAN_CATALOG["pro"], date(2026, 7, 1), date(2026, 8, 1), date(2026, 7, 16)
)
print(f"Same upgrade, standard rounding: ${standard_charge:.2f}")
print(f"Same upgrade, ceiling rounding:  ${ceiling_charge:.2f}")
Same upgrade, standard rounding: $15.48
Same upgrade, ceiling rounding:  $15.49

The same upgrade produces two different answers depending only on which RoundingStrategy ProrationCalculator was given, one cent apart. RoundHalfUpToCent is Ledgerly’s shipped default, a fair rounding rule a customer would expect. CeilingToCent exists as a documented alternative in case Ledgerly’s finance team ever decides the company should never round in the customer’s favor. Swapping between them means passing a different object into the constructor, not editing ProrationCalculator’s own math.


Stage 3: Implement It and Prove It Correct

SubscriptionUpgradeService orchestrates the whole upgrade: it asks ProrationCalculator for the charge, asks PaymentGateway to charge it, asks InvoiceRepository to record it, and asks NotificationService to tell the customer, exactly as User Story 2 required. Like ProrationCalculator, it holds each collaborator through composition and calls one method on each, never subclassing any of them.

from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import date


@dataclass
class Plan:
    name: str
    monthly_price: float


PLAN_CATALOG = {
    "basic": Plan("Basic", 19.00),
    "pro": Plan("Pro", 49.00),
    "enterprise": Plan("Enterprise", 99.00),
}


@dataclass
class Subscription:
    id: str
    customer_name: str
    plan_name: str
    cycle_start: date
    cycle_end: date


class RoundingStrategy(ABC):
    @abstractmethod
    def round_charge(self, raw_amount: float) -> float:
        raise NotImplementedError


class RoundHalfUpToCent(RoundingStrategy):
    def round_charge(self, raw_amount: float) -> float:
        return round(raw_amount, 2)


class ProrationCalculator:
    def __init__(self, rounding_strategy: RoundingStrategy):
        self._rounding_strategy = rounding_strategy

    def calculate_upgrade_charge(self, old_plan, new_plan, cycle_start, cycle_end, upgrade_date):
        if not (cycle_start <= upgrade_date < cycle_end):
            raise ValueError("upgrade_date must fall inside the billing cycle")
        days_in_cycle = (cycle_end - cycle_start).days
        days_remaining = (cycle_end - upgrade_date).days
        price_difference = new_plan.monthly_price - old_plan.monthly_price
        raw_amount = price_difference * days_remaining / days_in_cycle
        return self._rounding_strategy.round_charge(raw_amount)


class SubscriptionUpgradeService:
    """Orchestrates a plan upgrade: proration, charging, recording, and notifying.

    Composed with a ProrationCalculator, a payment gateway, an invoice
    repository, and a notification service. SubscriptionUpgradeService
    subclasses none of them; it only holds and calls each one.
    """

    def __init__(self, proration_calculator, payment_gateway, invoice_repository, notification_service):
        self._proration_calculator = proration_calculator
        self._payment_gateway = payment_gateway
        self._invoice_repository = invoice_repository
        self._notification_service = notification_service

    def upgrade(self, subscription: Subscription, new_plan_name: str, upgrade_date: date) -> dict:
        old_plan = PLAN_CATALOG[subscription.plan_name]
        new_plan = PLAN_CATALOG[new_plan_name]  # price always looked up server-side, never client-supplied

        charge_amount = self._proration_calculator.calculate_upgrade_charge(
            old_plan, new_plan, subscription.cycle_start, subscription.cycle_end, upgrade_date
        )

        receipt = self._payment_gateway.charge(charge_amount, subscription.customer_name)
        invoice_id = self._invoice_repository.save(subscription.id, charge_amount)
        subscription.plan_name = new_plan_name
        self._notification_service.notify_upgrade_billed(subscription, old_plan, new_plan, charge_amount)

        return {"invoice_id": invoice_id, "charge_amount": charge_amount, "receipt": receipt}


class StubPaymentGateway:
    def charge(self, amount, customer_name):
        return f"Charged ${amount:.2f} to {customer_name}"


class StubInvoiceRepository:
    def __init__(self):
        self._next_id = 1

    def save(self, subscription_id, amount):
        invoice_id = f"INV-UPG-{self._next_id:04d}"
        self._next_id += 1
        return invoice_id


class StubNotificationService:
    def notify_upgrade_billed(self, subscription, old_plan, new_plan, charge_amount):
        print(
            f"  [NotificationService] Notified {subscription.customer_name}: "
            f"upgraded {old_plan.name} to {new_plan.name}, charged ${charge_amount:.2f}"
        )


service = SubscriptionUpgradeService(
    ProrationCalculator(RoundHalfUpToCent()),
    StubPaymentGateway(),
    StubInvoiceRepository(),
    StubNotificationService(),
)

amara_subscription = Subscription(
    id="SUB-4001", customer_name="Amara Okafor", plan_name="basic",
    cycle_start=date(2026, 7, 1), cycle_end=date(2026, 8, 1),
)
result_1 = service.upgrade(amara_subscription, "pro", upgrade_date=date(2026, 7, 16))
print(f"Scenario 1 charge: ${result_1['charge_amount']:.2f} ({result_1['invoice_id']})")

marcus_subscription = Subscription(
    id="SUB-4002", customer_name="Marcus Webb", plan_name="pro",
    cycle_start=date(2026, 2, 1), cycle_end=date(2026, 3, 1),
)
result_2 = service.upgrade(marcus_subscription, "enterprise", upgrade_date=date(2026, 2, 20))
print(f"Scenario 2 charge: ${result_2['charge_amount']:.2f} ({result_2['invoice_id']})")
  [NotificationService] Notified Amara Okafor: upgraded Basic to Pro, charged $15.48
Scenario 1 charge: $15.48 (INV-UPG-0001)
  [NotificationService] Notified Marcus Webb: upgraded Pro to Enterprise, charged $16.07
Scenario 2 charge: $16.07 (INV-UPG-0002)

Amara upgrades from Basic ($19.00) to Pro ($49.00) on July 16, with 16 of July’s 31 days remaining. Ledgerly charges her $15.48, not the $49.00 full Pro price and not $0.00. Marcus upgrades from Pro ($49.00) to Enterprise ($99.00) on February 20, with 9 of February’s 28 days remaining, and Ledgerly charges him $16.07. Both notifications and both invoice records match, exactly as User Story 2 required.

Module 3 also required real, passing unit tests, not just a demonstration script. Here are five, covering both concrete date scenarios above, the edge case of upgrading on the cycle’s very first day, a rejected out-of-cycle date, and the rounding-strategy difference from Stage 2.

import unittest
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import date


@dataclass
class Plan:
    name: str
    monthly_price: float


PLAN_CATALOG = {
    "basic": Plan("Basic", 19.00),
    "pro": Plan("Pro", 49.00),
    "enterprise": Plan("Enterprise", 99.00),
}


class RoundingStrategy(ABC):
    @abstractmethod
    def round_charge(self, raw_amount: float) -> float:
        raise NotImplementedError


class RoundHalfUpToCent(RoundingStrategy):
    def round_charge(self, raw_amount: float) -> float:
        return round(raw_amount, 2)


class CeilingToCent(RoundingStrategy):
    def round_charge(self, raw_amount: float) -> float:
        import math
        return math.ceil(round(raw_amount, 10) * 100) / 100


class ProrationCalculator:
    def __init__(self, rounding_strategy: RoundingStrategy):
        self._rounding_strategy = rounding_strategy

    def calculate_upgrade_charge(self, old_plan, new_plan, cycle_start, cycle_end, upgrade_date):
        if not (cycle_start <= upgrade_date < cycle_end):
            raise ValueError("upgrade_date must fall inside the billing cycle")
        days_in_cycle = (cycle_end - cycle_start).days
        days_remaining = (cycle_end - upgrade_date).days
        price_difference = new_plan.monthly_price - old_plan.monthly_price
        raw_amount = price_difference * days_remaining / days_in_cycle
        return self._rounding_strategy.round_charge(raw_amount)


class TestProrationCalculator(unittest.TestCase):
    def setUp(self):
        self.calculator = ProrationCalculator(RoundHalfUpToCent())

    def test_basic_to_pro_upgrade_16_days_remaining_of_31(self):
        charge = self.calculator.calculate_upgrade_charge(
            PLAN_CATALOG["basic"], PLAN_CATALOG["pro"],
            date(2026, 7, 1), date(2026, 8, 1), date(2026, 7, 16),
        )
        self.assertEqual(charge, 15.48)

    def test_pro_to_enterprise_upgrade_9_days_remaining_of_28(self):
        charge = self.calculator.calculate_upgrade_charge(
            PLAN_CATALOG["pro"], PLAN_CATALOG["enterprise"],
            date(2026, 2, 1), date(2026, 3, 1), date(2026, 2, 20),
        )
        self.assertEqual(charge, 16.07)

    def test_upgrade_on_the_first_day_charges_the_full_difference(self):
        charge = self.calculator.calculate_upgrade_charge(
            PLAN_CATALOG["basic"], PLAN_CATALOG["pro"],
            date(2026, 7, 1), date(2026, 8, 1), date(2026, 7, 1),
        )
        self.assertEqual(charge, 30.00)

    def test_upgrade_date_outside_the_cycle_is_rejected(self):
        with self.assertRaises(ValueError):
            self.calculator.calculate_upgrade_charge(
                PLAN_CATALOG["basic"], PLAN_CATALOG["pro"],
                date(2026, 7, 1), date(2026, 8, 1), date(2026, 8, 1),
            )

    def test_ceiling_strategy_rounds_up_instead_of_to_nearest(self):
        ceiling_calculator = ProrationCalculator(CeilingToCent())
        charge = ceiling_calculator.calculate_upgrade_charge(
            PLAN_CATALOG["basic"], PLAN_CATALOG["pro"],
            date(2026, 7, 1), date(2026, 8, 1), date(2026, 7, 16),
        )
        self.assertEqual(charge, 15.49)


runner = unittest.TextTestRunner(verbosity=2)
runner.run(unittest.TestLoader().loadTestsFromTestCase(TestProrationCalculator))
test_basic_to_pro_upgrade_16_days_remaining_of_31 (__main__.TestProrationCalculator.test_basic_to_pro_upgrade_16_days_remaining_of_31) ... ok
test_ceiling_strategy_rounds_up_instead_of_to_nearest (__main__.TestProrationCalculator.test_ceiling_strategy_rounds_up_instead_of_to_nearest) ... ok
test_pro_to_enterprise_upgrade_9_days_remaining_of_28 (__main__.TestProrationCalculator.test_pro_to_enterprise_upgrade_9_days_remaining_of_28) ... ok
test_upgrade_date_outside_the_cycle_is_rejected (__main__.TestProrationCalculator.test_upgrade_date_outside_the_cycle_is_rejected) ... ok
test_upgrade_on_the_first_day_charges_the_full_difference (__main__.TestProrationCalculator.test_upgrade_on_the_first_day_charges_the_full_difference) ... ok

----------------------------------------------------------------------
Ran 5 tests in 0.001s

OK

All five tests pass. Module 3’s behaviour-driven development lessons also asked for the same behavior in business language. Here is Amara’s scenario in Gherkin, using the exact numbers the code above just produced.

Feature: Prorated mid-cycle subscription upgrade
  Ledgerly charges a customer only for the extra plan value they actually
  use for the days remaining in the current billing cycle.

  Scenario: A customer upgrades from Basic to Pro partway through July
    Given a subscription on the "Basic" plan billed monthly at $19.00
    And the current billing cycle runs from July 1 to August 1, 2026
    When the customer upgrades to the "Pro" plan on July 16, 2026
    Then Ledgerly should charge $15.48 for the remaining 16 days of the cycle
    And the subscription's plan should immediately become "Pro"
    But the customer should not be charged the full $49.00 Pro price again

Stage 4: What Ships It Safely

Module 4’s CI pipeline gates a deploy behind a test job and a security-lint job that must both pass first. This feature adds nothing new to that pipeline’s shape; it runs through the same test then security-lint then deploy sequence as every other Ledgerly feature, with the five unit tests from Stage 3 becoming part of what test runs.

The one secure-coding practice this feature must respect above all others: never trust a client-supplied price. SubscriptionUpgradeService.upgrade() accepts only a plan name, a string like "pro", and always looks the real price up in PLAN_CATALOG on the server. It never accepts a price as a parameter from a request. An unrecognized plan name fails safely rather than falling back to a guessed or attacker-supplied number.

from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import date


@dataclass
class Plan:
    name: str
    monthly_price: float


PLAN_CATALOG = {
    "basic": Plan("Basic", 19.00),
    "pro": Plan("Pro", 49.00),
    "enterprise": Plan("Enterprise", 99.00),
}


@dataclass
class Subscription:
    id: str
    customer_name: str
    plan_name: str
    cycle_start: date
    cycle_end: date


class RoundingStrategy(ABC):
    @abstractmethod
    def round_charge(self, raw_amount: float) -> float:
        raise NotImplementedError


class RoundHalfUpToCent(RoundingStrategy):
    def round_charge(self, raw_amount: float) -> float:
        return round(raw_amount, 2)


class ProrationCalculator:
    def __init__(self, rounding_strategy: RoundingStrategy):
        self._rounding_strategy = rounding_strategy

    def calculate_upgrade_charge(self, old_plan, new_plan, cycle_start, cycle_end, upgrade_date):
        if not (cycle_start <= upgrade_date < cycle_end):
            raise ValueError("upgrade_date must fall inside the billing cycle")
        days_in_cycle = (cycle_end - cycle_start).days
        days_remaining = (cycle_end - upgrade_date).days
        price_difference = new_plan.monthly_price - old_plan.monthly_price
        raw_amount = price_difference * days_remaining / days_in_cycle
        return self._rounding_strategy.round_charge(raw_amount)


class SubscriptionUpgradeService:
    def __init__(self, proration_calculator, payment_gateway, invoice_repository, notification_service):
        self._proration_calculator = proration_calculator
        self._payment_gateway = payment_gateway
        self._invoice_repository = invoice_repository
        self._notification_service = notification_service

    def upgrade(self, subscription, new_plan_name, upgrade_date):
        # new_plan_name is only ever a plan key like "pro". There is no
        # parameter here for a client-supplied price, on purpose: the
        # server is the only source of truth for what any plan costs.
        old_plan = PLAN_CATALOG[subscription.plan_name]
        new_plan = PLAN_CATALOG[new_plan_name]

        charge_amount = self._proration_calculator.calculate_upgrade_charge(
            old_plan, new_plan, subscription.cycle_start, subscription.cycle_end, upgrade_date
        )
        receipt = self._payment_gateway.charge(charge_amount, subscription.customer_name)
        invoice_id = self._invoice_repository.save(subscription.id, charge_amount)
        subscription.plan_name = new_plan_name
        self._notification_service.notify_upgrade_billed(subscription, old_plan, new_plan, charge_amount)
        return {"invoice_id": invoice_id, "charge_amount": charge_amount, "receipt": receipt}


class StubPaymentGateway:
    def charge(self, amount, customer_name):
        return f"Charged ${amount:.2f} to {customer_name}"


class StubInvoiceRepository:
    def save(self, subscription_id, amount):
        return "INV-UPG-9001"


class StubNotificationService:
    def notify_upgrade_billed(self, subscription, old_plan, new_plan, charge_amount):
        pass


service = SubscriptionUpgradeService(
    ProrationCalculator(RoundHalfUpToCent()), StubPaymentGateway(), StubInvoiceRepository(), StubNotificationService()
)

dana_subscription = Subscription(
    id="SUB-4003", customer_name="Dana Okafor", plan_name="basic",
    cycle_start=date(2026, 7, 1), cycle_end=date(2026, 8, 1),
)

# An attacker-controlled client cannot inject an arbitrary price: the only
# thing upgrade() accepts is a plan name, and an unknown one fails safely.
try:
    service.upgrade(dana_subscription, "pro_for_one_cent", upgrade_date=date(2026, 7, 16))
except KeyError as error:
    print(f"Rejected an unrecognized plan name safely: {error}")

# The legitimate path still works normally, priced only from the catalog.
result = service.upgrade(dana_subscription, "pro", upgrade_date=date(2026, 7, 16))
print(f"Legitimate upgrade charged: ${result['charge_amount']:.2f}")
Rejected an unrecognized plan name safely: 'pro_for_one_cent'
Legitimate upgrade charged: $15.48

PLAN_CATALOG["pro_for_one_cent"] raises KeyError immediately, before any charge attempt runs, the same fail-safely-on-bad-input instinct Module 4’s SQL injection fix relied on. The legitimate upgrade still charges Dana the same $15.48 Amara was charged in Stage 3, for the identical cycle and dates, confirming the security check adds a barrier without changing the math underneath it.

A diagram titled 'Prorated Mid-Cycle Upgrade: One Feature, Four Modules'. The top section shows a timeline bar for Amara Okafor's July billing cycle, split into a blue Basic plan segment for days 1 through 15 at $19.00 a month and a green Pro plan segment for days 16 through 31, marked prorated and charged $15.48, with a dashed red line marking the July 16 upgrade point. Below that, text reads: raw prorated amount, $30.00 price difference times 16 remaining days divided by 31 total days equals $15.483, and RoundHalfUpToCent rounds this to $15.48 while CeilingToCent would round it up to $15.49. The bottom section shows four connected boxes in a row: Module 1, Requirements, in purple, showing 2 user stories and acceptance criteria scoped into one two-week Scrum sprint; Module 2, Design, in blue, showing ProrationCalculator composed not inherited and a Strategy pattern for swappable rounding; Module 3, Tests, in orange, showing 5 unit tests covering 2 real date scenarios plus 1 Gherkin scenario, all passing; and Module 4, Ship It, in green, showing a CI gate of test then security-lint then deploy and the plan price always read server-side, never from a client. Arrows connect the four boxes left to right. A caption at the bottom states that one feature exercises every module's skills exactly once, in order, and the result is a customer who upgrades from Basic to Pro on July 16 charged exactly $15.48, never $49.00 or $0.00.
One feature, prorated mid-cycle subscription upgrades, draws on requirements, design, testing, and delivery in sequence, producing a verified $15.48 charge for a real upgrade on a real date.

The Final Report

Feature: Prorated mid-cycle subscription plan upgrades for Ledgerly, a small invoicing and subscription-billing app for freelancers.

Requirements (Module 1, Engineering Foundations): Two user stories define the feature: a customer pays only the prorated difference for an upgrade’s remaining days, and a customer gets a notification stating the exact amount charged. Because the requirements are stable and well understood before any code is written, and the work arrives as one planned feature rather than an unpredictable ticket, the team scopes it into a single two-week Scrum sprint rather than reaching for Waterfall’s heavier phase-gating or Kanban’s continuous, unplanned flow.

Design (Module 2, Design, Architecture & OOP): A new ProrationCalculator collaborator holds the proration math, composed into the upgrade flow rather than bolted onto Subscription itself. Its rounding rule is pulled out using the Strategy pattern: RoundHalfUpToCent, Ledgerly’s shipped default, and CeilingToCent, a documented alternative, produce different answers, $15.48 against $15.49, for the identical upgrade, proving the rounding policy is genuinely swappable without touching ProrationCalculator’s own math.

Implementation and tests (Module 3, Writing Quality, Tested Code): SubscriptionUpgradeService composes ProrationCalculator with PaymentGateway, InvoiceRepository, and NotificationService to run a full upgrade. Two concrete date scenarios prove the math: Amara Okafor’s Basic-to-Pro upgrade with 16 of 31 July days remaining charges $15.48, and Marcus Webb’s Pro-to-Enterprise upgrade with 9 of 28 February days remaining charges $16.07. Five unit tests, covering both scenarios plus a first-day edge case, an out-of-cycle rejection, and the rounding-strategy difference, all pass, and one Gherkin scenario expresses Amara’s case in business language.

Delivery and security (Module 4, Delivery & Operations): The feature adds no new shape to Ledgerly’s existing CI pipeline; it runs through the same test then security-lint then deploy gate as every prior feature. Its one non-negotiable secure-coding practice is that SubscriptionUpgradeService.upgrade() accepts only a plan name and always reads the real price from PLAN_CATALOG on the server, never from a client-supplied number, and an unrecognized plan name fails safely with a KeyError rather than silently charging an unverified amount.

Every number in this report traces to a passing test

The $15.48 and $16.07 charges above are not estimates. They come directly from the ProrationCalculator code in Stage 2 and Stage 3, and both figures are locked in by an assertion in a unit test that passed. That traceability, a concrete number backed by a concrete, rerunnable check, is the same discipline every guided project in this course has practiced, applied here one last time to a genuinely new feature.


Practice Exercises

Exercise 1: What would you flag as a risk in this feature before it ships?

Across all four modules of this course, what single risk in the proration feature above would you raise before approving its deploy?

Hint

The billing-cycle boundary itself. calculate_upgrade_charge rejects an upgrade_date on or after cycle_end, but a real system also needs to handle a customer who downgrades instead of upgrades, or upgrades more than once in the same cycle, neither of which this project’s scope covered. Flagging what a well-scoped feature deliberately left out is as important as verifying what it does cover, the same instinct Module 1’s acceptance criteria and Module 3’s edge-case tests both modeled.

Exercise 2: Why does the report separate “design” from “implementation and tests”?

The Final Report above gives Module 2’s design decision its own paragraph, separate from Module 3’s implementation and tests. Why keep those two apart instead of combining them into one step?

Hint

A design decision, like choosing composition over inheritance or picking the Strategy pattern for rounding, is a claim about how the code should be structured, and it can be right even before a single test runs. Keeping it separate from the tests that verify the resulting numbers makes clear which parts of the report are judgment calls a reviewer could reasonably disagree with, and which parts are simply facts a rerun of the test suite would confirm or deny. Collapsing the two would make it harder to tell a real defect from a legitimate design disagreement.

Exercise 3: What would you do differently for a real deployment?

If Ledgerly were a real company shipping this feature to real customers tomorrow, what beyond this single guided project would you want in place, drawing on anything from this course?

Hint

You would want Module 4’s CI pipeline to run these exact five unit tests on every push, not just once during development, so a future change to ProrationCalculator cannot silently break Amara’s or Marcus’s numbers without a red build catching it first. You would also want the deployment checklist from Module 4’s guided project applied here specifically, given that this feature touches real money, including a low-traffic deploy window and a known rollback path, since a proration bug that undercharges or overcharges customers is exactly the kind of mistake that is far cheaper to prevent than to refund afterward.


Summary

This capstone built prorated mid-cycle subscription upgrades for Ledgerly using all four modules of the course in sequence. Module 1 supplied two user stories and acceptance criteria, scoped into a single Scrum sprint because the requirements are stable and well understood. Module 2 supplied a composed ProrationCalculator collaborator and a Strategy-pattern rounding decision, proven swappable by producing $15.48 against $15.49 for the same upgrade under two different rounding rules. Module 3 supplied a working SubscriptionUpgradeService, verified against two real date scenarios charging $15.48 and $16.07, backed by five passing unit tests and one Gherkin scenario. Module 4 supplied the CI gate this feature ships through unchanged, plus one concrete rule it must never break: the plan price always comes from the server’s own catalog, never from a client.

Key Concepts

  • Composed collaboratorProrationCalculator and SubscriptionUpgradeService both hold their dependencies through composition, never through inheritance.
  • Strategy pattern for a swappable policyRoundHalfUpToCent and CeilingToCent implement the same interface, so the rounding rule can change without touching the proration math.
  • A well-scoped feature still needs real tests — five unit tests across two concrete date scenarios and two edge cases proved the math, not just one demonstration run.
  • Never trust a client-supplied priceupgrade() accepts only a plan name and always reads the real price from the server’s own catalog.

Why This Matters

No single module in this course could have shipped this feature on its own. Requirements without design produce a feature nobody agreed how to build. Design without tests produces a feature nobody can prove is correct. Tests without a delivery pipeline produce a feature that stays on one developer’s laptop forever. This project needed all four, in the same order a real engineering team would use them, to turn one plain-English idea, charge customers fairly for a mid-cycle upgrade, into two verified dollar amounts a real customer would actually see on a real invoice. That is the whole discipline this course has been teaching, and you have now practiced every part of it on one feature, end to end.

Next Steps

Back to Course Overview

Review the full Software Engineering Fundamentals course, all four modules, from foundations through this capstone.

Back to Module Overview

Return to the Capstone module overview


Continue Building Your Skills

You have now carried one real feature through requirements, design, testing, and delivery, the same four-stage arc this entire course followed across nineteen prior lessons. Ledgerly’s three-person team will keep building this way, one feature at a time, long after this course ends, and so can you, on whichever codebase you touch next, whether it already exists or you are about to start it from nothing.

Sponsor

Keep DATATWEETS free. Help fund practical data, AI, and engineering lessons for learners worldwide.

Buy Me a Coffee at ko-fi.com