From 94ebc22dcc0750f80a8b92eecbf94a1df9ec808f Mon Sep 17 00:00:00 2001 From: arcbtc Date: Mon, 15 Sep 2025 21:13:28 +0100 Subject: [PATCH] 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]: