SubscriptionService (src/service/subscription_service.py) is the Gateway’s Stripe integration. It owns three concerns: the Stripe money flow (customers, Checkout, Customer Portal, subscription mutations, payment history), the webhook sink that keeps the local Subscription/Payment rows in sync with Stripe, and the vehicle-quota gate (can_user_add_vehicle) that every drone-creation request runs through before a physical or SITL drone is provisioned.
The service is instantiated once as a module-global singleton in the composition root (src/application/app.py:51) and injected into billing_routes and drone_routes. Its constructor sets stripe.api_key = settings.STRIPE_SECRET_KEY at build time — see Service Layer & get_service Factory for how singletons are wired.
This page covers the service logic and pricing/quota model. The HTTP request/response contract for each
/billing/* endpoint is documented alongside the other account APIs in Billing, Calendar, VPN, Video & Isaac Sim API.Pricing model
Pricing is hardcoded in Python, in two module constants at the top ofsrc/service/subscription_service.py:
src/service/subscription_service.py:12-18
quantity), not the price.
get_pricing_info() (subscription_service.py:525) returns a static, unauthenticated marketing payload (price, currency EUR, billing_period: "year", and a features list) served by GET /api/v1/billing/pricing.
HTTP surface
All billing routes are registered under/api/v1/ (src/main.py:232). Every endpoint requires a Bearer JWT except /billing/pricing (public) and /billing/webhook (authenticated by Stripe signature instead).
| Method & path | Auth | Service call | Purpose |
|---|---|---|---|
GET /api/v1/billing/pricing | none | get_pricing_info() | Static pricing/features payload |
GET /api/v1/billing/subscription | JWT | get_subscription() | Current user’s subscription (or null) |
POST /api/v1/billing/checkout | JWT | create_checkout_session(user, vehicle_count) | Start a Stripe Checkout session |
POST /api/v1/billing/portal | JWT | create_portal_session(user) | Open the Stripe Customer Portal |
PUT /api/v1/billing/subscription/vehicles | JWT | update_vehicle_count(user_id, n) | Change subscription quantity (prorated) |
POST /api/v1/billing/subscription/cancel | JWT | cancel_subscription(user_id, at_period_end) | Cancel at period end or immediately |
POST /api/v1/billing/subscription/reactivate | JWT | reactivate_subscription(user_id) | Undo a pending period-end cancel |
GET /api/v1/billing/payments?limit= | JWT | get_payment_history(user_id, limit) | Invoice/receipt history (clamped 1–100) |
GET /api/v1/billing/can-add-vehicle?vehicle_type= | JWT | can_user_add_vehicle(...) | Pre-flight quota check for the UI |
POST /api/v1/billing/webhook | Stripe signature | handle_webhook_event(payload, sig) | Stripe event ingestion |
Vehicle-limit enforcement
The most load-bearing method iscan_user_add_vehicle(user_id, vehicle_counts, vehicle_type) (subscription_service.py:438). It is the gate on drone creation and is called in two places:
- Enforced at
POST /api/v1/drone(src/routes/drone_routes.py:99-108) — before any physical or SITL drone is provisioned. A denial returns HTTP 402 Payment Required with thereason,message, andsubscriptionfrom the check. - Advisory at
GET /api/v1/billing/can-add-vehicle(billing_routes.py:465) — so the Dashboard can grey out the “add vehicle” button before the user tries.
generic_drone_service.get_drone_counts_by_type(user_id) (src/service/drone_service.py:147), which returns {"physical": n, "sitl": n, "total": n} from two COUNT queries on the Drone table.
SITL, no active subscription (free tier)
If
sitl_count >= 1 → deny with reason: "no_subscription". Otherwise allow with reason: "free_tier" (message "Free simulated vehicle (1/1)"). Every user gets exactly one free SITL drone.SITL, active subscription
Limit is
max_sitl = subscription.vehicle_count + 1 (the +1 is the free-tier slot). If sitl_count >= max_sitl → deny reason: "vehicle_limit_reached", else allow.Physical, no subscription row
Deny with
reason: "no_subscription" — physical vehicles always require a subscription.Physical, subscription not active
Deny with
reason: "subscription_inactive" (message includes subscription.status.value, e.g. past_due).| Vehicle type | No subscription | Active subscription (vehicle_count = N) |
|---|---|---|
sitl | 1 (free tier) | N + 1 |
physical | 0 (denied) | N |
is_active() (src/models/subscription.py:72) treats both ACTIVE and TRIALING as active. A past_due, unpaid, incomplete, or canceled subscription is inactive, so physical-vehicle creation is denied and SITL falls back to the free-tier rule.Subscription lifecycle methods
get_or_create_stripe_customer(user)
get_or_create_stripe_customer(user)
Returns the existing
stripe_customer_id if the user already has a Subscription row with one. Otherwise it calls stripe.Customer.create(...) (with user_id/username metadata), and either updates the existing row or inserts a new Subscription with status INCOMPLETE. Committed immediately (subscription_service.py:34).create_checkout_session(user, vehicle_count=1)
create_checkout_session(user, vehicle_count=1)
Requires
STRIPE_PRICE_ID (raises ValueError("STRIPE_PRICE_ID not configured") otherwise). Refuses if the user already has an active subscription ("User already has an active subscription. Use update instead."). Creates a mode="subscription" Checkout session with one line item (price = STRIPE_PRICE_ID, quantity = vehicle_count), a success_url of {STRIPE_SUCCESS_URL}?session_id={CHECKOUT_SESSION_ID}, and metadata (user_id, vehicle_count) on both the session and subscription_data so webhooks can recover the user. Returns {checkout_url, session_id} (subscription_service.py:62).create_portal_session(user)
create_portal_session(user)
Opens a Stripe Customer Portal session for
stripe_customer_id. return_url is STRIPE_CANCEL_URL or STRIPE_SUCCESS_URL (the cancel URL is assumed to be the billing page). Raises if no subscription/customer exists (subscription_service.py:112).update_vehicle_count(user_id, new_vehicle_count)
update_vehicle_count(user_id, new_vehicle_count)
Changes the quantity on the live Stripe subscription. Requires an active subscription with a
stripe_subscription_id and new_vehicle_count >= 1. Retrieves the subscription, modifies items[0].quantity, and uses proration_behavior="create_prorations" so mid-cycle changes are charged/credited. Then mirrors vehicle_count locally (subscription_service.py:137).cancel_subscription(user_id, at_period_end=True)
cancel_subscription(user_id, at_period_end=True)
Default is a soft cancel —
stripe.Subscription.modify(..., cancel_at_period_end=True); the subscription stays active until period end and the local status is unchanged (the eventual customer.subscription.deleted webhook flips it to CANCELED). With at_period_end=False it calls stripe.Subscription.cancel(...) and immediately sets local status CANCELED + canceled_at. The route passes at_period_end=not immediate (billing_routes.py:311).reactivate_subscription(user_id)
reactivate_subscription(user_id)
Undoes a pending period-end cancel by setting
cancel_at_period_end=False in Stripe and clearing local canceled_at (subscription_service.py:211).get_payment_history(user_id, limit=20)
get_payment_history(user_id, limit=20)
Returns
Payment.to_dict() rows for the user, newest first. limit is defensively coerced to an int and clamped to 1..100 (MAX_PAYMENT_HISTORY_LIMIT). Note the route also validates 1..100 and rejects out-of-range values with 400 before this clamp ever runs (billing_routes.py:411).Webhook processing
handle_webhook_event(payload, sig_header) (subscription_service.py:228) is the single entry point for POST /api/v1/billing/webhook. It verifies the signature, decodes the event, and dispatches on event["type"] through a handler table.
Signature verification
src/service/subscription_service.py:238-250
Event dispatch table
Unhandled event types return{"status": "ignored"} (still HTTP 200, so Stripe does not retry).
| Stripe event | Handler | Effect on local state |
|---|---|---|
checkout.session.completed | _handle_checkout_completed | Sets stripe_subscription_id, vehicle_count, status → ACTIVE (reads user_id/vehicle_count from session metadata) |
customer.subscription.created | _handle_subscription_created | Inserts a Subscription if none, then syncs from Stripe |
customer.subscription.updated | _handle_subscription_updated | Looks up by stripe_subscription_id, syncs status/quantity/period |
customer.subscription.deleted | _handle_subscription_deleted | Status → CANCELED, sets canceled_at |
invoice.paid | _handle_invoice_paid | Idempotently inserts a Payment row |
invoice.payment_failed | _handle_invoice_payment_failed | Status → PAST_DUE |
_update_subscription_from_stripe (subscription_service.py:381) maps the Stripe status string to the SubscriptionStatus enum (unknown → INCOMPLETE), reads vehicle_count from items[0].quantity, and converts the current_period_start/end and canceled_at UNIX timestamps to timezone-aware datetimes.
Idempotent payment recording
invoice.paid is designed to be safe under Stripe’s at-least-once retry semantics. It dedupes on either stripe_invoice_id or stripe_payment_intent_id, and additionally catches IntegrityError (both columns are unique) to survive concurrent inserts:
src/service/subscription_service.py:343-369
Data model
Two tables back this service (src/models/subscription.py); full schema in Database Schema Overview.
subscription— one row per user (user_idisunique). Holdsstripe_customer_id,stripe_subscription_id,status(SubscriptionStatusenum stored by value),vehicle_count(the Stripe line-item quantity, default0), billing-period bounds, andcanceled_at.is_active()→ status in{ACTIVE, TRIALING}.payment— receipt history.amountis in cents (integer),currencydefaults"eur", andstripe_invoice_id/stripe_payment_intent_idare bothunique(the idempotency backbone).to_dict()adds anamount_formattedstring like"€120.00".
Configuration
| Env var | Default | Role |
|---|---|---|
STRIPE_SECRET_KEY | "" | API key; set on stripe.api_key in the service constructor |
STRIPE_PUBLISHABLE_KEY | "" | Front-end publishable key (not used server-side here) |
STRIPE_PRICE_ID | "" | Required for checkout; the Stripe Price that defines the real charge |
STRIPE_WEBHOOK_SECRET | "" | Required in non-local envs; enables signature verification |
STRIPE_SUCCESS_URL | https://skyhub.ai/billing/success | Checkout success redirect (gets ?session_id= appended) |
STRIPE_CANCEL_URL | https://skyhub.ai/billing/cancel | Checkout cancel redirect + portal return URL |
DEPLOYMENT_ENVIRONMENT | server | local ⇒ IS_LOCAL_ENVIRONMENT; only value that lets webhook signature checks be skipped |
src/application/settings.py:148-153. See the full list in Gateway Environment Variables.
Gotchas for future editors
- Two sources of truth for price.
PRICE_PER_VEHICLE_EUR(the display value fromget_pricing_info()) is independent of the StripeSTRIPE_PRICE_ID(actual charge) — keep them in sync.PRICE_PER_VEHICLE_CENTSis dead code (referenced nowhere); it drives nothing. - Quota math is
vehicle_count-based, not “1 physical + 1 SITL”. Physical limit =vehicle_count; SITL limit =vehicle_count + 1. The free tier is exactly 1 SITL and applies to users with no active subscription. - Enforcement lives at drone creation, returning HTTP 402. If you add a new provisioning path, it must also call
can_user_add_vehicle— the check is not centralized in the service layer’ssave()methods (see SITL Drone Lifecycle and Service Layer Overview). - Webhook signature bypass is environment-gated. A non-local env with a missing
STRIPE_WEBHOOK_SECRETrejects all webhooks; do not rely on the local bypass in staging/prod. invoice.paidis idempotent by design (dedup +IntegrityErrorcatch). Preserve bothuniqueconstraints onpayment.stripe_invoice_idandpayment.stripe_payment_intent_id.Subscription.can_add_vehicle()on the model is dead-ish code relative to this flow — the route uses the service’scan_user_add_vehicle. Don’t confuse the two when refactoring.

