Skip to content

Commit e154b4a

Browse files
committed
feat(billing): Paddle behind a port, checkout, portal and signed webhooks
Billing's only job is to write subscriptions correctly. A provider port with a Paddle adapter and a null adapter for self-host; checkout mints a transaction with the founding discount decided server-side; the webhook route verifies the signature, records the event id before acting, locks per subscription, re-fetches the entity from Paddle and applies the transition.
1 parent 2b06e75 commit e154b4a

48 files changed

Lines changed: 3278 additions & 13 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

config.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from __future__ import annotations
1212

13+
from datetime import datetime, timedelta
1314
from functools import cached_property
1415
from typing import Literal
1516
from urllib.parse import urlparse
@@ -681,11 +682,51 @@ class BillingSettings(BaseSettings):
681682
founding_monthly_usd: int = Field(default=9, ge=0)
682683
founding_year_usd: int = Field(default=90, ge=0)
683684
founding_seats: int = Field(default=100, ge=0)
685+
founding_window_days: int = Field(default=90, ge=0)
686+
# The founding window opens when checkout opens; unset means closed.
687+
founding_opened_at: datetime | None = None
688+
689+
paddle_env: Literal["sandbox", "production"] = "sandbox"
690+
paddle_api_key: str = ""
691+
paddle_webhook_secret: str = ""
692+
paddle_price_pro_monthly: str = ""
693+
paddle_price_pro_year: str = ""
694+
paddle_discount_founding_first_monthly: str = ""
695+
paddle_discount_founding_first_year: str = ""
696+
paddle_discount_founding_renew_monthly: str = ""
697+
paddle_discount_founding_renew_year: str = ""
684698

685699
@property
686700
def selfhost(self) -> bool:
687701
return self.provider == "none"
688702

703+
@property
704+
def founding_until(self) -> datetime | None:
705+
if self.founding_opened_at is None:
706+
return None
707+
return self.founding_opened_at + timedelta(days=self.founding_window_days)
708+
709+
@model_validator(mode="after")
710+
def _paddle_needs_its_keys(self) -> BillingSettings:
711+
if self.provider != "paddle":
712+
return self
713+
missing = [
714+
name
715+
for name in (
716+
"paddle_api_key",
717+
"paddle_webhook_secret",
718+
"paddle_price_pro_monthly",
719+
"paddle_price_pro_year",
720+
)
721+
if not getattr(self, name)
722+
]
723+
if missing:
724+
raise ValueError(
725+
"BILLING_PROVIDER=paddle needs "
726+
+ ", ".join(f"BILLING_{m.upper()}" for m in missing)
727+
)
728+
return self
729+
689730

690731
class AppSettings(BaseSettings):
691732
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@@ -955,6 +996,14 @@ def _populate_sub_configs_and_secret(self) -> AppSettings:
955996
"none for self-host, paddle for the cloud"
956997
)
957998

999+
# Sandbox checkouts take Paddle's test card; the webhook would grant real Pro.
1000+
if (
1001+
self.env == "production"
1002+
and self.billing.provider == "paddle"
1003+
and self.billing.paddle_env != "production"
1004+
):
1005+
raise ValueError("BILLING_PADDLE_ENV must be production in production")
1006+
9581007
return self
9591008

9601009
@property

dependencies/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
AccountDeletionSvc,
6363
ApiKeySvc,
6464
AppGrantRepo,
65+
BillingSvc,
6566
BulkUrlSvc,
6667
ClickSink,
6768
ClickSvc,
@@ -89,6 +90,7 @@
8990
get_account_deletion_service,
9091
get_api_key_service,
9192
get_app_grant_repo,
93+
get_billing_service,
9294
get_bulk_url_service,
9395
get_click_service,
9496
get_click_sink,
@@ -129,6 +131,7 @@
129131
"AppRegistryDep",
130132
# auth
131133
"AuthUser",
134+
"BillingSvc",
132135
"BulkUrlSvc",
133136
"ClickSink",
134137
"ClickSvc",
@@ -173,6 +176,7 @@
173176
"get_api_key_service",
174177
"get_app_grant_repo",
175178
"get_app_registry",
179+
"get_billing_service",
176180
"get_bulk_url_service",
177181
"get_click_service",
178182
"get_click_sink",

dependencies/services.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from services.auth.device import DeviceAuthService
2424
from services.auth.password import PasswordService
2525
from services.auth.verification import EmailVerificationService
26+
from services.billing.service import BillingService
2627
from services.bulk_url_service import BulkUrlService
2728
from services.click import ClickService
2829
from services.click.sinks import ClickEventSink
@@ -45,6 +46,10 @@
4546
from services.webhooks.service import WebhookService
4647

