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
import httpx
from httpx import HTTPStatusError
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
@@ -25,6 +26,34 @@ 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",
]
# 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 Config:
@@ -43,6 +72,22 @@ class StripeCheckoutOptions(BaseModel):
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 Config:
extra = "ignore"
@@ -50,6 +95,7 @@ class StripeCreateInvoiceOptions(BaseModel):
fiat_method: FiatMethod = "checkout"
terminal: StripeTerminalOptions | None = None
checkout: StripeCheckoutOptions | None = None
recurring: StripeRecurringOptions | None = None
class StripeWallet(FiatProvider):
@@ -89,12 +135,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 +160,11 @@ class StripeWallet(FiatProvider):
if not opts:
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":
return await self._create_checkout_invoice(
amount_cents, currency, payment_hash, memo, opts
@@ -170,6 +219,7 @@ class StripeWallet(FiatProvider):
r.raise_for_status()
return r.json()
# ---------- One-off Checkout ----------
async def _create_checkout_invoice(
self,
amount_cents: int,
@@ -223,6 +273,7 @@ class StripeWallet(FiatProvider):
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
# ---------- Terminal ----------
async def _create_terminal_invoice(
self,
amount_cents: int,
@@ -265,8 +316,189 @@ class StripeWallet(FiatProvider):
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:
"""Remove our internal prefix so Stripe sees a real id."""
return (
checking_id.replace("fiat_stripe_", "", 1)
if checking_id.startswith("fiat_stripe_")
@@ -274,11 +506,9 @@ class StripeWallet(FiatProvider):
)
def _status_from_checkout_session(self, data: dict) -> FiatPaymentStatus:
"""Map a Checkout Session to LNbits fiat status."""
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 +517,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]:
+29 -30
View File
@@ -154,7 +154,7 @@ function confettiStars() {
setTimeout(shoot, 200)
}
!(function (t, e) {
;(!(function t(e, n, a, i) {
!(function t(e, n, a, i) {
var o = !!(
e.Worker &&
e.Blob &&
@@ -248,12 +248,12 @@ function confettiStars() {
function e(e, n) {
t.postMessage({options: e || {}, callback: n})
}
;((t.init = function (e) {
;(t.init = function (e) {
var n = e.transferControlToOffscreen()
t.postMessage({canvas: n}, [n])
}),
(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)
return (g = l(function (a) {
function r(e) {
@@ -264,15 +264,15 @@ function confettiStars() {
i(),
a())
}
;(t.addEventListener('message', r),
t.addEventListener('message', r),
e(n, o),
(m[o] = r.bind(null, {data: {callback: o}})))
(m[o] = r.bind(null, {data: {callback: o}}))
}))
}),
(t.reset = function () {
for (var e in (t.postMessage({reset: !0}), m))
(m[e](), delete m[e])
}))
m[e](), delete m[e]
})
})(h)
}
return h
@@ -328,12 +328,12 @@ function confettiStars() {
)
}
function k(t) {
;((t.width = document.documentElement.clientWidth),
(t.height = document.documentElement.clientHeight))
;(t.width = document.documentElement.clientWidth),
(t.height = document.documentElement.clientHeight)
}
function I(t) {
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) {
var c,
@@ -342,10 +342,10 @@ function confettiStars() {
d = t.getContext('2d'),
f = l(function (e) {
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() {
;(!a ||
;(c = b.frame(function e() {
!a ||
(o.width === i.width && o.height === i.height) ||
((o.width = t.width = i.width), (o.height = t.height = i.height)),
o.width ||
@@ -354,7 +354,7 @@ function confettiStars() {
d.clearRect(0, 0, o.width, o.height),
(u = u.filter(function (t) {
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.wobble += 0.1),
(e.velocity *= e.decay),
@@ -363,7 +363,7 @@ function confettiStars() {
(e.tiltCos = Math.cos(e.tiltAngle)),
(e.random = Math.random() + 5),
(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,
a = e.x + e.random * e.tiltCos,
i = e.y + e.random * e.tiltSin,
@@ -393,12 +393,12 @@ function confettiStars() {
2 * Math.PI
)
: (function (t, e, n, a, i, o, r, l, c) {
;(t.save(),
t.save(),
t.translate(e, n),
t.rotate(o),
t.scale(a, i),
t.arc(0, 0, 1, r, l, c),
t.restore())
t.restore()
})(
t,
e.x,
@@ -420,18 +420,18 @@ function confettiStars() {
})(d, t)
})).length
? (c = b.frame(e))
: l())
: l()
})),
(s = l))
(s = l)
})
return {
addFettis: function (t) {
return ((u = u.concat(t)), f)
return (u = u.concat(t)), f
},
canvas: t,
promise: f,
reset: function () {
;(c && b.cancel(c), s && s())
c && b.cancel(c), s && s()
}
}
}
@@ -466,7 +466,7 @@ function confettiStars() {
k = p(e, 'scalar'),
I = (function (t) {
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 = d,
S = [],
@@ -531,7 +531,7 @@ function confettiStars() {
return l(function (t) {
t()
})
;(i && a
i && a
? (t = a.canvas)
: i &&
!t &&
@@ -547,7 +547,7 @@ function confettiStars() {
)
})(g)),
document.body.appendChild(t)),
r && !d && u(t))
r && !d && u(t)
var m = {width: t.width, height: t.height}
function b() {
if (s) {
@@ -564,9 +564,9 @@ function confettiStars() {
m.width = m.height = null
}
function v() {
;((a = null),
;(a = null),
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 (
s && !d && s.init(t),
@@ -578,13 +578,12 @@ function confettiStars() {
}
return (
(g.reset = function () {
;(s && s.reset(), a && a.reset())
s && s.reset(), a && a.reset()
}),
g
)
}
;((n.exports = E(null, {useWorker: !0, resize: !0})),
(n.exports.create = E))
;(n.exports = E(null, {useWorker: !0, resize: !0})), (n.exports.create = E)
})(
(function () {
return void 0 !== t ? t : 'undefined' != typeof self ? self : this || {}
@@ -592,5 +591,5 @@ function confettiStars() {
e,
!1
),
(t.confetti = e.exports))
(t.confetti = e.exports)
})(window, {})