Web razvoj
Next.jsStripePretplatePlaćanjaWebhookoviSaaS

Implementacija Stripe pretplata u Next.js-u: Billing Portal, webhookovi i prava pristupa

AO
Adrijan Omićević
·15 min čitanja

# Što ćete izgraditi#

Ovaj vodič prikazuje postav spreman za produkciju za Next.js Stripe pretplate uz tri neupitna zahtjeva: pouzdano stanje naplate, samostalno upravljanje korisnika i determinističku kontrolu pristupa.

Implementirat ćete:

  • Tok kupnje pretplate koji veže Stripe objekte uz zapise korisnika.
  • Stripe Billing Portal za nadogradnje, downgradeove, otkazivanja i preuzimanje računa.
  • Verificirane webhookove s idempotentnošću kako biste izbjegli dvostruku obradu.
  • Čisto mapiranje stanja pretplate u prava pristupa (entitlements) na značajke.
  • Strategiju testiranja sa Stripe CLI-jem, uz česte načine kvara i sigurnosne aspekte.

Ako želite širi API obrazac za integracije s vanjskim sustavima, pogledajte i naš vodič za integraciju API-ja.

# Preduvjeti i arhitektura#

Trebate jedan “izvor istine” za pristup. U produkciji to mora biti vaša baza podataka, a ne Stripe API pozivi na svaki zahtjev i ne provjere na klijentu.

Pretpostavke o stacku#

  • Next.js 14 ili 15 (App Router)
  • Node runtime za webhook rutu (provjera potpisa treba raw body)
  • Stripe Node SDK
  • Baza (preporučeno PostgreSQL) preko Prisma ili slično

Što ide gdje#

BrigaStripeVaša aplikacija
Cijene (proizvodi, cijene, trial)Definirano u Dashboardu ili preko API-jaReferencirate Price ID-eve u konfiguraciji
Identitet korisnikaCustomer objektMapirate userId na stripeCustomerId
Životni ciklus pretplateSubscription, Invoice, PaymentIntentPohranjujete trenutni status, datume obnove, plan
Samostalno upravljanjeBilling PortalDajete link na Portal session
Kontrola pristupaPrava pristupa izvedena iz zapisa pretplate
Ažuriranje istineWebhookoviVerificirani + idempotentni upisi

🎯 Ključna poruka: Stripe tretirajte kao engine za naplatu, a vašu bazu kao autoritet za pristup koji se ažurira putem webhookova.

# Korak 1: Modelirajte planove, probne periode i prava pristupa#

Izbjegnite kodiranje logike “Pro”, “Team” i “Enterprise” po cijeloj bazi koda. Držite jednu mapu planova koja referencira Stripe Price ID-eve i definira prava pristupa.

Konfiguracija planova#

Price ID-eve spremite u env varijable kako biste čisto razdvojili test i live mod.

PlanStripe Price ID env varTrial danaPrimjer prava pristupa
StarterSTRIPE_PRICE_STARTER7Osnovne značajke, 1 workspace
ProSTRIPE_PRICE_PRO14Napredne značajke, 5 workspaceova
TeamSTRIPE_PRICE_TEAM14SSO, 20 workspaceova, prioritetna podrška

Prava pristupa definirajte u kodu kao kompaktan “capabilities objekt”. Neka bude stabilan i verzionabilan.

TypeScript
// lib/billing/plans.ts
export type PlanKey = "starter" | "pro" | "team";
 
export const PLANS: Record<PlanKey, {
  priceId: string;
  trialDays: number;
  entitlements: {
    workspaces: number;
    sso: boolean;
    prioritySupport: boolean;
    advancedExports: boolean;
  };
}> = {
  starter: {
    priceId: process.env.STRIPE_PRICE_STARTER!,
    trialDays: 7,
    entitlements: { workspaces: 1, sso: false, prioritySupport: false, advancedExports: false },
  },
  pro: {
    priceId: process.env.STRIPE_PRICE_PRO!,
    trialDays: 14,
    entitlements: { workspaces: 5, sso: false, prioritySupport: false, advancedExports: true },
  },
  team: {
    priceId: process.env.STRIPE_PRICE_TEAM!,
    trialDays: 14,
    entitlements: { workspaces: 20, sso: true, prioritySupport: true, advancedExports: true },
  },
};

