init: company-haness 설계
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
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__)
|
||||
@@ -0,0 +1,175 @@
|
||||
# Stripe Usage-Based / Metered Billing — Official API Reference
|
||||
|
||||
> Local reference distilled from official Stripe documentation for the Pro hybrid
|
||||
> (committed base + metered overage) billing slice.
|
||||
>
|
||||
> **Cited:** 2026-07-07 (Asia/Seoul). API surface: Billing Meters (v1) + Meter Events (v1/v2).
|
||||
> **Note:** Stripe now surfaces Metronome as its recommended platform for *new* high-volume
|
||||
> integrations, but the first-party **Billing Meters API documented here is fully supported**
|
||||
> and is the correct primitive for a self-serve metered-overage subscription. Sources below.
|
||||
|
||||
## Sources (official URLs)
|
||||
|
||||
| # | Topic | URL |
|
||||
|---|-------|-----|
|
||||
| S1 | Usage-based billing overview | https://docs.stripe.com/billing/subscriptions/usage-based |
|
||||
| S2 | Create a Billing Meter (`POST /v1/billing/meters`) | https://docs.stripe.com/api/billing/meter/create |
|
||||
| S3 | Create a Meter Event (`POST /v1/billing/meter_events`) | https://docs.stripe.com/api/billing/meter-event/create |
|
||||
| S4 | Meter Event object v2 (dedup window) (`POST /v2/billing/meter_events`) | https://docs.stripe.com/api/v2/billing/meter-events/object |
|
||||
| S5 | Create a Price (metered / tiered) (`POST /v1/prices`) | https://docs.stripe.com/api/prices/create |
|
||||
| S6 | Create a Subscription (multi-item) (`POST /v1/subscriptions`) | https://docs.stripe.com/api/subscriptions/create |
|
||||
| S7 | Recording usage | https://docs.stripe.com/billing/subscriptions/usage-based/recording-usage |
|
||||
| S8 | Advanced usage-based (flat fee + overage) | https://docs.stripe.com/billing/subscriptions/usage-based/advanced/compare |
|
||||
|
||||
---
|
||||
|
||||
## Data model (end to end)
|
||||
|
||||
```
|
||||
Billing Meter ──defines──▶ event_name + aggregation (sum/count/last)
|
||||
▲ │
|
||||
│ recurring.meter │ meter events (usage) reference event_name
|
||||
│ ▼
|
||||
Metered Price ──item──▶ Subscription ◀──item── Licensed base Price (committed fee)
|
||||
│ │
|
||||
tiered/per_unit ▼
|
||||
End of billing period: aggregated usage → invoice line item
|
||||
```
|
||||
|
||||
The **hybrid** = one Subscription carrying **two items**:
|
||||
1. a **licensed** base price (fixed committed fee, `usage_type` unset/licensed, has `quantity`), and
|
||||
2. a **metered** price linked to a Meter (`usage_type=metered`, **no `quantity`**), whose graduated
|
||||
tiers make the *included allotment* cost 0 and only bill the **overage** per unit.
|
||||
|
||||
---
|
||||
|
||||
## 1. Create a Billing Meter — `POST /v1/billing/meters` (S2)
|
||||
|
||||
A Meter specifies how to aggregate meter events over a billing period.
|
||||
|
||||
Key parameters:
|
||||
- `display_name` (string, required) — internal name, not shown to customers.
|
||||
- `event_name` (string, required, max 100 chars) — links meter to its events.
|
||||
- `default_aggregation.formula` (enum, required) — `sum` | `count` | `last`.
|
||||
- `value_settings.event_payload_key` (string) — payload key holding the numeric value (default `value`).
|
||||
- `customer_mapping.type` (enum) — `by_id`.
|
||||
- `customer_mapping.event_payload_key` (string) — payload key holding the customer id (default `stripe_customer_id`).
|
||||
- `event_time_window` (nullable enum, optional) — `hour` | `day` for pre-aggregated events.
|
||||
|
||||
Python signature:
|
||||
```python
|
||||
stripe.billing.Meter.create(
|
||||
display_name="Pro API Calls",
|
||||
event_name="pro_api_call",
|
||||
default_aggregation={"formula": "sum"},
|
||||
value_settings={"event_payload_key": "value"},
|
||||
customer_mapping={"type": "by_id", "event_payload_key": "stripe_customer_id"},
|
||||
)
|
||||
# -> billing.meter { id: "mtr_...", status: "active", ... }
|
||||
```
|
||||
|
||||
## 2. Record a Meter Event — `POST /v1/billing/meter_events` (S3, S4)
|
||||
|
||||
Reports one usage event. Aggregated into invoice line items at end of period.
|
||||
|
||||
Key parameters:
|
||||
- `event_name` (string, required) — must match a Meter's `event_name`.
|
||||
- `payload` (object, required) — must contain the meter's `customer_mapping.event_payload_key`
|
||||
(default `stripe_customer_id`) and `value_settings.event_payload_key` (default `value`).
|
||||
- `identifier` (string, optional) — **idempotency / dedup key**. Uniqueness is enforced within a
|
||||
**rolling 24-hour window**; a repeated `identifier` is not counted twice. If omitted, Stripe
|
||||
generates one. Recommend a globally unique id (UUID / deterministic business key). (S4)
|
||||
- `timestamp` (unix seconds, optional) — must be **within the past 35 calendar days or up to 5
|
||||
minutes in the future**; defaults to now. (S4)
|
||||
|
||||
Python signature:
|
||||
```python
|
||||
stripe.billing.MeterEvent.create(
|
||||
event_name="pro_api_call",
|
||||
payload={"stripe_customer_id": "cus_123", "value": "25"},
|
||||
identifier="pro_api_call:cus_123:2026-07-07T09:00Z:req_abc", # dedup within 24h
|
||||
timestamp=1751878800, # optional
|
||||
)
|
||||
```
|
||||
> v2 equivalent `POST /v2/billing/meter_events` returns `v2.billing.meter_event` and does
|
||||
> synchronous validation; same `identifier` 24h-uniqueness rule. (S4)
|
||||
|
||||
## 3a. Create a metered Price — `POST /v1/prices` (S5)
|
||||
|
||||
Link a price to the meter and choose per-unit or tiered.
|
||||
|
||||
Key parameters:
|
||||
- `currency` (required) — e.g. `usd`.
|
||||
- `product` or `product_data` (one required).
|
||||
- `recurring.interval` (required) — `day` | `week` | `month` | `year`.
|
||||
- `recurring.usage_type` = `metered` (required for usage billing).
|
||||
- `recurring.meter` = `<meter id>` (required for metered) — ties price to the Meter from step 1.
|
||||
- `billing_scheme` — `per_unit` (with `unit_amount`) or `tiered`.
|
||||
- For tiered overage: `tiers_mode` = `graduated` | `volume`, and `tiers[]` = `{up_to, unit_amount}`.
|
||||
|
||||
Graduated overage example (first N units free/included, rest billed):
|
||||
```python
|
||||
stripe.Price.create(
|
||||
currency="usd",
|
||||
product="prod_pro",
|
||||
recurring={"interval": "month", "usage_type": "metered", "meter": "mtr_..."},
|
||||
billing_scheme="tiered",
|
||||
tiers_mode="graduated",
|
||||
tiers=[
|
||||
{"up_to": 10000, "unit_amount": 0}, # included allotment (covered by base fee)
|
||||
{"up_to": "inf", "unit_amount": 2}, # $0.02/unit overage above the allotment
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
## 3b. Create the base (committed) Price — `POST /v1/prices` (S5)
|
||||
|
||||
Fixed committed fee = a licensed recurring price (no `usage_type=metered`):
|
||||
```python
|
||||
stripe.Price.create(
|
||||
currency="usd", product="prod_pro",
|
||||
unit_amount=2000, # $20.00 committed base
|
||||
recurring={"interval": "month"}, # licensed (default usage_type)
|
||||
)
|
||||
```
|
||||
|
||||
## 4. Create the hybrid Subscription — `POST /v1/subscriptions` (S6)
|
||||
|
||||
One subscription, two items. **Metered items must NOT include `quantity`.**
|
||||
```python
|
||||
stripe.Subscription.create(
|
||||
customer="cus_123",
|
||||
items=[
|
||||
{"price": "price_base", "quantity": 1}, # licensed committed fee
|
||||
{"price": "price_metered"}, # metered overage — no quantity
|
||||
],
|
||||
idempotency_key="sub-create:cus_123:pro-v1", # safe retry (request header)
|
||||
)
|
||||
```
|
||||
- Metered usage from the previous period is charged **alongside** the fixed base for the new period
|
||||
on a **single invoice** at each renewal. (S6, quantities doc)
|
||||
- Use the `Idempotency-Key` request header to safely retry create requests (returns the same object).
|
||||
|
||||
---
|
||||
|
||||
## Overage calculation (concept)
|
||||
|
||||
Overage is computed by Stripe, not the app: it aggregates meter events per customer per period via
|
||||
`default_aggregation.formula`, then applies the metered price's tiers. With `graduated` tiers where
|
||||
tier-1 (`up_to = included_allotment`) has `unit_amount = 0`, the customer pays only for units above
|
||||
the allotment:
|
||||
|
||||
```
|
||||
billed_overage = max(0, aggregated_usage - included_allotment) * overage_unit_amount
|
||||
invoice_total = committed_base_fee + billed_overage # single invoice at renewal
|
||||
```
|
||||
|
||||
App-side we only *emit events* and (optionally) *mirror a usage estimate* for UX; the ledger of
|
||||
record is Stripe's aggregation.
|
||||
|
||||
## Idempotency & correctness rules (official)
|
||||
|
||||
- **Meter events:** set a deterministic `identifier`; dedup is enforced for a rolling 24h window (S4).
|
||||
Choose one identifier per real-world usage unit so retries/at-least-once delivery never double-count.
|
||||
- **Timestamp:** only accepted within past 35 days / +5 min; late or clock-skewed events are rejected (S4).
|
||||
- **Write APIs (Meter/Price/Subscription create):** pass the `Idempotency-Key` header to make retries safe (S6).
|
||||
Reference in New Issue
Block a user