withdraw/models.py
Padreug 9c0e58a87c
Some checks failed
lint.yml / feat: merge a link's `extra` into the payout payment (v1.2.2-aio.2) (pull_request) Failing after 0s
lint.yml / feat: merge a link's `extra` into the payout payment (v1.2.2-aio.2) (push) Failing after 0s
feat: merge a link's extra into the payout payment (v1.2.2-aio.2)
Adds an optional `extra` (JSON) field to a withdraw link. When the link
is claimed, that `extra` is merged onto the payout payment's `extra`, so
a caller can tag the resulting payment with metadata an external listener
keys on — the link is the only place to attach it (the customer-facing
LNURL-withdraw payout otherwise carries just `{tag, withdrawal_link_id}`).

Motivating use: bitSpire cash-in settlements. The operator's spirekeeper
listener fires a `cash_in` settlement (fee split to the platform) only on
an outbound payment stamped `source=bitspire`; before this there was no
way to stamp an LNURL-withdraw payout, so cash-ins never settled. bitSpire
now creates the cash-in link for the NET amount with
`extra={source, type:cash_in, principal_sats, fee_sats, ...}` and the
settlement fires on claim.

- models: `extra: dict | None` on CreateWithdrawData + WithdrawLink.
  LNbits' db layer (de)serializes dict columns to/from JSON natively
  (same as Payment.extra) — no per-field validator needed.
- migrations_fork.py: `withdraw_link.extra TEXT` under `withdraw_fork`,
  keeping the upstream-tracked migrations.py byte-identical for clean
  rebases (aiolabs/lnbits#8 pattern).
- views_lnurl: `extra={**(link.extra or {}), "tag": ..., "withdrawal_link_id": ...}`
  — the withdraw extension's own keys are written last so a caller cannot
  clobber them.

Verified end-to-end on the dev stack: a stamped link's payout carries the
merged extra and drives a spirekeeper cash_in settlement + super-fee payout.
2026-06-21 17:26:14 +02:00

80 lines
2.6 KiB
Python

from datetime import datetime
from fastapi import Query
from pydantic import BaseModel, Field
class CreateWithdrawData(BaseModel):
title: str = Query(...)
min_withdrawable: int = Query(..., ge=1)
max_withdrawable: int = Query(..., ge=1)
uses: int = Query(..., ge=1)
wait_time: int = Query(..., ge=1)
is_unique: bool
webhook_url: str = Query(None)
webhook_headers: str = Query(None)
webhook_body: str = Query(None)
custom_url: str = Query(None)
enabled: bool = Query(True)
# Arbitrary JSON merged into the payout payment's `extra` when this link is
# claimed (see views_lnurl). Lets a caller tag the resulting payment with
# settlement/attribution metadata an external listener can key on — e.g.
# bitSpire stamps {source, type, principal_sats, fee_sats, ...} so the
# spirekeeper cash-in settlement fires off an LNURL-withdraw payout.
extra: dict | None = None
class WithdrawLink(BaseModel):
id: str
wallet: str = Query(None)
title: str = Query(None)
min_withdrawable: int = Query(0)
max_withdrawable: int = Query(0)
uses: int = Query(0)
wait_time: int = Query(0)
is_unique: bool = Query(False)
unique_hash: str = Query(0)
k1: str = Query(None)
open_time: int = Query(0)
used: int = Query(0)
usescsv: str = Query(None)
number: int = Field(default=0, no_database=True)
webhook_url: str = Query(None)
webhook_headers: str = Query(None)
webhook_body: str = Query(None)
custom_url: str = Query(None)
# Persisted as TEXT (JSON); merged into the payout payment's `extra` on
# claim. LNbits' db layer (de)serializes dict-typed columns to/from JSON
# natively (same as Payment.extra) — no per-field validator needed.
extra: dict | None = None
created_at: datetime
enabled: bool = Query(True)
lnurl: str | None = Field(
default=None,
no_database=True,
deprecated=True,
description=(
"Deprecated: Instead of using this bech32 encoded string, dynamically "
"generate your own static link (lud17/bech32) on the client side. "
"Example: lnurlw://${window.location.hostname}/lnurlw/${id}"
),
)
lnurl_url: str | None = Field(
default=None,
no_database=True,
description="The raw LNURL callback URL (use for QR code generation)",
)
@property
def is_spent(self) -> bool:
return self.used >= self.uses
class HashCheck(BaseModel):
hash: bool
lnurl: bool
class PaginatedWithdraws(BaseModel):
data: list[WithdrawLink]
total: int