diff --git a/__init__.py b/__init__.py
index a6e22fb..0f8a435 100644
--- a/__init__.py
+++ b/__init__.py
@@ -4,7 +4,7 @@
from loguru import logger
from .crud import db
-from .tasks import wait_for_paid_invoices
+from .tasks import poll_onchain_payments, wait_for_paid_invoices
from .views import tpos_generic_router
from .views_api import tpos_api_router
from .views_atm import tpos_atm_router
@@ -37,8 +37,11 @@ def tpos_stop():
def tpos_start():
from lnbits.tasks import create_permanent_unique_task
- task = create_permanent_unique_task("ext_tpos", wait_for_paid_invoices)
- scheduled_tasks.append(task)
+ invoice_task = create_permanent_unique_task("ext_tpos", wait_for_paid_invoices)
+ onchain_task = create_permanent_unique_task(
+ "ext_tpos_onchain", poll_onchain_payments
+ )
+ scheduled_tasks.extend([invoice_task, onchain_task])
__all__ = [
diff --git a/crud.py b/crud.py
index 33fe23a..e903236 100644
--- a/crud.py
+++ b/crud.py
@@ -2,7 +2,7 @@
from lnbits.helpers import urlsafe_short_hash
from .helpers import serialize_inventory_tags
-from .models import CreateTposData, LnurlCharge, Tpos, TposClean
+from .models import CreateTposData, LnurlCharge, Tpos, TposClean, TposPayment
db = Database("ext_tpos")
@@ -70,3 +70,61 @@ async def get_tposs(wallet_ids: str | list[str]) -> list[Tpos]:
async def delete_tpos(tpos_id: str) -> None:
await db.execute("DELETE FROM tpos.pos WHERE id = :id", {"id": tpos_id})
+ await db.execute("DELETE FROM tpos.payments WHERE tpos_id = :id", {"id": tpos_id})
+
+
+async def create_tpos_payment(payment: TposPayment) -> TposPayment:
+ await db.insert("tpos.payments", payment)
+ return payment
+
+
+async def get_tpos_payment(payment_id: str) -> TposPayment | None:
+ return await db.fetchone(
+ "SELECT * FROM tpos.payments WHERE id = :id",
+ {"id": payment_id},
+ TposPayment,
+ )
+
+
+async def get_tpos_payment_by_hash(payment_hash: str) -> TposPayment | None:
+ return await db.fetchone(
+ "SELECT * FROM tpos.payments WHERE payment_hash = :payment_hash",
+ {"payment_hash": payment_hash},
+ TposPayment,
+ )
+
+
+async def get_tpos_payment_by_onchain_address(address: str) -> TposPayment | None:
+ return await db.fetchone(
+ "SELECT * FROM tpos.payments WHERE onchain_address = :address",
+ {"address": address},
+ TposPayment,
+ )
+
+
+async def get_pending_tpos_payments() -> list[TposPayment]:
+ return await db.fetchall(
+ """
+ SELECT * FROM tpos.payments
+ WHERE paid = false AND onchain_address IS NOT NULL
+ ORDER BY created_at ASC
+ """,
+ model=TposPayment,
+ )
+
+
+async def get_latest_tpos_payments(tpos_id: str, limit: int = 5) -> list[TposPayment]:
+ return await db.fetchall(
+ f"""
+ SELECT * FROM tpos.payments
+ WHERE tpos_id = :tpos_id AND paid = true
+ ORDER BY updated_at DESC LIMIT {int(limit)}
+ """,
+ {"tpos_id": tpos_id},
+ TposPayment,
+ )
+
+
+async def update_tpos_payment(payment: TposPayment) -> TposPayment:
+ await db.update("tpos.payments", payment)
+ return payment
diff --git a/migrations.py b/migrations.py
index a569fc0..0df71bb 100644
--- a/migrations.py
+++ b/migrations.py
@@ -260,3 +260,33 @@ async def m021_add_cash_settlement(db: Database):
await db.execute("""
ALTER TABLE tpos.pos ADD allow_cash_settlement BOOLEAN DEFAULT false;
""")
+
+
+async def m022_add_onchain_settings_and_payments(db: Database):
+ await db.execute("""
+ ALTER TABLE tpos.pos ADD onchain_enabled BOOLEAN DEFAULT false;
+ """)
+ await db.execute("""
+ ALTER TABLE tpos.pos ADD onchain_wallet_id TEXT NULL;
+ """)
+ await db.execute("""
+ ALTER TABLE tpos.pos ADD onchain_zero_conf BOOLEAN DEFAULT true;
+ """)
+ await db.execute("""
+ CREATE TABLE tpos.payments (
+ id TEXT PRIMARY KEY,
+ tpos_id TEXT NOT NULL,
+ payment_hash TEXT NOT NULL UNIQUE,
+ amount INTEGER NOT NULL DEFAULT 0,
+ paid BOOLEAN DEFAULT false,
+ payment_method TEXT NULL,
+ onchain_address TEXT NULL,
+ onchain_wallet_id TEXT NULL,
+ onchain_zero_conf BOOLEAN DEFAULT true,
+ mempool_endpoint TEXT NULL,
+ balance INTEGER NOT NULL DEFAULT 0,
+ pending INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
diff --git a/models.py b/models.py
index 2e69f12..2996e4a 100644
--- a/models.py
+++ b/models.py
@@ -28,6 +28,7 @@ class CreateTposInvoice(BaseModel):
internal_memo: str | None = Query(None, max_length=512)
pay_in_fiat: bool = Query(False)
fiat_method: str | None = Query(None)
+ payment_method: str | None = Query(None)
amount_fiat: float | None = Query(None, ge=0.0)
tip_amount_fiat: float | None = Query(None, ge=0.0)
@@ -72,6 +73,9 @@ class CreateTposData(BaseModel):
stripe_card_payments: bool = False
stripe_reader_id: str | None = None
allow_cash_settlement: bool = Field(False)
+ onchain_enabled: bool = Field(False)
+ onchain_wallet_id: str | None = None
+ onchain_zero_conf: bool = Field(True)
@validator("tax_default", pre=True, always=True)
def default_tax_when_none(cls, v):
@@ -108,6 +112,9 @@ class TposClean(BaseModel):
stripe_card_payments: bool = False
stripe_reader_id: str | None = None
allow_cash_settlement: bool = False
+ onchain_enabled: bool = False
+ onchain_wallet_id: str | None = None
+ onchain_zero_conf: bool = True
@property
def withdraw_maximum(self) -> int:
@@ -132,6 +139,35 @@ class Tpos(TposClean, BaseModel):
tip_wallet: str | None = None
+class TposPayment(BaseModel):
+ id: str
+ tpos_id: str
+ payment_hash: str
+ amount: int = 0
+ paid: bool = False
+ payment_method: str | None = None
+ onchain_address: str | None = None
+ onchain_wallet_id: str | None = None
+ onchain_zero_conf: bool = True
+ mempool_endpoint: str | None = None
+ balance: int = 0
+ pending: int = 0
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
+
+
+class TposInvoiceResponse(BaseModel):
+ payment_hash: str
+ bolt11: str
+ payment_request: str
+ tpos_payment_id: str
+ payment_options: list[str] = Field(default_factory=list)
+ onchain_address: str | None = None
+ onchain_amount_sat: int | None = None
+ payment_method: str | None = None
+ extra: dict[str, Any] = Field(default_factory=dict)
+
+
class LnurlCharge(BaseModel):
id: str
tpos_id: str
diff --git a/services.py b/services.py
index b65b4e2..9400c96 100644
--- a/services.py
+++ b/services.py
@@ -1,7 +1,11 @@
from typing import Any
import httpx
-from lnbits.core.crud import get_wallet
+from lnbits.core.crud import (
+ get_installed_extension,
+ get_user_active_extensions_ids,
+ get_wallet,
+)
from lnbits.core.models import User
from lnbits.helpers import create_access_token
from lnbits.settings import settings
@@ -121,6 +125,93 @@ def inventory_available_for_user(user: User | None) -> bool:
return bool(user and "inventory" in (user.extensions or []))
+async def watchonly_available_for_user(user_id: str) -> bool:
+ installed = await get_installed_extension("watchonly")
+ if not installed or not installed.active:
+ return False
+ active_extensions = await get_user_active_extensions_ids(user_id)
+ return "watchonly" in active_extensions
+
+
+async def fetch_watchonly_config(api_key: str) -> dict[str, Any]:
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/config",
+ headers={"X-API-KEY": api_key},
+ )
+ resp.raise_for_status()
+ return resp.json()
+
+
+async def fetch_watchonly_wallets(api_key: str, network: str) -> list[dict[str, Any]]:
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet",
+ headers={"X-API-KEY": api_key},
+ params={"network": network},
+ )
+ resp.raise_for_status()
+ return resp.json()
+
+
+async def fetch_watchonly_wallet(api_key: str, wallet_id: str) -> dict[str, Any]:
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet/{wallet_id}",
+ headers={"X-API-KEY": api_key},
+ )
+ resp.raise_for_status()
+ return resp.json()
+
+
+async def fetch_onchain_address(api_key: str, wallet_id: str) -> dict[str, Any]:
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/address/{wallet_id}",
+ headers={"X-API-KEY": api_key},
+ )
+ resp.raise_for_status()
+ return resp.json()
+
+
+def normalize_mempool_endpoint(
+ mempool_endpoint: str | None, onchain_address: str
+) -> str:
+ endpoint = (mempool_endpoint or "https://mempool.space").rstrip("/")
+ if "/testnet" in endpoint or "/signet" in endpoint:
+ return endpoint
+ if onchain_address.lower().startswith("tb1"):
+ return f"{endpoint}/testnet"
+ return endpoint
+
+
+async def fetch_onchain_balance(
+ mempool_endpoint: str, onchain_address: str
+) -> dict[str, Any]:
+ endpoint = normalize_mempool_endpoint(mempool_endpoint, onchain_address)
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(f"{endpoint}/api/address/{onchain_address}/txs")
+ resp.raise_for_status()
+ data = resp.json()
+ confirmed_txs = [tx for tx in data if tx["status"]["confirmed"]]
+ unconfirmed_txs = [tx for tx in data if not tx["status"]["confirmed"]]
+ return {
+ "confirmed": sum_transactions(onchain_address, confirmed_txs),
+ "unconfirmed": sum_transactions(onchain_address, unconfirmed_txs),
+ "txids": [tx["txid"] for tx in data],
+ }
+
+
+def sum_outputs(address: str, vouts: list[dict[str, Any]]) -> int:
+ return sum(
+ vout["value"] for vout in vouts if vout.get("scriptpubkey_address") == address
+ )
+
+
+def sum_transactions(address: str, txs: list[dict[str, Any]]) -> int:
+ return sum(sum_outputs(address, tx.get("vout", [])) for tx in txs)
+
+
async def push_order_to_orders(
user_id: str,
payment,
diff --git a/static/components/payment-dialog.js b/static/components/payment-dialog.js
new file mode 100644
index 0000000..bd6f45c
--- /dev/null
+++ b/static/components/payment-dialog.js
@@ -0,0 +1,129 @@
+window.app.component('tpos-payment-dialog', {
+ name: 'tpos-payment-dialog',
+ props: [
+ 'dialogData',
+ 'isMobileLandscaped',
+ 'activePaymentAmountFormatted',
+ 'activePaymentAmountWithTipFormatted',
+ 'tipOptions',
+ 'tipAmountFormatted',
+ 'nfcTagReading'
+ ],
+ emits: ['copy'],
+ computed: {
+ isOnchain() {
+ return this.dialogData?.payment_method === 'onchain'
+ },
+ onchainUri() {
+ const address = this.dialogData?.onchain_address
+ if (!address) return null
+
+ const params = new URLSearchParams()
+ const satAmount = Number(this.dialogData?.onchain_amount_sat || 0)
+
+ if (satAmount > 0) {
+ const btcAmount = (satAmount / 100000000)
+ .toFixed(8)
+ .replace(/\.?0+$/, '')
+ params.set('amount', btcAmount)
+ }
+
+ if (tpos?.name) {
+ params.set('label', `LNbits TPoS ${tpos.name}`)
+ } else {
+ params.set('label', 'LNbits TPoS')
+ }
+
+ params.set('message', 'Thank you for your order')
+
+ const query = params.toString()
+ return `bitcoin:${address}${query ? `?${query}` : ''}`
+ },
+ qrValue() {
+ if (this.isOnchain) {
+ return this.onchainUri
+ }
+ return this.dialogData?.lightning_payment_request || null
+ },
+ amountSummary() {
+ return this.activePaymentAmountFormatted || ''
+ },
+ totalSummary() {
+ return this.activePaymentAmountWithTipFormatted || ''
+ },
+ tipSummary() {
+ return this.tipOptions ? `(+ ${this.tipAmountFormatted} tip)` : ''
+ },
+ onchainHref() {
+ return this.onchainUri || ''
+ }
+ },
+ methods: {
+ copyCurrentValue() {
+ if (this.qrValue) {
+ this.$emit('copy', this.qrValue)
+ }
+ }
+ },
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ NFC supported
+
+
+
+
+
+
+
+
+ `
+})
diff --git a/static/js/index.js b/static/js/index.js
index 702ee09..09f7a80 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -47,6 +47,9 @@ const mapTpos = obj => {
: []
obj.only_show_sats_on_bitcoin = obj.only_show_sats_on_bitcoin ?? true
obj.allow_cash_settlement = Boolean(obj.allow_cash_settlement)
+ obj.onchain_enabled = Boolean(obj.onchain_enabled)
+ obj.onchain_wallet_id = obj.onchain_wallet_id || null
+ obj.onchain_zero_conf = obj.onchain_zero_conf ?? true
obj.useWrapper = false
obj.posLocation = ''
obj.auth = ''
@@ -74,6 +77,13 @@ window.app = Vue.createApp({
tags: [],
omit_tags: []
},
+ onchainStatus: {
+ available: false,
+ message: null,
+ network: null,
+ wallets: [],
+ mempool_endpoint: null
+ },
tpossTable: {
columns: [
{name: 'name', align: 'left', label: 'Name', field: 'name'},
@@ -141,7 +151,10 @@ window.app = Vue.createApp({
fiat: false,
stripe_card_payments: false,
stripe_reader_id: '',
- allow_cash_settlement: false
+ allow_cash_settlement: false,
+ onchain_enabled: false,
+ onchain_wallet_id: null,
+ onchain_zero_conf: true
},
advanced: {
tips: false,
@@ -233,7 +246,9 @@ window.app = Vue.createApp({
!data.name ||
!data.currency ||
!data.wallet ||
- (this.formDialog.advanced.otc && !data.withdraw_limit)
+ (this.formDialog.advanced.otc && !data.withdraw_limit) ||
+ (data.onchain_enabled &&
+ (!this.onchainStatus.available || !data.onchain_wallet_id))
)
},
inventoryModeOptions() {
@@ -263,6 +278,12 @@ window.app = Vue.createApp({
!!this.formDialog.data.currency &&
this.formDialog.data.currency !== 'sats'
)
+ },
+ onchainWalletOptions() {
+ return (this.onchainStatus.wallets || []).map(wallet => ({
+ label: wallet.title,
+ value: wallet.id
+ }))
}
},
methods: {
@@ -285,7 +306,10 @@ window.app = Vue.createApp({
fiat: false,
stripe_card_payments: false,
stripe_reader_id: '',
- allow_cash_settlement: false
+ allow_cash_settlement: false,
+ onchain_enabled: false,
+ onchain_wallet_id: null,
+ onchain_zero_conf: true
}
this.formDialog.advanced = {tips: false, otc: false}
},
@@ -321,6 +345,25 @@ window.app = Vue.createApp({
console.error(error)
}
},
+ async loadOnchainStatus() {
+ if (!this.g.user.wallets.length) return
+ try {
+ const {data} = await LNbits.api.request(
+ 'GET',
+ '/tpos/api/v1/onchain/status',
+ this.g.user.wallets[0].adminkey
+ )
+ this.onchainStatus = data
+ } catch (error) {
+ this.onchainStatus = {
+ available: false,
+ message: error.response?.data?.detail || 'Watchonly unavailable.',
+ network: null,
+ wallets: [],
+ mempool_endpoint: null
+ }
+ }
+ },
sendTposData() {
const data = {
...this.formDialog.data,
@@ -355,6 +398,10 @@ window.app = Vue.createApp({
if (data.currency === 'sats') {
data.allow_cash_settlement = false
}
+ if (!data.onchain_enabled) {
+ data.onchain_wallet_id = null
+ data.onchain_zero_conf = true
+ }
const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
@@ -760,6 +807,7 @@ window.app = Vue.createApp({
if (this.g.user.wallets.length) {
this.getTposs()
this.loadInventoryStatus()
+ this.loadOnchainStatus()
}
LNbits.api
.request('GET', '/api/v1/currencies')
diff --git a/static/js/tpos.js b/static/js/tpos.js
index e64523a..f44ef3d 100644
--- a/static/js/tpos.js
+++ b/static/js/tpos.js
@@ -44,6 +44,7 @@ window.app = Vue.createApp({
currency: null,
fiatProvider: null,
allowCashSettlement: false,
+ onchainEnabled: false,
payInFiat: false,
fiatMethod: 'checkout',
atmPremium: tpos.withdraw_premium / 100,
@@ -73,7 +74,15 @@ window.app = Vue.createApp({
},
invoiceDialog: {
show: false,
- data: {},
+ data: {
+ payment_hash: null,
+ payment_request: null,
+ lightning_payment_request: null,
+ onchain_address: null,
+ onchain_amount_sat: null,
+ payment_options: [],
+ payment_method: null
+ },
dismissMsg: null,
paymentChecker: null,
internalMemo: null
@@ -380,20 +389,62 @@ window.app = Vue.createApp({
this.tipAmount = payload.tip_amount || this.tipAmount
this.exchangeRate = payload.exchange_rate || this.exchangeRate
- this.openInvoiceDialog(payload.payment_hash, payload.payment_request)
+ this.openInvoiceDialog(payload)
this.subscribeToPaymentWS(payload.payment_hash)
},
- openInvoiceDialog(paymentHash, paymentRequest) {
+ normalizeInvoiceDialogData(paymentData, paymentRequest = null) {
+ if (typeof paymentData === 'string') {
+ return {
+ payment_hash: paymentData,
+ payment_request: paymentRequest,
+ lightning_payment_request:
+ paymentRequest &&
+ paymentRequest !== 'cash' &&
+ paymentRequest !== 'tap_to_pay'
+ ? paymentRequest
+ : null,
+ onchain_address: null,
+ onchain_amount_sat: null,
+ payment_options: ['lightning'],
+ payment_method: 'lightning'
+ }
+ }
+
+ const lightningPaymentRequest =
+ paymentData.payment_method === 'onchain'
+ ? null
+ : paymentData.payment_request &&
+ paymentData.payment_request !== 'cash' &&
+ paymentData.payment_request !== 'tap_to_pay'
+ ? paymentData.payment_request
+ : paymentData.bolt11
+ ? 'lightning:' + paymentData.bolt11.toUpperCase()
+ : null
+
+ return {
+ payment_hash: paymentData.payment_hash,
+ payment_request: paymentData.payment_request,
+ lightning_payment_request: lightningPaymentRequest,
+ onchain_address: paymentData.onchain_address || null,
+ onchain_amount_sat: paymentData.onchain_amount_sat || null,
+ payment_options: paymentData.payment_options || ['lightning'],
+ payment_method: paymentData.payment_method || 'lightning'
+ }
+ },
+ openInvoiceDialog(paymentData, paymentRequest = null) {
+ const dialogData = this.normalizeInvoiceDialogData(
+ paymentData,
+ paymentRequest
+ )
if (
this.invoiceDialog.show &&
- this.invoiceDialog.data.payment_hash === paymentHash
+ this.invoiceDialog.data.payment_hash === dialogData.payment_hash
) {
return
}
- this.invoiceDialog.data.payment_hash = paymentHash
- this.invoiceDialog.data.payment_request = paymentRequest
+ this.invoiceDialog.data = dialogData
this.invoiceDialog.show = true
- if (paymentRequest !== 'cash') {
+ if (dialogData.lightning_payment_request) {
this.readNfcTag()
this.invoiceDialog.dismissMsg = Quasar.Notify.create({
timeout: 0,
@@ -924,16 +975,22 @@ window.app = Vue.createApp({
selectPaymentMethod(method) {
this.currency_choice = false
if (this._currencyResolver) {
- if (method == 'fiat_tap') {
- this.fiatMethod = 'terminal'
- method = 'fiat'
- } else if (method == 'fiat') {
- this.fiatMethod = 'checkout'
- } else if (method == 'cash') {
- this.fiatMethod = 'cash'
- method = 'fiat'
- } else if (method == 'btc') {
- this.fiatMethod = 'checkout'
+ switch (method) {
+ case 'fiat_tap':
+ this.fiatMethod = 'terminal'
+ method = 'fiat'
+ break
+ case 'fiat':
+ this.fiatMethod = 'checkout'
+ break
+ case 'cash':
+ this.fiatMethod = 'cash'
+ method = 'fiat'
+ break
+ case 'btc':
+ case 'btc_onchain':
+ this.fiatMethod = 'checkout'
+ break
}
this._currencyResolver(method)
this._currencyResolver = null
@@ -948,7 +1005,8 @@ window.app = Vue.createApp({
exchange_rate: this.exchangeRate,
internal_memo: this.invoiceDialog.internalMemo || null,
pay_in_fiat: this.payInFiat,
- fiat_method: this.fiatMethod
+ fiat_method: this.fiatMethod,
+ payment_method: this.invoiceDialog.data.payment_method || 'btc'
}
if (this.currency != g.settings.denomination) {
params.amount_fiat = paymentAmount
@@ -1005,9 +1063,16 @@ window.app = Vue.createApp({
return
}
- if (this.fiatProvider || this.allowCashSettlement) {
+ if (
+ this.fiatProvider ||
+ this.allowCashSettlement ||
+ this.onchainEnabled
+ ) {
const method = await this.showPaymentMethod()
this.payInFiat = method === 'fiat'
+ this.invoiceDialog.data.payment_method = method
+ } else {
+ this.invoiceDialog.data.payment_method = 'btc'
}
const params = this.buildInvoiceParams()
@@ -1018,27 +1083,7 @@ window.app = Vue.createApp({
null,
params
)
- let paymentRequest = 'lightning:' + data.bolt11.toUpperCase()
- if (data.extra?.fiat_method === 'cash') {
- paymentRequest = 'cash'
- } else if (
- data.extra?.fiat_payment_request &&
- !data.extra.fiat_payment_request.startsWith('pi_')
- ) {
- paymentRequest = data.extra.fiat_payment_request
- } else if (
- data.extra?.fiat_payment_request &&
- data.extra.fiat_payment_request.startsWith('pi_')
- ) {
- paymentRequest = 'tap_to_pay'
- }
- if (
- !data.extra?.fiat_payment_request &&
- data.extra?.fiat_method !== 'cash'
- ) {
- paymentRequest = 'lightning:' + data.bolt11.toUpperCase()
- }
- this.openInvoiceDialog(data.payment_hash, paymentRequest)
+ this.openInvoiceDialog(data)
this.subscribeToPaymentWS(data.payment_hash)
} catch (error) {
console.error(error)
@@ -1060,37 +1105,68 @@ window.app = Vue.createApp({
this.cashValidating = false
}
},
+ finalizeSuccessfulPayment(paymentHash) {
+ Quasar.Notify.create({
+ type: 'positive',
+ message: 'Invoice Paid!'
+ })
+ this.invoiceDialog.show = false
+ this.invoiceDialog.internalMemo = null
+ this.clearCart()
+ this.showComplete()
+ if (this.enablePrint) {
+ this.promptPrintType(paymentHash)
+ }
+ },
+ startPaymentChecker(paymentHash) {
+ if (this.invoiceDialog.paymentChecker) {
+ clearInterval(this.invoiceDialog.paymentChecker)
+ }
+ this.invoiceDialog.paymentChecker = setInterval(async () => {
+ try {
+ const {data} = await LNbits.api.request(
+ 'GET',
+ `/tpos/api/v1/tposs/${this.tposId}/invoices/${paymentHash}`
+ )
+ if (data.paid) {
+ clearInterval(this.invoiceDialog.paymentChecker)
+ this.invoiceDialog.paymentChecker = null
+ this.finalizeSuccessfulPayment(paymentHash)
+ }
+ } catch (error) {
+ console.warn('TPoS payment status check failed:', error)
+ }
+ }, 3000)
+ },
subscribeToPaymentWS(paymentHash) {
if (this.paymentWsByHash[paymentHash]) return
+ this.startPaymentChecker(paymentHash)
try {
- const url = new URL(window.location)
- url.protocol = url.protocol === 'https:' ? 'wss' : 'ws'
- url.pathname = `/api/v1/ws/${paymentHash}`
+ const wsProtocol =
+ window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+ const url = new URL(`/api/v1/ws/${paymentHash}`, window.location.origin)
+ url.protocol = wsProtocol
const ws = new WebSocket(url)
this.paymentWsByHash[paymentHash] = ws
ws.onmessage = async ({data}) => {
const payment = JSON.parse(data)
if (payment.pending === false) {
- Quasar.Notify.create({
- type: 'positive',
- message: 'Invoice Paid!'
- })
- this.invoiceDialog.show = false
- this.invoiceDialog.internalMemo = null
- this.clearCart()
- this.showComplete()
- if (this.enablePrint) {
- this.promptPrintType(paymentHash)
+ if (this.invoiceDialog.paymentChecker) {
+ clearInterval(this.invoiceDialog.paymentChecker)
+ this.invoiceDialog.paymentChecker = null
}
+ this.finalizeSuccessfulPayment(paymentHash)
ws.close()
}
}
+ ws.onerror = err => {
+ console.warn('TPoS payment websocket error:', err)
+ }
ws.onclose = () => {
delete this.paymentWsByHash[paymentHash]
}
} catch (err) {
- console.warn(err)
- LNbits.utils.notifyApiError(err)
+ console.warn('TPoS payment websocket setup failed:', err)
}
},
readNfcTag() {
@@ -1221,7 +1297,7 @@ window.app = Vue.createApp({
})
},
payInvoice(lnurl) {
- const payment_request = this.invoiceDialog.data.payment_request
+ const payment_request = this.invoiceDialog.data.lightning_payment_request
.toLowerCase()
.replace('lightning:', '')
return axios
@@ -1580,6 +1656,7 @@ window.app = Vue.createApp({
new URL(window.location.href).searchParams.get('wrapper') === 'true'
this.fiatProvider = tpos.fiat_provider
this.allowCashSettlement = Boolean(tpos.allow_cash_settlement)
+ this.onchainEnabled = Boolean(tpos.onchain_enabled)
this.tip_options = tpos.tip_options == 'null' ? null : tpos.tip_options
diff --git a/tasks.py b/tasks.py
index 5d17ae9..47ad857 100644
--- a/tasks.py
+++ b/tasks.py
@@ -1,6 +1,8 @@
import asyncio
+import json
from lnbits.core.crud import get_user_active_extensions_ids, get_wallet
+from lnbits.core.crud.payments import get_standalone_payment, update_payment
from lnbits.core.models import Payment
from lnbits.core.services import (
create_invoice,
@@ -8,11 +10,20 @@
pay_invoice,
websocket_updater,
)
-from lnbits.tasks import register_invoice_listener
+from lnbits.tasks import internal_invoice_queue_put, register_invoice_listener
from loguru import logger
-from .crud import get_tpos
-from .services import deduct_inventory_stock, push_order_to_orders
+from .crud import (
+ get_pending_tpos_payments,
+ get_tpos,
+ get_tpos_payment_by_hash,
+ update_tpos_payment,
+)
+from .services import (
+ deduct_inventory_stock,
+ fetch_onchain_balance,
+ push_order_to_orders,
+)
async def wait_for_paid_invoices():
@@ -24,6 +35,53 @@ async def wait_for_paid_invoices():
await on_invoice_paid(payment)
+async def poll_onchain_payments():
+ while True:
+ pending_payments = await get_pending_tpos_payments()
+ for tpos_payment in pending_payments:
+ if not tpos_payment.onchain_address or not tpos_payment.mempool_endpoint:
+ continue
+ try:
+ balance = await fetch_onchain_balance(
+ tpos_payment.mempool_endpoint, tpos_payment.onchain_address
+ )
+ confirmed_balance = int(balance["confirmed"])
+ unconfirmed_balance = int(balance["unconfirmed"])
+ settled_balance = (
+ confirmed_balance + unconfirmed_balance
+ if tpos_payment.onchain_zero_conf
+ else confirmed_balance
+ )
+ changed = (
+ tpos_payment.balance != settled_balance
+ or tpos_payment.pending != unconfirmed_balance
+ )
+ tpos_payment.balance = settled_balance
+ tpos_payment.pending = unconfirmed_balance
+ if settled_balance >= tpos_payment.amount:
+ tpos_payment.paid = True
+ tpos_payment.payment_method = "onchain"
+ if changed or tpos_payment.paid:
+ await update_tpos_payment(tpos_payment)
+ await websocket_updater(
+ tpos_payment.payment_hash,
+ json.dumps(
+ {
+ "pending": not tpos_payment.paid,
+ "payment_hash": tpos_payment.payment_hash,
+ "onchain_balance": tpos_payment.balance,
+ "onchain_pending": tpos_payment.pending,
+ "payment_method": tpos_payment.payment_method,
+ }
+ ),
+ )
+ if tpos_payment.paid:
+ await settle_onchain_tpos_payment(tpos_payment)
+ except Exception as exc:
+ logger.warning(f"tpos: onchain polling failed: {exc}")
+ await asyncio.sleep(10)
+
+
async def on_invoice_paid(payment: Payment) -> None:
if (
not payment.extra
@@ -31,7 +89,51 @@ async def on_invoice_paid(payment: Payment) -> None:
or payment.extra.get("tipSplitted")
):
return
+
+ payment_method = payment.extra.get("payment_method") or _payment_method(payment)
+ tpos_payment = await get_tpos_payment_by_hash(payment.payment_hash)
+ if tpos_payment and not tpos_payment.paid:
+ tpos_payment.paid = True
+ tpos_payment.payment_method = payment_method
+ await update_tpos_payment(tpos_payment)
+
+ if payment.extra.get("tpos_processed"):
+ return
+
+ await process_paid_tpos_payment(payment, payment_method=payment_method)
+
+
+async def settle_onchain_tpos_payment(tpos_payment) -> None:
+ payment = await get_standalone_payment(tpos_payment.payment_hash, incoming=True)
+ if not payment or not payment.extra or payment.extra.get("tag") != "tpos":
+ return
+
+ if payment.success:
+ return
+
+ payment.extra["payment_method"] = "onchain"
+ payment.extra["settled_by_onchain"] = True
+ await update_payment(payment)
+ await internal_invoice_queue_put(payment.checking_id)
+
+
+async def process_paid_tpos_payment(
+ payment: Payment, *, payment_method: str = "lightning"
+) -> None:
+ if (
+ not payment.extra
+ or payment.extra.get("tag") != "tpos"
+ or payment.extra.get("tipSplitted")
+ ):
+ return
+
+ payment.extra["tpos_processed"] = True
+ payment.extra["payment_method"] = payment_method
+ await update_payment(payment)
+
tip_amount = payment.extra.get("tip_amount")
+ tpos_id = payment.extra.get("tpos_id")
+ assert tpos_id
stripped_payment = {
"amount": payment.amount,
@@ -39,11 +141,10 @@ async def on_invoice_paid(payment: Payment) -> None:
"checking_id": payment.checking_id,
"payment_hash": payment.payment_hash,
"bolt11": payment.bolt11,
+ "pending": False,
+ "payment_method": payment_method,
}
- tpos_id = payment.extra.get("tpos_id")
- assert tpos_id
-
tpos = await get_tpos(tpos_id)
assert tpos
if payment.extra.get("lnaddress") and payment.extra["lnaddress"] != "":
@@ -52,19 +153,21 @@ async def on_invoice_paid(payment: Payment) -> None:
if address:
try:
pr = await get_pr_from_lnurl(address, int(calc_amount))
- except Exception as e:
- logger.error(f"tpos: Error getting payment request from lnurl: {e}")
- return
-
- payment.extra["lnaddress"] = ""
- paid_payment = await pay_invoice(
- payment_request=pr,
- wallet_id=payment.wallet_id,
- extra={**payment.extra},
- )
- logger.debug(f"tpos: LNaddress paid cut: {paid_payment.checking_id}")
-
- await websocket_updater(tpos_id, str(stripped_payment))
+ except Exception as exc:
+ logger.error(f"tpos: Error getting payment request from lnurl: {exc}")
+ pr = None
+
+ if pr:
+ payment.extra["lnaddress"] = ""
+ paid_payment = await pay_invoice(
+ payment_request=pr,
+ wallet_id=payment.wallet_id,
+ extra={**payment.extra},
+ )
+ logger.debug(f"tpos: LNaddress paid cut: {paid_payment.checking_id}")
+
+ await websocket_updater(tpos_id, json.dumps(stripped_payment))
+ await websocket_updater(payment.payment_hash, json.dumps(stripped_payment))
await maybe_push_order(payment, tpos)
@@ -76,11 +179,11 @@ async def on_invoice_paid(payment: Payment) -> None:
logger.warning(f"tpos: inventory deduction failed: {exc}")
if not tip_amount:
- # no tip amount
return
wallet_id = tpos.tip_wallet
- assert wallet_id
+ if not wallet_id:
+ return
tip_payment = await create_invoice(
wallet_id=wallet_id,
@@ -98,6 +201,16 @@ async def on_invoice_paid(payment: Payment) -> None:
logger.debug(f"tpos: tip invoice paid: {paid_payment.checking_id}")
+def _payment_method(payment: Payment) -> str:
+ if payment.extra.get("payment_method"):
+ return str(payment.extra["payment_method"])
+ if payment.extra.get("fiat_method") == "cash":
+ return "cash"
+ if payment.extra.get("fiat_payment_request", "").startswith("pi_"):
+ return "fiat"
+ return "lightning"
+
+
async def maybe_push_order(payment: Payment, tpos) -> None:
wallet = await get_wallet(payment.wallet_id)
if not wallet:
diff --git a/templates/tpos/_cart.html b/templates/tpos/_cart.html
index ecb1c02..775914d 100644
--- a/templates/tpos/_cart.html
+++ b/templates/tpos/_cart.html
@@ -169,11 +169,11 @@
class="cursor-pointer"
>
-
{% include "tpos/_options_fab.html" %}
+ {% include "tpos/_options_fab.html" %}
@@ -187,7 +187,7 @@
- {% include "tpos/_options_fab.html" %}
+ {% include "tpos/_options_fab.html" %}
diff --git a/templates/tpos/dialogs.html b/templates/tpos/dialogs.html
index 5f25803..8029e84 100644
--- a/templates/tpos/dialogs.html
+++ b/templates/tpos/dialogs.html
@@ -43,36 +43,16 @@
-
-
-
${ activePaymentAmountWithTipFormatted }
-
- ${ activePaymentAmountFormatted }
- (+ ${ tipAmountFormatted } tip)
-
-
-
- NFC supported
-
- NFC not supported
-
-
- Copy invoice
- Close
-
+
@@ -395,7 +375,7 @@
size="xl"
color="primary"
rounded
- aria-label="Bitcoin"
+ aria-label="Lightning Network"
@click="selectPaymentMethod('btc')"
>
@@ -403,6 +383,25 @@
class="text-h4 text-weight-bold"
v-text="bitcoinSymbol"
>
+ Lightning
+
+
+
+
diff --git a/templates/tpos/index.html b/templates/tpos/index.html
index 9db2b82..73d4bc8 100644
--- a/templates/tpos/index.html
+++ b/templates/tpos/index.html
@@ -459,6 +459,59 @@ {{SITE_TITLE}} TPoS extension
+
+
+
+
+
+
+
+
+
+
+
+
+
+ If disabled, TPoS waits for the first confirmation before
+ completing the sale.
+
+
+
+
{{SITE_TITLE}} TPoS extension
-
+
{{SITE_TITLE}} TPoS extension
>
-
-
-
-
-
-
-
-
- If accepting Stripe payments, visit
- https://dashboard.stripe.com/terminal and grab a new location ID
-
+
-
-
-
+
+
+
+
+
+
+
+ If accepting Stripe payments, visit
+ https://dashboard.stripe.com/terminal and grab a new location ID
+
+
-
-
Press if accepting Stripe payments.
+
+
+
+
+
+ Press if accepting Stripe payments.
+
diff --git a/templates/tpos/tpos.html b/templates/tpos/tpos.html
index 6504d54..cfbe094 100644
--- a/templates/tpos/tpos.html
+++ b/templates/tpos/tpos.html
@@ -243,6 +243,7 @@
+
diff --git a/views_api.py b/views_api.py
index 161363c..f44bfda 100644
--- a/views_api.py
+++ b/views_api.py
@@ -9,7 +9,6 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from lnbits.core.crud import (
get_account,
- get_latest_payments_by_extension,
get_standalone_payment,
get_user,
get_wallet,
@@ -41,8 +40,11 @@
from .crud import (
create_tpos,
+ create_tpos_payment,
delete_tpos,
+ get_latest_tpos_payments,
get_tpos,
+ get_tpos_payment_by_hash,
get_tposs,
update_tpos,
)
@@ -65,11 +67,18 @@
ReceiptPrint,
TapToPay,
Tpos,
+ TposInvoiceResponse,
+ TposPayment,
)
from .services import (
+ fetch_onchain_address,
+ fetch_watchonly_config,
+ fetch_watchonly_wallet,
+ fetch_watchonly_wallets,
get_default_inventory,
get_inventory_items_for_tpos,
inventory_available_for_user,
+ watchonly_available_for_user,
)
tpos_api_router = APIRouter()
@@ -85,7 +94,9 @@ def _two_year_token_expiry_minutes() -> int:
return max(1, int((expires_at - now).total_seconds() // 60))
-def _build_receipt_data(tpos: Tpos, payment: Payment) -> ReceiptData:
+def _build_receipt_data(
+ tpos: Tpos, payment: Payment, tpos_payment: TposPayment | None = None
+) -> ReceiptData:
extra = payment.extra or {}
details = extra.get("details") or {}
items = details.get("items") or []
@@ -101,7 +112,7 @@ def _build_receipt_data(tpos: Tpos, payment: Payment) -> ReceiptData:
]
return ReceiptData(
- paid=payment.success,
+ paid=payment.success or bool(tpos_payment and tpos_payment.paid),
extra=ReceiptExtraData(
amount=int(extra.get("amount") or 0),
paid_in_fiat=bool(extra.get("paid_in_fiat")),
@@ -123,6 +134,129 @@ def _build_receipt_data(tpos: Tpos, payment: Payment) -> ReceiptData:
)
+async def _get_watchonly_status(wallet) -> dict[str, Any]:
+ if not await watchonly_available_for_user(wallet.user):
+ return {
+ "available": False,
+ "message": "Watchonly extension must be enabled for this user.",
+ "network": None,
+ "wallets": [],
+ }
+
+ try:
+ config = await fetch_watchonly_config(wallet.inkey)
+ network_value = config.get("network")
+ if not isinstance(network_value, str) or not network_value:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail="Watchonly extension returned an invalid network configuration.",
+ )
+ network = network_value
+ wallets = await fetch_watchonly_wallets(wallet.inkey, network)
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail=f"Watchonly extension is not reachable: {exc!s}",
+ ) from exc
+
+ return {
+ "available": True,
+ "message": None,
+ "network": network,
+ "wallets": wallets,
+ "mempool_endpoint": config.get("mempool_endpoint"),
+ }
+
+
+async def _validate_watchonly_settings(
+ *,
+ wallet,
+ onchain_enabled: bool,
+ onchain_wallet_id: str | None,
+) -> dict[str, Any] | None:
+ if not onchain_enabled:
+ return None
+ if not onchain_wallet_id:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail="Watchonly wallet is required when onchain payments are enabled.",
+ )
+
+ status = await _get_watchonly_status(wallet)
+ if not status["available"]:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail=status["message"] or "Watchonly extension is not available.",
+ )
+
+ try:
+ watch_wallet = await fetch_watchonly_wallet(wallet.inkey, onchain_wallet_id)
+ except Exception as exc:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail=f"Cannot access watchonly wallet: {exc!s}",
+ ) from exc
+
+ if watch_wallet.get("network") != status["network"]:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail="Watchonly wallet network does not match the user watchonly config.",
+ )
+
+ return {
+ "watch_wallet": watch_wallet,
+ "network": status["network"],
+ "mempool_endpoint": status["mempool_endpoint"],
+ }
+
+
+def _payment_method_from_payment(payment: Payment) -> str:
+ if payment.extra.get("payment_method"):
+ return str(payment.extra["payment_method"])
+ if payment.extra.get("fiat_method") == "cash":
+ return "cash"
+ if payment.extra.get("fiat_payment_request", "").startswith("pi_"):
+ return "fiat"
+ return "lightning"
+
+
+def _serialize_tpos_invoice_response(
+ payment: Payment, tpos_payment: TposPayment
+) -> TposInvoiceResponse:
+ payment_method = _payment_method_from_payment(payment)
+ payment_request = "lightning:" + payment.bolt11.upper()
+ if payment_method == "cash":
+ payment_request = "cash"
+ elif payment.extra.get("fiat_payment_request") and not payment.extra.get(
+ "fiat_payment_request", ""
+ ).startswith("pi_"):
+ payment_request = payment.extra["fiat_payment_request"]
+ elif payment_method == "fiat":
+ payment_request = "tap_to_pay"
+ elif payment_method == "onchain" and tpos_payment.onchain_address:
+ payment_request = tpos_payment.onchain_address
+
+ options = [payment_method]
+ if tpos_payment.onchain_address:
+ options = ["btc", "btc_onchain"]
+
+ return TposInvoiceResponse(
+ payment_hash=payment.payment_hash,
+ bolt11=payment.bolt11,
+ payment_request=payment_request,
+ tpos_payment_id=tpos_payment.id,
+ payment_options=options,
+ onchain_address=tpos_payment.onchain_address,
+ onchain_amount_sat=(
+ tpos_payment.amount if tpos_payment.onchain_address else None
+ ),
+ payment_method=payment_method,
+ extra=payment.extra or {},
+ )
+
+
@tpos_api_router.get("/api/v1/tposs", status_code=HTTPStatus.OK)
async def api_tposs(
all_wallets: bool = Query(False),
@@ -153,11 +287,23 @@ async def api_inventory_status(
}
+@tpos_api_router.get("/api/v1/onchain/status", status_code=HTTPStatus.OK)
+async def api_onchain_status(
+ key_info: WalletTypeInfo = Depends(require_admin_key),
+) -> dict[str, Any]:
+ return await _get_watchonly_status(key_info.wallet)
+
+
@tpos_api_router.post("/api/v1/tposs", status_code=HTTPStatus.CREATED)
async def api_tpos_create(
data: CreateTposData, wallet: WalletTypeInfo = Depends(require_admin_key)
):
data.wallet = wallet.wallet.id
+ await _validate_watchonly_settings(
+ wallet=wallet.wallet,
+ onchain_enabled=data.onchain_enabled,
+ onchain_wallet_id=data.onchain_wallet_id,
+ )
user = await get_user(wallet.wallet.user)
if not (user and user.super_user):
data.allow_cash_settlement = False
@@ -192,6 +338,17 @@ async def api_tpos_update(
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.")
user = await get_user(wallet.wallet.user)
update_payload = data.dict(exclude_unset=True)
+ desired_onchain_enabled = update_payload.get(
+ "onchain_enabled", tpos.onchain_enabled
+ )
+ desired_onchain_wallet_id = update_payload.get(
+ "onchain_wallet_id", tpos.onchain_wallet_id
+ )
+ await _validate_watchonly_settings(
+ wallet=wallet.wallet,
+ onchain_enabled=desired_onchain_enabled,
+ onchain_wallet_id=desired_onchain_wallet_id,
+ )
desired_currency = update_payload.get("currency", tpos.currency)
if desired_currency == "sats":
update_payload["allow_cash_settlement"] = False
@@ -331,7 +488,7 @@ async def api_tpos_create_wrapper_token(
)
async def api_tpos_create_invoice(
tpos_id: str, data: CreateTposInvoice, request: Request
-) -> Payment:
+) -> dict[str, Any]:
tpos = await get_tpos(tpos_id)
if not tpos:
@@ -378,11 +535,17 @@ async def api_tpos_create_invoice(
}
cash_method = data.pay_in_fiat and data.fiat_method == "cash"
+ onchain_method = data.payment_method == "btc_onchain"
if cash_method and not tpos.allow_cash_settlement:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Cash settlement is not enabled for this TPoS.",
)
+ if onchain_method and not tpos.onchain_enabled:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Onchain payments are not enabled for this TPoS.",
+ )
currency = tpos.currency if data.pay_in_fiat else "sat"
amount = data.amount + (data.tip_amount or 0.0)
if data.pay_in_fiat:
@@ -402,18 +565,23 @@ async def api_tpos_create_invoice(
"paid_in_fiat": data.pay_in_fiat,
"base_url": str(request.base_url),
}
- if cash_method:
+ if cash_method or onchain_method:
wallet = await get_wallet(tpos.wallet)
if wallet:
account = await get_account(wallet.user)
if account:
existing = {label.name for label in account.extra.labels or []}
- if "cash" not in existing:
+ label_name = "cash" if cash_method else "onchain"
+ label_description = (
+ "Cash payment" if cash_method else "Onchain payment"
+ )
+ label_color = "#FFC107" if cash_method else "#ED8403"
+ if label_name not in existing:
account.extra.labels.append(
UserLabel(
- name="cash",
- description="Cash payment",
- color="#FFC107",
+ name=label_name,
+ description=label_description,
+ color=label_color,
)
)
await update_account(account)
@@ -423,6 +591,8 @@ async def api_tpos_create_invoice(
extra["fiat_method"] = data.fiat_method if data.fiat_method else "checkout"
if data.fiat_method == "terminal" and tpos.stripe_reader_id:
extra["terminal"] = {"reader_id": tpos.stripe_reader_id}
+ if onchain_method:
+ extra["payment_method"] = "onchain"
invoice_data = CreateInvoice(
unit=currency,
out=False,
@@ -432,33 +602,69 @@ async def api_tpos_create_invoice(
fiat_provider=(
tpos.fiat_provider if data.pay_in_fiat and not cash_method else None
),
- internal=bool(cash_method),
- labels=["cash"] if cash_method else [],
+ internal=bool(cash_method or onchain_method),
+ labels=["cash"] if cash_method else (["onchain"] if onchain_method else []),
)
payment = await create_payment_request(tpos.wallet, invoice_data)
if cash_method:
new_checking_id = f"internal_cash_{payment.payment_hash}"
await update_payment_checking_id(payment.checking_id, new_checking_id)
payment.checking_id = new_checking_id
- payment_request_for_display = "lightning:" + payment.bolt11.upper()
- fiat_payment_request = payment.extra.get("fiat_payment_request")
- if cash_method:
- payment_request_for_display = "cash"
- elif fiat_payment_request and not fiat_payment_request.startswith("pi_"):
- payment_request_for_display = fiat_payment_request
- elif fiat_payment_request and fiat_payment_request.startswith("pi_"):
- payment_request_for_display = "tap_to_pay"
+ elif onchain_method:
+ new_checking_id = f"internal_onchain_{payment.payment_hash}"
+ await update_payment_checking_id(payment.checking_id, new_checking_id)
+ payment.checking_id = new_checking_id
+
+ onchain_address = None
+ mempool_endpoint = None
+ if onchain_method:
+ wallet_record = await get_wallet(tpos.wallet)
+ if not wallet_record:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail="Wallet not found for this TPoS.",
+ )
+ validation = await _validate_watchonly_settings(
+ wallet=wallet_record,
+ onchain_enabled=tpos.onchain_enabled,
+ onchain_wallet_id=tpos.onchain_wallet_id,
+ )
+ assert validation
+ address_data = await fetch_onchain_address(
+ wallet_record.inkey, tpos.onchain_wallet_id or ""
+ )
+ onchain_address = address_data.get("address")
+ mempool_endpoint = validation.get("mempool_endpoint")
+
+ tpos_payment = await create_tpos_payment(
+ TposPayment(
+ id=uuid4().hex,
+ tpos_id=tpos_id,
+ payment_hash=payment.payment_hash,
+ amount=int(data.amount + (data.tip_amount or 0)),
+ onchain_address=onchain_address,
+ onchain_wallet_id=tpos.onchain_wallet_id,
+ onchain_zero_conf=tpos.onchain_zero_conf,
+ mempool_endpoint=mempool_endpoint,
+ )
+ )
+ response_payload = _serialize_tpos_invoice_response(payment, tpos_payment)
if tpos.enable_remote:
payload = {
"type": "invoice_created",
"tpos_id": tpos_id,
"payment_hash": payment.payment_hash,
- "payment_request": payment_request_for_display,
+ "payment_request": response_payload.payment_request,
"paid_in_fiat": data.pay_in_fiat,
"amount_fiat": data.amount_fiat,
"tip_amount": data.tip_amount,
"exchange_rate": data.exchange_rate if data.exchange_rate else None,
+ "tpos_payment_id": response_payload.tpos_payment_id,
+ "payment_options": response_payload.payment_options,
+ "onchain_address": response_payload.onchain_address,
+ "onchain_amount_sat": response_payload.onchain_amount_sat,
+ "payment_method": response_payload.payment_method,
}
await websocket_updater(tpos_id, json.dumps(payload))
@@ -476,7 +682,7 @@ async def api_tpos_create_invoice(
payment_hash=payment.payment_hash,
)
await websocket_updater(tpos_id, json.dumps(tap_to_pay_payload.dict()))
- return payment
+ return response_payload.dict()
except Exception as exc:
raise HTTPException(
@@ -486,9 +692,12 @@ async def api_tpos_create_invoice(
@tpos_api_router.get("/api/v1/tposs/{tpos_id}/invoices")
async def api_tpos_get_latest_invoices(tpos_id: str):
- payments = await get_latest_payments_by_extension(ext_name="tpos", ext_id=tpos_id)
+ tpos_payments = await get_latest_tpos_payments(tpos_id)
result = []
- for payment in payments:
+ for tpos_payment in tpos_payments:
+ payment = await get_standalone_payment(tpos_payment.payment_hash, incoming=True)
+ if not payment:
+ continue
details = payment.extra.get("details", {})
currency = details.get("currency", None)
exchange_rate = details.get("exchangeRate") or payment.extra.get("exchangeRate")
@@ -497,9 +706,10 @@ async def api_tpos_get_latest_invoices(tpos_id: str):
"checking_id": payment.checking_id,
"amount": payment.amount,
"time": payment.time,
- "pending": payment.pending,
+ "pending": not tpos_payment.paid,
"currency": currency,
"exchange_rate": exchange_rate,
+ "payment_method": tpos_payment.payment_method,
}
)
return result
@@ -594,10 +804,11 @@ async def api_tpos_check_invoice(
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS payment does not exist."
)
+ tpos_payment = await get_tpos_payment_by_hash(payment_hash)
if extra:
- return _build_receipt_data(tpos, payment).to_api_dict()
- return {"paid": payment.success}
+ return _build_receipt_data(tpos, payment, tpos_payment).to_api_dict()
+ return {"paid": payment.success or bool(tpos_payment and tpos_payment.paid)}
@tpos_api_router.post(
@@ -626,7 +837,8 @@ async def api_tpos_print_invoice(
receipt_type: Literal["receipt", "order_receipt"] = (
"order_receipt" if data.receipt_type == "order_receipt" else "receipt"
)
- receipt = _build_receipt_data(tpos, payment)
+ tpos_payment = await get_tpos_payment_by_hash(payment_hash)
+ receipt = _build_receipt_data(tpos, payment, tpos_payment)
payload = ReceiptPrint(
tpos_id=tpos_id,
payment_hash=payment_hash,