Cart, Checkout and Access Entitlements for Course Platforms
How to separate orders, payments and access in a course platform — bundles, idempotent payment confirmation, and a single entitlement check that decides who sees what.
Selling access to content looks like an e-commerce problem, but there is a twist: the product is not a parcel to ship, it is the right to see something, often for a limited period. Treat that right as data of its own and most of the hard problems — refunds, bundles, expiry, restoring access on a new device — become manageable. This article describes a structure that keeps orders, payments and access separate. It contains no provider credentials or provider-specific secrets; the patterns are the same whichever payment gateway you use.
Three questions, three models
Resist a single purchases table that tries to answer everything. Split it into the three questions the system actually needs to answer:
- What did the customer try to buy? An order, with line items and prices at the time.
- Did money move? A payment, with a status and the provider's reference.
- What can the user access now? An entitlement, with a scope and an expiry.
model Order {
id String @id @default(auto()) @map("_id") @db.ObjectId
userId String @db.ObjectId
status String // PENDING | PAID | FAILED | REFUNDED
currency String
total Int // minor units
items OrderItem[]
createdAt DateTime @default(now())
@@index([userId, createdAt])
}
type OrderItem {
productType String // "COURSE" | "BUNDLE"
productId String
title String // snapshot at purchase time
unitPrice Int
}
model Payment {
id String @id @default(auto()) @map("_id") @db.ObjectId
orderId String @db.ObjectId
provider String
providerRef String?
status String
createdAt DateTime @default(now())
@@unique([provider, providerRef])
}
model Entitlement {
id String @id @default(auto()) @map("_id") @db.ObjectId
userId String @db.ObjectId
subjectId String @db.ObjectId
orderId String @db.ObjectId
grantedAt DateTime @default(now())
expiresAt DateTime?
@@unique([userId, subjectId])
@@index([userId, expiresAt])
}Notice what each one is not responsible for. The order does not grant access. The payment does not know about courses. The entitlement does not know about money, only that it was granted because of an order.
Money as integers, prices as snapshots
Store amounts in minor units as integers; floating-point arithmetic and currency do not mix. And copy the title and price onto the order item at purchase time. If the price changes next month, last month's receipts must not change with it.
Bundles are a pricing concept, not an access concept
A bundle — "everything for this level" — is a product that expands into several course grants. Keep that expansion explicit:
model Bundle {
id String @id @default(auto()) @map("_id") @db.ObjectId
title String
price Int
subjectIds String[] @db.ObjectId
validUntil DateTime?
}When an order for a bundle is paid, the system creates one entitlement per subject. Everything downstream — content access, progress, the mobile app — only ever sees entitlements per subject. It never needs to know that a bundle existed. That is what keeps access checks one line long, no matter how many ways you invent to sell things.
Confirming a payment: idempotency is the whole game
Payment confirmations arrive in more than one way — a redirect back to your site, a provider webhook, a manual retry — and can arrive twice or out of order. The handler that turns a successful payment into entitlements must be safe to run any number of times.
Three techniques make it so:
- A unique constraint on the provider reference, as in
@@unique([provider, providerRef]), so the same payment cannot be recorded twice. - A state check before acting: only an order in
PENDINGbecomesPAID. - Upserts for entitlements, so granting access twice is harmless.
export async function confirmPayment(orderId: string, providerRef: string) {
const order = await prisma.order.findUnique({ where: { id: orderId } });
if (!order) throw new Error("Order not found");
if (order.status === "PAID") return order; // already processed
// Verify with the provider server-side; never trust the browser redirect alone.
const verified = await verifyWithProvider(providerRef, order.total);
if (!verified) throw new Error("Payment could not be verified");
await prisma.$transaction(async (tx) => {
await tx.order.update({ where: { id: orderId }, data: { status: "PAID" } });
for (const subjectId of await expandToSubjects(order.items)) {
await tx.entitlement.upsert({
where: { userId_subjectId: { userId: order.userId, subjectId } },
update: { expiresAt: computeExpiry() },
create: { userId: order.userId, subjectId, orderId, expiresAt: computeExpiry() },
});
}
});
return order;
}Two points in that sketch matter more than the rest. Verify on the server, against the provider, comparing the amount to the order total; a success flag in a browser URL can be forged. And do the state change and the grants in one transaction, so a crash cannot leave a paid order with no access (note that MongoDB transactions require a replica set).
One function decides access
Every place that serves protected content — lesson pages, video URLs, quizzes, downloads — calls the same function:
export async function canAccess(userId: string | null, lesson: Lesson, subjectId: string) {
if (lesson.freePreview) return true;
if (!userId) return false;
const grant = await prisma.entitlement.findUnique({
where: { userId_subjectId: { userId, subjectId } },
});
return !!grant && (!grant.expiresAt || grant.expiresAt > new Date());
}Enforce it on the server, in the API, not just in the UI. Hiding a button is a convenience; refusing to return the video URL is security. If a web app and a mobile app both use the same backend (see One Backend, Two Clients), putting this check in the shared API means neither client can bypass it.
Cart state
A cart is a draft of an order. For signed-in users, persisting it on the server lets it follow them between devices; for visitors, local storage is enough until sign-in. Recompute prices on the server at checkout from the product records, ignoring any price the client sends. Convert the cart to a PENDING order at the moment of checkout, so what the customer agreed to is recorded before they leave for the payment page.
Coupons and discounts
Model a discount as its own record with rules (amount or percentage, validity window, usage limit) and record each redemption separately. The order stores the final price and a reference to the discount applied. Enforce usage limits with an atomic operation, such as a conditional increment, so two simultaneous checkouts cannot both use the last redemption.
Refunds and revocation
Because access is an entitlement that records which order produced it, a refund has a clean path: mark the order REFUNDED, then delete or expire the entitlements created by that order. Without the orderId link you would have to guess which grants came from where.
Operational details worth planning for
- Audit trail. Keep a log of grants, revocations and payment events. You will need it the first time someone says "I paid but cannot see my course".
- Reconciliation. A job that finds
PENDINGorders with a paid provider status, and vice versa, repairs missed webhooks. - Expiry. Check
expiresAtat read time rather than relying on a cleanup job to delete entitlements on time. - Secrets. Keep provider keys on the server; never expose them through
NEXT_PUBLIC_variables or the mobile bundle.
Summary
- Separate orders, payments and entitlements.
- Store integer amounts and snapshot prices.
- Expand bundles into per-subject entitlements at grant time.
- Make payment confirmation idempotent and verify server-side.
- Use a single
canAccessfunction, enforced in the API. - Link entitlements to the order that created them so refunds are simple.
For how the content being protected is modelled, see Designing an LMS Data Model. The E2A Learning case study describes a product built around this kind of purchase-and-access flow.
Related articles
- Designing an LMS Data Model: Courses, Lessons, Quizzes and Progress
A practical data model for a learning platform — content hierarchy, free previews, quiz attempts, past-paper practice and progress tracking — with the trade-offs behind each choice.
- One Backend, Two Clients: Architecture for a Next.js Web App and an Expo Mobile App
How to share an API, validation schemas and data-fetching patterns between a Next.js web app and an Expo / React Native app without duplicating business rules.