249 lines
9.8 KiB
Python
249 lines
9.8 KiB
Python
"""
|
|
Pro hybrid billing (committed base + metered overage) — Stripe Billing Meters sample.
|
|
|
|
Grounded in official Stripe docs (see refs/stripe-usage-billing.md for source URLs, cited 2026-07-07):
|
|
- POST /v1/billing/meters stripe.billing.Meter.create (S2)
|
|
- POST /v1/billing/meter_events stripe.billing.MeterEvent.create (S3, dedup via `identifier`)
|
|
- POST /v1/prices stripe.Price.create (S5, metered + tiered overage)
|
|
- POST /v1/subscriptions stripe.Subscription.create (S6, licensed base + metered item)
|
|
|
|
This is a reference implementation of the backend metering + billing slice. It is runnable against
|
|
a Stripe *test* account (STRIPE_API_KEY=sk_test_...). No network calls happen at import time.
|
|
|
|
pip install stripe
|
|
export STRIPE_API_KEY=sk_test_xxx
|
|
python metering_sample.py --demo # provisions meter/prices in the test account
|
|
|
|
Design intent:
|
|
* The committed base fee is a LICENSED price (fixed, predictable ARR).
|
|
* Overage is a METERED price whose graduated tier-1 (the included allotment) costs 0, so only
|
|
usage above the allotment is billed. Stripe does the aggregation + tiering; the app only emits
|
|
idempotent meter events.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import os
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
import stripe
|
|
|
|
stripe.api_key = os.environ.get("STRIPE_API_KEY", "")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config — the value-metric + pricing parameters (to be finalized by CPO/CFO,
|
|
# per exec packet user-decision-needed). Amounts are in the smallest currency unit.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
CURRENCY = "usd"
|
|
EVENT_NAME = "pro_api_call" # the value-metric event; keep <=100 chars, stable forever
|
|
AGGREGATION_FORMULA = "sum" # sum | count | last (sum of `value` per period)
|
|
INCLUDED_ALLOTMENT = 10_000 # units covered by the committed base fee (tier-1 = 0)
|
|
BASE_FEE_AMOUNT = 2_000 # $20.00/mo committed base (licensed)
|
|
OVERAGE_UNIT_AMOUNT = 2 # $0.02 per unit above the allotment (unit-margin floor guarded)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Provision the billing primitives (run once per environment)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class ProPlan:
|
|
meter_id: str
|
|
product_id: str
|
|
base_price_id: str
|
|
metered_price_id: str
|
|
|
|
|
|
def create_meter() -> stripe.billing.Meter:
|
|
"""POST /v1/billing/meters — defines how usage events aggregate over a period. (S2)"""
|
|
return stripe.billing.Meter.create(
|
|
display_name="Pro API Calls",
|
|
event_name=EVENT_NAME,
|
|
default_aggregation={"formula": AGGREGATION_FORMULA},
|
|
value_settings={"event_payload_key": "value"},
|
|
customer_mapping={"type": "by_id", "event_payload_key": "stripe_customer_id"},
|
|
)
|
|
|
|
|
|
def create_base_price(product_id: str) -> stripe.Price:
|
|
"""POST /v1/prices — licensed committed base fee (predictable ARR). (S5)"""
|
|
return stripe.Price.create(
|
|
currency=CURRENCY,
|
|
product=product_id,
|
|
unit_amount=BASE_FEE_AMOUNT,
|
|
recurring={"interval": "month"}, # default usage_type = licensed
|
|
)
|
|
|
|
|
|
def create_metered_overage_price(product_id: str, meter_id: str) -> stripe.Price:
|
|
"""POST /v1/prices — metered price with graduated tiers: allotment free, overage billed. (S5)"""
|
|
return stripe.Price.create(
|
|
currency=CURRENCY,
|
|
product=product_id,
|
|
recurring={"interval": "month", "usage_type": "metered", "meter": meter_id},
|
|
billing_scheme="tiered",
|
|
tiers_mode="graduated",
|
|
tiers=[
|
|
{"up_to": INCLUDED_ALLOTMENT, "unit_amount": 0}, # included in base fee
|
|
{"up_to": "inf", "unit_amount": OVERAGE_UNIT_AMOUNT}, # overage
|
|
],
|
|
)
|
|
|
|
|
|
def provision_pro_plan() -> ProPlan:
|
|
meter = create_meter()
|
|
product = stripe.Product.create(name="Pro")
|
|
base_price = create_base_price(product.id)
|
|
metered_price = create_metered_overage_price(product.id, meter.id)
|
|
return ProPlan(
|
|
meter_id=meter.id,
|
|
product_id=product.id,
|
|
base_price_id=base_price.id,
|
|
metered_price_id=metered_price.id,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Subscribe a customer to the hybrid plan
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def subscribe_customer(customer_id: str, plan: ProPlan) -> stripe.Subscription:
|
|
"""
|
|
POST /v1/subscriptions — one subscription, two items. (S6)
|
|
|
|
NOTE: the metered item must NOT carry a `quantity`; usage is reported via meter events.
|
|
The Idempotency-Key header makes the create safe to retry.
|
|
"""
|
|
return stripe.Subscription.create(
|
|
customer=customer_id,
|
|
items=[
|
|
{"price": plan.base_price_id, "quantity": 1}, # licensed committed fee
|
|
{"price": plan.metered_price_id}, # metered overage — no quantity
|
|
],
|
|
idempotency_key=f"sub-create:{customer_id}:pro-v1",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Record usage — the hot path. Idempotent by construction.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _dedup_identifier(customer_id: str, unit_key: str) -> str:
|
|
"""
|
|
Deterministic identifier so at-least-once delivery / retries never double-count.
|
|
Stripe enforces uniqueness within a rolling 24h window (S4), so `unit_key` must be
|
|
unique per real-world usage unit within that window (e.g. request id, job id).
|
|
"""
|
|
raw = f"{EVENT_NAME}:{customer_id}:{unit_key}"
|
|
return hashlib.sha256(raw.encode()).hexdigest()[:64]
|
|
|
|
|
|
def record_usage(
|
|
customer_id: str,
|
|
value: int,
|
|
unit_key: str,
|
|
when: Optional[datetime] = None,
|
|
) -> stripe.billing.MeterEvent:
|
|
"""
|
|
POST /v1/billing/meter_events — report one usage event. (S3)
|
|
|
|
* `identifier` = deterministic dedup key (idempotency within 24h).
|
|
* `timestamp` = event time; Stripe accepts within past 35 days / +5 min (S4). Defaults to now.
|
|
"""
|
|
payload = {"stripe_customer_id": customer_id, "value": str(value)}
|
|
kwargs = {
|
|
"event_name": EVENT_NAME,
|
|
"payload": payload,
|
|
"identifier": _dedup_identifier(customer_id, unit_key),
|
|
}
|
|
if when is not None:
|
|
kwargs["timestamp"] = int(when.replace(tzinfo=timezone.utc).timestamp())
|
|
return stripe.billing.MeterEvent.create(**kwargs)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Overage estimate (UX-only). Stripe remains the ledger of record.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class UsageEstimate:
|
|
aggregated_usage: int
|
|
included_allotment: int = INCLUDED_ALLOTMENT
|
|
overage_unit_amount: int = OVERAGE_UNIT_AMOUNT
|
|
base_fee_amount: int = BASE_FEE_AMOUNT
|
|
|
|
@property
|
|
def overage_units(self) -> int:
|
|
return max(0, self.aggregated_usage - self.included_allotment)
|
|
|
|
@property
|
|
def estimated_overage_charge(self) -> int:
|
|
return self.overage_units * self.overage_unit_amount
|
|
|
|
@property
|
|
def estimated_invoice_total(self) -> int:
|
|
# Single invoice at renewal: committed base + metered overage (S6).
|
|
return self.base_fee_amount + self.estimated_overage_charge
|
|
|
|
|
|
def estimate_overage(aggregated_usage: int) -> UsageEstimate:
|
|
"""
|
|
Client-facing estimate for 60/80/100% nudges. This mirrors Stripe's tiering math for UX only;
|
|
the authoritative amount is computed by Stripe at invoice finalization.
|
|
"""
|
|
return UsageEstimate(aggregated_usage=aggregated_usage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Simple consuming-team interface (product/frontend call these two, nothing else)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class MeteringClient:
|
|
"""Thin facade the rest of the product uses. Hides Stripe API surface + idempotency."""
|
|
plan: ProPlan
|
|
_seen: set = field(default_factory=set) # optional local guard; Stripe is source of truth
|
|
|
|
def report(self, customer_id: str, value: int, unit_key: str) -> None:
|
|
record_usage(customer_id=customer_id, value=value, unit_key=unit_key)
|
|
|
|
def estimate(self, customer_id: str, aggregated_usage: int) -> UsageEstimate:
|
|
return estimate_overage(aggregated_usage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Demo
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _demo() -> None:
|
|
if not stripe.api_key:
|
|
raise SystemExit("Set STRIPE_API_KEY=sk_test_... to run the demo.")
|
|
plan = provision_pro_plan()
|
|
print("Provisioned:", plan)
|
|
customer = stripe.Customer.create(name="Demo Co")
|
|
sub = subscribe_customer(customer.id, plan)
|
|
print("Subscription:", sub.id, "status:", sub.status)
|
|
# Emit a couple of idempotent usage events (retry-safe by unit_key).
|
|
req_id = str(uuid.uuid4())
|
|
record_usage(customer.id, value=25, unit_key=req_id)
|
|
record_usage(customer.id, value=25, unit_key=req_id) # duplicate -> deduped by identifier
|
|
est = estimate_overage(aggregated_usage=12_500)
|
|
print(f"Est. overage units={est.overage_units} charge={est.estimated_overage_charge} "
|
|
f"invoice_total={est.estimated_invoice_total}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description="Stripe metered-overage sample")
|
|
parser.add_argument("--demo", action="store_true", help="provision + run against test account")
|
|
args = parser.parse_args()
|
|
if args.demo:
|
|
_demo()
|
|
else:
|
|
print(__doc__)
|