[feat] Stripe subscription (#3369)

This commit is contained in:
Vlad Stan
2025-10-16 23:14:06 +01:00
committed by GitHub
parent 182894fd93
commit bf06def9b7
7 changed files with 472 additions and 107 deletions
+76
View File
@@ -4,6 +4,8 @@ from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator, Coroutine
from typing import TYPE_CHECKING, Any, NamedTuple
from pydantic import BaseModel, Field
if TYPE_CHECKING:
pass
@@ -76,6 +78,54 @@ class FiatPaymentStatus(NamedTuple):
return "pending"
class FiatSubscriptionPaymentOptions(BaseModel):
memo: str | None = Field(
default=None,
description="Payments created by the recurring subscription"
" will have this memo.",
)
wallet_id: str | None = Field(
default=None,
description="Payments created by the recurring subscription"
" will be made to this wallet.",
)
subscription_request_id: str | None = Field(
default=None,
description="Unique ID that can be used to identify the subscription request."
"If not provided, one will be generated.",
)
tag: str | None = Field(
default=None,
description="Payments created by the recurring subscription"
" will have this tag. Admin only.",
)
extra: dict[str, Any] | None = Field(
default=None,
description="Payments created by the recurring subscription"
" will merge this extra data to the payment extra. Admin only.",
)
success_url: str | None = Field(
default="https://my.lnbits.com",
description="The URL to redirect the user to after the"
" subscription is successfully created.",
)
class CreateFiatSubscription(BaseModel):
subscription_id: str
quantity: int
payment_options: FiatSubscriptionPaymentOptions
class FiatSubscriptionResponse(BaseModel):
ok: bool = True
subscription_request_id: str | None = None
checkout_session_url: str | None = None
error_message: str | None = None
class FiatPaymentSuccessStatus(FiatPaymentStatus):
paid = True
@@ -111,6 +161,32 @@ class FiatProvider(ABC):
) -> Coroutine[None, None, FiatInvoiceResponse]:
pass
@abstractmethod
def create_subscription(
self,
subscription_id: str,
quantity: int,
payment_options: FiatSubscriptionPaymentOptions,
**kwargs,
) -> Coroutine[None, None, FiatSubscriptionResponse]:
pass
@abstractmethod
def cancel_subscription(
self,
subscription_id: str,
correlation_id: str,
**kwargs,
) -> Coroutine[None, None, FiatSubscriptionResponse]:
"""
Cancel a subscription.
Args:
subscription_id: The ID of the subscription to cancel.
correlation_id: An identifier used to verify that the subscription belongs
to the user that made the request. Usually the wallet ID.
"""
pass
@abstractmethod
def pay_invoice(
self,
+159 -9
View File
@@ -1,5 +1,6 @@
import asyncio
import json
import uuid
from collections.abc import AsyncGenerator
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
@@ -9,7 +10,7 @@ import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from lnbits.helpers import normalize_endpoint
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings
from .base import (
@@ -21,9 +22,11 @@ from .base import (
FiatPaymentSuccessStatus,
FiatProvider,
FiatStatusResponse,
FiatSubscriptionPaymentOptions,
FiatSubscriptionResponse,
)
FiatMethod = Literal["checkout", "terminal"]
FiatMethod = Literal["checkout", "terminal", "subscription"]
class StripeTerminalOptions(BaseModel):
@@ -43,6 +46,14 @@ class StripeCheckoutOptions(BaseModel):
line_item_name: str | None = None
class StripeSubscriptionOptions(BaseModel):
class Config:
extra = "ignore"
checking_id: str | None = None
payment_request: str | None = None
class StripeCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
@@ -50,6 +61,7 @@ class StripeCreateInvoiceOptions(BaseModel):
fiat_method: FiatMethod = "checkout"
terminal: StripeTerminalOptions | None = None
checkout: StripeCheckoutOptions | None = None
subscription: StripeSubscriptionOptions | None = None
class StripeWallet(FiatProvider):
@@ -118,17 +130,125 @@ class StripeWallet(FiatProvider):
if opts.fiat_method == "checkout":
return await self._create_checkout_invoice(
amount_cents, currency, payment_hash, memo, opts
amount_cents, currency, payment_hash, memo, opts.checkout
)
if opts.fiat_method == "terminal":
return await self._create_terminal_invoice(
amount_cents, currency, payment_hash, opts
amount_cents, currency, payment_hash, opts.terminal
)
if opts.fiat_method == "subscription":
return self._create_subscription_invoice(opts.subscription)
return FiatInvoiceResponse(
ok=False, error_message=f"Unsupported fiat_method: {opts.fiat_method}"
)
async def create_subscription(
self,
subscription_id: str,
quantity: int,
payment_options: FiatSubscriptionPaymentOptions,
**kwargs,
) -> FiatSubscriptionResponse:
success_url = (
payment_options.success_url
or settings.stripe_payment_success_url
or "https://lnbits.com"
)
if not payment_options.subscription_request_id:
payment_options.subscription_request_id = str(uuid.uuid4())
payment_options.extra = payment_options.extra or {}
payment_options.extra["subscription_request_id"] = (
payment_options.subscription_request_id
)
form_data: list[tuple[str, str]] = [
("mode", "subscription"),
("success_url", success_url),
("line_items[0][price]", subscription_id),
("line_items[0][quantity]", f"{quantity}"),
]
subscription_data = {**payment_options.dict(), "lnbits_action": "subscription"}
subscription_data["extra"] = json.dumps(subscription_data.get("extra") or {})
form_data += self._encode_metadata(
"subscription_data[metadata]",
subscription_data,
)
try:
r = await self.client.post(
"/v1/checkout/sessions",
headers=self._build_headers_form(),
content=urlencode(form_data),
)
r.raise_for_status()
data = r.json()
url = data.get("url")
if not url:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: missing url"
)
return FiatSubscriptionResponse(
ok=True,
checkout_session_url=url,
subscription_request_id=payment_options.subscription_request_id,
)
except json.JSONDecodeError as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def cancel_subscription(
self,
subscription_id: str,
correlation_id: str,
**kwargs,
) -> FiatSubscriptionResponse:
try:
params = {
"query": f"metadata['wallet_id']:'{correlation_id}'"
" AND "
f"metadata['subscription_request_id']:'{subscription_id}'"
}
r = await self.client.get(
"/v1/subscriptions/search",
params=params,
)
r.raise_for_status()
search_result = r.json()
data = search_result.get("data") or []
if not data or len(data) == 0:
return FiatSubscriptionResponse(
ok=False, error_message="Subscription not found."
)
subscription = data[0]
subscription_id = subscription.get("id")
if not subscription_id:
return FiatSubscriptionResponse(
ok=False, error_message="Subscription ID not found."
)
r = await self.client.delete(f"/v1/subscriptions/{subscription_id}")
r.raise_for_status()
return FiatSubscriptionResponse(ok=True)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message="Unable to un subscribe."
)
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
raise NotImplementedError("Stripe does not support paying invoices directly.")
@@ -146,6 +266,11 @@ class StripeWallet(FiatProvider):
r.raise_for_status()
return self._status_from_payment_intent(r.json())
if stripe_id.startswith("in_"):
r = await self.client.get(f"/v1/invoices/{stripe_id}")
r.raise_for_status()
return self._status_from_invoice(r.json())
logger.debug(f"Unknown Stripe id prefix: {checking_id}")
return FiatPaymentPendingStatus()
@@ -176,9 +301,9 @@ class StripeWallet(FiatProvider):
currency: str,
payment_hash: str,
memo: str | None,
opts: StripeCreateInvoiceOptions,
opts: StripeCheckoutOptions | None = None,
) -> FiatInvoiceResponse:
co = opts.checkout or StripeCheckoutOptions()
co = opts or StripeCheckoutOptions()
success_url = (
co.success_url
or settings.stripe_payment_success_url
@@ -190,6 +315,7 @@ class StripeWallet(FiatProvider):
("mode", "payment"),
("success_url", success_url),
("metadata[payment_hash]", payment_hash),
("metadata[lnbits_action]", "invoice"),
("line_items[0][price_data][currency]", currency.lower()),
("line_items[0][price_data][product_data][name]", line_item_name),
("line_items[0][price_data][unit_amount]", str(amount_cents)),
@@ -228,9 +354,9 @@ class StripeWallet(FiatProvider):
amount_cents: int,
currency: str,
payment_hash: str,
opts: StripeCreateInvoiceOptions,
opts: StripeTerminalOptions | None = None,
) -> FiatInvoiceResponse:
term = opts.terminal or StripeTerminalOptions()
term = opts or StripeTerminalOptions()
data: dict[str, str] = {
"amount": str(amount_cents),
"currency": currency.lower(),
@@ -265,6 +391,18 @@ class StripeWallet(FiatProvider):
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
def _create_subscription_invoice(
self,
opts: StripeSubscriptionOptions | None = None,
) -> FiatInvoiceResponse:
term = opts or StripeSubscriptionOptions()
return FiatInvoiceResponse(
ok=True,
checking_id=term.checking_id or urlsafe_short_hash(),
payment_request=term.payment_request or "",
)
def _normalize_stripe_id(self, checking_id: str) -> str:
"""Remove our internal prefix so Stripe sees a real id."""
return (
@@ -308,6 +446,18 @@ class StripeWallet(FiatProvider):
return FiatPaymentPendingStatus()
def _status_from_invoice(self, invoice: dict) -> FiatPaymentStatus:
"""Map an Invoice to LNbits fiat status."""
status = invoice.get("status")
if status == "paid":
return FiatPaymentSuccessStatus()
if status in ["uncollectible", "void"]:
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
def _build_headers_form(self) -> dict[str, str]:
return {**self.headers, "Content-Type": "application/x-www-form-urlencoded"}
@@ -316,7 +466,7 @@ class StripeWallet(FiatProvider):
) -> list[tuple[str, str]]:
out: list[tuple[str, str]] = []
for k, v in (md or {}).items():
out.append((f"{prefix}[{k}]", str(v)))
out.append((f"{prefix}[{k}]", str(v or "")))
return out
def _parse_create_opts(