|
10 | 10 |
|
11 | 11 | from __future__ import annotations |
12 | 12 |
|
| 13 | +from datetime import datetime, timedelta |
13 | 14 | from functools import cached_property |
14 | 15 | from typing import Literal |
15 | 16 | from urllib.parse import urlparse |
@@ -681,11 +682,51 @@ class BillingSettings(BaseSettings): |
681 | 682 | founding_monthly_usd: int = Field(default=9, ge=0) |
682 | 683 | founding_year_usd: int = Field(default=90, ge=0) |
683 | 684 | 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 = "" |
684 | 698 |
|
685 | 699 | @property |
686 | 700 | def selfhost(self) -> bool: |
687 | 701 | return self.provider == "none" |
688 | 702 |
|
| 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 | + |
689 | 730 |
|
690 | 731 | class AppSettings(BaseSettings): |
691 | 732 | model_config = SettingsConfigDict(env_file=".env", extra="ignore") |
@@ -955,6 +996,14 @@ def _populate_sub_configs_and_secret(self) -> AppSettings: |
955 | 996 | "none for self-host, paddle for the cloud" |
956 | 997 | ) |
957 | 998 |
|
| 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 | + |
958 | 1007 | return self |
959 | 1008 |
|
960 | 1009 | @property |
|
0 commit comments