Tablice u bazi koje su vam stvarno potrebne#

Držite billing podatke minimalnima, ali dovoljnima za provjere pristupa, korisničku podršku i usklađivanje (reconciliation).

TablicaKljučna poljaZašto je bitno
Userid, email, stripeCustomerIdStabilno mapiranje korisnika na Stripe Customer
SubscriptionuserId, stripeSubscriptionId, status, priceId, currentPeriodEnd, cancelAtPeriodEndBrzo rješavanje prava pristupa
WebhookEventstripeEventId, type, processedAtIdempotentnost i audit trail
EntitlementSnapshot (opcionalno)userId, json, updatedAtBrza čitanja, feature flagovi, reporting

Dobro pravilo: prava pristupa računajte iz polja Subscription u runtimeu, a snapshot spremite samo ako su čitanja ekstremno učestala ili trebate povijesni reporting.

# Korak 2: Napravite Checkout za nove pretplate#

Za većinu SaaS timova Stripe Checkout je najbrži put do usklađenog, lokaliziranog toka kupnje koji podržava poreze.

Ruta za kreiranje Checkout Sessiona#

Koristite server-side rutu koja prima planKey, učitava odgovarajući priceId i kreira Checkout Session u subscription modu.

TypeScript
// app/api/billing/checkout/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { PLANS, PlanKey } from "@/lib/billing/plans";
import { getCurrentUser } from "@/lib/auth/getCurrentUser";
 
export const runtime = "nodejs";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-02-24.acacia",
});
 
export async function POST(req: Request) {
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
 
  const { planKey } = await req.json();
  const plan = PLANS[planKey as PlanKey];
  if (!plan) return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
 
  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    customer: user.stripeCustomerId ?? undefined,
    customer_email: user.stripeCustomerId ? undefined : user.email,
    line_items: [{ price: plan.priceId, quantity: 1 }],
    subscription_data: { trial_period_days: plan.trialDays },
    allow_promotion_codes: true,
    success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/pricing`,
    metadata: { userId: user.id, planKey },
  });
 
  return NextResponse.json({ url: session.url });
}

Ova ruta namjerno ne dodjeljuje pristup. Pristup se mijenja tek nakon obrade webhookova.

⚠️ Upozorenje: Ne otključavajte značajke u success_url na temelju query parametara. Korisnici mogu doći na tu stranicu bez uspješne naplate, a ponovni pokušaji mogu stvoriti stanje izvan redoslijeda. Webhookovi su izvor istine.

Vežite customer ID uz korisnika#

Nakon prvog checkouta Stripe će kreirati Customer ako ste poslali customer_email. Uhvatite customer iz checkout.session.completed webhooka i spremite ga u User.stripeCustomerId.

# Korak 3: Dodajte Stripe Billing Portal (Self-Serve)#

Billing Portal smanjuje opterećenje podrške i povećava zadržavanje jer nadogradnje i ispravci plaćanja postaju bez trenja. Stripe je objavio da su neuspjela plaćanja veliki izvor nenamjernog churn-a; dati korisnicima self-serve način za ažuriranje kartice jedna je od billing promjena s najvećim ROI-jem.

Ruta za kreiranje Portal sessiona#

TypeScript
// app/api/billing/portal/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth/getCurrentUser";
 
export const runtime = "nodejs";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-02-24.acacia",
});
 
export async function POST() {
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  if (!user.stripeCustomerId) {
    return NextResponse.json({ error: "No Stripe customer" }, { status: 400 });
  }
 
  const session = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.APP_URL}/billing`,
  });
 
  return NextResponse.json({ url: session.url });
}

