From 2abedccbc8b87b80cdcae40a8dbfd63f4aca5cdd Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 12 May 2026 10:56:38 +0300 Subject: [PATCH] feat: first subscription --- lnbits/core/crud/payments.py | 29 +++ lnbits/core/views/callback_api.py | 166 ++++++++++++++ lnbits/fiat/square.py | 207 ++++++++++++++++-- .../components/admin/fiat_providers.vue | 3 +- tests/api/test_callback_api.py | 98 ++++++++- tests/unit/test_fiat_providers.py | 127 ++++++++++- 6 files changed, 609 insertions(+), 21 deletions(-) diff --git a/lnbits/core/crud/payments.py b/lnbits/core/crud/payments.py index 0a9a36729..f890d8e97 100644 --- a/lnbits/core/crud/payments.py +++ b/lnbits/core/crud/payments.py @@ -107,6 +107,35 @@ async def get_latest_payments_by_extension( ) +async def get_latest_payment_by_extra_key_value( + key: str, + value: str, + wallet_id: str | None = None, + conn: Connection | None = None, +) -> Payment | None: + values = { + "extra_key": f'%"{key}"%', + "extra_value": f'%"{value}"%', + } + clause = ["extra LIKE :extra_key", "extra LIKE :extra_value"] + if wallet_id: + wallet = await get_wallet(wallet_id, conn=conn) + if not wallet or not wallet.can_view_payments: + return None + values["wallet_id"] = wallet.source_wallet_id + clause.append("wallet_id = :wallet_id") + + return await (conn or db).fetchone( + f""" + SELECT * FROM apipayments + WHERE {" AND ".join(clause)} + ORDER BY time DESC LIMIT 1 + """, # noqa: S608 + values, + Payment, + ) + + async def get_payments_paginated( # noqa: C901 *, wallet_id: str | None = None, diff --git a/lnbits/core/views/callback_api.py b/lnbits/core/views/callback_api.py index 3a1376328..d06e9b0a4 100644 --- a/lnbits/core/views/callback_api.py +++ b/lnbits/core/views/callback_api.py @@ -4,8 +4,11 @@ from fastapi import APIRouter, Request from loguru import logger from lnbits.core.crud.payments import ( + get_latest_payment_by_extra_key_value, get_standalone_payment, + update_payment, ) +from lnbits.core.models import Payment from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.payments import CreateInvoice from lnbits.core.services.fiat_providers import ( @@ -15,7 +18,9 @@ from lnbits.core.services.fiat_providers import ( verify_paypal_webhook, ) from lnbits.core.services.payments import create_fiat_invoice +from lnbits.fiat import get_fiat_provider from lnbits.fiat.base import FiatSubscriptionPaymentOptions +from lnbits.fiat.square import SquareWallet from lnbits.settings import settings callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"]) @@ -309,11 +314,23 @@ async def handle_square_event(event: dict): await _handle_square_payment_event(event) return + if event_type == "invoice.payment_made": + await _handle_square_invoice_payment_made(event) + return + logger.warning(f"Unhandled Square event type: '{event_type}'.") async def _handle_square_payment_event(event: dict): payment = _square_extract_payment(event) + payment_options = _deserialize_square_metadata(_square_payment_note(payment)) + if payment_options.wallet_id: + if not _square_payment_is_completed(payment): + logger.debug("Square subscription payment is not completed yet.") + return + await _handle_square_subscription_payment(payment, payment_options) + return + order_id = payment.get("order_id") if not order_id: logger.warning("Square payment event missing order_id.") @@ -327,6 +344,155 @@ async def _handle_square_payment_event(event: dict): await check_fiat_status(lnbits_payment) +async def _handle_square_invoice_payment_made(event: dict): + invoice = event.get("data", {}).get("object", {}).get("invoice") or {} + order_id = invoice.get("order_id") + if not order_id: + logger.warning("Square invoice.payment_made event missing order_id.") + return + subscription_id = invoice.get("subscription_id") + + fiat_provider = await get_fiat_provider("square") + if not isinstance(fiat_provider, SquareWallet): + logger.warning("Square fiat provider is not configured.") + return + + payment = await fiat_provider.get_payment_for_order(order_id) + if not payment: + logger.warning(f"No Square payment found for invoice order: '{order_id}'.") + return + + payment_options = _deserialize_square_metadata(_square_payment_note(payment)) + if not payment_options.wallet_id: + stored_payment = ( + await get_latest_payment_by_extra_key_value( + "square_subscription_id", subscription_id + ) + if subscription_id + else None + ) + if stored_payment: + payment_options = _square_payment_options_from_payment(stored_payment) + else: + logger.warning("Square subscription payment missing LNbits metadata.") + return + + await _handle_square_subscription_payment( + payment, + payment_options, + invoice.get("public_url") or "", + square_subscription_id=subscription_id, + ) + + +async def _handle_square_subscription_payment( + payment: dict, + payment_options: FiatSubscriptionPaymentOptions, + payment_request: str = "", + square_subscription_id: str | None = None, +): + amount_money = payment.get("amount_money") or {} + amount = amount_money.get("amount") + currency = (amount_money.get("currency") or "").upper() + payment_id = payment.get("id") + if amount is None or not currency or not payment_id: + raise ValueError("Square subscription payment event missing payment amount.") + wallet_id = payment_options.wallet_id + if not wallet_id: + raise ValueError("Square subscription payment event missing wallet_id.") + + checking_id = f"payment_{payment_id}" + existing_payment = await get_standalone_payment(f"fiat_square_{checking_id}") + if existing_payment: + if ( + square_subscription_id + and (existing_payment.extra or {}).get("square_subscription_id") + != square_subscription_id + ): + existing_payment.extra["square_subscription_id"] = square_subscription_id + await update_payment(existing_payment) + await check_fiat_status(existing_payment) + return + + square_subscription_id = square_subscription_id or ( + payment_options.extra or {} + ).get("square_subscription_id") + extra = { + **(payment_options.extra or {}), + "subscription_request_id": payment_options.subscription_request_id, + "fiat_method": "subscription", + "tag": payment_options.tag, + "subscription": { + "checking_id": checking_id, + "payment_request": payment_request, + }, + } + if square_subscription_id: + extra["square_subscription_id"] = square_subscription_id + + lnbits_payment = await create_fiat_invoice( + wallet_id=wallet_id, + invoice_data=CreateInvoice( + unit=currency, + amount=amount / 100, + memo=payment_options.memo or "", + extra=extra, + fiat_provider="square", + ), + ) + + await check_fiat_status(lnbits_payment) + + +def _square_payment_options_from_payment( + payment: Payment, +) -> FiatSubscriptionPaymentOptions: + extra = payment.extra or {} + return FiatSubscriptionPaymentOptions( + wallet_id=payment.wallet_id, + tag=extra.get("tag") or payment.tag, + subscription_request_id=extra.get("subscription_request_id"), + extra=extra, + memo=payment.memo, + ) + + def _square_extract_payment(event: dict) -> dict: event_object = event.get("data", {}).get("object", {}) return event_object.get("payment") or event_object + + +def _square_payment_is_completed(payment: dict) -> bool: + return (payment.get("status") or "").upper() == "COMPLETED" + + +def _square_payment_note(payment: dict) -> str: + return payment.get("note") or payment.get("payment_note") or "" + + +def _deserialize_square_metadata(custom_id: str) -> FiatSubscriptionPaymentOptions: + try: + meta = json.loads(custom_id) + if not isinstance(meta, list): + return FiatSubscriptionPaymentOptions() + wallet_id = meta[0] if len(meta) > 0 else None + tag = meta[1] if len(meta) > 1 else None + subscription_request_id = meta[2] if len(meta) > 2 else None + extra_link = meta[3] if len(meta) > 3 else None + memo = meta[4] if len(meta) > 4 else None + + extra = { + "link": extra_link, + "subscription_request_id": subscription_request_id, + } + + return FiatSubscriptionPaymentOptions( + wallet_id=wallet_id, + tag=tag, + subscription_request_id=subscription_request_id, + extra=extra, + memo=memo, + ) + except (json.JSONDecodeError, IndexError, TypeError) as e: + logger.debug(f"Failed to deserialize Square metadata: {e}") + return FiatSubscriptionPaymentOptions() diff --git a/lnbits/fiat/square.py b/lnbits/fiat/square.py index f2c2c724d..5b69d2743 100644 --- a/lnbits/fiat/square.py +++ b/lnbits/fiat/square.py @@ -7,7 +7,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 ( @@ -23,7 +23,7 @@ from .base import ( FiatSubscriptionResponse, ) -FiatMethod = Literal["checkout"] +FiatMethod = Literal["checkout", "subscription"] class SquareCheckoutOptions(BaseModel): @@ -35,12 +35,21 @@ class SquareCheckoutOptions(BaseModel): line_item_name: str | None = None +class SquareSubscriptionOptions(BaseModel): + class Config: + extra = "ignore" + + checking_id: str | None = None + payment_request: str | None = None + + class SquareCreateInvoiceOptions(BaseModel): class Config: extra = "ignore" fiat_method: FiatMethod = "checkout" checkout: SquareCheckoutOptions | None = None + subscription: SquareSubscriptionOptions | None = None class SquareWallet(FiatProvider): @@ -105,6 +114,9 @@ class SquareWallet(FiatProvider): if not opts: return FiatInvoiceResponse(ok=False, error_message="Invalid Square options") + if opts.fiat_method == "subscription": + return self._create_subscription_invoice(opts.subscription) + amount_cents = int(amount * 100) co = opts.checkout or SquareCheckoutOptions() success_url = ( @@ -175,19 +187,86 @@ class SquareWallet(FiatProvider): payment_options: FiatSubscriptionPaymentOptions, **kwargs, ) -> FiatSubscriptionResponse: - return FiatSubscriptionResponse( - ok=False, error_message="Square subscriptions are not supported." + success_url = ( + payment_options.success_url + or settings.square_payment_success_url + or "https://lnbits.com" ) + if not payment_options.subscription_request_id: + payment_options.subscription_request_id = urlsafe_short_hash() + payment_options.extra = payment_options.extra or {} + payment_options.extra["subscription_request_id"] = ( + payment_options.subscription_request_id + ) + print("### create_subscription", subscription_id, quantity, payment_options) + try: + price_money = await self._get_subscription_price_money(subscription_id) + print("### price_money", price_money) + metadata = self._serialize_metadata(payment_options) + print("### metadata", metadata) + payload = { + "idempotency_key": payment_options.subscription_request_id, + "description": metadata, + "quick_pay": { + "name": (payment_options.memo or "LNbits Subscription")[:255], + "price_money": price_money, + "location_id": self.location_id, + }, + "checkout_options": { + "redirect_url": success_url, + "subscription_plan_id": subscription_id, + }, + "payment_note": metadata, + } + r = await self.client.post( + "/v2/online-checkout/payment-links", json=payload + ) + r.raise_for_status() + data = r.json() + print("### response data", data) + payment_link = data.get("payment_link") or {} + url = payment_link.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: - return FiatSubscriptionResponse( - ok=False, error_message="Square subscriptions are not supported." - ) + try: + square_subscription_id = await self._get_square_subscription_id( + subscription_id, correlation_id + ) + r = await self.client.post( + f"/v2/subscriptions/{square_subscription_id}/cancel" + ) + r.raise_for_status() + return FiatSubscriptionResponse(ok=True) + except Exception as exc: + logger.warning(exc) + return FiatSubscriptionResponse( + ok=False, error_message="Unable to cancel subscription." + ) async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse: raise NotImplementedError("Square does not support paying invoices directly.") @@ -222,16 +301,8 @@ class SquareWallet(FiatProvider): yield value async def _get_order_status(self, order_id: str) -> FiatPaymentStatus: - r = await self.client.get(f"/v2/orders/{order_id}") - r.raise_for_status() - order = r.json().get("order") or {} - tenders = order.get("tenders") or [] - payment_id = None - for tender in tenders: - payment_id = tender.get("payment_id") - if payment_id: - break - + order = await self._get_order(order_id) + payment_id = self._payment_id_from_order(order) if payment_id: return await self._get_payment_status(payment_id) @@ -239,10 +310,60 @@ class SquareWallet(FiatProvider): return FiatPaymentFailedStatus() return FiatPaymentPendingStatus() + async def _get_order(self, order_id: str) -> dict[str, Any]: + r = await self.client.get(f"/v2/orders/{order_id}") + r.raise_for_status() + return r.json().get("order") or {} + + async def get_payment_for_order(self, order_id: str) -> dict[str, Any] | None: + order = await self._get_order(order_id) + payment_id = self._payment_id_from_order(order) + if not payment_id: + return None + return await self._get_payment(payment_id) + + def _payment_id_from_order(self, order: dict[str, Any]) -> str | None: + tenders = order.get("tenders") or [] + for tender in tenders: + payment_id = tender.get("payment_id") + if payment_id: + return payment_id + return None + async def _get_payment_status(self, payment_id: str) -> FiatPaymentStatus: + return self._status_from_payment(await self._get_payment(payment_id)) + + async def _get_payment(self, payment_id: str) -> dict[str, Any]: r = await self.client.get(f"/v2/payments/{payment_id}") r.raise_for_status() - return self._status_from_payment(r.json().get("payment") or {}) + return r.json().get("payment") or {} + + async def _get_subscription_price_money( + self, plan_variation_id: str + ) -> dict[str, Any]: + r = await self.client.get(f"/v2/catalog/object/{plan_variation_id}") + r.raise_for_status() + catalog_object = r.json().get("object") or {} + if catalog_object.get("type") != "SUBSCRIPTION_PLAN_VARIATION": + raise ValueError("Square subscription ID must be a plan variation ID.") + + variation_data = catalog_object.get("subscription_plan_variation_data") or {} + phases = variation_data.get("phases") or [] + for phase in phases: + pricing = phase.get("pricing") or {} + price_money = pricing.get("price_money") or phase.get( + "recurring_price_money" + ) + if ( + price_money + and price_money.get("amount") is not None + and price_money.get("currency") + ): + return { + "amount": int(price_money["amount"]), + "currency": price_money["currency"].upper(), + } + raise ValueError("Square subscription plan variation is missing price_money.") def _status_from_payment(self, payment: dict[str, Any]) -> FiatPaymentStatus: status = (payment.get("status") or "").upper() @@ -252,6 +373,17 @@ class SquareWallet(FiatProvider): return FiatPaymentFailedStatus() return FiatPaymentPendingStatus() + def _create_subscription_invoice( + self, opts: SquareSubscriptionOptions | None + ) -> FiatInvoiceResponse: + term = opts or SquareSubscriptionOptions() + checking_id = term.checking_id or f"payment_{urlsafe_short_hash()}" + return FiatInvoiceResponse( + ok=True, + checking_id=checking_id, + payment_request=term.payment_request or "", + ) + def _normalize_square_id(self, checking_id: str) -> str: return ( checking_id.replace("fiat_square_", "", 1) @@ -268,6 +400,45 @@ class SquareWallet(FiatProvider): logger.warning(f"Invalid Square options: {e}") return None + def _serialize_metadata( + self, payment_options: FiatSubscriptionPaymentOptions + ) -> str: + extra_link = None + if payment_options.extra: + raw_link = payment_options.extra.get("link") + extra_link = str(raw_link)[:200] if raw_link else None + + meta = [ + payment_options.wallet_id, + payment_options.tag, + payment_options.subscription_request_id, + extra_link, + ] + + memo_limit = 493 - len(json.dumps(meta, separators=(",", ":"))) + if memo_limit > 0 and payment_options.memo: + meta.append(payment_options.memo[:memo_limit]) + else: + meta.append(None) + + metadata = json.dumps(meta, separators=(",", ":")) + if len(metadata) > 500: + raise ValueError("Square subscription metadata is too long.") + return metadata + + async def _get_square_subscription_id( + self, subscription_id: str, wallet_id: str + ) -> str: + from lnbits.core.crud.payments import get_latest_payment_by_extra_key_value + + payment = await get_latest_payment_by_extra_key_value( + "subscription_request_id", subscription_id, wallet_id=wallet_id + ) + if not payment: + return subscription_id + square_subscription_id = (payment.extra or {}).get("square_subscription_id") + return square_subscription_id or subscription_id + def _settings_connection_fields(self) -> str: return "-".join( [ diff --git a/lnbits/templates/components/admin/fiat_providers.vue b/lnbits/templates/components/admin/fiat_providers.vue index 1d07cb645..0a4330fea 100644 --- a/lnbits/templates/components/admin/fiat_providers.vue +++ b/lnbits/templates/components/admin/fiat_providers.vue @@ -698,6 +698,7 @@ @@ -914,7 +915,7 @@ Checkout - Subscriptions