621 lines
22 KiB
Python
621 lines
22 KiB
Python
import asyncio
|
|
import json
|
|
from collections.abc import AsyncGenerator
|
|
from typing import Any, Literal
|
|
|
|
import httpx
|
|
from loguru import logger
|
|
from pydantic import BaseModel, Field, ValidationError
|
|
|
|
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
|
|
from lnbits.settings import settings
|
|
|
|
from .base import (
|
|
FiatInvoiceResponse,
|
|
FiatPaymentFailedStatus,
|
|
FiatPaymentPendingStatus,
|
|
FiatPaymentResponse,
|
|
FiatPaymentStatus,
|
|
FiatPaymentSuccessStatus,
|
|
FiatProvider,
|
|
FiatStatusResponse,
|
|
FiatSubscriptionPaymentOptions,
|
|
FiatSubscriptionResponse,
|
|
)
|
|
|
|
FiatMethod = Literal["checkout", "subscription"]
|
|
|
|
|
|
class SquareCheckoutOptions(BaseModel):
|
|
class Config:
|
|
extra = "ignore"
|
|
|
|
success_url: str | None = None
|
|
metadata: dict[str, str] = Field(default_factory=dict)
|
|
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 SquareSubscriptionCheckoutInfo(BaseModel):
|
|
plan_variation_id: str
|
|
price_money: dict[str, Any]
|
|
|
|
|
|
class SquareWallet(FiatProvider):
|
|
"""https://developer.squareup.com/reference/square"""
|
|
|
|
def __init__(self):
|
|
logger.debug("Initializing SquareWallet")
|
|
self._settings_fields = self._settings_connection_fields()
|
|
if not settings.square_api_endpoint:
|
|
raise ValueError("Cannot initialize SquareWallet: missing endpoint.")
|
|
if not settings.square_access_token:
|
|
raise ValueError("Cannot initialize SquareWallet: missing access token.")
|
|
if not settings.square_location_id:
|
|
raise ValueError("Cannot initialize SquareWallet: missing location ID.")
|
|
|
|
self.endpoint = normalize_endpoint(settings.square_api_endpoint)
|
|
self.location_id = settings.square_location_id
|
|
self.headers = {
|
|
"Authorization": f"Bearer {settings.square_access_token}",
|
|
"Square-Version": settings.square_api_version,
|
|
"Content-Type": "application/json",
|
|
"User-Agent": settings.user_agent,
|
|
}
|
|
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
|
|
logger.info("SquareWallet initialized.")
|
|
|
|
async def cleanup(self):
|
|
try:
|
|
await self.client.aclose()
|
|
except RuntimeError as e:
|
|
logger.warning(f"Error closing Square wallet connection: {e}")
|
|
|
|
async def status(
|
|
self, only_check_settings: bool | None = False
|
|
) -> FiatStatusResponse:
|
|
if only_check_settings:
|
|
if self._settings_fields != self._settings_connection_fields():
|
|
return FiatStatusResponse("Connection settings have changed.", 0)
|
|
return FiatStatusResponse(balance=0)
|
|
|
|
try:
|
|
r = await self.client.get(f"/v2/locations/{self.location_id}", timeout=15)
|
|
r.raise_for_status()
|
|
_ = r.json()
|
|
return FiatStatusResponse(balance=0)
|
|
except json.JSONDecodeError:
|
|
return FiatStatusResponse("Server error: 'invalid json response'", 0)
|
|
except Exception as exc:
|
|
logger.warning(exc)
|
|
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
|
|
|
|
async def create_invoice(
|
|
self,
|
|
amount: float,
|
|
payment_hash: str,
|
|
currency: str,
|
|
memo: str | None = None,
|
|
extra: dict[str, Any] | None = None,
|
|
**kwargs,
|
|
) -> FiatInvoiceResponse:
|
|
opts = self._parse_create_opts(extra or {})
|
|
if not opts:
|
|
return FiatInvoiceResponse(ok=False, error_message="Invalid Square options")
|
|
|
|
if opts.fiat_method == "subscription":
|
|
return self._create_subscription_invoice(opts.subscription)
|
|
|
|
return await self._create_checkout_invoice(
|
|
amount=amount,
|
|
payment_hash=payment_hash,
|
|
currency=currency,
|
|
opts=opts,
|
|
memo=memo,
|
|
)
|
|
|
|
async def create_subscription(
|
|
self,
|
|
subscription_id: str,
|
|
quantity: int,
|
|
payment_options: FiatSubscriptionPaymentOptions,
|
|
**kwargs,
|
|
) -> FiatSubscriptionResponse:
|
|
if settings.lnbits_running:
|
|
return FiatSubscriptionResponse(
|
|
ok=False, error_message="Subscription not supported for Square."
|
|
)
|
|
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
|
|
)
|
|
try:
|
|
checkout_info = await self._get_subscription_checkout_info(subscription_id)
|
|
metadata = self._serialize_metadata(payment_options)
|
|
payload = {
|
|
"idempotency_key": payment_options.subscription_request_id,
|
|
"description": metadata,
|
|
"quick_pay": {
|
|
"name": (payment_options.memo or "LNbits Subscription")[:255],
|
|
"price_money": checkout_info.price_money,
|
|
"location_id": self.location_id,
|
|
},
|
|
"checkout_options": {
|
|
"redirect_url": success_url,
|
|
"subscription_plan_id": checkout_info.plan_variation_id,
|
|
},
|
|
"payment_note": metadata,
|
|
}
|
|
r = await self.client.post(
|
|
"/v2/online-checkout/payment-links", json=payload
|
|
)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
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:
|
|
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.")
|
|
|
|
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
|
|
try:
|
|
square_id = self._normalize_square_id(checking_id)
|
|
if square_id.startswith("payment_"):
|
|
payment_id = square_id.replace("payment_", "", 1)
|
|
return await self._get_payment_status(payment_id)
|
|
|
|
order_id = (
|
|
square_id.replace("order_", "", 1)
|
|
if square_id.startswith("order_")
|
|
else square_id
|
|
)
|
|
return await self._get_order_status(order_id)
|
|
except Exception as exc:
|
|
logger.debug(f"Error getting Square invoice status: {exc}")
|
|
return FiatPaymentPendingStatus()
|
|
|
|
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
|
|
raise NotImplementedError("Square does not support outgoing payments.")
|
|
|
|
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
|
logger.warning(
|
|
"Square does not support paid invoices stream. Use webhooks instead."
|
|
)
|
|
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
|
|
while settings.lnbits_running:
|
|
value = await mock_queue.get()
|
|
yield value
|
|
|
|
async def _get_order_status(self, order_id: str) -> FiatPaymentStatus:
|
|
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)
|
|
|
|
if (order.get("state") or "").upper() == "CANCELED":
|
|
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 r.json().get("payment") or {}
|
|
|
|
async def _get_subscription_checkout_info(
|
|
self, subscription_plan_id: str
|
|
) -> SquareSubscriptionCheckoutInfo:
|
|
catalog_object = await self._get_catalog_object(subscription_plan_id)
|
|
if catalog_object.get("type") == "SUBSCRIPTION_PLAN":
|
|
return await self._get_plan_checkout_info(catalog_object)
|
|
|
|
if catalog_object.get("type") == "SUBSCRIPTION_PLAN_VARIATION":
|
|
price_money = await self._get_subscription_price_money(
|
|
catalog_object,
|
|
)
|
|
plan_variation_id = catalog_object.get("id")
|
|
if not plan_variation_id:
|
|
raise ValueError("Square subscription plan variation is missing an ID.")
|
|
return SquareSubscriptionCheckoutInfo(
|
|
plan_variation_id=plan_variation_id,
|
|
price_money=price_money,
|
|
)
|
|
|
|
raise ValueError(
|
|
"Square subscription ID must be a plan ID or plan variation ID."
|
|
)
|
|
|
|
async def _get_plan_checkout_info(
|
|
self, catalog_object: dict[str, Any]
|
|
) -> SquareSubscriptionCheckoutInfo:
|
|
plan_data = catalog_object.get("subscription_plan_data") or {}
|
|
plan_variations = plan_data.get("subscription_plan_variations") or []
|
|
eligible_item_ids = plan_data.get("eligible_item_ids") or []
|
|
plan_variation = next(
|
|
(
|
|
variation
|
|
for variation in plan_variations
|
|
if not variation.get("is_deleted")
|
|
),
|
|
None,
|
|
)
|
|
if not plan_variation:
|
|
raise ValueError("Square subscription plan is missing a variation.")
|
|
|
|
price_money = await self._get_subscription_price_money(
|
|
plan_variation,
|
|
eligible_item_ids=eligible_item_ids,
|
|
)
|
|
plan_variation_id = plan_variation.get("id")
|
|
if not plan_variation_id:
|
|
raise ValueError("Square subscription plan variation is missing an ID.")
|
|
|
|
return SquareSubscriptionCheckoutInfo(
|
|
plan_variation_id=plan_variation_id,
|
|
price_money=price_money,
|
|
)
|
|
|
|
async def _get_catalog_object(self, object_id: str) -> dict[str, Any]:
|
|
r = await self.client.get(f"/v2/catalog/object/{object_id}")
|
|
r.raise_for_status()
|
|
return r.json().get("object") or {}
|
|
|
|
async def _get_subscription_price_money(
|
|
self,
|
|
plan_variation: dict[str, Any],
|
|
eligible_item_ids: list[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
variation_data = plan_variation.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"
|
|
)
|
|
parsed_price_money = self._parse_price_money(price_money)
|
|
if parsed_price_money:
|
|
return parsed_price_money
|
|
|
|
if pricing.get("type") == "RELATIVE":
|
|
return await self._get_relative_subscription_price_money(
|
|
eligible_item_ids or []
|
|
)
|
|
|
|
raise ValueError("Square subscription plan variation is missing price_money.")
|
|
|
|
async def _get_relative_subscription_price_money(
|
|
self, eligible_item_ids: list[str]
|
|
) -> dict[str, Any]:
|
|
if len(eligible_item_ids) != 1:
|
|
raise ValueError(
|
|
"Square relative subscription plan must have exactly one item."
|
|
)
|
|
|
|
item = await self._get_catalog_object(eligible_item_ids[0])
|
|
item_variations: list[dict[str, Any]] = []
|
|
if item.get("type") == "ITEM":
|
|
item_variations = (item.get("item_data") or {}).get("variations") or []
|
|
elif item.get("type") == "ITEM_VARIATION":
|
|
item_variations = [item]
|
|
|
|
item_variation = next(
|
|
(
|
|
variation
|
|
for variation in item_variations
|
|
if not variation.get("is_deleted")
|
|
),
|
|
None,
|
|
)
|
|
if not item_variation:
|
|
raise ValueError("Square subscription item is missing a variation.")
|
|
|
|
price_money = self._parse_price_money(
|
|
(item_variation.get("item_variation_data") or {}).get("price_money")
|
|
)
|
|
if price_money:
|
|
return price_money
|
|
|
|
raise ValueError("Square subscription item variation is missing price_money.")
|
|
|
|
def _parse_price_money(
|
|
self, price_money: dict[str, Any] | None
|
|
) -> dict[str, Any] | None:
|
|
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(),
|
|
}
|
|
return None
|
|
|
|
def _status_from_payment(self, payment: dict[str, Any]) -> FiatPaymentStatus:
|
|
status = (payment.get("status") or "").upper()
|
|
if status == "COMPLETED":
|
|
return FiatPaymentSuccessStatus()
|
|
if status in ["CANCELED", "FAILED"]:
|
|
return FiatPaymentFailedStatus()
|
|
return FiatPaymentPendingStatus()
|
|
|
|
async def _create_checkout_invoice(
|
|
self,
|
|
amount: float,
|
|
payment_hash: str,
|
|
currency: str,
|
|
opts: SquareCreateInvoiceOptions,
|
|
memo: str | None = None,
|
|
) -> FiatInvoiceResponse:
|
|
amount_cents = int(amount * 100)
|
|
co = opts.checkout or SquareCheckoutOptions()
|
|
success_url = (
|
|
co.success_url
|
|
or settings.square_payment_success_url
|
|
or "https://lnbits.com"
|
|
)
|
|
line_item_name = (co.line_item_name or memo or "LNbits Invoice")[:255]
|
|
metadata = {
|
|
**co.metadata,
|
|
"payment_hash": payment_hash,
|
|
"alan_action": "invoice",
|
|
}
|
|
|
|
payload = {
|
|
"idempotency_key": payment_hash,
|
|
"order": {
|
|
"location_id": self.location_id,
|
|
"metadata": metadata,
|
|
"line_items": [
|
|
{
|
|
"name": line_item_name,
|
|
"quantity": "1",
|
|
"base_price_money": {
|
|
"amount": amount_cents,
|
|
"currency": currency.upper(),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
"checkout_options": {"redirect_url": success_url},
|
|
}
|
|
if memo:
|
|
payload["payment_note"] = memo[:500]
|
|
|
|
try:
|
|
r = await self.client.post(
|
|
"/v2/online-checkout/payment-links", json=payload
|
|
)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
payment_link = data.get("payment_link") or {}
|
|
order_id = payment_link.get("order_id")
|
|
url = payment_link.get("url")
|
|
if not order_id or not url:
|
|
return FiatInvoiceResponse(
|
|
ok=False, error_message="Server error: missing order id or url"
|
|
)
|
|
return FiatInvoiceResponse(
|
|
ok=True,
|
|
checking_id=f"order_{order_id}",
|
|
payment_request=url,
|
|
)
|
|
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}."
|
|
)
|
|
|
|
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)
|
|
if checking_id.startswith("fiat_square_")
|
|
else checking_id
|
|
)
|
|
|
|
def _parse_create_opts(
|
|
self, raw_opts: dict[str, Any]
|
|
) -> SquareCreateInvoiceOptions | None:
|
|
try:
|
|
return SquareCreateInvoiceOptions.parse_obj(raw_opts)
|
|
except ValidationError as e:
|
|
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:
|
|
try:
|
|
from lnbits.core.crud.payments import get_payments
|
|
from lnbits.core.models import PaymentFilters
|
|
from lnbits.db import Filter, Filters
|
|
|
|
payments = await get_payments(
|
|
wallet_id=wallet_id,
|
|
filters=Filters(
|
|
filters=[
|
|
Filter.parse_query(
|
|
"external_id", [subscription_id], PaymentFilters
|
|
)
|
|
],
|
|
model=PaymentFilters,
|
|
sortby="created_at",
|
|
direction="desc",
|
|
limit=1,
|
|
),
|
|
)
|
|
payment = next(
|
|
(
|
|
payment
|
|
for payment in payments
|
|
if payment.external_id and payment.fiat_provider == "square"
|
|
),
|
|
None,
|
|
)
|
|
if payment and payment.external_id:
|
|
return payment.external_id
|
|
|
|
payments = await get_payments(
|
|
wallet_id=wallet_id,
|
|
incoming=True,
|
|
filters=Filters(
|
|
model=PaymentFilters,
|
|
sortby="created_at",
|
|
direction="desc",
|
|
),
|
|
)
|
|
payment = next(
|
|
(
|
|
payment
|
|
for payment in payments
|
|
if payment.external_id
|
|
and payment.fiat_provider == "square"
|
|
and (payment.extra or {}).get("subscription_request_id")
|
|
== subscription_id
|
|
),
|
|
None,
|
|
)
|
|
if payment and payment.external_id:
|
|
return payment.external_id
|
|
except Exception as exc:
|
|
logger.warning(exc)
|
|
|
|
return subscription_id
|
|
|
|
def _settings_connection_fields(self) -> str:
|
|
return "-".join(
|
|
[
|
|
str(settings.square_api_endpoint),
|
|
str(settings.square_access_token),
|
|
str(settings.square_location_id),
|
|
str(settings.square_api_version),
|
|
]
|
|
)
|