sample 02/functional MVP
Simone Educação Digital
A course platform that swapped subscriptions for one-time purchases before launch, without starting over.
- Next
- Prisma
- Stripe
- Vimeo
- Turbo
01
Context
Simone Mendes is a professional organizer who teaches through online courses. The platform brings together the public site, the student area with video lessons and an admin panel.
The project started with a monthly subscription. Before launch, the business model changed to one-time purchases with time-limited access, and the structure had to follow without losing what was already built.
02
Starting point
The MVP started with a monthly subscription and access granted by plan tier.
3
monthly subscription tiers
2
portals: student and admin
0
payments integrated on day one
03
Timeline
47 commits in the project history. These are the milestones.
Jul 08, 2026
MVP live
Public site, student area and admin in a monorepo, deployed in the São Paulo region, close to the database.
Jul 12, 2026
Protected video
Vimeo lessons served through a route that checks access before releasing the video, plus free sample lessons for visitors.
Aug 24, 2026
Payments and polish
Stripe checkout in test mode, a rewritten landing page and rate limiting on login.
Aug 27, 2026
Business model change
From subscription to one-time purchase with time-limited access. The new data model went in alongside the old one, breaking nothing.
Aug 28, 2026
Access by entitlement
One-time Stripe payments, course and single-module pages, manual access grants in the admin and a per-module student area.
Sep 13, 2026
Emails and cutover
Four transactional email flows, security headers on both portals and removal of the old subscription model.
Sep 14, 2026
Security hardening
Audit rounds with automated security tests covering authentication, authorization, payments and private files.
Sep 16, 2026
Customer dashboard
Redesigned student dashboard, a course carousel and final checkout adjustments.
04
Decision map
Reconstructed from the commit history, the README and the project documentation.
01
Additive migration, cutover last
The new model went in next to the old one, and access checks honored both until the cutover. Only then was the subscription model removed. The platform was never broken mid-way.
02
Access is an expiring entitlement
Each purchase creates access entitlements with an expiry date, and every check reads them. The scope can be one module or everything, including content released later.
03
The client's vocabulary
In the admin, names follow how Simone talks: course, module and lesson. The database keeps its technical names, and the translation happens in the interface.
04
The admin edits everything
Prices, access periods, course composition, single modules and seat limits are data, not code. Nothing commercial is hard-coded.
05
Email never breaks the flow
Sending email never throws: if it fails, it logs and moves on. A broken email never blocks a sign-up, a purchase or an expiry notice.
06
Payment verified and processed once
The webhook verifies Stripe's signature, checks that amount, currency and price match the recorded order, and uses a database lock so one payment never becomes two purchases.
05
Architecture
The same five layers as this page, applied to the project.
000 m/interface
- Public site and student area
- Separate admin panel
- Shared components
030 m/route
- Proxy with role-protected routes
- Rate limits on login, sign-up and password
- CSP and security headers
060 m/service
- Entitlement access resolver
- Stripe checkout and webhook
- Transactional email with Resend
- Daily expiry notice
090 m/repository
- Prisma with Postgres on Supabase
- Auth, database, email and storage packages
120 m/persistence
- Private files on Supabase Storage
- Reset tokens stored only as hashes
- Passwords with bcrypt
06
Code
Real excerpts from the public repository.
Ownership check before releasing content
Being logged in isn't enough. Access requires an active entitlement, from a paid purchase, covering that module. A blocked user loses access immediately, without waiting for the session to expire.
/** Usuário existe e não está bloqueado/excluído? (choke point de bloqueio) */
async function isUserActive(userId: string): Promise<boolean> {
const u = await prisma.user.findUnique({
where: { id: userId },
select: { blockedAt: true, deletedAt: true },
});
return Boolean(u && !u.blockedAt && !u.deletedAt);
}
/** Existe entitlement ativo que cobre este módulo (Course)? */
export async function hasCourseEntitlement(
userId: string,
courseId: string,
): Promise<boolean> {
if (!(await isUserActive(userId))) return false;
const now = new Date();
const found = await prisma.entitlement.findFirst({
where: {
userId,
expiresAt: { gt: now },
purchase: { status: 'PAID', accessSuspended: false },
OR: [{ scope: "ALL" }, { scope: "COURSE", courseId }],
},
select: { id: true },
});
return Boolean(found);
}Webhook: signature, verification and lock
The signature is verified on the raw body, before any processing. On confirmation, a per-payment Postgres lock ensures two deliveries of the same event produce a single purchase.
export async function POST(req: Request): Promise<NextResponse> {
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) return NextResponse.json({ error: "not_configured" }, { status: 503 });
const signature = req.headers.get("stripe-signature");
if (!signature) return NextResponse.json({ error: "no_signature" }, { status: 400 });
const rawBody = await req.text();
if (Buffer.byteLength(rawBody) > 1_048_576) return NextResponse.json({ error: "too_large" }, { status: 413 });
let event: Stripe.Event;
try { event = getStripe().webhooks.constructEvent(rawBody, signature, secret); }
catch { return NextResponse.json({ error: "invalid_signature" }, { status: 400 }); }
// ... despacha pelo tipo do evento
}
// Na confirmação da compra, depois de conferir valor, moeda e preço com o pedido:
await prisma.$transaction(async tx => {
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(${"payment:" + paymentId}, 0))::text`;
const current = await tx.purchase.findUniqueOrThrow({ where: { id: purchaseId } });
if (current.status !== "PENDING") return;
// ... marca como paga e materializa os direitos de acesso
});Fail-safe email
The result comes back as a value, not an exception. The caller decides what to do, and the idempotency key prevents duplicate emails when the same event is processed again.
/**
* Envia um e-mail transacional. À PROVA DE FALHA por design: nunca lança.
* Se a key não existe ou o Resend falha, loga e retorna { ok: false } — assim
* um e-mail quebrado JAMAIS derruba o fluxo que o disparou (signup, compra…).
*/
export async function sendEmail(params: {
to: string;
subject: string;
html: string;
replyTo?: string;
idempotencyKey?: string;
}): Promise<SendResult> {
const resend = getResend();
if (!resend) {
// ... registra o aviso
return { ok: false, skipped: true };
}
try {
const { data, error } = await resend.emails.send({
from: FROM_EMAIL,
to: params.to,
subject: params.subject,
html: params.html,
replyTo: params.replyTo ?? REPLY_TO,
}, params.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : undefined);
if (error) {
// ... registra a recusa
return { ok: false, error: error.message ?? String(error) };
}
return { ok: true, id: data?.id };
} catch (err) {
// ... registra a exceção
return {
ok: false,
error: err instanceof Error ? err.message : "erro desconhecido",
};
}
}07
Outcome
21
automated security tests
4
transactional email flows
8
shared packages in the monorepo
2
access models running in parallel until cutover
Current status
Functional MVP live, in pre-launch. Courses, prices and access periods are configured from the admin panel itself.