U Stripe Dashboardu konfigurirajte Portal postavke za dopuštene promjene. Tipična produkcijska postava:

Postavka PortalaPreporukaZašto
Otkazivanje pretplateDopustitiSmanjuje broj tiketa
Ažuriranje načina plaćanjaDopustitiSmanjuje churn zbog neuspjelih obnova
Promjena planaDopustiti, ali ograničiti na vaš proizvodSprječava neusklađene cijene
Povijest računaDopustitiManje pitanja o naplati
ProrationOdluka prema poslovnom modeluIzbjegnite iznenadne račune

# Korak 4: Webhookovi koji su verificirani, idempotentni i deterministični#

Webhookovi su mjesto gdje se većina billing implementacija “radilo je lokalno” raspadne. Morate pokriti: retry, dostavu izvan redoslijeda i parcijalne kvarove.

Odaberite evente koji su vam stvarno potrebni#

Minimalan, robustan set:

EventKoristite zaNapomene
checkout.session.completedPovezivanje korisnika s customerom, čitanje subscription ID-aNije garancija plaćenog invoicea za neke tokove
customer.subscription.createdKreiranje lokalnog zapisa pretplateKorisno kad se pretplata kreira izvan Checkouta
customer.subscription.updatedPromjene statusa, otkazivanja, promjene planaVećina promjena stanja prolazi ovdje
customer.subscription.deletedHard delete ili završetak pretplateOdmah označite kao otkazanu
invoice.payment_succeededOznačite plaćeni period, obrade obnoveJak signal da je pretplata financirana
invoice.payment_failedDunning tokovi, politika ograničenja pristupaOdlučite grace period

Možete krenuti s manjim brojem, ali morate pokriti životni ciklus koji utječe na pristup.

Implementirajte webhook rutu s provjerom potpisa#

Stripe zahtijeva korištenje raw body-ja za provjeru potpisa. U Next.js App Routeru često se koristi req.text().

TypeScript
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { upsertSubscriptionFromStripe } from "@/lib/billing/sync";
import { markWebhookEventProcessed, wasWebhookEventProcessed } from "@/lib/billing/idempotency";
 
export const runtime = "nodejs";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-02-24.acacia",
});
 
export async function POST(req: Request) {
  const sig = (await headers()).get("stripe-signature");
  if (!sig) return NextResponse.json({ error: "Missing signature" }, { status: 400 });
 
  const rawBody = await req.text();
 
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      rawBody,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }
 
  if (await wasWebhookEventProcessed(event.id)) {
    return NextResponse.json({ received: true });
  }
 
  try {
    switch (event.type) {
      case "checkout.session.completed": {
        const session = event.data.object as Stripe.Checkout.Session;
        await upsertSubscriptionFromStripe({ checkoutSession: session });
        break;
      }
      case "customer.subscription.created":
      case "customer.subscription.updated":
      case "customer.subscription.deleted": {
        const sub = event.data.object as Stripe.Subscription;
        await upsertSubscriptionFromStripe({ subscription: sub });
        break;
      }
      case "invoice.payment_succeeded":
      case "invoice.payment_failed": {
        const invoice = event.data.object as Stripe.Invoice;
        await upsertSubscriptionFromStripe({ invoice });
        break;
      }
      default:
        break;
    }
 
    await markWebhookEventProcessed(event.id, event.type);
    return NextResponse.json({ received: true });
  } catch (e) {
    // Stripe will retry on non-2xx responses
    return NextResponse.json({ error: "Webhook handler failed" }, { status: 500 });
  }
}

Strategija idempotentnosti koja radi pod retry-jevima#

Stripe će pokušavati ponovno slati webhookove danima ako vaš endpoint vraća non-2xx. Duplikate morate obraditi čak i kad vraćate 200, jer se dostava može dogoditi dvaput.

