feat: first subscription
This commit is contained in:
@@ -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
|
async def get_payments_paginated( # noqa: C901
|
||||||
*,
|
*,
|
||||||
wallet_id: str | None = None,
|
wallet_id: str | None = None,
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ from fastapi import APIRouter, Request
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.crud.payments import (
|
from lnbits.core.crud.payments import (
|
||||||
|
get_latest_payment_by_extra_key_value,
|
||||||
get_standalone_payment,
|
get_standalone_payment,
|
||||||
|
update_payment,
|
||||||
)
|
)
|
||||||
|
from lnbits.core.models import Payment
|
||||||
from lnbits.core.models.misc import SimpleStatus
|
from lnbits.core.models.misc import SimpleStatus
|
||||||
from lnbits.core.models.payments import CreateInvoice
|
from lnbits.core.models.payments import CreateInvoice
|
||||||
from lnbits.core.services.fiat_providers import (
|
from lnbits.core.services.fiat_providers import (
|
||||||
@@ -15,7 +18,9 @@ from lnbits.core.services.fiat_providers import (
|
|||||||
verify_paypal_webhook,
|
verify_paypal_webhook,
|
||||||
)
|
)
|
||||||
from lnbits.core.services.payments import create_fiat_invoice
|
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.base import FiatSubscriptionPaymentOptions
|
||||||
|
from lnbits.fiat.square import SquareWallet
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
|
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)
|
await _handle_square_payment_event(event)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if event_type == "invoice.payment_made":
|
||||||
|
await _handle_square_invoice_payment_made(event)
|
||||||
|
return
|
||||||
|
|
||||||
logger.warning(f"Unhandled Square event type: '{event_type}'.")
|
logger.warning(f"Unhandled Square event type: '{event_type}'.")
|
||||||
|
|
||||||
|
|
||||||
async def _handle_square_payment_event(event: dict):
|
async def _handle_square_payment_event(event: dict):
|
||||||
payment = _square_extract_payment(event)
|
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")
|
order_id = payment.get("order_id")
|
||||||
if not order_id:
|
if not order_id:
|
||||||
logger.warning("Square payment event missing 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)
|
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:
|
def _square_extract_payment(event: dict) -> dict:
|
||||||
event_object = event.get("data", {}).get("object", {})
|
event_object = event.get("data", {}).get("object", {})
|
||||||
return event_object.get("payment") or event_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()
|
||||||
|
|||||||
+189
-18
@@ -7,7 +7,7 @@ import httpx
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel, Field, ValidationError
|
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 lnbits.settings import settings
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
@@ -23,7 +23,7 @@ from .base import (
|
|||||||
FiatSubscriptionResponse,
|
FiatSubscriptionResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
FiatMethod = Literal["checkout"]
|
FiatMethod = Literal["checkout", "subscription"]
|
||||||
|
|
||||||
|
|
||||||
class SquareCheckoutOptions(BaseModel):
|
class SquareCheckoutOptions(BaseModel):
|
||||||
@@ -35,12 +35,21 @@ class SquareCheckoutOptions(BaseModel):
|
|||||||
line_item_name: str | None = None
|
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 SquareCreateInvoiceOptions(BaseModel):
|
||||||
class Config:
|
class Config:
|
||||||
extra = "ignore"
|
extra = "ignore"
|
||||||
|
|
||||||
fiat_method: FiatMethod = "checkout"
|
fiat_method: FiatMethod = "checkout"
|
||||||
checkout: SquareCheckoutOptions | None = None
|
checkout: SquareCheckoutOptions | None = None
|
||||||
|
subscription: SquareSubscriptionOptions | None = None
|
||||||
|
|
||||||
|
|
||||||
class SquareWallet(FiatProvider):
|
class SquareWallet(FiatProvider):
|
||||||
@@ -105,6 +114,9 @@ class SquareWallet(FiatProvider):
|
|||||||
if not opts:
|
if not opts:
|
||||||
return FiatInvoiceResponse(ok=False, error_message="Invalid Square options")
|
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)
|
amount_cents = int(amount * 100)
|
||||||
co = opts.checkout or SquareCheckoutOptions()
|
co = opts.checkout or SquareCheckoutOptions()
|
||||||
success_url = (
|
success_url = (
|
||||||
@@ -175,19 +187,86 @@ class SquareWallet(FiatProvider):
|
|||||||
payment_options: FiatSubscriptionPaymentOptions,
|
payment_options: FiatSubscriptionPaymentOptions,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> FiatSubscriptionResponse:
|
) -> FiatSubscriptionResponse:
|
||||||
return FiatSubscriptionResponse(
|
success_url = (
|
||||||
ok=False, error_message="Square subscriptions are not supported."
|
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(
|
async def cancel_subscription(
|
||||||
self,
|
self,
|
||||||
subscription_id: str,
|
subscription_id: str,
|
||||||
correlation_id: str,
|
correlation_id: str,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> FiatSubscriptionResponse:
|
) -> FiatSubscriptionResponse:
|
||||||
return FiatSubscriptionResponse(
|
try:
|
||||||
ok=False, error_message="Square subscriptions are not supported."
|
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:
|
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
|
||||||
raise NotImplementedError("Square does not support paying invoices directly.")
|
raise NotImplementedError("Square does not support paying invoices directly.")
|
||||||
@@ -222,16 +301,8 @@ class SquareWallet(FiatProvider):
|
|||||||
yield value
|
yield value
|
||||||
|
|
||||||
async def _get_order_status(self, order_id: str) -> FiatPaymentStatus:
|
async def _get_order_status(self, order_id: str) -> FiatPaymentStatus:
|
||||||
r = await self.client.get(f"/v2/orders/{order_id}")
|
order = await self._get_order(order_id)
|
||||||
r.raise_for_status()
|
payment_id = self._payment_id_from_order(order)
|
||||||
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
|
|
||||||
|
|
||||||
if payment_id:
|
if payment_id:
|
||||||
return await self._get_payment_status(payment_id)
|
return await self._get_payment_status(payment_id)
|
||||||
|
|
||||||
@@ -239,10 +310,60 @@ class SquareWallet(FiatProvider):
|
|||||||
return FiatPaymentFailedStatus()
|
return FiatPaymentFailedStatus()
|
||||||
return FiatPaymentPendingStatus()
|
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:
|
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 = await self.client.get(f"/v2/payments/{payment_id}")
|
||||||
r.raise_for_status()
|
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:
|
def _status_from_payment(self, payment: dict[str, Any]) -> FiatPaymentStatus:
|
||||||
status = (payment.get("status") or "").upper()
|
status = (payment.get("status") or "").upper()
|
||||||
@@ -252,6 +373,17 @@ class SquareWallet(FiatProvider):
|
|||||||
return FiatPaymentFailedStatus()
|
return FiatPaymentFailedStatus()
|
||||||
return FiatPaymentPendingStatus()
|
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:
|
def _normalize_square_id(self, checking_id: str) -> str:
|
||||||
return (
|
return (
|
||||||
checking_id.replace("fiat_square_", "", 1)
|
checking_id.replace("fiat_square_", "", 1)
|
||||||
@@ -268,6 +400,45 @@ class SquareWallet(FiatProvider):
|
|||||||
logger.warning(f"Invalid Square options: {e}")
|
logger.warning(f"Invalid Square options: {e}")
|
||||||
return None
|
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:
|
def _settings_connection_fields(self) -> str:
|
||||||
return "-".join(
|
return "-".join(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -698,6 +698,7 @@
|
|||||||
<span v-text="$t('webhook_events_list')"></span>
|
<span v-text="$t('webhook_events_list')"></span>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>payment.updated</code></li>
|
<li><code>payment.updated</code></li>
|
||||||
|
<li><code>invoice.payment_made</code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
@@ -914,7 +915,7 @@
|
|||||||
<q-chip dense color="positive" text-color="white" icon="check"
|
<q-chip dense color="positive" text-color="white" icon="check"
|
||||||
>Checkout</q-chip
|
>Checkout</q-chip
|
||||||
>
|
>
|
||||||
<q-chip dense color="negative" text-color="white" icon="close"
|
<q-chip dense color="positive" text-color="white" icon="check"
|
||||||
>Subscriptions</q-chip
|
>Subscriptions</q-chip
|
||||||
>
|
>
|
||||||
<q-chip dense color="negative" text-color="white" icon="close"
|
<q-chip dense color="negative" text-color="white" icon="close"
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from lnbits.core.views.callback_api import (
|
|||||||
handle_square_event,
|
handle_square_event,
|
||||||
handle_stripe_event,
|
handle_stripe_event,
|
||||||
)
|
)
|
||||||
|
from lnbits.fiat.square import SquareWallet
|
||||||
|
from lnbits.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -140,7 +142,9 @@ async def test_callback_api_handles_square_paid_events(mocker):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_callback_api_handles_subscription_flows_and_validation(mocker):
|
async def test_callback_api_handles_subscription_flows_and_validation(
|
||||||
|
mocker, settings: Settings
|
||||||
|
):
|
||||||
user = await create_user_account(
|
user = await create_user_account(
|
||||||
Account(
|
Account(
|
||||||
id=uuid4().hex,
|
id=uuid4().hex,
|
||||||
@@ -208,6 +212,98 @@ async def test_callback_api_handles_subscription_flows_and_validation(mocker):
|
|||||||
)
|
)
|
||||||
assert create_fiat_invoice_mock.await_count == 2
|
assert create_fiat_invoice_mock.await_count == 2
|
||||||
|
|
||||||
|
await handle_square_event(
|
||||||
|
{
|
||||||
|
"event_id": "evt_square_subscription",
|
||||||
|
"type": "payment.updated",
|
||||||
|
"data": {
|
||||||
|
"object": {
|
||||||
|
"payment": {
|
||||||
|
"id": "PAYMENT_SUB_1",
|
||||||
|
"order_id": "ORDER_SUB_1",
|
||||||
|
"status": "COMPLETED",
|
||||||
|
"amount_money": {"amount": 925, "currency": "USD"},
|
||||||
|
"note": json.dumps(
|
||||||
|
[
|
||||||
|
wallet.id,
|
||||||
|
"members",
|
||||||
|
"subscription_square_1",
|
||||||
|
"link-1",
|
||||||
|
"Square Members",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert create_fiat_invoice_mock.await_count == 3
|
||||||
|
square_call = create_fiat_invoice_mock.await_args.kwargs
|
||||||
|
assert square_call["wallet_id"] == wallet.id
|
||||||
|
square_invoice = square_call["invoice_data"]
|
||||||
|
assert square_invoice.fiat_provider == "square"
|
||||||
|
assert square_invoice.amount == 9.25
|
||||||
|
assert square_invoice.memo == "Square Members"
|
||||||
|
assert square_invoice.extra["fiat_method"] == "subscription"
|
||||||
|
assert square_invoice.extra["tag"] == "members"
|
||||||
|
assert (
|
||||||
|
square_invoice.extra["subscription"]["checking_id"] == "payment_PAYMENT_SUB_1"
|
||||||
|
)
|
||||||
|
|
||||||
|
payment.extra = {
|
||||||
|
"subscription_request_id": "subscription_square_1",
|
||||||
|
"tag": "members",
|
||||||
|
"square_subscription_id": "SUBSCRIPTION_1",
|
||||||
|
"link": "link-1",
|
||||||
|
}
|
||||||
|
payment.memo = "Square Members"
|
||||||
|
settings.square_api_endpoint = "https://connect.squareupsandbox.com"
|
||||||
|
settings.square_access_token = "square-token"
|
||||||
|
settings.square_location_id = "LOC123"
|
||||||
|
settings.square_api_version = "2026-01-22"
|
||||||
|
square_provider = SquareWallet()
|
||||||
|
mocker.patch.object(
|
||||||
|
square_provider,
|
||||||
|
"get_payment_for_order",
|
||||||
|
return_value={
|
||||||
|
"id": "PAYMENT_SUB_2",
|
||||||
|
"status": "COMPLETED",
|
||||||
|
"amount_money": {"amount": 925, "currency": "USD"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mocker.patch(
|
||||||
|
"lnbits.core.views.callback_api.get_fiat_provider",
|
||||||
|
mocker.AsyncMock(return_value=square_provider),
|
||||||
|
)
|
||||||
|
mocker.patch(
|
||||||
|
"lnbits.core.views.callback_api.get_latest_payment_by_extra_key_value",
|
||||||
|
mocker.AsyncMock(return_value=payment),
|
||||||
|
)
|
||||||
|
|
||||||
|
await handle_square_event(
|
||||||
|
{
|
||||||
|
"event_id": "evt_square_invoice",
|
||||||
|
"type": "invoice.payment_made",
|
||||||
|
"data": {
|
||||||
|
"object": {
|
||||||
|
"invoice": {
|
||||||
|
"order_id": "ORDER_SUB_2",
|
||||||
|
"subscription_id": "SUBSCRIPTION_1",
|
||||||
|
"public_url": "https://square.example/invoice",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert create_fiat_invoice_mock.await_count == 4
|
||||||
|
square_invoice_call = create_fiat_invoice_mock.await_args.kwargs
|
||||||
|
square_invoice = square_invoice_call["invoice_data"]
|
||||||
|
assert square_invoice.extra["square_subscription_id"] == "SUBSCRIPTION_1"
|
||||||
|
assert (
|
||||||
|
square_invoice.extra["subscription"]["payment_request"]
|
||||||
|
== "https://square.example/invoice"
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError, match="PayPal subscription event missing custom metadata."
|
ValueError, match="PayPal subscription event missing custom metadata."
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
@@ -25,7 +26,12 @@ from lnbits.core.services.fiat_providers import (
|
|||||||
test_connection as fiat_provider_connection,
|
test_connection as fiat_provider_connection,
|
||||||
)
|
)
|
||||||
from lnbits.core.services.users import create_user_account
|
from lnbits.core.services.users import create_user_account
|
||||||
from lnbits.fiat.base import FiatInvoiceResponse, FiatPaymentStatus, FiatStatusResponse
|
from lnbits.fiat.base import (
|
||||||
|
FiatInvoiceResponse,
|
||||||
|
FiatPaymentStatus,
|
||||||
|
FiatStatusResponse,
|
||||||
|
FiatSubscriptionPaymentOptions,
|
||||||
|
)
|
||||||
from lnbits.fiat.square import SquareWallet
|
from lnbits.fiat.square import SquareWallet
|
||||||
from lnbits.settings import Settings
|
from lnbits.settings import Settings
|
||||||
from tests.helpers import get_random_string
|
from tests.helpers import get_random_string
|
||||||
@@ -401,6 +407,125 @@ async def test_square_wallet_create_invoice(settings: Settings):
|
|||||||
assert payload["order"]["line_items"][0]["base_price_money"]["amount"] == 123
|
assert payload["order"]["line_items"][0]["base_price_money"]["amount"] == 123
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_square_wallet_create_subscription(settings: Settings):
|
||||||
|
settings.square_api_endpoint = "https://connect.squareupsandbox.com"
|
||||||
|
settings.square_access_token = "square-token"
|
||||||
|
settings.square_location_id = "LOC123"
|
||||||
|
settings.square_api_version = "2026-01-22"
|
||||||
|
settings.square_payment_success_url = "https://lnbits.example/success"
|
||||||
|
|
||||||
|
wallet = SquareWallet()
|
||||||
|
client = MockHTTPClient(
|
||||||
|
[
|
||||||
|
MockHTTPResponse(
|
||||||
|
json_data={
|
||||||
|
"object": {
|
||||||
|
"type": "SUBSCRIPTION_PLAN_VARIATION",
|
||||||
|
"subscription_plan_variation_data": {
|
||||||
|
"phases": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"pricing": {
|
||||||
|
"type": "STATIC",
|
||||||
|
"price_money": {
|
||||||
|
"amount": 1500,
|
||||||
|
"currency": "USD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
MockHTTPResponse(
|
||||||
|
json_data={
|
||||||
|
"payment_link": {
|
||||||
|
"id": "plink_123",
|
||||||
|
"url": "https://square.link/u/sub_123",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
wallet.client = client # type: ignore[assignment]
|
||||||
|
|
||||||
|
payment_options = FiatSubscriptionPaymentOptions(
|
||||||
|
wallet_id="wallet_1",
|
||||||
|
memo="Monthly Gold",
|
||||||
|
tag="gold",
|
||||||
|
extra={"link": "link-1"},
|
||||||
|
success_url="https://lnbits.example/subscription-success",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await wallet.create_subscription(
|
||||||
|
"PLAN_VARIATION_123", 1, payment_options
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.ok is True
|
||||||
|
assert response.checkout_session_url == "https://square.link/u/sub_123"
|
||||||
|
assert response.subscription_request_id is not None
|
||||||
|
assert client.calls[0][0] == "/v2/catalog/object/PLAN_VARIATION_123"
|
||||||
|
assert client.calls[1][0] == "/v2/online-checkout/payment-links"
|
||||||
|
payload = client.calls[1][1]["json"]
|
||||||
|
assert payload["idempotency_key"] == response.subscription_request_id
|
||||||
|
assert payload["quick_pay"]["location_id"] == "LOC123"
|
||||||
|
assert payload["quick_pay"]["price_money"] == {"amount": 1500, "currency": "USD"}
|
||||||
|
assert payload["checkout_options"] == {
|
||||||
|
"redirect_url": "https://lnbits.example/subscription-success",
|
||||||
|
"subscription_plan_id": "PLAN_VARIATION_123",
|
||||||
|
}
|
||||||
|
metadata = json.loads(payload["payment_note"])
|
||||||
|
assert metadata[:3] == ["wallet_1", "gold", response.subscription_request_id]
|
||||||
|
assert metadata[3:] == ["link-1", "Monthly Gold"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_square_wallet_create_subscription_invoice(settings: Settings):
|
||||||
|
settings.square_api_endpoint = "https://connect.squareupsandbox.com"
|
||||||
|
settings.square_access_token = "square-token"
|
||||||
|
settings.square_location_id = "LOC123"
|
||||||
|
settings.square_api_version = "2026-01-22"
|
||||||
|
|
||||||
|
wallet = SquareWallet()
|
||||||
|
|
||||||
|
response = await wallet.create_invoice(
|
||||||
|
amount=15,
|
||||||
|
payment_hash="hash123",
|
||||||
|
currency="USD",
|
||||||
|
memo="Square subscription payment",
|
||||||
|
extra={
|
||||||
|
"fiat_method": "subscription",
|
||||||
|
"subscription": {
|
||||||
|
"checking_id": "payment_PAYMENT123",
|
||||||
|
"payment_request": "https://square.example/invoice",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.ok is True
|
||||||
|
assert response.checking_id == "payment_PAYMENT123"
|
||||||
|
assert response.payment_request == "https://square.example/invoice"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_square_wallet_cancel_subscription(settings: Settings):
|
||||||
|
settings.square_api_endpoint = "https://connect.squareupsandbox.com"
|
||||||
|
settings.square_access_token = "square-token"
|
||||||
|
settings.square_location_id = "LOC123"
|
||||||
|
settings.square_api_version = "2026-01-22"
|
||||||
|
|
||||||
|
wallet = SquareWallet()
|
||||||
|
client = MockHTTPClient([MockHTTPResponse(json_data={"subscription": {}})])
|
||||||
|
wallet.client = client # type: ignore[assignment]
|
||||||
|
|
||||||
|
response = await wallet.cancel_subscription("SUBSCRIPTION123", "wallet_1")
|
||||||
|
|
||||||
|
assert response.ok is True
|
||||||
|
assert client.calls[0][0] == "/v2/subscriptions/SUBSCRIPTION123/cancel"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_square_wallet_get_invoice_status(settings: Settings):
|
async def test_square_wallet_get_invoice_status(settings: Settings):
|
||||||
settings.square_api_endpoint = "https://connect.squareupsandbox.com"
|
settings.square_api_endpoint = "https://connect.squareupsandbox.com"
|
||||||
|
|||||||
Reference in New Issue
Block a user