Blog
Paid modules change more than the checkout screen
Adding paid access to a learning platform touches enrolment, permissions, completion records, certificates and support. I'd work through those before polishing checkout, because a clean payment page doesn't help much if the learner ends up with the wrong access.

When you add a paid module to a learning platform, you're joining two systems that think about state differently. The payment provider knows about an attempt, an authorisation, a capture and maybe a refund. The learning platform knows about a person, an enrolment, access to material and progress through it. Before I'd spend time on the checkout screen, I'd want to write down everything that has to happen between "payment captured" and "the learner can open the module".
Start with what access actually means here. It might begin now or on a set date, last forever or expire, cover one module or a bundle, or depend on a subscription that can be paused. So I'd store the entitlement as its own record: who it's for, the module or bundle, where it came from (a payment, a manual grant, an organisational licence, a promotion), a start, an optional end and a status. Give the purchase and the entitlement separate identifiers, since one purchase can create several entitlements and one learner can hold overlapping grants. A single has_paid flag on the account can't represent any of that, and you don't want an expired subscription taking away access the learner still has through a separate purchase.
Don't treat the success page as fulfilment either. The learner can close the tab, and the browser can arrive before the provider's server notification does. I'd run fulfilment off a verified event from the payment provider, roughly like this:
- Store the incoming event and its verification result.
- Match it to an internal purchase using a stable reference created before checkout.
- Update the purchase state only if the transition is allowed.
- Create or update the entitlement.
- Record each follow-on action, including enrolment and notification.
Keep the provider's event identifier and process it idempotently, so if the same event turns up twice the second run finds the finished work and stops without another enrolment or email. Keep the account, the enrolment and the permission apart too. The account says who can sign in, the enrolment links them to a learning item, and the permission says what they can open right now. Check permission against the current entitlement in one place and use it for pages, downloads, APIs and background jobs (hiding a link in the interface isn't access control).
Completion records and certificates are a record of what the learner did. A refund or failed renewal changes the commercial relationship, but it shouldn't quietly rewrite their history. If a refund really does need to invalidate a certificate, make that an explicit rule and record the reason. Certificate generation needs idempotency too, or a retry after a timeout can issue duplicate certificate numbers.
Most support work will come from the unhappy paths, like payment accepted but enrolment failed, so give each step a visible status and let a retry target the failed step instead of replaying the whole purchase. That's why a paid module changes more than the checkout screen: the work is in getting from "payment captured" to the right entitlement, enrolment and permission, while leaving the learner's completion history alone. Before release, I'd run a clean purchase, a duplicate notification, a delayed notification, a failed enrolment, an account mismatch, a refund and a retry, and write down the expected payment state, entitlement, enrolment and support view for each. If nobody can say what one of those should be, that's a product rule to decide before anyone polishes the checkout.
On X
Got a take on this?
Share it on X. I read the replies.