Praktičan pristup:

  • Kreirajte WebhookEvent redak ključan po stripeEventId.
  • Stavite unique constraint na stripeEventId.
  • Ako insert padne zbog unique, preskočite obradu.

Ako trebate i semantiku “exactly once” preko više upisa, koristite DB transakciju koja uključuje:

  1. 1
    Insert u WebhookEvent
  2. 2
    Upsert stanja pretplate

Sync logika: normalizirajte Stripe objekte u svoju shemu#

Vaše provjere pristupa ne bi trebale ovisiti o desecima Stripe polja. Mapirajte u stabilan lokalni model.

TypeScript
// lib/billing/normalize.ts
import Stripe from "stripe";
 
export function normalizeSubscription(sub: Stripe.Subscription) {
  return {
    stripeSubscriptionId: sub.id,
    stripeCustomerId: sub.customer as string,
    status: sub.status,
    priceId: (sub.items.data[0]?.price?.id ?? null) as string | null,
    cancelAtPeriodEnd: sub.cancel_at_period_end,
    currentPeriodEnd: new Date(sub.current_period_end * 1000),
    trialEnd: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
  };
}

Zatim upsertSubscriptionFromStripe može:

  • Razriješiti userId iz stripeCustomerId (mora biti spremljen na useru).
  • Upsertati Subscription po stripeSubscriptionId.
  • Ažurirati User.stripeCustomerId kad ga prvi put vidite.

ℹ️ Napomena: Neki eventi uključuju samo ID-eve osim ako ne expandate objekte. U webhookovima Stripe tipično uključuje pune objekte za primarni objekt eventa, ali budite konzervativni i obradite i slučajeve kad nedostaju ugniježđena polja.

# Korak 5: Izračunajte prava pristupa iz stanja pretplate#

Vaša aplikacija treba brzo odgovoriti na jedno pitanje: “Što ovaj korisnik smije raditi upravo sada?”

Definirajte politiku pristupa#

Česta produkcijska politika:

  • Pristup je aktivan kad je status active ili trialing.
  • Opcionalan grace period kad je status past_due kraće od N dana.
  • Nema pristupa kad je canceled, unpaid ili incomplete_expired.

Također uzmite u obzir: otkazivanja s cancel_at_period_end i dalje dopuštaju pristup do current_period_end.

Funkcija za rješavanje prava pristupa#

Ovu funkciju trebaju koristiti API rute i server komponente. Ne smije zvati Stripe.

TypeScript
// lib/billing/entitlements.ts
import { PLANS, PlanKey } from "@/lib/billing/plans";
 
const PRICE_ID_TO_PLAN: Record<string, PlanKey> = Object.fromEntries(
  Object.entries(PLANS).map(([k, v]) => [v.priceId, k as PlanKey])
);
 
export function resolveEntitlements(subscription: {
  status: string;
  priceId: string | null;
  currentPeriodEnd: Date;
  trialEnd: Date | null;
  cancelAtPeriodEnd: boolean;
}) {
  const now = Date.now();
  const periodEnd = subscription.currentPeriodEnd.getTime();
 
  const isActive =
    subscription.status === "active" ||
    subscription.status === "trialing" ||
    (subscription.status === "canceled" && now < periodEnd);
 
  const planKey = subscription.priceId ? PRICE_ID_TO_PLAN[subscription.priceId] : null;
 
  if (!isActive || !planKey) {
    return { isActive: false, planKey: null, entitlements: null };
  }
 
  return {
    isActive: true,
    planKey,
    entitlements: PLANS[planKey].entitlements,
  };
}

Provedite prava pristupa u proizvodu#

Prava pristupa koristite u točkama odluke, ne rasuto po UI-ju.

Primjeri:

  • Kreiranje workspacea: usporedite currentWorkspaceCount s entitlements.workspaces.
  • SSO rute: zahtijevajte entitlements.sso === true.
  • Export: dopustite napredne exporte samo za Pro i više.

