Compare commits

...
Author SHA1 Message Date
Arc 1f46545dea Reapply "Merge remote-tracking branch 'origin/dev' into stripe_recurring"
This reverts commit 37d2a695a7.
2025-09-26 11:49:42 +01:00
Arc 37d2a695a7 Revert "Merge remote-tracking branch 'origin/dev' into stripe_recurring"
This reverts commit 63458ced1e, reversing
changes made to cb3e340a78.
2025-09-26 11:47:50 +01:00
Arc 63458ced1e Merge remote-tracking branch 'origin/dev' into stripe_recurring 2025-09-26 11:44:04 +01:00
Arc cb3e340a78 mypy fix 2025-09-26 11:42:18 +01:00
arcbtc ae24f7e43c Merge branch 'or_stripe' into stripe_recurring 2025-09-25 05:18:26 +01:00
arcbtc 609808f6a2 added helper for getting subscription id 2025-09-25 05:17:08 +01:00
arcbtc c760e6f63d added a helper to get the subscription id
if returned that can be used for the checking id
2025-09-25 05:13:04 +01:00
ArcandArc 991ac4d7fe make 2025-09-25 03:58:14 +01:00
ArcandArc c1c622524e Working 2025-09-25 03:58:14 +01:00
arcbtcandArc ccc784c8fc recuuring payments 2025-09-25 03:58:14 +01:00
Arc 7c72766bbd make 2025-09-16 22:49:55 +01:00
Arc 416d170996 Working 2025-09-16 22:45:55 +01:00
arcbtc 94ebc22dcc recuuring payments 2025-09-15 21:13:28 +01:00
2 changed files with 265 additions and 43 deletions
+236 -13
View File
@@ -6,6 +6,7 @@ from typing import Any, Literal
from urllib.parse import urlencode from urllib.parse import urlencode
import httpx import httpx
from httpx import HTTPStatusError
from loguru import logger from loguru import logger
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
@@ -25,6 +26,34 @@ from .base import (
FiatMethod = Literal["checkout", "terminal"] 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",
]
# Typed map to ensure mypy sees return values as StripeStatus (not plain str)
_STRIPE_STATUS_MAP: dict[str, StripeStatus] = {
"active": "active",
"trialing": "trialing",
"past_due": "past_due",
"unpaid": "unpaid",
"canceled": "canceled",
"incomplete": "incomplete",
"incomplete_expired": "incomplete_expired",
"paused": "paused",
}
class StripeTerminalOptions(BaseModel): class StripeTerminalOptions(BaseModel):
class Config: class Config:
@@ -43,6 +72,22 @@ class StripeCheckoutOptions(BaseModel):
line_item_name: str | None = None line_item_name: str | None = None
# === Direct-debit subscription options ===
class StripeRecurringOptions(BaseModel):
class Config:
extra = "ignore"
price_id: str | None = None
price_lookup_key: str | None = None
payment_method_types: list[str] = Field(default_factory=lambda: ["bacs_debit"])
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
class StripeCreateInvoiceOptions(BaseModel): class StripeCreateInvoiceOptions(BaseModel):
class Config: class Config:
extra = "ignore" extra = "ignore"
@@ -50,6 +95,7 @@ class StripeCreateInvoiceOptions(BaseModel):
fiat_method: FiatMethod = "checkout" fiat_method: FiatMethod = "checkout"
terminal: StripeTerminalOptions | None = None terminal: StripeTerminalOptions | None = None
checkout: StripeCheckoutOptions | None = None checkout: StripeCheckoutOptions | None = None
recurring: StripeRecurringOptions | None = None
class StripeWallet(FiatProvider): class StripeWallet(FiatProvider):
@@ -89,12 +135,10 @@ class StripeWallet(FiatProvider):
r = await self.client.get(url="/v1/balance", timeout=15) r = await self.client.get(url="/v1/balance", timeout=15)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
available = data.get("available") or [] available = data.get("available") or []
available_balance = 0 available_balance = 0
if available and isinstance(available, list): if available and isinstance(available, list):
available_balance = int(available[0].get("amount", 0)) available_balance = int(available[0].get("amount", 0))
return FiatStatusResponse(balance=available_balance) return FiatStatusResponse(balance=available_balance)
except json.JSONDecodeError: except json.JSONDecodeError:
return FiatStatusResponse("Server error: 'invalid json response'", 0) return FiatStatusResponse("Server error: 'invalid json response'", 0)
@@ -116,6 +160,11 @@ class StripeWallet(FiatProvider):
if not opts: if not opts:
return FiatInvoiceResponse(ok=False, error_message="Invalid Stripe options") return FiatInvoiceResponse(ok=False, error_message="Invalid Stripe options")
if opts.recurring is not None:
return await self._create_subscription_checkout_session(
payment_hash, memo, opts
)
if opts.fiat_method == "checkout": if opts.fiat_method == "checkout":
return await self._create_checkout_invoice( return await self._create_checkout_invoice(
amount_cents, currency, payment_hash, memo, opts amount_cents, currency, payment_hash, memo, opts
@@ -170,6 +219,7 @@ class StripeWallet(FiatProvider):
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
# ---------- One-off Checkout ----------
async def _create_checkout_invoice( async def _create_checkout_invoice(
self, self,
amount_cents: int, amount_cents: int,
@@ -223,6 +273,7 @@ class StripeWallet(FiatProvider):
ok=False, error_message=f"Unable to connect to {self.endpoint}." ok=False, error_message=f"Unable to connect to {self.endpoint}."
) )
# ---------- Terminal ----------
async def _create_terminal_invoice( async def _create_terminal_invoice(
self, self,
amount_cents: int, amount_cents: int,
@@ -265,8 +316,189 @@ class StripeWallet(FiatProvider):
ok=False, error_message=f"Unable to connect to {self.endpoint}." ok=False, error_message=f"Unable to connect to {self.endpoint}."
) )
# ---------- Subscription Checkout ----------
async def _create_subscription_checkout_session(
self,
payment_hash: str,
memo: str | None,
opts: StripeCreateInvoiceOptions,
) -> FiatInvoiceResponse:
rc = opts.recurring or StripeRecurringOptions()
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",
)
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"
)
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"),
]
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))
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 HTTPStatusError as e:
body = e.response.text if e.response is not None else "<no body>"
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}."
)
# ---------- 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"
return _STRIPE_STATUS_MAP.get(str(status).lower().strip(), "unknown")
# ---------- Helpers ----------
async def _get_price_id_by_lookup_key(self, lookup_key: str) -> str | None:
params = {"active": "true", "expand[]": "data.product", "limit": "1"}
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]:
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 []
def _normalize_stripe_id(self, checking_id: str) -> str: def _normalize_stripe_id(self, checking_id: str) -> str:
"""Remove our internal prefix so Stripe sees a real id."""
return ( return (
checking_id.replace("fiat_stripe_", "", 1) checking_id.replace("fiat_stripe_", "", 1)
if checking_id.startswith("fiat_stripe_") if checking_id.startswith("fiat_stripe_")
@@ -274,11 +506,9 @@ class StripeWallet(FiatProvider):
) )
def _status_from_checkout_session(self, data: dict) -> FiatPaymentStatus: def _status_from_checkout_session(self, data: dict) -> FiatPaymentStatus:
"""Map a Checkout Session to LNbits fiat status."""
if data.get("payment_status") == "paid": if data.get("payment_status") == "paid":
return FiatPaymentSuccessStatus() return FiatPaymentSuccessStatus()
# Consider an expired session a fail (existing 24h rule).
expires_at = data.get("expires_at") expires_at = data.get("expires_at")
_24h_ago = datetime.now(timezone.utc) - timedelta(hours=24) _24h_ago = datetime.now(timezone.utc) - timedelta(hours=24)
if expires_at and float(expires_at) < _24h_ago.timestamp(): if expires_at and float(expires_at) < _24h_ago.timestamp():
@@ -287,25 +517,18 @@ class StripeWallet(FiatProvider):
return FiatPaymentPendingStatus() return FiatPaymentPendingStatus()
def _status_from_payment_intent(self, pi: dict) -> FiatPaymentStatus: def _status_from_payment_intent(self, pi: dict) -> FiatPaymentStatus:
"""Map a PaymentIntent to LNbits fiat status (card_present friendly)."""
status = pi.get("status") status = pi.get("status")
if status == "succeeded": if status == "succeeded":
return FiatPaymentSuccessStatus() return FiatPaymentSuccessStatus()
if status in ("canceled", "payment_failed"): if status in ("canceled", "payment_failed"):
return FiatPaymentFailedStatus() return FiatPaymentFailedStatus()
if status == "requires_payment_method": if status == "requires_payment_method":
if pi.get("last_payment_error"): if pi.get("last_payment_error"):
return FiatPaymentFailedStatus() return FiatPaymentFailedStatus()
now_ts = datetime.now(timezone.utc).timestamp() now_ts = datetime.now(timezone.utc).timestamp()
created_ts = float(pi.get("created") or now_ts) created_ts = float(pi.get("created") or now_ts)
is_stale = (now_ts - created_ts) > 300 if (now_ts - created_ts) > 300:
if is_stale:
return FiatPaymentFailedStatus() return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus() return FiatPaymentPendingStatus()
def _build_headers_form(self) -> dict[str, str]: def _build_headers_form(self) -> dict[str, str]:
+29 -30
View File
@@ -154,7 +154,7 @@ function confettiStars() {
setTimeout(shoot, 200) setTimeout(shoot, 200)
} }
!(function (t, e) { !(function (t, e) {
;(!(function t(e, n, a, i) { !(function t(e, n, a, i) {
var o = !!( var o = !!(
e.Worker && e.Worker &&
e.Blob && e.Blob &&
@@ -248,12 +248,12 @@ function confettiStars() {
function e(e, n) { function e(e, n) {
t.postMessage({options: e || {}, callback: n}) t.postMessage({options: e || {}, callback: n})
} }
;((t.init = function (e) { ;(t.init = function (e) {
var n = e.transferControlToOffscreen() var n = e.transferControlToOffscreen()
t.postMessage({canvas: n}, [n]) t.postMessage({canvas: n}, [n])
}), }),
(t.fire = function (n, a, i) { (t.fire = function (n, a, i) {
if (g) return (e(n, null), g) if (g) return e(n, null), g
var o = Math.random().toString(36).slice(2) var o = Math.random().toString(36).slice(2)
return (g = l(function (a) { return (g = l(function (a) {
function r(e) { function r(e) {
@@ -264,15 +264,15 @@ function confettiStars() {
i(), i(),
a()) a())
} }
;(t.addEventListener('message', r), t.addEventListener('message', r),
e(n, o), e(n, o),
(m[o] = r.bind(null, {data: {callback: o}}))) (m[o] = r.bind(null, {data: {callback: o}}))
})) }))
}), }),
(t.reset = function () { (t.reset = function () {
for (var e in (t.postMessage({reset: !0}), m)) for (var e in (t.postMessage({reset: !0}), m))
(m[e](), delete m[e]) m[e](), delete m[e]
})) })
})(h) })(h)
} }
return h return h
@@ -328,12 +328,12 @@ function confettiStars() {
) )
} }
function k(t) { function k(t) {
;((t.width = document.documentElement.clientWidth), ;(t.width = document.documentElement.clientWidth),
(t.height = document.documentElement.clientHeight)) (t.height = document.documentElement.clientHeight)
} }
function I(t) { function I(t) {
var e = t.getBoundingClientRect() var e = t.getBoundingClientRect()
;((t.width = e.width), (t.height = e.height)) ;(t.width = e.width), (t.height = e.height)
} }
function T(t, e, n, o, r) { function T(t, e, n, o, r) {
var c, var c,
@@ -342,10 +342,10 @@ function confettiStars() {
d = t.getContext('2d'), d = t.getContext('2d'),
f = l(function (e) { f = l(function (e) {
function l() { function l() {
;((c = s = null), d.clearRect(0, 0, o.width, o.height), r(), e()) ;(c = s = null), d.clearRect(0, 0, o.width, o.height), r(), e()
} }
;((c = b.frame(function e() { ;(c = b.frame(function e() {
;(!a || !a ||
(o.width === i.width && o.height === i.height) || (o.width === i.width && o.height === i.height) ||
((o.width = t.width = i.width), (o.height = t.height = i.height)), ((o.width = t.width = i.width), (o.height = t.height = i.height)),
o.width || o.width ||
@@ -354,7 +354,7 @@ function confettiStars() {
d.clearRect(0, 0, o.width, o.height), d.clearRect(0, 0, o.width, o.height),
(u = u.filter(function (t) { (u = u.filter(function (t) {
return (function (t, e) { return (function (t, e) {
;((e.x += Math.cos(e.angle2D) * e.velocity + e.drift), ;(e.x += Math.cos(e.angle2D) * e.velocity + e.drift),
(e.y += Math.sin(e.angle2D) * e.velocity + e.gravity), (e.y += Math.sin(e.angle2D) * e.velocity + e.gravity),
(e.wobble += 0.1), (e.wobble += 0.1),
(e.velocity *= e.decay), (e.velocity *= e.decay),
@@ -363,7 +363,7 @@ function confettiStars() {
(e.tiltCos = Math.cos(e.tiltAngle)), (e.tiltCos = Math.cos(e.tiltAngle)),
(e.random = Math.random() + 5), (e.random = Math.random() + 5),
(e.wobbleX = e.x + 10 * e.scalar * Math.cos(e.wobble)), (e.wobbleX = e.x + 10 * e.scalar * Math.cos(e.wobble)),
(e.wobbleY = e.y + 10 * e.scalar * Math.sin(e.wobble))) (e.wobbleY = e.y + 10 * e.scalar * Math.sin(e.wobble))
var n = e.tick++ / e.totalTicks, var n = e.tick++ / e.totalTicks,
a = e.x + e.random * e.tiltCos, a = e.x + e.random * e.tiltCos,
i = e.y + e.random * e.tiltSin, i = e.y + e.random * e.tiltSin,
@@ -393,12 +393,12 @@ function confettiStars() {
2 * Math.PI 2 * Math.PI
) )
: (function (t, e, n, a, i, o, r, l, c) { : (function (t, e, n, a, i, o, r, l, c) {
;(t.save(), t.save(),
t.translate(e, n), t.translate(e, n),
t.rotate(o), t.rotate(o),
t.scale(a, i), t.scale(a, i),
t.arc(0, 0, 1, r, l, c), t.arc(0, 0, 1, r, l, c),
t.restore()) t.restore()
})( })(
t, t,
e.x, e.x,
@@ -420,18 +420,18 @@ function confettiStars() {
})(d, t) })(d, t)
})).length })).length
? (c = b.frame(e)) ? (c = b.frame(e))
: l()) : l()
})), })),
(s = l)) (s = l)
}) })
return { return {
addFettis: function (t) { addFettis: function (t) {
return ((u = u.concat(t)), f) return (u = u.concat(t)), f
}, },
canvas: t, canvas: t,
promise: f, promise: f,
reset: function () { reset: function () {
;(c && b.cancel(c), s && s()) c && b.cancel(c), s && s()
} }
} }
} }
@@ -466,7 +466,7 @@ function confettiStars() {
k = p(e, 'scalar'), k = p(e, 'scalar'),
I = (function (t) { I = (function (t) {
var e = p(t, 'origin', Object) var e = p(t, 'origin', Object)
return ((e.x = p(e, 'x', Number)), (e.y = p(e, 'y', Number)), e) return (e.x = p(e, 'x', Number)), (e.y = p(e, 'y', Number)), e
})(e), })(e),
E = d, E = d,
S = [], S = [],
@@ -531,7 +531,7 @@ function confettiStars() {
return l(function (t) { return l(function (t) {
t() t()
}) })
;(i && a i && a
? (t = a.canvas) ? (t = a.canvas)
: i && : i &&
!t && !t &&
@@ -547,7 +547,7 @@ function confettiStars() {
) )
})(g)), })(g)),
document.body.appendChild(t)), document.body.appendChild(t)),
r && !d && u(t)) r && !d && u(t)
var m = {width: t.width, height: t.height} var m = {width: t.width, height: t.height}
function b() { function b() {
if (s) { if (s) {
@@ -564,9 +564,9 @@ function confettiStars() {
m.width = m.height = null m.width = m.height = null
} }
function v() { function v() {
;((a = null), ;(a = null),
r && e.removeEventListener('resize', b), r && e.removeEventListener('resize', b),
i && t && (document.body.removeChild(t), (t = null), (d = !1))) i && t && (document.body.removeChild(t), (t = null), (d = !1))
} }
return ( return (
s && !d && s.init(t), s && !d && s.init(t),
@@ -578,13 +578,12 @@ function confettiStars() {
} }
return ( return (
(g.reset = function () { (g.reset = function () {
;(s && s.reset(), a && a.reset()) s && s.reset(), a && a.reset()
}), }),
g g
) )
} }
;((n.exports = E(null, {useWorker: !0, resize: !0})), ;(n.exports = E(null, {useWorker: !0, resize: !0})), (n.exports.create = E)
(n.exports.create = E))
})( })(
(function () { (function () {
return void 0 !== t ? t : 'undefined' != typeof self ? self : this || {} return void 0 !== t ? t : 'undefined' != typeof self ? self : this || {}
@@ -592,5 +591,5 @@ function confettiStars() {
e, e,
!1 !1
), ),
(t.confetti = e.exports)) (t.confetti = e.exports)
})(window, {}) })(window, {})