feat: introduce external_id

This commit is contained in:
Vlad Stan
2026-05-21 14:12:43 +03:00
parent 9a4e55f469
commit c4ee3a7c6b
10 changed files with 303 additions and 58 deletions
+1 -29
View File
@@ -107,35 +107,6 @@ 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,
@@ -321,6 +292,7 @@ async def create_payment(
tag=extra.get("tag", None), tag=extra.get("tag", None),
extra=extra, extra=extra,
labels=data.labels or [], labels=data.labels or [],
external_id=data.external_id,
) )
await (conn or db).insert("apipayments", payment) await (conn or db).insert("apipayments", payment)
+13
View File
@@ -802,3 +802,16 @@ async def m044_add_activated_to_accounts(db: Connection):
Used for account activation status. Used for account activation status.
""" """
await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true") await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true")
async def m045_add_external_id_to_payments(db: Connection):
"""
Adds external_id column to apipayments.
Used for external payment references.
"""
await db.execute("ALTER TABLE apipayments ADD COLUMN external_id TEXT")
logger.debug("Creating index idx_payments_external_id...")
await db.execute("""
CREATE INDEX IF NOT EXISTS idx_payments_external_id
ON apipayments (external_id);
""")
+28
View File
@@ -13,6 +13,7 @@ from lnbits.db import FilterModel
from lnbits.fiat.base import ( from lnbits.fiat.base import (
FiatPaymentStatus, FiatPaymentStatus,
) )
from lnbits.helpers import is_valid_external_id
from lnbits.utils.exchange_rates import allowed_currencies from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets.base import ( from lnbits.wallets.base import (
PaymentStatus, PaymentStatus,
@@ -53,6 +54,11 @@ class CreatePayment(BaseModel):
webhook: str | None = None webhook: str | None = None
fee: int = 0 fee: int = 0
labels: list[str] | None = None labels: list[str] | None = None
external_id: str | None = None
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
class Payment(BaseModel): class Payment(BaseModel):
@@ -77,6 +83,11 @@ class Payment(BaseModel):
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
labels: list[str] = [] labels: list[str] = []
extra: dict = {} extra: dict = {}
external_id: str | None = None
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
def __init__(self, **data): def __init__(self, **data):
super().__init__(**data) super().__init__(**data)
@@ -151,6 +162,7 @@ class PaymentFilters(FilterModel):
"status", "status",
"time", "time",
"labels", "labels",
"external_id",
] ]
__sort_fields__ = [ __sort_fields__ = [
@@ -161,11 +173,13 @@ class PaymentFilters(FilterModel):
"memo", "memo",
"time", "time",
"tag", "tag",
"external_id",
] ]
status: str | None status: str | None
tag: str | None tag: str | None
checking_id: str | None checking_id: str | None
external_id: str | None
amount: int amount: int
fee: int fee: int
memo: str | None memo: str | None
@@ -249,6 +263,7 @@ class CreateInvoice(BaseModel):
lnurl_withdraw: LnurlWithdrawResponse | None = None lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None fiat_provider: str | None = None
labels: list[str] = [] labels: list[str] = []
external_id: str | None = Query(default=None, max_length=256)
@validator("payment_hash") @validator("payment_hash")
def check_hex(cls, v): def check_hex(cls, v):
@@ -263,6 +278,10 @@ class CreateInvoice(BaseModel):
raise ValueError("The provided unit is not supported") raise ValueError("The provided unit is not supported")
return v return v
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
class PaymentsStatusCount(BaseModel): class PaymentsStatusCount(BaseModel):
incoming: int = 0 incoming: int = 0
@@ -301,3 +320,12 @@ class CancelInvoice(BaseModel):
class UpdatePaymentLabels(BaseModel): class UpdatePaymentLabels(BaseModel):
labels: list[str] = [] labels: list[str] = []
def _validate_external_id(external_id: str | None) -> str | None:
if external_id and not is_valid_external_id(external_id):
raise ValueError(
"Invalid external id. Max length is 256 characters. "
"Space and newlines are not allowed."
)
return external_id
+5
View File
@@ -64,6 +64,7 @@ async def pay_invoice(
description: str = "", description: str = "",
tag: str = "", tag: str = "",
labels: list[str] | None = None, labels: list[str] | None = None,
external_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Payment: ) -> Payment:
if settings.lnbits_only_allow_incoming_payments: if settings.lnbits_only_allow_incoming_payments:
@@ -97,6 +98,7 @@ async def pay_invoice(
memo=description or invoice.description or "", memo=description or invoice.description or "",
extra=extra, extra=extra,
labels=labels, labels=labels,
external_id=external_id,
) )
async with db.reuse_conn(conn) if conn else db.connect() as new_conn: async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
@@ -217,6 +219,7 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
internal=data.internal, internal=data.internal,
payment_hash=data.payment_hash, payment_hash=data.payment_hash,
labels=data.labels, labels=data.labels,
external_id=data.external_id,
conn=conn, conn=conn,
) )
@@ -258,6 +261,7 @@ async def create_invoice(
internal: bool | None = False, internal: bool | None = False,
payment_hash: str | None = None, payment_hash: str | None = None,
labels: list[str] | None = None, labels: list[str] | None = None,
external_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Payment: ) -> Payment:
if not amount > 0: if not amount > 0:
@@ -342,6 +346,7 @@ async def create_invoice(
webhook=webhook, webhook=webhook,
fee=invoice_response.fee_msat or 0, fee=invoice_response.fee_msat or 0,
labels=labels, labels=labels,
external_id=external_id,
) )
payment = await create_payment( payment = await create_payment(
+25 -13
View File
@@ -4,11 +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_payments,
get_standalone_payment, get_standalone_payment,
update_payment, update_payment,
) )
from lnbits.core.models import Payment from lnbits.core.models import Payment, PaymentFilters
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 (
@@ -18,6 +18,7 @@ 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.db import Filter, Filters
from lnbits.fiat import get_fiat_provider 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.fiat.square import SquareWallet
@@ -364,13 +365,27 @@ async def _handle_square_invoice_payment_made(event: dict):
payment_options = _deserialize_square_metadata(_square_payment_note(payment)) payment_options = _deserialize_square_metadata(_square_payment_note(payment))
if not payment_options.wallet_id: if not payment_options.wallet_id:
payment_id = payment.get("id")
stored_payment = ( stored_payment = (
await get_latest_payment_by_extra_key_value( await get_standalone_payment(f"fiat_square_payment_{payment_id}")
"square_subscription_id", subscription_id if payment_id
)
if subscription_id
else None else None
) )
if not stored_payment and subscription_id:
stored_payments = await get_payments(
filters=Filters(
filters=[
Filter.parse_query(
"external_id", [subscription_id], PaymentFilters
)
],
model=PaymentFilters,
sortby="created_at",
direction="desc",
limit=1,
)
)
stored_payment = stored_payments[0] if stored_payments else None
if stored_payment: if stored_payment:
payment_options = _square_payment_options_from_payment(stored_payment) payment_options = _square_payment_options_from_payment(stored_payment)
else: else:
@@ -406,10 +421,9 @@ async def _handle_square_subscription_payment(
if existing_payment: if existing_payment:
if ( if (
square_subscription_id square_subscription_id
and (existing_payment.extra or {}).get("square_subscription_id") and existing_payment.external_id != square_subscription_id
!= square_subscription_id
): ):
existing_payment.extra["square_subscription_id"] = square_subscription_id existing_payment.external_id = square_subscription_id
await update_payment(existing_payment) await update_payment(existing_payment)
await check_fiat_status(existing_payment) await check_fiat_status(existing_payment)
return return
@@ -427,8 +441,6 @@ async def _handle_square_subscription_payment(
"payment_request": payment_request, "payment_request": payment_request,
}, },
} }
if square_subscription_id:
extra["square_subscription_id"] = square_subscription_id
lnbits_payment = await create_fiat_invoice( lnbits_payment = await create_fiat_invoice(
wallet_id=wallet_id, wallet_id=wallet_id,
@@ -438,6 +450,7 @@ async def _handle_square_subscription_payment(
memo=payment_options.memo or "", memo=payment_options.memo or "",
extra=extra, extra=extra,
fiat_provider="square", fiat_provider="square",
external_id=square_subscription_id,
), ),
) )
@@ -493,6 +506,5 @@ def _deserialize_square_metadata(custom_id: str) -> FiatSubscriptionPaymentOptio
extra=extra, extra=extra,
memo=memo, memo=memo,
) )
except (json.JSONDecodeError, IndexError, TypeError) as e: except (json.JSONDecodeError, IndexError, TypeError):
logger.debug(f"Failed to deserialize Square metadata: {e}")
return FiatSubscriptionPaymentOptions() return FiatSubscriptionPaymentOptions()
+1
View File
@@ -263,6 +263,7 @@ async def api_payments_create(
payment_request=invoice_data.bolt11, payment_request=invoice_data.bolt11,
extra=invoice_data.extra, extra=invoice_data.extra,
labels=invoice_data.labels, labels=invoice_data.labels,
external_id=invoice_data.external_id,
) )
return payment return payment
+51 -8
View File
@@ -312,6 +312,7 @@ class SquareWallet(FiatProvider):
) -> SquareSubscriptionCheckoutInfo: ) -> SquareSubscriptionCheckoutInfo:
plan_data = catalog_object.get("subscription_plan_data") or {} plan_data = catalog_object.get("subscription_plan_data") or {}
plan_variations = plan_data.get("subscription_plan_variations") or [] plan_variations = plan_data.get("subscription_plan_variations") or []
eligible_item_ids = plan_data.get("eligible_item_ids") or []
plan_variation = next( plan_variation = next(
( (
variation variation
@@ -325,7 +326,7 @@ class SquareWallet(FiatProvider):
price_money = await self._get_subscription_price_money( price_money = await self._get_subscription_price_money(
plan_variation, plan_variation,
eligible_item_ids=plan_data.get("eligible_item_ids") or [], eligible_item_ids=eligible_item_ids,
) )
plan_variation_id = plan_variation.get("id") plan_variation_id = plan_variation.get("id")
if not plan_variation_id: if not plan_variation_id:
@@ -547,15 +548,57 @@ class SquareWallet(FiatProvider):
async def _get_square_subscription_id( async def _get_square_subscription_id(
self, subscription_id: str, wallet_id: str self, subscription_id: str, wallet_id: str
) -> str: ) -> str:
from lnbits.core.crud.payments import get_latest_payment_by_extra_key_value from lnbits.core.crud.payments import get_payments
from lnbits.core.models import PaymentFilters
from lnbits.db import Filter, Filters
payment = await get_latest_payment_by_extra_key_value( payments = await get_payments(
"subscription_request_id", subscription_id, wallet_id=wallet_id wallet_id=wallet_id,
filters=Filters(
filters=[
Filter.parse_query("external_id", [subscription_id], PaymentFilters)
],
model=PaymentFilters,
sortby="created_at",
direction="desc",
limit=1,
),
) )
if not payment: payment = next(
return subscription_id (
square_subscription_id = (payment.extra or {}).get("square_subscription_id") payment
return square_subscription_id or subscription_id 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
return subscription_id
def _settings_connection_fields(self) -> str: def _settings_connection_fields(self) -> str:
return "-".join( return "-".join(
+86 -5
View File
@@ -4,7 +4,7 @@ from uuid import uuid4
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from lnbits.core.models import Account, CreateInvoice from lnbits.core.models import Account, CreateInvoice, Payment
from lnbits.core.services.payments import create_wallet_invoice from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account from lnbits.core.services.users import create_user_account
from lnbits.core.views.callback_api import ( from lnbits.core.views.callback_api import (
@@ -253,9 +253,9 @@ async def test_callback_api_handles_subscription_flows_and_validation(
payment.extra = { payment.extra = {
"subscription_request_id": "subscription_square_1", "subscription_request_id": "subscription_square_1",
"tag": "members", "tag": "members",
"square_subscription_id": "SUBSCRIPTION_1",
"link": "link-1", "link": "link-1",
} }
payment.external_id = "SUBSCRIPTION_1"
payment.memo = "Square Members" payment.memo = "Square Members"
settings.square_api_endpoint = "https://connect.squareupsandbox.com" settings.square_api_endpoint = "https://connect.squareupsandbox.com"
settings.square_access_token = "square-token" settings.square_access_token = "square-token"
@@ -276,8 +276,8 @@ async def test_callback_api_handles_subscription_flows_and_validation(
mocker.AsyncMock(return_value=square_provider), mocker.AsyncMock(return_value=square_provider),
) )
mocker.patch( mocker.patch(
"lnbits.core.views.callback_api.get_latest_payment_by_extra_key_value", "lnbits.core.views.callback_api.get_payments",
mocker.AsyncMock(return_value=payment), mocker.AsyncMock(return_value=[payment]),
) )
await handle_square_event( await handle_square_event(
@@ -298,7 +298,8 @@ async def test_callback_api_handles_subscription_flows_and_validation(
assert create_fiat_invoice_mock.await_count == 4 assert create_fiat_invoice_mock.await_count == 4
square_invoice_call = create_fiat_invoice_mock.await_args.kwargs square_invoice_call = create_fiat_invoice_mock.await_args.kwargs
square_invoice = square_invoice_call["invoice_data"] square_invoice = square_invoice_call["invoice_data"]
assert square_invoice.extra["square_subscription_id"] == "SUBSCRIPTION_1" assert square_invoice.external_id == "SUBSCRIPTION_1"
assert "square_subscription_id" not in square_invoice.extra
assert ( assert (
square_invoice.extra["subscription"]["payment_request"] square_invoice.extra["subscription"]["payment_request"]
== "https://square.example/invoice" == "https://square.example/invoice"
@@ -314,3 +315,83 @@ async def test_callback_api_handles_subscription_flows_and_validation(
"resource": {"amount": {"currency": "USD", "total": "5.00"}}, "resource": {"amount": {"currency": "USD", "total": "5.00"}},
} }
) )
@pytest.mark.anyio
async def test_square_invoice_payment_updates_existing_subscription_external_id(
settings: Settings, mocker
):
payment = Payment(
checking_id="fiat_square_payment_PAYMENT_SUB_1",
payment_hash="hash_square_subscription",
wallet_id="wallet_1",
amount=925000,
fee=0,
bolt11="lnbc1square",
fiat_provider="square",
extra={
"subscription_request_id": "subscription_square_1",
"tag": "members",
"link": "link-1",
},
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_1",
"status": "COMPLETED",
"amount_money": {"amount": 925, "currency": "USD"},
},
)
mocker.patch(
"lnbits.core.views.callback_api.get_fiat_provider",
mocker.AsyncMock(return_value=square_provider),
)
get_standalone_payment_mock = mocker.patch(
"lnbits.core.views.callback_api.get_standalone_payment",
mocker.AsyncMock(return_value=payment),
)
update_payment_mock = mocker.patch(
"lnbits.core.views.callback_api.update_payment",
mocker.AsyncMock(),
)
get_payments_mock = mocker.patch(
"lnbits.core.views.callback_api.get_payments",
mocker.AsyncMock(return_value=[]),
)
create_fiat_invoice_mock = mocker.patch(
"lnbits.core.views.callback_api.create_fiat_invoice",
mocker.AsyncMock(),
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_square_event(
{
"event_id": "evt_square_invoice",
"type": "invoice.payment_made",
"data": {
"object": {
"invoice": {
"order_id": "ORDER_SUB_1",
"subscription_id": "SUBSCRIPTION_1",
}
}
},
}
)
get_standalone_payment_mock.assert_awaited_with("fiat_square_payment_PAYMENT_SUB_1")
assert payment.external_id == "SUBSCRIPTION_1"
update_payment_mock.assert_awaited_once_with(payment)
fiat_status_mock.assert_awaited_once_with(payment)
get_payments_mock.assert_not_awaited()
create_fiat_invoice_mock.assert_not_awaited()
+58 -3
View File
@@ -4,9 +4,10 @@ from uuid import uuid4
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import ValidationError
from lnbits.core.crud.payments import create_payment from lnbits.core.crud.payments import create_payment, get_payments
from lnbits.core.models import Account, CreateInvoice, PaymentState from lnbits.core.models import Account, CreateInvoice, PaymentFilters, PaymentState
from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice
from lnbits.core.models.users import AccountId from lnbits.core.models.users import AccountId
from lnbits.core.models.wallets import KeyType, WalletTypeInfo from lnbits.core.models.wallets import KeyType, WalletTypeInfo
@@ -21,7 +22,7 @@ from lnbits.core.views.payment_api import (
api_payments_settle, api_payments_settle,
api_payments_wallets_stats, api_payments_wallets_stats,
) )
from lnbits.db import Filters from lnbits.db import Filter, Filters
from lnbits.wallets.base import InvoiceResponse from lnbits.wallets.base import InvoiceResponse
ZERO_AMOUNT_INVOICE = ( ZERO_AMOUNT_INVOICE = (
@@ -91,6 +92,60 @@ async def test_payment_api_stats_and_all_paginated(admin_user):
assert second_wallet.id in wallet_ids assert second_wallet.id in wallet_ids
@pytest.mark.anyio
async def test_payment_external_id_is_stored_and_validated():
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
first_payment = await create_wallet_invoice(
wallet.id,
CreateInvoice(
out=False,
amount=21,
memo="external reference",
external_id="provider_payment_123",
),
)
second_payment = await create_wallet_invoice(
wallet.id,
CreateInvoice(
out=False,
amount=22,
memo="external reference newest",
external_id="provider_payment_123",
),
)
assert first_payment.external_id == "provider_payment_123"
assert second_payment.external_id == "provider_payment_123"
stored_payments = await get_payments(
wallet_id=wallet.id,
filters=Filters(
filters=[
Filter.parse_query(
"external_id", ["provider_payment_123"], PaymentFilters
)
],
model=PaymentFilters,
sortby="created_at",
direction="desc",
),
)
assert [payment.checking_id for payment in stored_payments] == [
second_payment.checking_id,
first_payment.checking_id,
]
with pytest.raises(ValidationError, match="Invalid external id"):
CreateInvoice(out=False, amount=21, external_id="provider payment 123")
@pytest.mark.anyio @pytest.mark.anyio
async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker): async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
user = await create_user_account( user = await create_user_account(
+35
View File
@@ -629,6 +629,41 @@ async def test_square_wallet_cancel_subscription(settings: Settings):
assert client.calls[0][0] == "/v2/subscriptions/SUBSCRIPTION123/cancel" assert client.calls[0][0] == "/v2/subscriptions/SUBSCRIPTION123/cancel"
@pytest.mark.anyio
async def test_square_wallet_cancel_subscription_by_request_id(
settings: Settings, mocker: MockerFixture
):
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]
payment = Payment(
checking_id="fiat_square_payment_PAYMENT123",
payment_hash="hash123",
wallet_id="wallet_1",
amount=1000,
fee=0,
bolt11="lnbc1square",
fiat_provider="square",
extra={"subscription_request_id": "REQUEST123"},
external_id="SUBSCRIPTION123",
)
get_payments_mock = mocker.patch(
"lnbits.core.crud.payments.get_payments",
AsyncMock(side_effect=[[], [payment]]),
)
response = await wallet.cancel_subscription("REQUEST123", "wallet_1")
assert response.ok is True
assert client.calls[0][0] == "/v2/subscriptions/SUBSCRIPTION123/cancel"
assert get_payments_mock.await_count == 2
@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"