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" %}