From c05122e5fbff993f5ddff901ece69256c704af27 Mon Sep 17 00:00:00 2001 From: Hezron Karani <58668109+hkarani@users.noreply.github.com> Date: Wed, 17 Sep 2025 23:16:54 +0300 Subject: [PATCH 1/5] chore: reorder .env for a common block for .env only settings (#3350) --- .env.example | 147 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 88 insertions(+), 59 deletions(-) diff --git a/.env.example b/.env.example index 0de8de268..0f5e3f6ec 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,95 @@ #For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/ +###################################### +###### .env ONLY SETTINGS ############ +###################################### +# The following settings are ONLY set in your .env file. +# They are NOT managed by the Admin UI and are not stored in the database. + +# === Logging and Development === + +DEBUG=False +DEBUG_DATABASE=False +BUNDLE_ASSETS=True + +# logging into LNBITS_DATA_FOLDER/logs/ +ENABLE_LOG_TO_FILE=true + +# https://loguru.readthedocs.io/en/stable/api/logger.html#file +LOG_ROTATION="100 MB" +LOG_RETENTION="3 months" + +# for database cleanup commands +# CLEANUP_WALLETS_DAYS=90 + +# === Admin Settings === + +# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available. +# Warning: Enabling this will make LNbits ignore most configurations in file. Only the +# configurations defined in `ReadOnlySettings` will still be read from the environment variables. +# The rest of the settings will be stored in your database and you will be able to change them +# only through the Admin UI. +# Disable this and clear `settings` table from database to make LNbits use this config file again. +LNBITS_ADMIN_UI=true + +HOST=127.0.0.1 +PORT=5000 +# VERSION= +# USER_AGENT= + +# === LNbits === + +# Database: to use SQLite, specify LNBITS_DATA_FOLDER +# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://... +# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://... +# for both PostgreSQL and CockroachDB, you'll need to install +# psycopg2 as an additional dependency +LNBITS_DATA_FOLDER="./data" +# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename" + +# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart. +# The extension must be removed from this list in order to not be re-installed. +LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos" + +# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json" +# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN +# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx + +# which fundingsources are allowed in the admin ui +# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet" + +# uvicorn variable, allow https behind a proxy +# IMPORTANT: this also needs the webserver to be configured to forward the headers +# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https +FORWARDED_ALLOW_IPS="*" + +# Path where extensions will be installed (defaults to `./lnbits/`). +# Inside this directory the `extensions` and `upgrades` sub-directories will be created. +# LNBITS_EXTENSIONS_PATH="/path/to/some/dir" + +# ID of the super user. The user ID must exist. +# SUPER_USER="" + +# LNBITS_TITLE="LNbits API" +# LNBITS_PATH="folder/path" + +# === Auth Configurations === + +# Secret Key: will default to the hash of the super user. +# !!!!! It is strongly recommended that you set your own strong random value !!!! +AUTH_SECRET_KEY="" + +# === Funding Source ===# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet +# FUNDING_SOURCE_MAX_RETRIES=4 + +###################################### +###### END .env ONLY SETTINGS ######## +###################################### + ###################################### ####### Auth Configurations ########## ###################################### -# Secret Key: will default to the hash of the super user. -# !!!!! It is strongly recommended that you set your own strong random value !!!! -AUTH_SECRET_KEY="" + AUTH_TOKEN_EXPIRE_MINUTES=525600 # Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth AUTH_ALLOWED_METHODS="user-id-only, username-password" @@ -16,14 +100,6 @@ AUTH_ALLOWED_METHODS="user-id-only, username-password" ########### Admin Settings ########### ###################################### -# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available. -# Warning: Enabling this will make LNbits ignore most configurations in file. Only the -# configurations defined in `ReadOnlySettings` will still be read from the environment variables. -# The rest of the settings will be stored in your database and you will be able to change them -# only through the Admin UI. -# Disable this and clear `settings` table from database to make LNbits use this config file again. -LNBITS_ADMIN_UI=true - # Change theme LNBITS_SITE_TITLE="LNbits" LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform" @@ -32,22 +108,14 @@ LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run f LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber" # LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg" -HOST=127.0.0.1 -PORT=5000 - ###################################### ########## Funding Source ############ ###################################### -# which fundingsources are allowed in the admin ui -# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet" - LNBITS_BACKEND_WALLET_CLASS=VoidWallet # VoidWallet is just a fallback that works without any actual Lightning capabilities, # just so you can see the UI before dealing with this file. -# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet -# FUNDING_SOURCE_MAX_RETRIES=4 # Invoice expiry for LND, CLN, Eclair, LNbits funding sources LIGHTNING_INVOICE_EXPIRY=3600 @@ -195,11 +263,6 @@ KEYCLOAK_CLIENT_CUSTOM_ICON="" ###################################### -# uvicorn variable, allow https behind a proxy -# IMPORTANT: this also needs the webserver to be configured to forward the headers -# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https -FORWARDED_ALLOW_IPS="*" - # Server security, rate limiting ips, blocked ips, allowed ips LNBITS_RATE_LIMIT_NO="200" LNBITS_RATE_LIMIT_UNIT="minute" @@ -210,8 +273,7 @@ LNBITS_BLOCKED_IPS="" # if set new users will not be able to create accounts LNBITS_ALLOWED_USERS="" LNBITS_ADMIN_USERS="" -# ID of the super user. The user ID must exist. -# SUPER_USER="" + # Extensions only admin can access LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient" @@ -244,26 +306,6 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet" # Hides wallet api, extensions can choose to honor LNBITS_HIDE_API=false -# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json" -# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN -# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx - -# Path where extensions will be installed (defaults to `./lnbits/`). -# Inside this directory the `extensions` and `upgrades` sub-directories will be created. -# LNBITS_EXTENSIONS_PATH="/path/to/some/dir" - -# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart. -# The extension must be removed from this list in order to not be re-installed. -LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos" - -# Database: to use SQLite, specify LNBITS_DATA_FOLDER -# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://... -# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://... -# for both PostgreSQL and CockroachDB, you'll need to install -# psycopg2 as an additional dependency -LNBITS_DATA_FOLDER="./data" -# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename" - # the service fee (in percent) LNBITS_SERVICE_FEE=0.0 # the wallet where fees go to @@ -292,16 +334,3 @@ LNBITS_RESERVE_FEE_PERCENT=1.0 ###### Logging and Development ####### ###################################### -DEBUG=false -DEBUG_DATABASE=false -BUNDLE_ASSETS=true - -# logging into LNBITS_DATA_FOLDER/logs/ -ENABLE_LOG_TO_FILE=true - -# https://loguru.readthedocs.io/en/stable/api/logger.html#file -LOG_ROTATION="100 MB" -LOG_RETENTION="3 months" - -# for database cleanup commands -# CLEANUP_WALLETS_DAYS=90 From ccc784c8fca2859b42aa38ff3f4f565def0b7dc7 Mon Sep 17 00:00:00 2001 From: arcbtc Date: Mon, 15 Sep 2025 21:13:28 +0100 Subject: [PATCH 2/5] recuuring payments --- lnbits/fiat/stripe.py | 137 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 125 insertions(+), 12 deletions(-) diff --git a/lnbits/fiat/stripe.py b/lnbits/fiat/stripe.py index 25d0c6ee2..747620fe3 100644 --- a/lnbits/fiat/stripe.py +++ b/lnbits/fiat/stripe.py @@ -43,6 +43,26 @@ class StripeCheckoutOptions(BaseModel): line_item_name: str | None = None +# === NEW: Direct-debit subscription options === +class StripeRecurringOptions(BaseModel): + class Config: + extra = "ignore" + + # You will pass one of these (prefer price_id). We DO NOT create prices here. + price_id: str | None = None + price_lookup_key: str | None = None # convenient if you use lookup keys in Stripe + + # Direct-debit rails to allow in Checkout (defaults to UK Bacs only) + # Use ["sepa_debit"] for EUR, or ["us_bank_account"] for US ACH. + payment_method_types: list[str] = Field(default_factory=lambda: ["bacs_debit"]) + + # Optional niceties + success_url: str | None = None + metadata: dict[str, str] = Field(default_factory=dict) + customer_email: str | None = None + trial_days: int | None = None # Stripe supports trials on subs + + class StripeCreateInvoiceOptions(BaseModel): class Config: extra = "ignore" @@ -50,6 +70,8 @@ class StripeCreateInvoiceOptions(BaseModel): fiat_method: FiatMethod = "checkout" terminal: StripeTerminalOptions | None = None checkout: StripeCheckoutOptions | None = None + # NEW: when present we do mode=subscription with DD + recurring: StripeRecurringOptions | None = None class StripeWallet(FiatProvider): @@ -89,12 +111,10 @@ class StripeWallet(FiatProvider): r = await self.client.get(url="/v1/balance", timeout=15) r.raise_for_status() data = r.json() - available = data.get("available") or [] available_balance = 0 if available and isinstance(available, list): available_balance = int(available[0].get("amount", 0)) - return FiatStatusResponse(balance=available_balance) except json.JSONDecodeError: return FiatStatusResponse("Server error: 'invalid json response'", 0) @@ -116,6 +136,12 @@ class StripeWallet(FiatProvider): if not opts: return FiatInvoiceResponse(ok=False, error_message="Invalid Stripe options") + # Direct-debit subscriptions via Checkout (mode=subscription) + if opts.recurring is not None: + return await self._create_subscription_checkout_session( + payment_hash, memo, opts + ) + if opts.fiat_method == "checkout": return await self._create_checkout_invoice( amount_cents, currency, payment_hash, memo, opts @@ -170,6 +196,7 @@ class StripeWallet(FiatProvider): r.raise_for_status() return r.json() + # ---------- One-off Checkout (existing) ---------- async def _create_checkout_invoice( self, amount_cents: int, @@ -223,6 +250,7 @@ class StripeWallet(FiatProvider): ok=False, error_message=f"Unable to connect to {self.endpoint}." ) + # ---------- Terminal (existing) ---------- async def _create_terminal_invoice( self, amount_cents: int, @@ -265,6 +293,99 @@ class StripeWallet(FiatProvider): ok=False, error_message=f"Unable to connect to {self.endpoint}." ) + # ---------- NEW: Direct-debit subscription via Checkout ---------- + async def _create_subscription_checkout_session( + self, + payment_hash: str, + memo: str | None, + opts: StripeCreateInvoiceOptions, + ) -> FiatInvoiceResponse: + rc = opts.recurring or StripeRecurringOptions() + # Resolve a price_id (prefer explicit price_id; else lookup_key) + try: + price_id = rc.price_id + if not price_id and rc.price_lookup_key: + price_id = await self._get_price_id_by_lookup_key(rc.price_lookup_key) + if not price_id: + return FiatInvoiceResponse(ok=False, error_message="Stripe: missing price_id or price_lookup_key for subscription") + + success_url = ( + rc.success_url + or (opts.checkout.success_url if opts.checkout else None) + or settings.stripe_payment_success_url + or "https://lnbits.com" + ) + + form_data: list[tuple[str, str]] = [ + ("mode", "subscription"), + ("success_url", success_url), + ("metadata[payment_hash]", payment_hash), + ("line_items[0][price]", price_id), + ("line_items[0][quantity]", "1"), + ] + + # Allow only direct-debit rails (default: ["bacs_debit"]) + for t in rc.payment_method_types: + form_data.append(("payment_method_types[]", t)) + + if rc.trial_days: + form_data.append(("subscription_data[trial_period_days]", str(rc.trial_days))) + + if rc.customer_email: + form_data.append(("customer_email", rc.customer_email)) + + # Attach arbitrary metadata (helps link invoices to your user) + form_data += self._encode_metadata("metadata", rc.metadata) + + r = await self.client.post( + "/v1/checkout/sessions", + headers=self._build_headers_form(), + content=urlencode(form_data), + ) + r.raise_for_status() + data = r.json() + session_id, url = data.get("id"), data.get("url") + if not session_id or not url: + return FiatInvoiceResponse( + ok=False, error_message="Server error: missing id or url (subscription)" + ) + return FiatInvoiceResponse(ok=True, checking_id=session_id, payment_request=url) + + except json.JSONDecodeError: + return FiatInvoiceResponse(ok=False, error_message="Server error: invalid json response") + except Exception as exc: + logger.warning(exc) + return FiatInvoiceResponse(ok=False, error_message=f"Unable to connect to {self.endpoint}.") + + # ---------- NEW: Fetch helpers (no creation) ---------- + async def _get_price_id_by_lookup_key(self, lookup_key: str) -> str | None: + """ + Return the active price id for a given lookup_key, or None. + Tip: in Stripe dashboard set a unique lookup_key on your recurring price. + """ + # Stripe allows filtering prices by lookup_keys[]=&active=true + params = {"active": "true", "expand[]": "data.product", "limit": "1"} + # passing array param: + qs = urlencode(params) + f"&lookup_keys[]={lookup_key}" + r = await self.client.get(f"/v1/prices?{qs}") + r.raise_for_status() + data = r.json() + items = (data or {}).get("data") or [] + if not items: + return None + return items[0].get("id") + + async def list_prices_for_product(self, product_id: str) -> list[dict]: + """ + List active recurring prices for a given product (handy for admin UI). + """ + qs = urlencode({"product": product_id, "active": "true", "limit": "100"}) + r = await self.client.get(f"/v1/prices?{qs}") + r.raise_for_status() + data = r.json() + return (data or {}).get("data") or [] + + # ---------- utils ---------- def _normalize_stripe_id(self, checking_id: str) -> str: """Remove our internal prefix so Stripe sees a real id.""" return ( @@ -274,11 +395,10 @@ class StripeWallet(FiatProvider): ) def _status_from_checkout_session(self, data: dict) -> FiatPaymentStatus: - """Map a Checkout Session to LNbits fiat status.""" + # For one-offs, "paid" means done; subs rely on webhooks (invoice.paid) if data.get("payment_status") == "paid": return FiatPaymentSuccessStatus() - # Consider an expired session a fail (existing 24h rule). expires_at = data.get("expires_at") _24h_ago = datetime.now(timezone.utc) - timedelta(hours=24) if expires_at and float(expires_at) < _24h_ago.timestamp(): @@ -287,25 +407,18 @@ class StripeWallet(FiatProvider): return FiatPaymentPendingStatus() def _status_from_payment_intent(self, pi: dict) -> FiatPaymentStatus: - """Map a PaymentIntent to LNbits fiat status (card_present friendly).""" status = pi.get("status") - if status == "succeeded": return FiatPaymentSuccessStatus() - if status in ("canceled", "payment_failed"): return FiatPaymentFailedStatus() - if status == "requires_payment_method": if pi.get("last_payment_error"): return FiatPaymentFailedStatus() - now_ts = datetime.now(timezone.utc).timestamp() created_ts = float(pi.get("created") or now_ts) - is_stale = (now_ts - created_ts) > 300 - if is_stale: + if (now_ts - created_ts) > 300: return FiatPaymentFailedStatus() - return FiatPaymentPendingStatus() def _build_headers_form(self) -> dict[str, str]: From c1c622524ebeb27e5d9a8c231efe6f3a6dc53610 Mon Sep 17 00:00:00 2001 From: Arc Date: Tue, 16 Sep 2025 22:45:55 +0100 Subject: [PATCH 3/5] Working --- lnbits/fiat/stripe.py | 48 +++++++++++++++---------------------------- 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/lnbits/fiat/stripe.py b/lnbits/fiat/stripe.py index 747620fe3..7e2e28c85 100644 --- a/lnbits/fiat/stripe.py +++ b/lnbits/fiat/stripe.py @@ -6,6 +6,7 @@ from typing import Any, Literal from urllib.parse import urlencode import httpx +from httpx import HTTPStatusError from loguru import logger from pydantic import BaseModel, Field, ValidationError @@ -43,24 +44,20 @@ class StripeCheckoutOptions(BaseModel): line_item_name: str | None = None -# === NEW: Direct-debit subscription options === +# === Direct-debit subscription options === class StripeRecurringOptions(BaseModel): class Config: extra = "ignore" - # You will pass one of these (prefer price_id). We DO NOT create prices here. price_id: str | None = None - price_lookup_key: str | None = None # convenient if you use lookup keys in Stripe - - # Direct-debit rails to allow in Checkout (defaults to UK Bacs only) - # Use ["sepa_debit"] for EUR, or ["us_bank_account"] for US ACH. + price_lookup_key: str | None = None payment_method_types: list[str] = Field(default_factory=lambda: ["bacs_debit"]) - # Optional niceties success_url: str | None = None + cancel_url: str | None = None metadata: dict[str, str] = Field(default_factory=dict) customer_email: str | None = None - trial_days: int | None = None # Stripe supports trials on subs + trial_days: int | None = None class StripeCreateInvoiceOptions(BaseModel): @@ -70,7 +67,6 @@ class StripeCreateInvoiceOptions(BaseModel): fiat_method: FiatMethod = "checkout" terminal: StripeTerminalOptions | None = None checkout: StripeCheckoutOptions | None = None - # NEW: when present we do mode=subscription with DD recurring: StripeRecurringOptions | None = None @@ -136,7 +132,6 @@ class StripeWallet(FiatProvider): if not opts: return FiatInvoiceResponse(ok=False, error_message="Invalid Stripe options") - # Direct-debit subscriptions via Checkout (mode=subscription) if opts.recurring is not None: return await self._create_subscription_checkout_session( payment_hash, memo, opts @@ -196,7 +191,7 @@ class StripeWallet(FiatProvider): r.raise_for_status() return r.json() - # ---------- One-off Checkout (existing) ---------- + # ---------- One-off Checkout ---------- async def _create_checkout_invoice( self, amount_cents: int, @@ -250,7 +245,7 @@ class StripeWallet(FiatProvider): ok=False, error_message=f"Unable to connect to {self.endpoint}." ) - # ---------- Terminal (existing) ---------- + # ---------- Terminal ---------- async def _create_terminal_invoice( self, amount_cents: int, @@ -293,7 +288,7 @@ class StripeWallet(FiatProvider): ok=False, error_message=f"Unable to connect to {self.endpoint}." ) - # ---------- NEW: Direct-debit subscription via Checkout ---------- + # ---------- Subscription Checkout ---------- async def _create_subscription_checkout_session( self, payment_hash: str, @@ -301,7 +296,6 @@ class StripeWallet(FiatProvider): opts: StripeCreateInvoiceOptions, ) -> FiatInvoiceResponse: rc = opts.recurring or StripeRecurringOptions() - # Resolve a price_id (prefer explicit price_id; else lookup_key) try: price_id = rc.price_id if not price_id and rc.price_lookup_key: @@ -315,26 +309,24 @@ class StripeWallet(FiatProvider): or settings.stripe_payment_success_url or "https://lnbits.com" ) + cancel_url = rc.cancel_url or success_url form_data: list[tuple[str, str]] = [ ("mode", "subscription"), ("success_url", success_url), + ("cancel_url", cancel_url), + ("payment_method_collection", "always"), ("metadata[payment_hash]", payment_hash), ("line_items[0][price]", price_id), ("line_items[0][quantity]", "1"), ] - # Allow only direct-debit rails (default: ["bacs_debit"]) - for t in rc.payment_method_types: - form_data.append(("payment_method_types[]", t)) - if rc.trial_days: form_data.append(("subscription_data[trial_period_days]", str(rc.trial_days))) if rc.customer_email: form_data.append(("customer_email", rc.customer_email)) - # Attach arbitrary metadata (helps link invoices to your user) form_data += self._encode_metadata("metadata", rc.metadata) r = await self.client.post( @@ -351,21 +343,19 @@ class StripeWallet(FiatProvider): ) return FiatInvoiceResponse(ok=True, checking_id=session_id, payment_request=url) + except HTTPStatusError as e: + body = e.response.text if e.response is not None else "" + logger.warning(f"Stripe subscription 400: {body}") + return FiatInvoiceResponse(ok=False, error_message=body) except json.JSONDecodeError: return FiatInvoiceResponse(ok=False, error_message="Server error: invalid json response") except Exception as exc: logger.warning(exc) return FiatInvoiceResponse(ok=False, error_message=f"Unable to connect to {self.endpoint}.") - # ---------- NEW: Fetch helpers (no creation) ---------- + # ---------- Helpers ---------- async def _get_price_id_by_lookup_key(self, lookup_key: str) -> str | None: - """ - Return the active price id for a given lookup_key, or None. - Tip: in Stripe dashboard set a unique lookup_key on your recurring price. - """ - # Stripe allows filtering prices by lookup_keys[]=&active=true params = {"active": "true", "expand[]": "data.product", "limit": "1"} - # passing array param: qs = urlencode(params) + f"&lookup_keys[]={lookup_key}" r = await self.client.get(f"/v1/prices?{qs}") r.raise_for_status() @@ -376,18 +366,13 @@ class StripeWallet(FiatProvider): return items[0].get("id") async def list_prices_for_product(self, product_id: str) -> list[dict]: - """ - List active recurring prices for a given product (handy for admin UI). - """ qs = urlencode({"product": product_id, "active": "true", "limit": "100"}) r = await self.client.get(f"/v1/prices?{qs}") r.raise_for_status() data = r.json() return (data or {}).get("data") or [] - # ---------- utils ---------- def _normalize_stripe_id(self, checking_id: str) -> str: - """Remove our internal prefix so Stripe sees a real id.""" return ( checking_id.replace("fiat_stripe_", "", 1) if checking_id.startswith("fiat_stripe_") @@ -395,7 +380,6 @@ class StripeWallet(FiatProvider): ) def _status_from_checkout_session(self, data: dict) -> FiatPaymentStatus: - # For one-offs, "paid" means done; subs rely on webhooks (invoice.paid) if data.get("payment_status") == "paid": return FiatPaymentSuccessStatus() From 991ac4d7fe1ffc2a7554ab32bfa18e9f08b187c6 Mon Sep 17 00:00:00 2001 From: Arc Date: Tue, 16 Sep 2025 22:49:55 +0100 Subject: [PATCH 4/5] make --- lnbits/fiat/stripe.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/lnbits/fiat/stripe.py b/lnbits/fiat/stripe.py index 7e2e28c85..87a8849d9 100644 --- a/lnbits/fiat/stripe.py +++ b/lnbits/fiat/stripe.py @@ -301,7 +301,10 @@ class StripeWallet(FiatProvider): if not price_id and rc.price_lookup_key: price_id = await self._get_price_id_by_lookup_key(rc.price_lookup_key) if not price_id: - return FiatInvoiceResponse(ok=False, error_message="Stripe: missing price_id or price_lookup_key for subscription") + return FiatInvoiceResponse( + ok=False, + error_message="Stripe: missing price_id or price_lookup_key", + ) success_url = ( rc.success_url @@ -322,7 +325,9 @@ class StripeWallet(FiatProvider): ] if rc.trial_days: - form_data.append(("subscription_data[trial_period_days]", str(rc.trial_days))) + form_data.append( + ("subscription_data[trial_period_days]", str(rc.trial_days)) + ) if rc.customer_email: form_data.append(("customer_email", rc.customer_email)) @@ -339,19 +344,26 @@ class StripeWallet(FiatProvider): session_id, url = data.get("id"), data.get("url") if not session_id or not url: return FiatInvoiceResponse( - ok=False, error_message="Server error: missing id or url (subscription)" + ok=False, + error_message="Server error: missing id or url (subscription)", ) - return FiatInvoiceResponse(ok=True, checking_id=session_id, payment_request=url) + return FiatInvoiceResponse( + ok=True, checking_id=session_id, payment_request=url + ) except HTTPStatusError as e: body = e.response.text if e.response is not None else "" logger.warning(f"Stripe subscription 400: {body}") return FiatInvoiceResponse(ok=False, error_message=body) except json.JSONDecodeError: - return FiatInvoiceResponse(ok=False, error_message="Server error: invalid json response") + return FiatInvoiceResponse( + ok=False, error_message="Server error: invalid json response" + ) except Exception as exc: logger.warning(exc) - return FiatInvoiceResponse(ok=False, error_message=f"Unable to connect to {self.endpoint}.") + return FiatInvoiceResponse( + ok=False, error_message=f"Unable to connect to {self.endpoint}." + ) # ---------- Helpers ---------- async def _get_price_id_by_lookup_key(self, lookup_key: str) -> str | None: From 609808f6a23d9fffc89e47e6345dddcd92207b70 Mon Sep 17 00:00:00 2001 From: arcbtc Date: Thu, 25 Sep 2025 05:17:08 +0100 Subject: [PATCH 5/5] added helper for getting subscription id --- lnbits/fiat/stripe.py | 114 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/lnbits/fiat/stripe.py b/lnbits/fiat/stripe.py index 87a8849d9..d713ae414 100644 --- a/lnbits/fiat/stripe.py +++ b/lnbits/fiat/stripe.py @@ -26,6 +26,22 @@ from .base import ( FiatMethod = Literal["checkout", "terminal"] +# ---- NEW: normalized subscription status type ---- +StripeStatus = Literal[ + "active", + "trialing", + "past_due", + "unpaid", + "canceled", + "incomplete", + "incomplete_expired", + "paused", + "not_found", + "pending", + "error", + "unknown", +] + class StripeTerminalOptions(BaseModel): class Config: @@ -365,6 +381,104 @@ class StripeWallet(FiatProvider): ok=False, error_message=f"Unable to connect to {self.endpoint}." ) + # ---------- Subscription status helpers (NEW) ---------- + async def get_subscription_status(self, sub_or_session_id: str) -> StripeStatus: + """ + Accepts either a 'sub_...' or 'cs_...' id. If it's a 'cs_...', + returns 'pending' until the subscription exists; once it does, + returns the mapped subscription status. + """ + sid = self._normalize_stripe_id(sub_or_session_id) + try: + if sid.startswith("sub_"): + r = await self.client.get(f"/v1/subscriptions/{sid}") + if r.status_code == 404: + return "not_found" + r.raise_for_status() + return self._status_from_subscription(r.json()) + + if sid.startswith("cs_"): + r = await self.client.get(f"/v1/checkout/sessions/{sid}") + if r.status_code == 404: + return "not_found" + r.raise_for_status() + data = r.json() + subscription_id = data.get("subscription") + if not subscription_id: + return "pending" + r2 = await self.client.get(f"/v1/subscriptions/{subscription_id}") + if r2.status_code == 404: + return "not_found" + r2.raise_for_status() + return self._status_from_subscription(r2.json()) + + return "unknown" + + except httpx.HTTPStatusError: + return "error" + except Exception: + return "error" + + async def get_subscription_status_and_promote( + self, sub_or_session_id: str + ) -> tuple[StripeStatus, str]: + """ + Returns (status, effective_id). If given a 'cs_...' and the Checkout + Session has created a subscription, returns the subscription status + AND the promoted 'sub_...' id so you can persist it. If given a 'sub_...', + returns its status and the same id. + """ + sid = self._normalize_stripe_id(sub_or_session_id) + try: + if sid.startswith("sub_"): + r = await self.client.get(f"/v1/subscriptions/{sid}") + if r.status_code == 404: + return ("not_found", sid) + r.raise_for_status() + return (self._status_from_subscription(r.json()), sid) + + if sid.startswith("cs_"): + r = await self.client.get(f"/v1/checkout/sessions/{sid}") + if r.status_code == 404: + return ("not_found", sid) + r.raise_for_status() + data = r.json() + subscription_id = data.get("subscription") + if not subscription_id: + return ("pending", sid) + + # Promote to the subscription id + r2 = await self.client.get(f"/v1/subscriptions/{subscription_id}") + if r2.status_code == 404: + return ("not_found", subscription_id) + r2.raise_for_status() + return (self._status_from_subscription(r2.json()), subscription_id) + + return ("unknown", sid) + + except httpx.HTTPStatusError: + return ("error", sid) + except Exception: + return ("error", sid) + + def _status_from_subscription(self, sub: dict) -> StripeStatus: + status = (sub or {}).get("status") + if not status: + return "unknown" + + status = str(status).lower().strip() + known: set[str] = { + "active", + "trialing", + "past_due", + "unpaid", + "canceled", + "incomplete", + "incomplete_expired", + "paused", + } + return status if status in known else "unknown" + # ---------- Helpers ---------- async def _get_price_id_by_lookup_key(self, lookup_key: str) -> str | None: params = {"active": "true", "expand[]": "data.product", "limit": "1"}