4748

49+
def get_billing_service(request: Request) -> BillingService:
50+
return request.app.state.billing_service
51+
52+
4853
def get_url_service(request: Request) -> UrlService:
4954
return request.app.state.url_service
5055

@@ -172,6 +177,7 @@ def get_webhook_service(request: Request) -> WebhookService:
172177
# ── Annotated type aliases — Depends shortcuts for route signatures ──────────
173178

174179
UrlSvc = Annotated[UrlService, Depends(get_url_service)]
180+
BillingSvc = Annotated[BillingService, Depends(get_billing_service)]
175181
UrlPolicy = Annotated[UrlPolicyService, Depends(get_url_policy)]
176182
BulkUrlSvc = Annotated[BulkUrlService, Depends(get_bulk_url_service)]
177183
TagSvc = Annotated[TagService, Depends(get_tag_service)]

dependencies/wiring.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
)
3535
from repositories.api_key_repository import ApiKeyRepository
3636
from repositories.app_grant_repository import AppGrantRepository
37+
from repositories.billing_event_repository import BillingEventRepository
3738
from repositories.blocked_domain_repository import BlockedDomainRepository
3839
from repositories.blocked_url_repository import BlockedUrlRepository
3940
from repositories.click_repository import ClickRepository
@@ -77,6 +78,12 @@
7778
from services.auth.otp import OtpService
7879
from services.auth.password import PasswordService
7980
from services.auth.verification import EmailVerificationService
81+
from services.billing import (
82+
BillingProvider,
83+
BillingService,
84+
NullBillingProvider,
85+
PaddleProvider,
86+
)
8087
from services.bulk_url_service import BulkUrlService
8188
from services.cf_saas_backend import CfSaasBackend
8289
from services.click import ClickService, LegacyClickHandler, V2ClickHandler
@@ -323,6 +330,37 @@ def build_entitlement_service(
323330
)
324331

325332

333+
def build_billing_service(
334+
db,
335+
settings: AppSettings,
336+
redis_client,
337+
*,
338+
http_client,
339+
entitlements: EntitlementService,
340+
subscriptions: SubscriptionRepository,
341+
) -> BillingService:
342+
billing = settings.billing
343+
provider: BillingProvider
344+
if billing.provider == "paddle":
345+
provider = PaddleProvider(
346+
http_client,
347+
api_key=billing.paddle_api_key,
348+
webhook_secret=billing.paddle_webhook_secret,
349+
env=billing.paddle_env,
350+
)
351+
else:
352+
provider = NullBillingProvider()
353+
return BillingService(
354+
provider,
355+
entitlements,
356+
subscriptions,
357+
BillingEventRepository(db["billing_events"]),
358+
redis_client,
359+
billing,
360+
app_url=settings.app_url,
361+
)
362+
363+
326364
def build_account_erasure_service(
327365
db,
328366
settings: AppSettings,
@@ -479,6 +517,14 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
479517
app.state.entitlement_service = build_entitlement_service(
480518
db, settings, redis_client, store=ent_store
481519
)
520+
app.state.billing_service = build_billing_service(
521+
db,
522+
settings,
523+
redis_client,
524+
http_client=http_client,
525+
entitlements=app.state.entitlement_service,
526+
subscriptions=subscription_repo,
527+
)
482528

483529
# ── Infrastructure ───────────────────────────────────────────────────
484530
url_cache = UrlCache(redis_client, ttl_seconds=settings.redis.redis_ttl_seconds)

errors.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,14 @@ class NotConfiguredError(AppError):
212212
error_code = "not_configured"
213213

214214

215+
class BillingProviderError(AppError):
216+
"""The billing provider answered with an error or not at all; the caller
217+
retries or fails loudly, never guesses a plan state."""
218+
219+
status_code = 502
220+
error_code = "billing_provider_error"
221+
222+
215223
class R2StorageError(AppError):
216224
"""R2 object PUT failed — the user write that needed it must fail
217225
loudly rather than store a broken image URL."""

middleware/rate_limiter.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ class Limits:
7777
API_KEY_READ = "60 per minute"
7878
API_KEY_DELETE = "30 per minute"
7979

80+
# Billing: checkout and portal links are cheap to mint, expensive to spam.
81+
BILLING_WRITE = "10 per minute"
82+
8083
# Per-user page layouts (client debounces writes)
8184
LAYOUT_READ = "120 per minute"
8285
LAYOUT_WRITE = "60 per minute"

0 commit comments

Comments
 (0)