M-Bus Gateway
← Tilbage til blog
· 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.

Se Stripe webhook guide eller Stripe Checkout guide.