M-Bus Gateway
← Tilbage til blog
· Stripe· SaaS· Checkout· abonnement· subscription· trial· Python· FastAPI· betalinger

Stripe Checkout til SaaS — abonnement, trial og hardware-køb

Stripe Checkout og Billing til SaaS: subscription oprettelse, trial-perioder, hardware-køb som one-time charge, customer portal og proration ved plan-skift.

Af M-Bus Gateway

M-Bus Gateway platformen kombinerer Stripe Subscriptions (månedlig SaaS) med one-time Checkout til hardware. Her er implementeringen.


To produkttyper i Stripe

M-Bus Gateway Stripe-produkter:

1. Software-abonnement (recurring):
   Pris: 49 kr./enhed/md (variabel baseret på aktive lejemål)
   Type: Stripe Subscription med metered billing
   Trial: 30 dage gratis (kreditkort kræves ved signup)

2. Hardware (one-time):
   Installationspakke S: 3.500 kr. (Gateway + 6 HCA)
   Installationspakke L: 7.200 kr. (Gateway + 15 HCA)
   Ekstra HCA: 320 kr./stk.
   Type: Stripe Checkout one-time payment

Workflow:
  Udlejer opretter konto → 30-dages trial starter
  → Bestiller hardware (Checkout)
  → Hardware leveres, installeres
  → Trial udløber → Abonnement starter (per aktivt lejemål)

Subscription-oprettelse ved registrering

# server/src/stripe/service.py
import stripe
from server.src.core.config import settings

stripe.api_key = settings.STRIPE_SECRET_KEY

def create_customer_and_subscription(
    email: str,
    tenant_id: str,
    trial_days: int = 30,
) -> dict:
    """
    Opret Stripe Customer + Subscription med trial.
    Kræver IKKE betalingskort ved oprettelse (trial_period_days).
    """
    # Opret Stripe Customer:
    customer = stripe.Customer.create(
        email=email,
        metadata={"tenant_id": str(tenant_id)},
    )

    # Opret Subscription med trial:
    subscription = stripe.Subscription.create(
        customer=customer.id,
        items=[
            {
                "price": settings.STRIPE_PRICE_ID_PER_UNIT,  # pr. aktiv enhed
                "quantity": 0,  # Starter med 0 — justeres månedligt via Celery
            }
        ],
        trial_period_days=trial_days,
        payment_settings={"save_default_payment_method": "on_subscription"},
        expand=["latest_invoice.payment_intent"],
    )

    return {
        "stripe_customer_id": customer.id,
        "stripe_subscription_id": subscription.id,
        "trial_end": subscription.trial_end,
        "status": subscription.status,
    }

Stripe Checkout til hardware

# server/src/stripe/checkout.py

HARDWARE_PRODUCTS = {
    "pakke_s": settings.STRIPE_PRICE_ID_PAKKE_S,  # 3.500 kr. one-time
    "pakke_l": settings.STRIPE_PRICE_ID_PAKKE_L,  # 7.200 kr. one-time
    "hca_enkelt": settings.STRIPE_PRICE_ID_HCA,   # 320 kr./stk.
}

def create_hardware_checkout_session(
    tenant_id: str,
    stripe_customer_id: str,
    items: list[dict],  # [{"product": "pakke_s", "quantity": 1}]
    success_url: str,
    cancel_url: str,
) -> str:
    """Returner Stripe Checkout URL til hardware-køb."""
    line_items = [
        {
            "price": HARDWARE_PRODUCTS[item["product"]],
            "quantity": item["quantity"],
        }
        for item in items
    ]

    session = stripe.checkout.Session.create(
        customer=stripe_customer_id,
        mode="payment",  # One-time payment (ikke subscription)
        line_items=line_items,
        success_url=success_url,
        cancel_url=cancel_url,
        metadata={"tenant_id": str(tenant_id)},
        # Automatisk moms (Stripe Tax):
        automatic_tax={"enabled": True},
        # Faktura som PDF:
        invoice_creation={"enabled": True},
    )

    return session.url

Customer Portal: Selvbetjening

# server/src/stripe/portal.py

def create_customer_portal_session(
    stripe_customer_id: str,
    return_url: str,
) -> str:
    """
    Stripe Customer Portal: Lejer kan selv:
    - Opdatere betalingsmetode
    - Se fakturahistorik
    - Annullere abonnement
    """
    session = stripe.billing_portal.Session.create(
        customer=stripe_customer_id,
        return_url=return_url,
    )
    return session.url


@router.get("/billing/portal")
async def get_billing_portal(
    user: User = Depends(require_role("landlord")),
    session: AsyncSession = Depends(get_session),
):
    """Redirect til Stripe Customer Portal."""
    tenant = await session.get(Tenant, user.tenant_id)
    portal_url = create_customer_portal_session(
        stripe_customer_id=tenant.stripe_customer_id,
        return_url=f"{settings.BASE_URL}/portal/profile",
    )
    return {"portal_url": portal_url}

Webhook-handlers

# server/src/stripe/router.py
from fastapi import APIRouter, Request, HTTPException
import stripe

router = APIRouter(prefix="/webhooks/stripe", tags=["stripe"])

@router.post("")
async def stripe_webhook(
    request: Request,
    session: AsyncSession = Depends(get_session),
):
    payload = await request.body()
    sig_header = request.headers.get("stripe-signature")

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
        )
    except stripe.error.SignatureVerificationError:
        raise HTTPException(400, "Invalid signature")

    handlers = {
        "customer.subscription.created": _handle_subscription_created,
        "customer.subscription.updated": _handle_subscription_updated,
        "customer.subscription.deleted": _handle_subscription_deleted,
        "invoice.payment_failed": _handle_invoice_payment_failed,
        "invoice.paid": _handle_invoice_paid,
        "checkout.session.completed": _handle_checkout_completed,
    }

    handler = handlers.get(event["type"])
    if handler:
        await handler(event["data"]["object"], session)

    return {"received": True}

async def _handle_checkout_completed(checkout_session: dict, session: AsyncSession):
    """Hardware-ordre gennemført → opret ordre i system."""
    tenant_id = checkout_session["metadata"]["tenant_id"]
    # Opret ordre, send bekræftelsesmail, registrér hardware-enheder

Proration ved plan-skift

# Automatisk proration ved skift af antal enheder:
# (Stripe håndterer det automatisk ved quantity-opdatering)

async def update_subscription_quantity(
    stripe_subscription_id: str,
    stripe_subscription_item_id: str,
    new_quantity: int,
):
    """
    Opdatér antal fakturerede enheder.
    Stripe beregner automatisk proration for indeværende periode.
    """
    stripe.SubscriptionItem.modify(
        stripe_subscription_item_id,
        quantity=new_quantity,
        proration_behavior="create_prorations",  # Beregn og opret proration-linje
    )

Konklusion

Stripe Checkout kombinerer hardware-salg (one-time) og SaaS-abonnement (recurring) i ét system. Trial med 30 dage og ingen kreditkort ved oprettelse maximerer konvertering. Customer Portal giver selvbetjening uden support-tickets. Webhook-handlers med signaturverifikation er kritisk for sikkerhed.

Se Stripe webhooks guide eller Celery task guide.