· Stripe· customer portal· SaaS· abonnement· FastAPI· React· webhook· selvbetjening
Stripe Customer Portal — selvbetjening til SaaS-abonnenter
Stripe Customer Portal til SaaS: opsætning, session-oprettelse, tilladte handlinger, webhook events, branded portal og integration i FastAPI + React.
Af M-Bus Gateway
Stripe Customer Portal giver abonnenter mulighed for at håndtere betalingskort, fakturering og abonnementer selv — uden at du bygger det. Her er integrationen i M-Bus Gateway platformen.
Hvad Customer Portal kan
Selvbetjeningshandlinger (konfigurerbart i Stripe dashboard):
Betalingsmetoder:
✅ Tilføj/fjern betalingskort
✅ Sæt standard betalingsmetode
✅ Opdatér faktureringsadresse
Abonnement:
✅ Se nuværende plan og pris
✅ Skift plan (upgrade/downgrade)
✅ Annullér abonnement (med cancellation flow)
✅ Se og download fakturaer
Konfigurérbart:
→ Hvilke planer der kan skiftes til
→ Om annullering er mulig (cancel at period end vs. immediate)
→ Branding: logo, farver, link tilbageknap
FastAPI: opret portal session
# server/src/stripe/router.py
@router.post("/billing/portal")
async def create_billing_portal_session(
user: TokenPayload = Depends(require_role("landlord", "external_admin")),
session: AsyncSession = Depends(get_session),
):
"""
Opret Stripe Customer Portal session og returnér redirect URL.
Brugeren sendes til Stripe's hosted portal og returneres til /portal/profile.
"""
# Hent Stripe customer ID fra tenant
tenant = await get_or_404(session, Tenant, user.tenant_id, user.tenant_id)
if not tenant.stripe_customer_id:
raise HTTPException(400, "No Stripe customer — subscribe first")
portal_session = stripe.billing_portal.Session.create(
customer=tenant.stripe_customer_id,
return_url=f"{settings.FRONTEND_URL}/portal/profile",
# Valgfrit: angiv specifik konfiguration
# configuration="bpc_...",
)
await audit_log(session, user, "billing_portal_access", {
"portal_url": portal_session.url[:50] + "...",
})
return {"url": portal_session.url}
React: åbn Customer Portal
// ui/src/pages/portal/Profile.tsx
import { useState } from "react";
import { api } from "../../api";
export function BillingSection({ subscription }: { subscription: SubscriptionData }) {
const [loading, setLoading] = useState(false);
async function openPortal() {
setLoading(true);
try {
const { url } = await api.billing.portal();
window.location.href = url; // Redirect til Stripe Portal
} catch (err) {
console.error("Portal error:", err);
} finally {
setLoading(false);
}
}
return (
<div className="billing-card">
<h3>Abonnement</h3>
<div className="billing-row">
<span>Plan</span>
<strong>{subscription.plan_name ?? "Professional"}</strong>
</div>
<div className="billing-row">
<span>Status</span>
<span className={`badge badge-${subscription.status === "active" ? "green" : "amber"}`}>
{subscription.status}
</span>
</div>
<div className="billing-row">
<span>Aktive enheder</span>
<strong>{subscription.active_units} × 49 kr./md.</strong>
</div>
<button
className="btn btn-secondary"
onClick={openPortal}
disabled={loading}
>
{loading ? "Åbner..." : "Administrér abonnement"}
</button>
</div>
);
}
API typer
// ui/src/api.ts
export interface SubscriptionData {
status: "active" | "trialing" | "past_due" | "canceled" | "no_subscription";
plan_name: string | null;
active_units: number;
trial_end: string | null;
current_period_end: string | null;
cancel_at_period_end: boolean;
}
// api.billing endpoints:
billing: {
getSubscription: () => request<SubscriptionData>("GET", "/api/v1/billing/subscription"),
portal: () => request<{ url: string }>("POST", "/api/v1/billing/portal"),
checkout: (priceId: string) => request<{ url: string }>("POST", "/api/v1/billing/checkout", { price_id: priceId }),
}
Webhook events fra Customer Portal
# server/src/stripe/router.py
STRIPE_EVENTS = {
# Customer Portal events:
"customer.subscription.updated": _handle_subscription_updated,
"customer.subscription.deleted": _handle_subscription_deleted,
"payment_method.attached": _handle_payment_method_attached,
"customer.updated": _handle_customer_updated,
# Billing Portal session events:
"billing_portal.session.created": _handle_portal_session_created,
}
async def _handle_subscription_updated(
event: stripe.Event,
session: AsyncSession,
) -> None:
"""
Opdatér tenant subscription status når abonnement ændres i portal.
Sker ved: plan-skift, annullering, genaktivering.
"""
sub = event["data"]["object"]
tenant_id = await _tenant_id_from_customer(session, sub["customer"])
if not tenant_id:
return
status = sub["status"]
cancel_at_end = sub.get("cancel_at_period_end", False)
await session.execute(
update(Tenant)
.where(Tenant.id == tenant_id)
.values(
subscription_status=status,
stripe_subscription_id=sub["id"],
cancel_at_period_end=cancel_at_end,
)
)
await session.commit()
# Send bekræftelsesmail ved annullering
if cancel_at_end:
period_end = datetime.fromtimestamp(sub["current_period_end"])
await send_cancellation_confirmed_email.delay(
str(tenant_id), period_end.isoformat()
)
async def _handle_subscription_deleted(
event: stripe.Event,
session: AsyncSession,
) -> None:
"""Abonnement endeligt annulleret (after period end)."""
sub = event["data"]["object"]
tenant_id = await _tenant_id_from_customer(session, sub["customer"])
if not tenant_id:
return
await session.execute(
update(Tenant)
.where(Tenant.id == tenant_id)
.values(subscription_status="canceled")
)
await session.commit()
Stripe Dashboard konfiguration
1. Gå til Stripe Dashboard → Billing → Customer Portal
2. Funktioner:
✅ Fakturahistorik (download PDF)
✅ Betalingsmetoder (opdatér/tilføj)
✅ Annullér abonnement (end of period)
❌ Immediate cancellation (fravalgt — ingen refund)
3. Planer (upgrade/downgrade):
✅ Professional (49 kr./enhed/md.)
✅ Enterprise (kontakt os)
❌ Free (ingen gratis tier)
4. Branding:
Logo: upload SVG
Primærfarve: #3b82f6 (M-Bus Gateway blå)
Return button text: "Tilbage til M-Bus Gateway"
5. Business information:
Firmanavn: M-Bus Gateway ApS
Support email: support@mbus-gateway.dk
Privacy policy: https://mbus-gateway.dk/privatliv
Terms: https://mbus-gateway.dk/handelsbetingelser
Annulleringsflow med retention
# server/src/stripe/router.py
@router.post("/billing/cancel-feedback")
async def submit_cancellation_feedback(
body: CancellationFeedback,
user: TokenPayload = Depends(require_role("landlord")),
session: AsyncSession = Depends(get_session),
):
"""
Capture cancellation reason INDEN vi sender til Stripe portal.
Bruges til churn analyse.
"""
# Gem feedback
feedback = ChurnFeedback(
tenant_id=user.tenant_id,
reason=body.reason,
comment=body.comment,
created_at=datetime.now(timezone.utc),
)
session.add(feedback)
await session.commit()
# Tilbyd retention: rabat eller forlænget trial
if body.reason in ("too_expensive", "not_using"):
# Send retention offer email
await send_retention_offer_email.delay(str(user.tenant_id))
return {"offer_sent": True}
# Ellers send direkte til portal
portal = stripe.billing_portal.Session.create(
customer=(await get_tenant(session, user.tenant_id)).stripe_customer_id,
return_url=f"{settings.FRONTEND_URL}/portal/profile",
)
return {"url": portal.url, "offer_sent": False}
Konklusion
Stripe Customer Portal eliminerer behovet for at bygge fakturavisning, kortadministration og abonnementsstyring selv. POST /billing/portal opretter en kortlivet session-URL som brugeren redirectes til. Webhook events (subscription.updated/deleted) synkroniserer Stripe-state tilbage til platformen. Retention flow via churn-feedback og offer-email reducerer frivillig afgang.