feat: introduce external_id

This commit is contained in:
Vlad Stan
2026-05-13 15:23:30 +03:00
parent 739c13df4e
commit c77207405b
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
*,
wallet_id: str | None = None,
@@ -321,6 +292,7 @@ async def create_payment(
tag=extra.get("tag", None),
extra=extra,
labels=data.labels or [],
external_id=data.external_id,
)
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.
"""
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 (
FiatPaymentStatus,
)
from lnbits.helpers import is_valid_external_id
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets.base import (
PaymentStatus,
@@ -53,6 +54,11 @@ class CreatePayment(BaseModel):
webhook: str | None = None
fee: int = 0
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):
@@ -77,6 +83,11 @@ class Payment(BaseModel):
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
labels: list[str] = []
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):
super().__init__(**data)
@@ -151,6 +162,7 @@ class PaymentFilters(FilterModel):
"status",
"time",
"labels",
"external_id",
]
__sort_fields__ = [
@@ -161,11 +173,13 @@ class PaymentFilters(FilterModel):
"memo",
"time",
"tag",
"external_id",
]
status: str | None
tag: str | None
checking_id: str | None
external_id: str | None
amount: int
fee: int
memo: str | None
@@ -249,6 +263,7 @@ class CreateInvoice(BaseModel):
lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None
labels: list[str] = []
external_id: str | None = Query(default=None, max_length=256)
@validator("payment_hash")
def check_hex(cls, v):
@@ -263,6 +278,10 @@ class CreateInvoice(BaseModel):
raise ValueError("The provided unit is not supported")
return v
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
class PaymentsStatusCount(BaseModel):
incoming: int = 0
@@ -301,3 +320,12 @@ class CancelInvoice(BaseModel):
class UpdatePaymentLabels(BaseModel):
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 = "",
tag: str = "",
labels: list[str] | None = None,
external_id: str | None = None,
conn: Connection | None = None,
) -> Payment:
if settings.lnbits_only_allow_incoming_payments:
@@ -97,6 +98,7 @@ async def pay_invoice(
memo=description or invoice.description or "",
extra=extra,
labels=labels,
external_id=external_id,
)
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,
payment_hash=data.payment_hash,
labels=data.labels,
external_id=data.external_id,
conn=conn,
)
@@ -258,6 +261,7 @@ async def create_invoice(
internal: bool | None = False,
payment_hash: str | None = None,
labels: list[str] | None = None,
external_id: str | None = None,
conn: Connection | None = None,
) -> Payment:
if not amount > 0:
@@ -342,6 +346,7 @@ async def create_invoice(
webhook=webhook,
fee=invoice_response.fee_msat or 0,
labels=labels,
external_id=external_id,
)
payment = await create_payment(
+25 -13
View File
@@ -4,11 +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_payments,
get_standalone_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.payments import CreateInvoice
from lnbits.core.services.fiat_providers import (
@@ -18,6 +18,7 @@ from lnbits.core.services.fiat_providers import (
verify_paypal_webhook,
)
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.base import FiatSubscriptionPaymentOptions
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))
if not payment_options.wallet_id:
payment_id = payment.get("id")
stored_payment = (
await get_latest_payment_by_extra_key_value(
"square_subscription_id", subscription_id
)
if subscription_id
await get_standalone_payment(f"fiat_square_payment_{payment_id}")
if payment_id
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:
payment_options = _square_payment_options_from_payment(stored_payment)
else:
@@ -406,10 +421,9 @@ async def _handle_square_subscription_payment(
if existing_payment:
if (
square_subscription_id
and (existing_payment.extra or {}).get("square_subscription_id")
!= square_subscription_id
and existing_payment.external_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 check_fiat_status(existing_payment)
return
@@ -427,8 +441,6 @@ async def _handle_square_subscription_payment(
"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,
@@ -438,6 +450,7 @@ async def _handle_square_subscription_payment(
memo=payment_options.memo or "",
extra=extra,
fiat_provider="square",
external_id=square_subscription_id,
),
)
@@ -493,6 +506,5 @@ def _deserialize_square_metadata(custom_id: str) -> FiatSubscriptionPaymentOptio
extra=extra,
memo=memo,
)
except (json.JSONDecodeError, IndexError, TypeError) as e:
logger.debug(f"Failed to deserialize Square metadata: {e}")
except (json.JSONDecodeError, IndexError, TypeError):
return FiatSubscriptionPaymentOptions()
+1
View File
@@ -263,6 +263,7 @@ async def api_payments_create(
payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
labels=invoice_data.labels,
external_id=invoice_data.external_id,
)
return payment
+51 -8
View File
@@ -312,6 +312,7 @@ class SquareWallet(FiatProvider):
) -> 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
@@ -325,7 +326,7 @@ class SquareWallet(FiatProvider):
price_money = await self._get_subscription_price_money(
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")
if not plan_variation_id:
@@ -547,15 +548,57 @@ class SquareWallet(FiatProvider):
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
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(
"subscription_request_id", subscription_id, wallet_id=wallet_id
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,
),
)
if not payment:
return subscription_id
square_subscription_id = (payment.extra or {}).get("square_subscription_id")
return square_subscription_id or subscription_id
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
return subscription_id
def _settings_connection_fields(self) -> str:
return "-".join(
+86 -5
View File
@@ -4,7 +4,7 @@ from uuid import uuid4
import pytest
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.users import create_user_account
from lnbits.core.views.callback_api import (
@@ -253,9 +253,9 @@ async def test_callback_api_handles_subscription_flows_and_validation(
payment.extra = {
"subscription_request_id": "subscription_square_1",
"tag": "members",
"square_subscription_id": "SUBSCRIPTION_1",
"link": "link-1",
}
payment.external_id = "SUBSCRIPTION_1"
payment.memo = "Square Members"
settings.square_api_endpoint = "https://connect.squareupsandbox.com"
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.patch(
"lnbits.core.views.callback_api.get_latest_payment_by_extra_key_value",
mocker.AsyncMock(return_value=payment),
"lnbits.core.views.callback_api.get_payments",
mocker.AsyncMock(return_value=[payment]),
)
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
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.external_id == "SUBSCRIPTION_1"
assert "square_subscription_id" not in square_invoice.extra
assert (
square_invoice.extra["subscription"]["payment_request"]
== "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"}},
}
)
@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
from fastapi import HTTPException
from pydantic import ValidationError
from lnbits.core.crud.payments import create_payment
from lnbits.core.models import Account, CreateInvoice, PaymentState
from lnbits.core.crud.payments import create_payment, get_payments
from lnbits.core.models import Account, CreateInvoice, PaymentFilters, PaymentState
from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice
from lnbits.core.models.users import AccountId
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
@@ -21,7 +22,7 @@ from lnbits.core.views.payment_api import (
api_payments_settle,
api_payments_wallets_stats,
)
from lnbits.db import Filters
from lnbits.db import Filter, Filters
from lnbits.wallets.base import InvoiceResponse
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
@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
async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
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"
@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
async def test_square_wallet_get_invoice_status(settings: Settings):
settings.square_api_endpoint = "https://connect.squareupsandbox.com"