· Stripe· Python· SaaS· billing· metered billing· subscription· webhooks· backend
Stripe metered billing til SaaS — forbrug-baseret fakturering
Stripe metered billing i Python: usage records, subscription items, meter events, aggregation modes, proration og webhooks til forbrug-baseret SaaS-fakturering.
Af M-Bus Gateway
Stripe metered billing fakturerer kunder baseret på faktisk forbrug — perfekt til SaaS der skalerer med antal aktive enheder. Her er den komplette implementation.
Metered billing vs. flat-rate
# Flat-rate: Fast beløb pr. periode uanset forbrug
# Metered: Beløb baseret på rapporteret forbrug
# M-Bus Gateway model:
# - Fast: 99 DKK/md (platform-adgang)
# - Metered: 19 DKK pr. aktiv lejeenhed/md
import stripe
stripe.api_key = settings.stripe_secret_key.get_secret_value()
# Opret Stripe Price for metered billing:
metered_price = stripe.Price.create(
product="prod_mbus_platform",
currency="dkk",
unit_amount=1900, # 19 DKK i øre
recurring={
"interval": "month",
"usage_type": "metered", # Metered (ikke licensed)
"aggregate_usage": "max", # Brug højeste usage-record pr. periode
# Alternativer: "sum" (total), "last_during_period", "last_ever"
},
nickname="Aktiv lejeenhed/md",
)
# Flat-rate platform-adgang:
flat_price = stripe.Price.create(
product="prod_mbus_platform",
currency="dkk",
unit_amount=9900, # 99 DKK
recurring={"interval": "month"},
nickname="Platform-adgang",
)
Opret subscription med metered item
# server/src/stripe/service.py
import stripe
from datetime import datetime
async def create_subscription_with_metered(
customer_id: str,
flat_price_id: str,
metered_price_id: str,
) -> stripe.Subscription:
"""
Opret subscription med to items:
1. Flat rate (platform-adgang)
2. Metered (aktive enheder)
"""
subscription = stripe.Subscription.create(
customer=customer_id,
items=[
{"price": flat_price_id}, # Fast månedlig pris
{"price": metered_price_id}, # Metered — ingen quantity her
],
payment_behavior="default_incomplete",
expand=["latest_invoice.payment_intent"],
)
# Gem subscription_item_id for metered item (bruges til usage records):
metered_item = next(
item for item in subscription["items"]["data"]
if item["price"]["id"] == metered_price_id
)
return subscription, metered_item["id"]
async def report_usage(
subscription_item_id: str,
quantity: int,
timestamp: datetime | None = None,
) -> stripe.UsageRecord:
"""
Rapportér forbrug til Stripe.
Kaldse én gang pr. dag/uge med aktuelle enhedsantal.
"""
return stripe.SubscriptionItem.create_usage_record(
subscription_item_id,
quantity=quantity,
timestamp=int((timestamp or datetime.utcnow()).timestamp()),
action="set", # "set" erstatter (ved max aggregation), "increment" summerer
)
Celery task: Månedlig enhedssynk
# server/src/workers/tasks/subscription_sync.py
from celery import shared_task
from sqlalchemy import select, func
from server.src.db.models import Occupancy, Tenant
import stripe
import structlog
log = structlog.get_logger()
@shared_task(name="billing.sync_unit_counts", acks_late=True)
def sync_active_unit_counts():
"""
Kør månedligt: Tæl aktive lejemål pr. tenant og rapport til Stripe.
Kørsel: 1. i måneden kl. 05:00 UTC (inden Stripe fakturerer).
"""
from server.src.db import get_sync_session
from server.src.config import get_settings
settings = get_settings()
stripe.api_key = settings.stripe_secret_key.get_secret_value()
with get_sync_session() as session:
# Tæl aktive occupancies pr. tenant
active_counts = session.execute(
select(Tenant.id, Tenant.stripe_subscription_item_id, func.count(Occupancy.id))
.join(Occupancy, Occupancy.tenant_id == Tenant.id)
.where(
Occupancy.deleted_at.is_(None),
Occupancy.end_date.is_(None), # Aktiv (ingen slutdato)
)
.group_by(Tenant.id, Tenant.stripe_subscription_item_id)
).all()
for tenant_id, stripe_item_id, count in active_counts:
if not stripe_item_id:
log.warning("Tenant mangler Stripe subscription item", tenant_id=str(tenant_id))
continue
try:
stripe.SubscriptionItem.create_usage_record(
stripe_item_id,
quantity=count,
action="set",
)
log.info("Usage reported", tenant_id=str(tenant_id), units=count)
except stripe.StripeError as e:
log.error("Stripe usage report failed",
tenant_id=str(tenant_id), error=str(e))
# Celery beat schedule (kørsel 1. i måneden kl. 05:00 UTC):
CELERY_BEAT_SCHEDULE = {
"sync-unit-counts": {
"task": "billing.sync_active_unit_counts",
"schedule": crontab(day_of_month="1", hour="5", minute="0"),
},
}
Webhooks: Metered billing events
# server/src/stripe/router.py
@router.post("/webhook")
async def stripe_webhook(request: Request):
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
event = stripe.Webhook.construct_event(
payload, sig_header, settings.stripe_webhook_secret.get_secret_value()
)
handlers = {
"invoice.created": handle_invoice_created,
"invoice.upcoming": handle_invoice_upcoming,
"invoice.paid": handle_invoice_paid,
"invoice.payment_failed": handle_payment_failed,
"customer.subscription.updated": handle_subscription_updated,
}
handler = handlers.get(event["type"])
if handler:
await handler(event["data"]["object"])
return {"received": True}
async def handle_invoice_created(invoice: dict) -> None:
"""
Invoice oprettet (typisk 1 time inden faktureringsperiode slutter).
Mulighed for at korrigere usage records FØR faktura lukkes.
"""
if invoice.get("billing_reason") == "subscription_cycle":
# Verificér at usage records er korrekte
tenant = await get_tenant_by_stripe_customer(invoice["customer"])
if tenant:
actual_count = await count_active_occupancies(tenant.id)
# Opdatér usage record med præcist antal
await report_usage(
tenant.stripe_subscription_item_id,
actual_count,
)
log.info("Usage corrected at invoice creation",
tenant_id=str(tenant.id), units=actual_count)
Proration ved plan-skift
# Opgrader fra Starter til Pro midt i faktureringsperiode:
async def upgrade_subscription(
subscription_id: str,
new_price_id: str,
prorate: bool = True,
) -> stripe.Subscription:
subscription = stripe.Subscription.retrieve(subscription_id)
item_id = subscription["items"]["data"][0]["id"]
updated = stripe.Subscription.modify(
subscription_id,
items=[{
"id": item_id,
"price": new_price_id,
}],
proration_behavior="create_prorations" if prorate else "none",
# "create_prorations": Kreditér resterende periode, fakturér ny
# "always_invoice": Opret straks faktura for difference
# "none": Ingen proration — skift ved næste periode
)
return updated
# Forhåndsvis proration (til UI):
async def preview_proration(subscription_id: str, new_price_id: str) -> dict:
upcoming = stripe.Invoice.upcoming(
subscription=subscription_id,
subscription_items=[{
"id": subscription["items"]["data"][0]["id"],
"price": new_price_id,
}],
subscription_proration_behavior="create_prorations",
subscription_proration_date=int(datetime.utcnow().timestamp()),
)
return {
"amount_due": upcoming["amount_due"] / 100,
"currency": upcoming["currency"].upper(),
"period_end": datetime.fromtimestamp(upcoming["period_end"]).isoformat(),
}
Test af metered billing
# server/tests/test_stripe_billing.py
import pytest
from unittest.mock import patch, MagicMock
async def test_usage_report_on_invoice_created(client, test_tenant, mock_stripe):
"""Usage records korrigeres ved invoice.created webhook."""
mock_stripe.SubscriptionItem.create_usage_record = MagicMock(return_value={})
# Simulér webhook:
event = {
"type": "invoice.created",
"data": {"object": {
"customer": test_tenant.stripe_customer_id,
"billing_reason": "subscription_cycle",
}}
}
with patch("stripe.Webhook.construct_event", return_value=event):
resp = await client.post(
"/api/v1/stripe/webhook",
content=b"{}",
headers={"stripe-signature": "dummy"},
)
assert resp.status_code == 200
mock_stripe.SubscriptionItem.create_usage_record.assert_called_once()
call_args = mock_stripe.SubscriptionItem.create_usage_record.call_args
assert call_args.kwargs["action"] == "set"
Konklusion
Stripe metered billing med aggregate_usage="max" er ideel til SaaS der fakturerer efter aktive lejeenheder: Quantity sættes dagligt/månedligt og Stripe beregner automatisk fakturabeløbet. invoice.created webhook giver mulighed for at korrigere usage records inden faktura lukkes. Proration-preview i UI giver transparens ved plan-skift.
Se Stripe abonnement SaaS guide eller Stripe webhook events guide.