Skip to content
KW
Back to projects

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.

  1. 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.

  2. Jul 12, 2026

    Protected video

    Vimeo lessons served through a route that checks access before releasing the video, plus free sample lessons for visitors.

  3. Aug 24, 2026

    Payments and polish

    Stripe checkout in test mode, a rewritten landing page and rate limiting on login.

  4. 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.

  5. 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.

  6. Sep 13, 2026

    Emails and cutover

    Four transactional email flows, security headers on both portals and removal of the old subscription model.

  7. Sep 14, 2026

    Security hardening

    Audit rounds with automated security tests covering authentication, authorization, payments and private files.

  8. 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.

  1. 000 m/interface

    • Public site and student area
    • Separate admin panel
    • Shared components
  2. 030 m/route

    • Proxy with role-protected routes
    • Rate limits on login, sign-up and password
    • CSP and security headers
  3. 060 m/service

    • Entitlement access resolver
    • Stripe checkout and webhook
    • Transactional email with Resend
    • Daily expiry notice
  4. 090 m/repository

    • Prisma with Postgres on Supabase
    • Auth, database, email and storage packages
  5. 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.

apps/web/lib/entitlements.tsts
/** 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.

apps/web/app/api/stripe/webhook/route.tsts
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.

packages/email/src/send.tsts
/**
 * 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.