To nadogradnje čini mjerljivima. Možete logirati evente “feature blocked due to plan” kako biste vidjeli što potiče konverziju i povezati to s praksama observabilityja iz našeg vodiča za observability web aplikacija.

# Korak 6: Lokalno testiranje sa Stripe CLI (i što treba provjeriti)#

Stripe CLI je najbrži način da validirate webhook handler, uključujući provjeru potpisa i retry-jeve.

Instalacija i prijava#

Bash
stripe login
stripe --version

Prosljeđivanje webhookova u Next.js#

Pokrenite aplikaciju na http://localhost:3000, zatim:

Bash
stripe listen --forward-to localhost:3000/api/stripe/webhook

Kopirajte webhook signing secret koji CLI ispiše i postavite ga kao STRIPE_WEBHOOK_SECRET u lokalni env.

Okidanje relevantnih eventa#

Bash
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_succeeded
stripe trigger invoice.payment_failed

Kako izgleda dobar testni checklist#

TestKakoOčekivani rezultat
Provjera potpisaPošaljite request bez potpisaEndpoint vraća 400
IdempotentnostPonovno pošaljite isti event IDNema duplih upisa u DB
Eventi izvan redoslijedaOkidajte update prije createdUpsert i dalje daje konzistentan redak
Retry nakon greškeJednom forsirajte DB greškuStripe retry-a i na kraju uspije
Mapiranje planaKoristite različite Price ID-eveTočan planKey i prava pristupa
Ponašanje otkazivanjaPostavite cancel_at_period_endPristup ostaje do current_period_end

💡 Savjet: U stagingu konfigurirajte zaseban webhook endpoint i Stripe “test mode” ključeve. Nikad ne usmjeravajte Stripe test mode na produkcijske podatkovne storeove.

# Korak 7: Načini kvara za koje morate dizajnirati#

Billing kvarovi nisu rubni slučajevi. Oni su normalne operacije u većem opsegu: istek kartice, nedovoljno sredstava, bankovna autentifikacija i mrežni problemi.

Česti načini kvara i mitigacije#

Način kvaraŠto se događaMitigacija
Webhook handler vraća 500Stripe retry-a satima ili danimaHandler neka bude brz, transakcijski i observable
Duplikati webhook dostaveIsti event dođe dvaputUnique constraint na stripeEventId
Dostava izvan redoslijedaUpdate dođe prije createUpsert po Stripe ID-evima, ne po lokalnim pretpostavkama
Nedostaje mapiranje customer→userPretplata postoji, ali nema useraKoristite metadata userId i uskladite backfill jobom
Neusklađen planPrice ID nije u configuDefault na no access i alert
Neuspjelo plaćanjePretplata postane past_dueOdlučite grace period; vodite korisnika u Billing Portal
Aplikacija zove Stripe na svaki requestLatencija i rate limitoviPrava pristupa rješavajte iz DB-a, osvježavajte webhooksima

Observability zahtjevi za billing#

Minimalno logirajte:

  • Stripe event.id, event.type i vrijeme obrade
  • Ishode upisa u bazu
  • Incidente “Unknown Price ID”
  • Incidente “No user for stripeCustomerId”

Zatim izložite metrike:

  • Stopa uspješnosti obrade webhookova
  • Prosječna latencija obrade webhookova
  • Broj korisnika u past_due stanju
  • Broj webhook retry-jeva

Ako želite praktičan setup za logove, metrike i tracing, koristite naš vodič za observability.

# Korak 8: Sigurnosni aspekti (produkcijski checklist)#

Billing dotiče identitet, novac i kontrolu pristupa. Tretirajte ga kao sigurnosno kritičnu površinu.

Ključna sigurnosna pravila#

RizikLoš ishodMitigacija
Neverificirani webhookoviNapadač si dodijeli pristupUvijek validirajte Stripe potpis
Povjerenje u status s klijentaKorisnici zaobiđu paywallPristup ograničite server-side iz DB-a
Curenje secret ključevaPotpuni kompromis naplateKoristite server-only env varove, rotirajte ključeve
Previše permisivan PortalNeželjene promjene planovaOgraničite Portal konfiguraciju
Manipulacija metapodacimaPogrešno mapiranje useraNe vjerujte isključivo client metadata; provjerite ownership customera
SSRF i injectionLateralno kretanjeValidirajte inpute; least privilege

Također provedite širu provjeru prema našem sigurnosnom checklistu za web aplikacije, posebno oko rukovanja tajnama i granica autorizacije.

⚠️ Upozorenje: Ne izlažite Stripe secret ključeve u Next.js public env varijablama. Sve s prefiksom NEXT_PUBLIC_ može završiti u pregledniku.

# Korak 9: Obrasci za “hardening” u produkciji#

Kad osnove rade, ovi obrasci uklanjaju zadnjih 10% billing boli.

1) Reconciliation job#

Webhookovi mogu zakazati zbog privremenih ispada. Pokrenite dnevni job za usklađivanje:

  • Query korisnika s “aktivno-izgledajućim” stanjima
  • Dohvat Stripe pretplate po spremljenom ID-u
  • Popravak neslaganja

Neka bude friendly prema rate limitovima tako da radite batching i dohvaćate samo nedavne promjene.

2) Snapshot prava pristupa za ultra-brza čitanja#

Ako svaki request provjerava prava pristupa i DB je “vruć”, spremite EntitlementSnapshot JSON koji se ažurira obradom webhookova.

To olakšava edge middleware i caching strategije, jer čitate jedan jedini redak.

3) Admin alati#

Dajte podršci siguran način da:

  • Vidi link na Stripe customera
  • Vidi trenutni status i datum obnove
  • Pokrene “resync from Stripe” za jednog korisnika

To smanjuje “ne možemo reproducirati” cikluse i spušta vrijeme podrške po tiketu.

# Ključne poruke#

  • Koristite Stripe Checkout za kupnju i Billing Portal za promjene; izbjegnite gradnju vlastitog UI-ja za upravljanje planovima osim ako vam stvarno treba.
  • Verificirajte webhookove koristeći raw request body, obrađujte ih idempotentno preko unique constrainta na stripeEventId i tretirajte bazu kao jedini izvor istine za pristup.
  • Normalizirajte Stripe podatke pretplate u mali lokalni model, pa prava pristupa računajte iz status, priceId i period datuma.
  • Testirajte lokalno sa Stripe CLI-jem provjeru potpisa, retry-jeve, duplikate i evente izvan redoslijeda prije isporuke.
  • Dizajnirajte za kvar: reconciliation jobovi, alerti za nemapirane cijene i jasno rukovanje past_due te otkazivanjem na kraju perioda.
  • Primijenite sigurnosne osnove: higijena secret ključeva, stroga autorizacija i nikad povjerenje u client-side billing stanje.

# Zaključak#

Čvrsta implementacija Next.js Stripe pretplata je većinom pitanje ispravnosti u realnim uvjetima: retry, parcijalni kvarovi i promjene stanja koje niste direktno okinuli. Ako kontrolu pristupa usidrite u bazi podataka, webhook obradu držite verificiranom i idempotentnom te pretplate mapirate u eksplicitna prava pristupa, izbjeći ćete najskuplje billing bugove.

Ako želite da Samioda pregleda vašu billing arhitekturu ili implementira kompletan sustav pretplata uz observability i sigurnosno hardening, kontaktirajte nas i pomoći ćemo vam da brže isporučite postav spreman za produkciju.

FAQ

Share
A
Adrijan OmićevićSamioda Team
All articles →

Trebate pomoć s projektom?

Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.