Merge branch 'main' into diagon-alley
This commit is contained in:
+1
-3
@@ -4,7 +4,7 @@ import uvloop
|
||||
from loguru import logger
|
||||
from starlette.requests import Request
|
||||
|
||||
from .commands import bundle_vendored, migrate_databases, transpile_scss
|
||||
from .commands import migrate_databases
|
||||
from .settings import (
|
||||
DEBUG,
|
||||
HOST,
|
||||
@@ -19,8 +19,6 @@ from .settings import (
|
||||
uvloop.install()
|
||||
|
||||
asyncio.create_task(migrate_databases())
|
||||
transpile_scss()
|
||||
bundle_vendored()
|
||||
|
||||
from .app import create_app
|
||||
|
||||
|
||||
+43
-22
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
import warnings
|
||||
@@ -17,7 +18,6 @@ from loguru import logger
|
||||
import lnbits.settings
|
||||
from lnbits.core.tasks import register_task_listeners
|
||||
|
||||
from .commands import db_migrate, handle_assets
|
||||
from .core import core_app
|
||||
from .core.views.generic import core_html_routes
|
||||
from .helpers import (
|
||||
@@ -45,10 +45,19 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||
"""
|
||||
configure_logger()
|
||||
|
||||
app = FastAPI()
|
||||
app.mount("/static", StaticFiles(directory="lnbits/static"), name="static")
|
||||
app = FastAPI(
|
||||
title="LNbits API",
|
||||
description="API for LNbits, the free and open source bitcoin wallet and accounts system with plugins.",
|
||||
license_info={
|
||||
"name": "MIT License",
|
||||
"url": "https://raw.githubusercontent.com/lnbits/lnbits-legend/main/LICENSE",
|
||||
},
|
||||
)
|
||||
app.mount("/static", StaticFiles(packages=[("lnbits", "static")]), name="static")
|
||||
app.mount(
|
||||
"/core/static", StaticFiles(directory="lnbits/core/static"), name="core_static"
|
||||
"/core/static",
|
||||
StaticFiles(packages=[("lnbits.core", "static")]),
|
||||
name="core_static",
|
||||
)
|
||||
|
||||
origins = ["*"]
|
||||
@@ -67,7 +76,11 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
|
||||
if "text/html" in request.headers["accept"]:
|
||||
if (
|
||||
request.headers
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html",
|
||||
{"request": request, "err": f"{exc.errors()} is not a valid UUID."},
|
||||
@@ -84,7 +97,6 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||
check_funding_source(app)
|
||||
register_assets(app)
|
||||
register_routes(app)
|
||||
# register_commands(app)
|
||||
register_async_tasks(app)
|
||||
register_exception_handlers(app)
|
||||
|
||||
@@ -94,16 +106,27 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||
def check_funding_source(app: FastAPI) -> None:
|
||||
@app.on_event("startup")
|
||||
async def check_wallet_status():
|
||||
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||
|
||||
def signal_handler(signal, frame):
|
||||
logger.debug(f"SIGINT received, terminating LNbits.")
|
||||
sys.exit(1)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
while True:
|
||||
error_message, balance = await WALLET.status()
|
||||
if not error_message:
|
||||
break
|
||||
logger.error(
|
||||
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
||||
RuntimeWarning,
|
||||
)
|
||||
logger.info("Retrying connection to backend in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
try:
|
||||
error_message, balance = await WALLET.status()
|
||||
if not error_message:
|
||||
break
|
||||
logger.error(
|
||||
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
||||
RuntimeWarning,
|
||||
)
|
||||
logger.info("Retrying connection to backend in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
except:
|
||||
pass
|
||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||
logger.info(
|
||||
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
|
||||
)
|
||||
@@ -137,12 +160,6 @@ def register_routes(app: FastAPI) -> None:
|
||||
)
|
||||
|
||||
|
||||
def register_commands(app: FastAPI):
|
||||
"""Register Click commands."""
|
||||
app.cli.add_command(db_migrate)
|
||||
app.cli.add_command(handle_assets)
|
||||
|
||||
|
||||
def register_assets(app: FastAPI):
|
||||
"""Serve each vendored asset separately or a bundle."""
|
||||
|
||||
@@ -184,7 +201,11 @@ def register_exception_handlers(app: FastAPI):
|
||||
traceback.print_exception(etype, err, tb)
|
||||
exc = traceback.format_exc()
|
||||
|
||||
if "text/html" in request.headers["accept"]:
|
||||
if (
|
||||
request.headers
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": err}
|
||||
)
|
||||
|
||||
+3
-3
@@ -61,7 +61,7 @@ def decode(pr: str) -> Invoice:
|
||||
invoice = Invoice()
|
||||
|
||||
# decode the amount from the hrp
|
||||
m = re.search("[^\d]+", hrp[2:])
|
||||
m = re.search(r"[^\d]+", hrp[2:])
|
||||
if m:
|
||||
amountstr = hrp[2 + m.end() :]
|
||||
if amountstr != "":
|
||||
@@ -216,7 +216,7 @@ def lnencode(addr, privkey):
|
||||
expirybits = expirybits[5:]
|
||||
data += tagged("x", expirybits)
|
||||
elif k == "h":
|
||||
data += tagged_bytes("h", hashlib.sha256(v.encode("utf-8")).digest())
|
||||
data += tagged_bytes("h", v)
|
||||
elif k == "n":
|
||||
data += tagged_bytes("n", v)
|
||||
else:
|
||||
@@ -296,7 +296,7 @@ def _unshorten_amount(amount: str) -> int:
|
||||
# BOLT #11:
|
||||
# A reader SHOULD fail if `amount` contains a non-digit, or is followed by
|
||||
# anything except a `multiplier` in the table above.
|
||||
if not re.fullmatch("\d+[pnum]?", str(amount)):
|
||||
if not re.fullmatch(r"\d+[pnum]?", str(amount)):
|
||||
raise ValueError("Invalid amount '{}'".format(amount))
|
||||
|
||||
if unit in units:
|
||||
|
||||
+4
-2
@@ -4,6 +4,8 @@ from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import COCKROACH, POSTGRES, Connection
|
||||
from lnbits.settings import DEFAULT_WALLET_NAME, LNBITS_ADMIN_USERS
|
||||
@@ -113,7 +115,7 @@ async def create_wallet(
|
||||
async def update_wallet(
|
||||
wallet_id: str, new_name: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Wallet]:
|
||||
await (conn or db).execute(
|
||||
return await (conn or db).execute(
|
||||
"""
|
||||
UPDATE wallets SET
|
||||
name = ?
|
||||
@@ -334,7 +336,7 @@ async def delete_expired_invoices(
|
||||
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
if expiration_date > datetime.datetime.utcnow():
|
||||
continue
|
||||
|
||||
logger.debug(f"Deleting expired invoice: {invoice.payment_hash}")
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
DELETE FROM apipayments
|
||||
|
||||
+10
-2
@@ -106,6 +106,8 @@ class Payment(BaseModel):
|
||||
|
||||
@property
|
||||
def tag(self) -> Optional[str]:
|
||||
if self.extra is None:
|
||||
return ""
|
||||
return self.extra.get("tag")
|
||||
|
||||
@property
|
||||
@@ -139,19 +141,25 @@ class Payment(BaseModel):
|
||||
if self.is_uncheckable:
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}"
|
||||
)
|
||||
|
||||
if self.is_out:
|
||||
status = await WALLET.get_payment_status(self.checking_id)
|
||||
else:
|
||||
status = await WALLET.get_invoice_status(self.checking_id)
|
||||
|
||||
logger.debug(f"Status: {status}")
|
||||
|
||||
if self.is_out and status.failed:
|
||||
logger.info(
|
||||
f" - deleting outgoing failed payment {self.checking_id}: {status}"
|
||||
f"Deleting outgoing failed payment {self.checking_id}: {status}"
|
||||
)
|
||||
await self.delete()
|
||||
elif not status.pending:
|
||||
logger.info(
|
||||
f" - marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
|
||||
f"Marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
|
||||
)
|
||||
await self.set_pending(status.pending)
|
||||
|
||||
|
||||
+36
-22
@@ -6,15 +6,22 @@ from typing import Dict, Optional, Tuple
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends
|
||||
from lnurl import LnurlErrorResponse
|
||||
from lnurl import decode as decode_lnurl # type: ignore
|
||||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import Connection
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import url_for, urlsafe_short_hash
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import FAKE_WALLET, WALLET
|
||||
from lnbits.settings import FAKE_WALLET, RESERVE_FEE_MIN, RESERVE_FEE_PERCENT, WALLET
|
||||
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
||||
|
||||
from . import db
|
||||
@@ -47,6 +54,7 @@ async def create_invoice(
|
||||
amount: int, # in satoshis
|
||||
memo: str,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
webhook: Optional[str] = None,
|
||||
internal: Optional[bool] = False,
|
||||
@@ -58,7 +66,10 @@ async def create_invoice(
|
||||
wallet = FAKE_WALLET if internal else WALLET
|
||||
|
||||
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
|
||||
amount=amount, memo=invoice_memo, description_hash=description_hash
|
||||
amount=amount,
|
||||
memo=invoice_memo,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
)
|
||||
if not ok:
|
||||
raise InvoiceFailure(error_message or "unexpected backend error.")
|
||||
@@ -102,18 +113,15 @@ async def pay_invoice(
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
|
||||
# put all parameters that don't change here
|
||||
PaymentKwargs = TypedDict(
|
||||
"PaymentKwargs",
|
||||
{
|
||||
"wallet_id": str,
|
||||
"payment_request": str,
|
||||
"payment_hash": str,
|
||||
"amount": int,
|
||||
"memo": str,
|
||||
"extra": Optional[Dict],
|
||||
},
|
||||
)
|
||||
payment_kwargs: PaymentKwargs = dict(
|
||||
class PaymentKwargs(TypedDict):
|
||||
wallet_id: str
|
||||
payment_request: str
|
||||
payment_hash: str
|
||||
amount: int
|
||||
memo: str
|
||||
extra: Optional[Dict]
|
||||
|
||||
payment_kwargs: PaymentKwargs = PaymentKwargs(
|
||||
wallet_id=wallet_id,
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
@@ -152,7 +160,7 @@ async def pay_invoice(
|
||||
logger.debug("balance is too low, deleting temporary payment")
|
||||
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
|
||||
raise PaymentFailure(
|
||||
f"You must reserve at least 1% ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
|
||||
f"You must reserve at least ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
|
||||
)
|
||||
raise PermissionError("Insufficient balance.")
|
||||
|
||||
@@ -178,7 +186,7 @@ async def pay_invoice(
|
||||
payment_request, fee_reserve_msat
|
||||
)
|
||||
logger.debug(f"backend: pay_invoice finished {temp_id}")
|
||||
if payment.checking_id:
|
||||
if payment.ok and payment.checking_id:
|
||||
logger.debug(f"creating final payment {payment.checking_id}")
|
||||
async with db.connect() as conn:
|
||||
await create_payment(
|
||||
@@ -192,7 +200,7 @@ async def pay_invoice(
|
||||
logger.debug(f"deleting temporary payment {temp_id}")
|
||||
await delete_payment(temp_id, conn=conn)
|
||||
else:
|
||||
logger.debug(f"backend payment failed, no checking_id {temp_id}")
|
||||
logger.debug(f"backend payment failed")
|
||||
async with db.connect() as conn:
|
||||
logger.debug(f"deleting temporary payment {temp_id}")
|
||||
await delete_payment(temp_id, conn=conn)
|
||||
@@ -258,12 +266,15 @@ async def redeem_lnurl_withdraw(
|
||||
|
||||
|
||||
async def perform_lnurlauth(
|
||||
callback: str, conn: Optional[Connection] = None
|
||||
callback: str,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[LnurlErrorResponse]:
|
||||
cb = urlparse(callback)
|
||||
|
||||
k1 = unhexlify(parse_qs(cb.query)["k1"][0])
|
||||
key = g().wallet.lnurlauth_key(cb.netloc)
|
||||
|
||||
key = wallet.wallet.lnurlauth_key(cb.netloc)
|
||||
|
||||
def int_to_bytes_suitable_der(x: int) -> bytes:
|
||||
"""for strict DER we need to encode the integer with some quirks"""
|
||||
@@ -330,13 +341,16 @@ async def perform_lnurlauth(
|
||||
)
|
||||
|
||||
|
||||
async def check_invoice_status(
|
||||
async def check_transaction_status(
|
||||
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
|
||||
) -> PaymentStatus:
|
||||
payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||
if not payment:
|
||||
return PaymentStatus(None)
|
||||
status = await WALLET.get_invoice_status(payment.checking_id)
|
||||
if payment.is_out:
|
||||
status = await WALLET.get_payment_status(payment.checking_id)
|
||||
else:
|
||||
status = await WALLET.get_invoice_status(payment.checking_id)
|
||||
if not payment.pending:
|
||||
return status
|
||||
if payment.is_out and status.failed:
|
||||
@@ -352,4 +366,4 @@ async def check_invoice_status(
|
||||
|
||||
# WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ
|
||||
def fee_reserve(amount_msat: int) -> int:
|
||||
return max(2000, int(amount_msat * 0.01))
|
||||
return max(int(RESERVE_FEE_MIN), int(amount_msat * RESERVE_FEE_PERCENT / 100.0))
|
||||
|
||||
@@ -1,4 +1,36 @@
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
data: function () {
|
||||
return {
|
||||
searchTerm: '',
|
||||
filteredExtensions: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.filteredExtensions = this.g.extensions
|
||||
},
|
||||
watch: {
|
||||
searchTerm(term) {
|
||||
// Reset the filter
|
||||
this.filteredExtensions = this.g.extensions
|
||||
if (term !== '') {
|
||||
// Filter the extensions list
|
||||
function extensionNameContains(searchTerm) {
|
||||
return function (extension) {
|
||||
return (
|
||||
extension.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
extension.shortDescription
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.filteredExtensions = this.filteredExtensions.filter(
|
||||
extensionNameContains(term)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
mixins: [windowMixin]
|
||||
})
|
||||
|
||||
@@ -55,7 +55,7 @@ async def dispatch_webhook(payment: Payment):
|
||||
data = payment.dict()
|
||||
try:
|
||||
logger.debug("sending webhook", payment.webhook)
|
||||
r = await client.post(payment.webhook, json=data, timeout=40)
|
||||
r = await client.post(payment.webhook, json=data, timeout=40) # type: ignore
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
await mark_webhook_sent(payment, -1)
|
||||
|
||||
@@ -2,10 +2,23 @@
|
||||
%} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="/core/static/js/extensions.js"></script>
|
||||
{% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-sm-3 col-xs-8 q-ml-auto">
|
||||
<q-input v-model="searchTerm" label="Search extensions">
|
||||
<q-icon
|
||||
v-if="searchTerm !== ''"
|
||||
name="close"
|
||||
@click="searchTerm = ''"
|
||||
class="cursor-pointer q-mt-lg"
|
||||
/>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md">
|
||||
<div
|
||||
class="col-6 col-md-4 col-lg-3"
|
||||
v-for="extension in g.extensions"
|
||||
v-for="extension in filteredExtensions"
|
||||
:key="extension.code"
|
||||
>
|
||||
<q-card>
|
||||
|
||||
@@ -689,7 +689,7 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-tabs
|
||||
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-max"
|
||||
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top"
|
||||
active-class="px-0"
|
||||
indicator-color="transparent"
|
||||
>
|
||||
|
||||
+78
-53
@@ -3,32 +3,30 @@ import hashlib
|
||||
import json
|
||||
from binascii import unhexlify
|
||||
from http import HTTPStatus
|
||||
from typing import Dict, List, Optional, Union
|
||||
from io import BytesIO
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
from fastapi import Header, Query, Request
|
||||
import pyqrcode
|
||||
from fastapi import Depends, Header, Query, Request
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.param_functions import Depends
|
||||
from fastapi.params import Body
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic.fields import Field
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
from starlette.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from lnbits import bolt11, lnurl
|
||||
from lnbits.bolt11 import Invoice
|
||||
from lnbits.core.models import Payment, Wallet
|
||||
from lnbits.decorators import (
|
||||
WalletAdminKeyChecker,
|
||||
WalletInvoiceKeyChecker,
|
||||
WalletTypeInfo,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import url_for, urlsafe_short_hash
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import LNBITS_ADMIN_USERS, LNBITS_SITE_TITLE
|
||||
from lnbits.utils.exchange_rates import (
|
||||
currencies,
|
||||
@@ -50,7 +48,7 @@ from ..crud import (
|
||||
from ..services import (
|
||||
InvoiceFailure,
|
||||
PaymentFailure,
|
||||
check_invoice_status,
|
||||
check_transaction_status,
|
||||
create_invoice,
|
||||
pay_invoice,
|
||||
perform_lnurlauth,
|
||||
@@ -125,7 +123,7 @@ async def api_payments(
|
||||
offset=offset,
|
||||
)
|
||||
for payment in pendingPayments:
|
||||
await check_invoice_status(
|
||||
await check_transaction_status(
|
||||
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
|
||||
)
|
||||
return await get_payments(
|
||||
@@ -143,6 +141,7 @@ class CreateInvoiceData(BaseModel):
|
||||
memo: Optional[str] = None
|
||||
unit: Optional[str] = "sat"
|
||||
description_hash: Optional[str] = None
|
||||
unhashed_description: Optional[str] = None
|
||||
lnurl_callback: Optional[str] = None
|
||||
lnurl_balance_check: Optional[str] = None
|
||||
extra: Optional[dict] = None
|
||||
@@ -154,9 +153,15 @@ class CreateInvoiceData(BaseModel):
|
||||
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
if data.description_hash:
|
||||
description_hash = unhexlify(data.description_hash)
|
||||
unhashed_description = b""
|
||||
memo = ""
|
||||
elif data.unhashed_description:
|
||||
unhashed_description = unhexlify(data.unhashed_description)
|
||||
description_hash = b""
|
||||
memo = ""
|
||||
else:
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or LNBITS_SITE_TITLE
|
||||
if data.unit == "sat":
|
||||
amount = int(data.amount)
|
||||
@@ -172,6 +177,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
amount=amount,
|
||||
memo=memo,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
extra=data.extra,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
@@ -186,11 +192,8 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
|
||||
lnurl_response: Union[None, bool, str] = None
|
||||
if data.lnurl_callback:
|
||||
if "lnurl_balance_check" in data:
|
||||
assert (
|
||||
data.lnurl_balance_check is not None
|
||||
), "lnurl_balance_check is required"
|
||||
save_balance_check(wallet.id, data.lnurl_balance_check)
|
||||
if data.lnurl_balance_check is not None:
|
||||
await save_balance_check(wallet.id, data.lnurl_balance_check)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
@@ -247,13 +250,11 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
|
||||
|
||||
@core_app.post(
|
||||
"/api/v1/payments",
|
||||
# deprecated=True,
|
||||
# description="DEPRECATED. Use /api/v2/TBD and /api/v2/TBD instead",
|
||||
status_code=HTTPStatus.CREATED,
|
||||
)
|
||||
async def api_payments_create(
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
invoiceData: CreateInvoiceData = Body(...),
|
||||
invoiceData: CreateInvoiceData = Body(...), # type: ignore
|
||||
):
|
||||
if invoiceData.out is True and wallet.wallet_type == 0:
|
||||
if not invoiceData.bolt11:
|
||||
@@ -269,7 +270,7 @@ async def api_payments_create(
|
||||
return await api_payments_create_invoice(invoiceData, wallet.wallet)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
@@ -284,7 +285,7 @@ class CreateLNURLData(BaseModel):
|
||||
|
||||
@core_app.post("/api/v1/payments/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLNURLData, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
data: CreateLNURLData, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
domain = urlparse(data.callback).netloc
|
||||
|
||||
@@ -296,7 +297,7 @@ async def api_payments_pay_lnurl(
|
||||
timeout=40,
|
||||
)
|
||||
if r.is_error:
|
||||
raise httpx.ConnectError
|
||||
raise httpx.ConnectError("LNURL callback connection error")
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
@@ -310,6 +311,12 @@ async def api_payments_pay_lnurl(
|
||||
detail=f"{domain} said: '{params.get('reason', '')}'",
|
||||
)
|
||||
|
||||
if not params.get("pr"):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} did not return a payment request.",
|
||||
)
|
||||
|
||||
invoice = bolt11.decode(params["pr"])
|
||||
if invoice.amount_msat != data.amount:
|
||||
raise HTTPException(
|
||||
@@ -317,11 +324,11 @@ async def api_payments_pay_lnurl(
|
||||
detail=f"{domain} returned an invalid invoice. Expected {data.amount} msat, got {invoice.amount_msat}.",
|
||||
)
|
||||
|
||||
# if invoice.description_hash != data.description_hash:
|
||||
# raise HTTPException(
|
||||
# status_code=HTTPStatus.BAD_REQUEST,
|
||||
# detail=f"{domain} returned an invalid invoice. Expected description_hash == {data.description_hash}, got {invoice.description_hash}.",
|
||||
# )
|
||||
if invoice.description_hash != data.description_hash:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} returned an invalid invoice. Expected description_hash == {data.description_hash}, got {invoice.description_hash}.",
|
||||
)
|
||||
|
||||
extra = {}
|
||||
|
||||
@@ -353,7 +360,7 @@ async def subscribe(request: Request, wallet: Wallet):
|
||||
logger.debug("adding sse listener", payment_queue)
|
||||
api_invoice_listeners.append(payment_queue)
|
||||
|
||||
send_queue: asyncio.Queue[tuple[str, Payment]] = asyncio.Queue(0)
|
||||
send_queue: asyncio.Queue[Tuple[str, Payment]] = asyncio.Queue(0)
|
||||
|
||||
async def payment_received() -> None:
|
||||
while True:
|
||||
@@ -392,21 +399,18 @@ async def api_payments_sse(
|
||||
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||
# We use X_Api_Key here because we want this call to work with and without keys
|
||||
# If a valid key is given, we also return the field "details", otherwise not
|
||||
wallet = None
|
||||
try:
|
||||
if X_Api_Key.extra:
|
||||
logger.warning("No key")
|
||||
except:
|
||||
wallet = await get_wallet_for_key(X_Api_Key)
|
||||
wallet = await get_wallet_for_key(X_Api_Key) if type(X_Api_Key) == str else None
|
||||
|
||||
# we have to specify the wallet id here, because postgres and sqlite return internal payments in different order
|
||||
# and get_standalone_payment otherwise just fetches the first one, causing unpredictable results
|
||||
payment = await get_standalone_payment(
|
||||
payment_hash, wallet_id=wallet.id if wallet else None
|
||||
) # we have to specify the wallet id here, because postgres and sqlite return internal payments in different order
|
||||
# and get_standalone_payment otherwise just fetches the first one, causing unpredictable results
|
||||
)
|
||||
if payment is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
)
|
||||
await check_invoice_status(payment.wallet_id, payment_hash)
|
||||
await check_transaction_status(payment.wallet_id, payment_hash)
|
||||
payment = await get_standalone_payment(
|
||||
payment_hash, wallet_id=wallet.id if wallet else None
|
||||
)
|
||||
@@ -435,10 +439,8 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
||||
|
||||
|
||||
@core_app.get(
|
||||
"/api/v1/lnurlscan/{code}", dependencies=[Depends(WalletInvoiceKeyChecker())]
|
||||
)
|
||||
async def api_lnurlscan(code: str):
|
||||
@core_app.get("/api/v1/lnurlscan/{code}")
|
||||
async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
try:
|
||||
url = lnurl.decode(code)
|
||||
domain = urlparse(url).netloc
|
||||
@@ -466,7 +468,7 @@ async def api_lnurlscan(code: str):
|
||||
params.update(kind="auth")
|
||||
params.update(callback=url) # with k1 already in it
|
||||
|
||||
lnurlauth_key = g().wallet.lnurlauth_key(domain)
|
||||
lnurlauth_key = wallet.wallet.lnurlauth_key(domain)
|
||||
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
|
||||
else:
|
||||
async with httpx.AsyncClient() as client:
|
||||
@@ -489,7 +491,8 @@ async def api_lnurlscan(code: str):
|
||||
)
|
||||
|
||||
try:
|
||||
tag = data["tag"]
|
||||
tag: str = data.get("tag")
|
||||
params.update(**data)
|
||||
if tag == "channelRequest":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
@@ -499,10 +502,7 @@ async def api_lnurlscan(code: str):
|
||||
"message": "unsupported",
|
||||
},
|
||||
)
|
||||
|
||||
params.update(**data)
|
||||
|
||||
if tag == "withdrawRequest":
|
||||
elif tag == "withdrawRequest":
|
||||
params.update(kind="withdraw")
|
||||
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
|
||||
|
||||
@@ -520,8 +520,7 @@ async def api_lnurlscan(code: str):
|
||||
query=urlencode(qs, doseq=True)
|
||||
)
|
||||
params.update(callback=urlunparse(parsed_callback))
|
||||
|
||||
if tag == "payRequest":
|
||||
elif tag == "payRequest":
|
||||
params.update(kind="pay")
|
||||
params.update(fixed=data["minSendable"] == data["maxSendable"])
|
||||
|
||||
@@ -539,8 +538,8 @@ async def api_lnurlscan(code: str):
|
||||
params.update(image=data_uri)
|
||||
if k == "text/email" or k == "text/identifier":
|
||||
params.update(targetUser=v)
|
||||
|
||||
params.update(commentAllowed=data.get("commentAllowed", 0))
|
||||
|
||||
except KeyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -582,14 +581,19 @@ async def api_payments_decode(data: DecodePayment):
|
||||
return {"message": "Failed to decode"}
|
||||
|
||||
|
||||
@core_app.post("/api/v1/lnurlauth", dependencies=[Depends(WalletAdminKeyChecker())])
|
||||
async def api_perform_lnurlauth(callback: str):
|
||||
err = await perform_lnurlauth(callback)
|
||||
class Callback(BaseModel):
|
||||
callback: str = Query(...)
|
||||
|
||||
|
||||
@core_app.post("/api/v1/lnurlauth")
|
||||
async def api_perform_lnurlauth(
|
||||
callback: Callback, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
err = await perform_lnurlauth(callback.callback, wallet=wallet)
|
||||
if err:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
|
||||
)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
@@ -608,8 +612,8 @@ class ConversionData(BaseModel):
|
||||
async def api_fiat_as_sats(data: ConversionData):
|
||||
output = {}
|
||||
if data.from_ == "sat":
|
||||
output["sats"] = int(data.amount)
|
||||
output["BTC"] = data.amount / 100000000
|
||||
output["sats"] = int(data.amount)
|
||||
for currency in data.to.split(","):
|
||||
output[currency.strip().upper()] = await satoshis_amount_as_fiat(
|
||||
data.amount, currency.strip()
|
||||
@@ -620,3 +624,24 @@ async def api_fiat_as_sats(data: ConversionData):
|
||||
output["sats"] = await fiat_amount_as_satoshis(data.amount, data.from_)
|
||||
output["BTC"] = output["sats"] / 100000000
|
||||
return output
|
||||
|
||||
|
||||
@core_app.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
|
||||
async def img(request: Request, data):
|
||||
qr = pyqrcode.create(data)
|
||||
stream = BytesIO()
|
||||
qr.svg(stream, scale=3)
|
||||
stream.seek(0)
|
||||
|
||||
async def _generator(stream: BytesIO):
|
||||
yield stream.getvalue()
|
||||
|
||||
return StreamingResponse(
|
||||
_generator(stream),
|
||||
headers={
|
||||
"Content-Type": "image/svg+xml",
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ from lnbits.settings import (
|
||||
SERVICE_FEE,
|
||||
)
|
||||
|
||||
from ...helpers import get_valid_extensions
|
||||
from ..crud import (
|
||||
create_account,
|
||||
create_wallet,
|
||||
@@ -54,9 +55,9 @@ async def home(request: Request, lightning: str = None):
|
||||
)
|
||||
async def extensions(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
enable: str = Query(None),
|
||||
disable: str = Query(None),
|
||||
user: User = Depends(check_user_exists), # type: ignore
|
||||
enable: str = Query(None), # type: ignore
|
||||
disable: str = Query(None), # type: ignore
|
||||
):
|
||||
extension_to_enable = enable
|
||||
extension_to_disable = disable
|
||||
@@ -66,6 +67,14 @@ async def extensions(
|
||||
HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension."
|
||||
)
|
||||
|
||||
# check if extension exists
|
||||
if extension_to_enable or extension_to_disable:
|
||||
ext = extension_to_enable or extension_to_disable
|
||||
if ext not in [e.code for e in get_valid_extensions()]:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, f"Extension '{ext}' doesn't exist."
|
||||
)
|
||||
|
||||
if extension_to_enable:
|
||||
logger.info(f"Enabling extension: {extension_to_enable} for user {user.id}")
|
||||
await update_user_extension(
|
||||
@@ -79,7 +88,7 @@ async def extensions(
|
||||
|
||||
# Update user as his extensions have been updated
|
||||
if extension_to_enable or extension_to_disable:
|
||||
user = await get_user(user.id)
|
||||
user = await get_user(user.id) # type: ignore
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/extensions.html", {"request": request, "user": user.dict()}
|
||||
@@ -100,10 +109,10 @@ nothing: create everything<br>
|
||||
""",
|
||||
)
|
||||
async def wallet(
|
||||
request: Request = Query(None),
|
||||
nme: Optional[str] = Query(None),
|
||||
usr: Optional[UUID4] = Query(None),
|
||||
wal: Optional[UUID4] = Query(None),
|
||||
request: Request = Query(None), # type: ignore
|
||||
nme: Optional[str] = Query(None), # type: ignore
|
||||
usr: Optional[UUID4] = Query(None), # type: ignore
|
||||
wal: Optional[UUID4] = Query(None), # type: ignore
|
||||
):
|
||||
user_id = usr.hex if usr else None
|
||||
wallet_id = wal.hex if wal else None
|
||||
@@ -112,7 +121,7 @@ async def wallet(
|
||||
|
||||
if not user_id:
|
||||
user = await get_user((await create_account()).id)
|
||||
logger.info(f"Created new account for user {user.id}")
|
||||
logger.info(f"Create user {user.id}") # type: ignore
|
||||
else:
|
||||
user = await get_user(user_id)
|
||||
if not user:
|
||||
@@ -126,22 +135,24 @@ async def wallet(
|
||||
if LNBITS_ADMIN_USERS and user_id in LNBITS_ADMIN_USERS:
|
||||
user.admin = True
|
||||
if not wallet_id:
|
||||
if user.wallets and not wallet_name:
|
||||
wallet = user.wallets[0]
|
||||
if user.wallets and not wallet_name: # type: ignore
|
||||
wallet = user.wallets[0] # type: ignore
|
||||
else:
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name) # type: ignore
|
||||
logger.info(
|
||||
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}"
|
||||
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}" # type: ignore
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
logger.info(f"Access wallet {wallet_name} of user {user.id}")
|
||||
wallet = user.get_wallet(wallet_id)
|
||||
if not wallet:
|
||||
logger.debug(
|
||||
f"Access {'user '+ user.id + ' ' if user else ''} {'wallet ' + wallet_name if wallet_name else ''}"
|
||||
)
|
||||
userwallet = user.get_wallet(wallet_id) # type: ignore
|
||||
if not userwallet:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "Wallet not found"}
|
||||
)
|
||||
@@ -150,10 +161,10 @@ async def wallet(
|
||||
"core/wallet.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"wallet": wallet.dict(),
|
||||
"user": user.dict(), # type: ignore
|
||||
"wallet": userwallet.dict(),
|
||||
"service_fee": service_fee,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest", # type: ignore
|
||||
},
|
||||
)
|
||||
|
||||
@@ -207,20 +218,20 @@ async def lnurl_full_withdraw_callback(request: Request):
|
||||
|
||||
|
||||
@core_html_routes.get("/deletewallet", response_class=RedirectResponse)
|
||||
async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)):
|
||||
async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)): # type: ignore
|
||||
user = await get_user(usr)
|
||||
user_wallet_ids = [u.id for u in user.wallets]
|
||||
user_wallet_ids = [u.id for u in user.wallets] # type: ignore
|
||||
|
||||
if wal not in user_wallet_ids:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
else:
|
||||
await delete_wallet(user_id=user.id, wallet_id=wal)
|
||||
await delete_wallet(user_id=user.id, wallet_id=wal) # type: ignore
|
||||
user_wallet_ids.remove(wal)
|
||||
logger.debug("Deleted wallet {wal} of user {user.id}")
|
||||
|
||||
if user_wallet_ids:
|
||||
return RedirectResponse(
|
||||
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]),
|
||||
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]), # type: ignore
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
@@ -233,7 +244,7 @@ async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query
|
||||
async def lnurl_balance_notify(request: Request, service: str):
|
||||
bc = await get_balance_check(request.query_params.get("wal"), service)
|
||||
if bc:
|
||||
redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||
await redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||
|
||||
|
||||
@core_html_routes.get(
|
||||
@@ -243,7 +254,7 @@ async def lnurlwallet(request: Request):
|
||||
async with db.connect() as conn:
|
||||
account = await create_account(conn=conn)
|
||||
user = await get_user(account.id, conn=conn)
|
||||
wallet = await create_wallet(user_id=user.id, conn=conn)
|
||||
wallet = await create_wallet(user_id=user.id, conn=conn) # type: ignore
|
||||
|
||||
asyncio.create_task(
|
||||
redeem_lnurl_withdraw(
|
||||
@@ -256,7 +267,7 @@ async def lnurlwallet(request: Request):
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
|
||||
+28
-19
@@ -1,4 +1,5 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Union
|
||||
|
||||
from cerberus import Validator # type: ignore
|
||||
from fastapi import status
|
||||
@@ -29,20 +30,21 @@ class KeyChecker(SecurityBase):
|
||||
self._key_type = "invoice"
|
||||
self._api_key = api_key
|
||||
if api_key:
|
||||
self.model: APIKey = APIKey(
|
||||
key = APIKey(
|
||||
**{"in": APIKeyIn.query},
|
||||
name="X-API-KEY",
|
||||
description="Wallet API Key - QUERY",
|
||||
)
|
||||
else:
|
||||
self.model: APIKey = APIKey(
|
||||
key = APIKey(
|
||||
**{"in": APIKeyIn.header},
|
||||
name="X-API-KEY",
|
||||
description="Wallet API Key - HEADER",
|
||||
)
|
||||
self.wallet = None
|
||||
self.wallet = None # type: ignore
|
||||
self.model: APIKey = key
|
||||
|
||||
async def __call__(self, request: Request) -> Wallet:
|
||||
async def __call__(self, request: Request):
|
||||
try:
|
||||
key_value = (
|
||||
self._api_key
|
||||
@@ -52,7 +54,7 @@ class KeyChecker(SecurityBase):
|
||||
# FIXME: Find another way to validate the key. A fetch from DB should be avoided here.
|
||||
# Also, we should not return the wallet here - thats silly.
|
||||
# Possibly store it in a Redis DB
|
||||
self.wallet = await get_wallet_for_key(key_value, self._key_type)
|
||||
self.wallet = await get_wallet_for_key(key_value, self._key_type) # type: ignore
|
||||
if not self.wallet:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
@@ -120,8 +122,8 @@ api_key_query = APIKeyQuery(
|
||||
|
||||
async def get_key_type(
|
||||
r: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
api_key_header: str = Security(api_key_header), # type: ignore
|
||||
api_key_query: str = Security(api_key_query), # type: ignore
|
||||
) -> WalletTypeInfo:
|
||||
# 0: admin
|
||||
# 1: invoice
|
||||
@@ -134,9 +136,9 @@ async def get_key_type(
|
||||
token = api_key_header if api_key_header else api_key_query
|
||||
|
||||
try:
|
||||
checker = WalletAdminKeyChecker(api_key=token)
|
||||
await checker.__call__(r)
|
||||
wallet = WalletTypeInfo(0, checker.wallet)
|
||||
admin_checker = WalletAdminKeyChecker(api_key=token)
|
||||
await admin_checker.__call__(r)
|
||||
wallet = WalletTypeInfo(0, admin_checker.wallet) # type: ignore
|
||||
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
|
||||
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
|
||||
):
|
||||
@@ -153,9 +155,9 @@ async def get_key_type(
|
||||
raise
|
||||
|
||||
try:
|
||||
checker = WalletInvoiceKeyChecker(api_key=token)
|
||||
await checker.__call__(r)
|
||||
wallet = WalletTypeInfo(1, checker.wallet)
|
||||
invoice_checker = WalletInvoiceKeyChecker(api_key=token)
|
||||
await invoice_checker.__call__(r)
|
||||
wallet = WalletTypeInfo(1, invoice_checker.wallet) # type: ignore
|
||||
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
|
||||
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
|
||||
):
|
||||
@@ -167,15 +169,16 @@ async def get_key_type(
|
||||
if e.status_code == HTTPStatus.BAD_REQUEST:
|
||||
raise
|
||||
if e.status_code == HTTPStatus.UNAUTHORIZED:
|
||||
return WalletTypeInfo(2, None)
|
||||
return WalletTypeInfo(2, None) # type: ignore
|
||||
except:
|
||||
raise
|
||||
return wallet
|
||||
|
||||
|
||||
async def require_admin_key(
|
||||
r: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
api_key_header: str = Security(api_key_header), # type: ignore
|
||||
api_key_query: str = Security(api_key_query), # type: ignore
|
||||
):
|
||||
token = api_key_header if api_key_header else api_key_query
|
||||
|
||||
@@ -193,10 +196,16 @@ async def require_admin_key(
|
||||
|
||||
async def require_invoice_key(
|
||||
r: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
api_key_header: str = Security(api_key_header), # type: ignore
|
||||
api_key_query: str = Security(api_key_query), # type: ignore
|
||||
):
|
||||
token = api_key_header if api_key_header else api_key_query
|
||||
token = api_key_header or api_key_query
|
||||
|
||||
if token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
wallet = await get_key_type(r, token)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ db = Database("ext_bleskomat")
|
||||
bleskomat_static_files = [
|
||||
{
|
||||
"path": "/bleskomat/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/bleskomat/static"),
|
||||
"app": StaticFiles(packages=[("lnbits", "extensions/bleskomat/static")]),
|
||||
"name": "bleskomat_static",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -62,4 +62,5 @@
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/Bleskomat"></q-btn>
|
||||
</q-expansion-item>
|
||||
|
||||
@@ -12,7 +12,7 @@ db = Database("ext_copilot")
|
||||
copilot_static_files = [
|
||||
{
|
||||
"path": "/copilot/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/copilot/static"),
|
||||
"app": StaticFiles(packages=[("lnbits", "extensions/copilot/static")]),
|
||||
"name": "copilot_static",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -73,11 +73,9 @@ async def lnurl_callback(
|
||||
wallet_id=cp.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=cp.lnurl_title,
|
||||
description_hash=hashlib.sha256(
|
||||
(
|
||||
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
|
||||
).encode("utf-8")
|
||||
).digest(),
|
||||
unhashed_description=(
|
||||
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
|
||||
).encode("utf-8"),
|
||||
extra={"tag": "copilot", "copilotid": cp.id, "comment": comment},
|
||||
)
|
||||
payResponse = {"pr": payment_request, "routes": []}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/copilot"></q-btn>
|
||||
<q-expansion-item group="api" dense expand-separator label="Create copilot">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
|
||||
@@ -9,7 +9,7 @@ db = Database("ext_discordbot")
|
||||
discordbot_static_files = [
|
||||
{
|
||||
"path": "/discordbot/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/discordbot/static"),
|
||||
"app": StaticFiles(packages=[("lnbits", "extensions/discordbot/static")]),
|
||||
"name": "discordbot_static",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/discordbot"></q-btn>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET users">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
|
||||
@@ -16,7 +16,7 @@ async def create_ticket(
|
||||
INSERT INTO events.ticket (id, wallet, event, name, email, registered, paid)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(payment_hash, wallet, event, name, email, False, False),
|
||||
(payment_hash, wallet, event, name, email, False, True),
|
||||
)
|
||||
|
||||
ticket = await get_ticket(payment_hash)
|
||||
|
||||
@@ -20,4 +20,5 @@
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/events"></q-btn>
|
||||
</q-expansion-item>
|
||||
|
||||
@@ -135,15 +135,7 @@
|
||||
var self = this
|
||||
axios
|
||||
|
||||
.post(
|
||||
'/events/api/v1/tickets/' + '{{ event_id }}/{{ event_price }}',
|
||||
{
|
||||
event: '{{ event_id }}',
|
||||
event_name: '{{ event_name }}',
|
||||
name: self.formDialog.data.name,
|
||||
email: self.formDialog.data.email
|
||||
}
|
||||
)
|
||||
.get('/events/api/v1/tickets/' + '{{ event_id }}')
|
||||
.then(function (response) {
|
||||
self.paymentReq = response.data.payment_request
|
||||
self.paymentCheck = response.data.payment_hash
|
||||
@@ -161,7 +153,17 @@
|
||||
|
||||
paymentChecker = setInterval(function () {
|
||||
axios
|
||||
.get('/events/api/v1/tickets/' + self.paymentCheck)
|
||||
.post(
|
||||
'/events/api/v1/tickets/' +
|
||||
'{{ event_id }}/' +
|
||||
self.paymentCheck,
|
||||
{
|
||||
event: '{{ event_id }}',
|
||||
event_name: '{{ event_name }}',
|
||||
name: self.formDialog.data.name,
|
||||
email: self.formDialog.data.email
|
||||
}
|
||||
)
|
||||
.then(function (res) {
|
||||
if (res.data.paid) {
|
||||
clearInterval(paymentChecker)
|
||||
|
||||
@@ -133,7 +133,10 @@
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request('GET', '/events/api/v1/register/ticket/' + res)
|
||||
.request(
|
||||
'GET',
|
||||
'/events/api/v1/register/ticket/' + res.split('//')[1]
|
||||
)
|
||||
.then(function (response) {
|
||||
self.$q.notify({
|
||||
type: 'positive',
|
||||
|
||||
@@ -13,9 +13,8 @@
|
||||
<br />
|
||||
|
||||
<qrcode
|
||||
:value="'{{ ticket_id }}'"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
:value="'ticket://{{ ticket_id }}'"
|
||||
:options="{width: 500}"
|
||||
></qrcode>
|
||||
<br />
|
||||
<q-btn @click="printWindow" color="grey" class="q-ml-auto">
|
||||
|
||||
@@ -97,8 +97,8 @@ async def api_tickets(
|
||||
return [ticket.dict() for ticket in await get_tickets(wallet_ids)]
|
||||
|
||||
|
||||
@events_ext.post("/api/v1/tickets/{event_id}/{sats}")
|
||||
async def api_ticket_make_ticket(event_id, sats, data: CreateTicket):
|
||||
@events_ext.get("/api/v1/tickets/{event_id}")
|
||||
async def api_ticket_make_ticket(event_id):
|
||||
event = await get_event(event_id)
|
||||
if not event:
|
||||
raise HTTPException(
|
||||
@@ -107,37 +107,36 @@ async def api_ticket_make_ticket(event_id, sats, data: CreateTicket):
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=event.wallet,
|
||||
amount=int(sats),
|
||||
amount=event.price_per_ticket,
|
||||
memo=f"{event_id}",
|
||||
extra={"tag": "events"},
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
ticket = await create_ticket(
|
||||
payment_hash=payment_hash,
|
||||
wallet=event.wallet,
|
||||
event=event_id,
|
||||
name=data.name,
|
||||
email=data.email,
|
||||
)
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail=f"Event could not be fetched."
|
||||
)
|
||||
|
||||
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
||||
|
||||
|
||||
@events_ext.get("/api/v1/tickets/{payment_hash}")
|
||||
async def api_ticket_send_ticket(payment_hash):
|
||||
ticket = await get_ticket(payment_hash)
|
||||
|
||||
@events_ext.post("/api/v1/tickets/{event_id}/{payment_hash}")
|
||||
async def api_ticket_send_ticket(event_id, payment_hash, data: CreateTicket):
|
||||
event = await get_event(event_id)
|
||||
try:
|
||||
status = await api_payment(payment_hash)
|
||||
if status["paid"]:
|
||||
await set_ticket_paid(payment_hash=payment_hash)
|
||||
ticket = await create_ticket(
|
||||
payment_hash=payment_hash,
|
||||
wallet=event.wallet,
|
||||
event=event_id,
|
||||
name=data.name,
|
||||
email=data.email,
|
||||
)
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail=f"Event could not be fetched.",
|
||||
)
|
||||
|
||||
return {"paid": True, "ticket_id": ticket.id}
|
||||
except Exception:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Not paid")
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Invoices
|
||||
|
||||
## Create invoices that you can send to your client to pay online over Lightning.
|
||||
|
||||
This extension allows users to create "traditional" invoices (not in the lightning sense) that contain one or more line items. Line items are denominated in a user-configurable fiat currency. Each invoice contains one or more payments up to the total of the invoice. Each invoice creates a public link that can be shared with a customer that they can use to (partially or in full) pay the invoice.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Create an invoice by clicking "NEW INVOICE"\
|
||||

|
||||
2. Fill the options for your INVOICE
|
||||
- select the wallet
|
||||
- select the fiat currency the invoice will be denominated in
|
||||
- select a status for the invoice (default is draft)
|
||||
- enter a company name, first name, last name, email, phone & address (optional)
|
||||
- add one or more line items
|
||||
- enter a name & price for each line item
|
||||
3. You can then use share your invoice link with your customer to receive payment\
|
||||

|
||||
@@ -0,0 +1,36 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_invoices")
|
||||
|
||||
invoices_static_files = [
|
||||
{
|
||||
"path": "/invoices/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/invoices/static"),
|
||||
"name": "invoices_static",
|
||||
}
|
||||
]
|
||||
|
||||
invoices_ext: APIRouter = APIRouter(prefix="/invoices", tags=["invoices"])
|
||||
|
||||
|
||||
def invoices_renderer():
|
||||
return template_renderer(["lnbits/extensions/invoices/templates"])
|
||||
|
||||
|
||||
from .tasks import wait_for_paid_invoices
|
||||
|
||||
|
||||
def invoices_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||
|
||||
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Invoices",
|
||||
"short_description": "Create invoices for your clients.",
|
||||
"icon": "request_quote",
|
||||
"contributors": ["leesalminen"]
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import (
|
||||
CreateInvoiceData,
|
||||
CreateInvoiceItemData,
|
||||
CreatePaymentData,
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
Payment,
|
||||
UpdateInvoiceData,
|
||||
UpdateInvoiceItemData,
|
||||
)
|
||||
|
||||
|
||||
async def get_invoice(invoice_id: str) -> Optional[Invoice]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM invoices.invoices WHERE id = ?", (invoice_id,)
|
||||
)
|
||||
return Invoice.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_invoice_items(invoice_id: str) -> List[InvoiceItem]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM invoices.invoice_items WHERE invoice_id = ?", (invoice_id,)
|
||||
)
|
||||
|
||||
return [InvoiceItem.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def get_invoice_item(item_id: str) -> InvoiceItem:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM invoices.invoice_items WHERE id = ?", (item_id,)
|
||||
)
|
||||
return InvoiceItem.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_invoice_total(items: List[InvoiceItem]) -> int:
|
||||
return sum(item.amount for item in items)
|
||||
|
||||
|
||||
async def get_invoices(wallet_ids: Union[str, List[str]]) -> List[Invoice]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM invoices.invoices WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Invoice.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def get_invoice_payments(invoice_id: str) -> List[Payment]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM invoices.payments WHERE invoice_id = ?", (invoice_id,)
|
||||
)
|
||||
|
||||
return [Payment.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def get_invoice_payment(payment_id: str) -> Payment:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM invoices.payments WHERE id = ?", (payment_id,)
|
||||
)
|
||||
return Payment.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_payments_total(payments: List[Payment]) -> int:
|
||||
return sum(item.amount for item in payments)
|
||||
|
||||
|
||||
async def create_invoice_internal(wallet_id: str, data: CreateInvoiceData) -> Invoice:
|
||||
invoice_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO invoices.invoices (id, wallet, status, currency, company_name, first_name, last_name, email, phone, address)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
invoice_id,
|
||||
wallet_id,
|
||||
data.status,
|
||||
data.currency,
|
||||
data.company_name,
|
||||
data.first_name,
|
||||
data.last_name,
|
||||
data.email,
|
||||
data.phone,
|
||||
data.address,
|
||||
),
|
||||
)
|
||||
|
||||
invoice = await get_invoice(invoice_id)
|
||||
assert invoice, "Newly created invoice couldn't be retrieved"
|
||||
return invoice
|
||||
|
||||
|
||||
async def create_invoice_items(
|
||||
invoice_id: str, data: List[CreateInvoiceItemData]
|
||||
) -> List[InvoiceItem]:
|
||||
for item in data:
|
||||
item_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO invoices.invoice_items (id, invoice_id, description, amount)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
item_id,
|
||||
invoice_id,
|
||||
item.description,
|
||||
int(item.amount * 100),
|
||||
),
|
||||
)
|
||||
|
||||
invoice_items = await get_invoice_items(invoice_id)
|
||||
return invoice_items
|
||||
|
||||
|
||||
async def update_invoice_internal(wallet_id: str, data: UpdateInvoiceData) -> Invoice:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE invoices.invoices
|
||||
SET wallet = ?, currency = ?, status = ?, company_name = ?, first_name = ?, last_name = ?, email = ?, phone = ?, address = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
wallet_id,
|
||||
data.currency,
|
||||
data.status,
|
||||
data.company_name,
|
||||
data.first_name,
|
||||
data.last_name,
|
||||
data.email,
|
||||
data.phone,
|
||||
data.address,
|
||||
data.id,
|
||||
),
|
||||
)
|
||||
|
||||
invoice = await get_invoice(data.id)
|
||||
assert invoice, "Newly updated invoice couldn't be retrieved"
|
||||
return invoice
|
||||
|
||||
|
||||
async def update_invoice_items(
|
||||
invoice_id: str, data: List[UpdateInvoiceItemData]
|
||||
) -> List[InvoiceItem]:
|
||||
updated_items = []
|
||||
for item in data:
|
||||
if item.id:
|
||||
updated_items.append(item.id)
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE invoices.invoice_items
|
||||
SET description = ?, amount = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(item.description, int(item.amount * 100), item.id),
|
||||
)
|
||||
|
||||
placeholders = ",".join("?" for i in range(len(updated_items)))
|
||||
if not placeholders:
|
||||
placeholders = "?"
|
||||
updated_items = ("skip",)
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
DELETE FROM invoices.invoice_items
|
||||
WHERE invoice_id = ?
|
||||
AND id NOT IN ({placeholders})
|
||||
""",
|
||||
(
|
||||
invoice_id,
|
||||
*tuple(updated_items),
|
||||
),
|
||||
)
|
||||
|
||||
for item in data:
|
||||
if not item.id:
|
||||
await create_invoice_items(invoice_id=invoice_id, data=[item])
|
||||
|
||||
invoice_items = await get_invoice_items(invoice_id)
|
||||
return invoice_items
|
||||
|
||||
|
||||
async def create_invoice_payment(invoice_id: str, amount: int) -> Payment:
|
||||
payment_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO invoices.payments (id, invoice_id, amount)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
payment_id,
|
||||
invoice_id,
|
||||
amount,
|
||||
),
|
||||
)
|
||||
|
||||
payment = await get_invoice_payment(payment_id)
|
||||
assert payment, "Newly created payment couldn't be retrieved"
|
||||
return payment
|
||||
@@ -0,0 +1,55 @@
|
||||
async def m001_initial_invoices(db):
|
||||
|
||||
# STATUS COLUMN OPTIONS: 'draft', 'open', 'paid', 'canceled'
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE invoices.invoices (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
|
||||
currency TEXT NOT NULL,
|
||||
|
||||
company_name TEXT DEFAULT NULL,
|
||||
first_name TEXT DEFAULT NULL,
|
||||
last_name TEXT DEFAULT NULL,
|
||||
email TEXT DEFAULT NULL,
|
||||
phone TEXT DEFAULT NULL,
|
||||
address TEXT DEFAULT NULL,
|
||||
|
||||
|
||||
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE invoices.invoice_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
invoice_id TEXT NOT NULL,
|
||||
|
||||
description TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
|
||||
FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE invoices.payments (
|
||||
id TEXT PRIMARY KEY,
|
||||
invoice_id TEXT NOT NULL,
|
||||
|
||||
amount INT NOT NULL,
|
||||
|
||||
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
|
||||
FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
from enum import Enum
|
||||
from sqlite3 import Row
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi.param_functions import Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class InvoiceStatusEnum(str, Enum):
|
||||
draft = "draft"
|
||||
open = "open"
|
||||
paid = "paid"
|
||||
canceled = "canceled"
|
||||
|
||||
|
||||
class CreateInvoiceItemData(BaseModel):
|
||||
description: str
|
||||
amount: float = Query(..., ge=0.01)
|
||||
|
||||
|
||||
class CreateInvoiceData(BaseModel):
|
||||
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
|
||||
currency: str
|
||||
company_name: Optional[str]
|
||||
first_name: Optional[str]
|
||||
last_name: Optional[str]
|
||||
email: Optional[str]
|
||||
phone: Optional[str]
|
||||
address: Optional[str]
|
||||
items: List[CreateInvoiceItemData]
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
class UpdateInvoiceItemData(BaseModel):
|
||||
id: Optional[str]
|
||||
description: str
|
||||
amount: float = Query(..., ge=0.01)
|
||||
|
||||
|
||||
class UpdateInvoiceData(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
|
||||
currency: str
|
||||
company_name: Optional[str]
|
||||
first_name: Optional[str]
|
||||
last_name: Optional[str]
|
||||
email: Optional[str]
|
||||
phone: Optional[str]
|
||||
address: Optional[str]
|
||||
items: List[UpdateInvoiceItemData]
|
||||
|
||||
|
||||
class Invoice(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
|
||||
currency: str
|
||||
company_name: Optional[str]
|
||||
first_name: Optional[str]
|
||||
last_name: Optional[str]
|
||||
email: Optional[str]
|
||||
phone: Optional[str]
|
||||
address: Optional[str]
|
||||
time: int
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Invoice":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class InvoiceItem(BaseModel):
|
||||
id: str
|
||||
invoice_id: str
|
||||
description: str
|
||||
amount: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "InvoiceItem":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class Payment(BaseModel):
|
||||
id: str
|
||||
invoice_id: str
|
||||
amount: int
|
||||
time: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Payment":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class CreatePaymentData(BaseModel):
|
||||
invoice_id: str
|
||||
amount: int
|
||||
@@ -0,0 +1,65 @@
|
||||
#invoicePage>.row:first-child>.col-md-6 {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#invoicePage>.row:first-child>.col-md-6>.q-card {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#invoicePage .clear {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
#printQrCode {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
#invoicePage>.row:first-child>.col-md-6:first-child>div {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
#invoicePage>.row:first-child>.col-md-6:nth-child(2)>div {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media print {
|
||||
* {
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
header, button, #payButtonContainer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
main, .q-page-container {
|
||||
padding-top: 0px !important;
|
||||
}
|
||||
|
||||
.q-card {
|
||||
box-shadow: none !important;
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
.q-item {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.q-card__section {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#printQrCode {
|
||||
display: block;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 0px !important;
|
||||
}
|
||||
|
||||
#invoicePage .clear {
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.tasks import internal_invoice_queue, register_invoice_listener
|
||||
|
||||
from .crud import (
|
||||
create_invoice_payment,
|
||||
get_invoice,
|
||||
get_invoice_items,
|
||||
get_invoice_payments,
|
||||
get_invoice_total,
|
||||
get_payments_total,
|
||||
update_invoice_internal,
|
||||
)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
invoice_queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue)
|
||||
|
||||
while True:
|
||||
payment = await invoice_queue.get()
|
||||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if payment.extra.get("tag") != "invoices":
|
||||
# not relevant
|
||||
return
|
||||
|
||||
invoice_id = payment.extra.get("invoice_id")
|
||||
|
||||
payment = await create_invoice_payment(
|
||||
invoice_id=invoice_id, amount=payment.extra.get("famount")
|
||||
)
|
||||
|
||||
invoice = await get_invoice(invoice_id)
|
||||
|
||||
invoice_items = await get_invoice_items(invoice_id)
|
||||
invoice_total = await get_invoice_total(invoice_items)
|
||||
|
||||
invoice_payments = await get_invoice_payments(invoice_id)
|
||||
payments_total = await get_payments_total(invoice_payments)
|
||||
|
||||
if payments_total >= invoice_total:
|
||||
invoice.status = "paid"
|
||||
await update_invoice_internal(invoice.wallet, invoice)
|
||||
|
||||
return
|
||||
@@ -0,0 +1,153 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-expansion-item group="api" dense expand-separator label="List Invoices">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span> /invoices/api/v1/invoices</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<invoice_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}invoices/api/v1/invoices -H
|
||||
"X-Api-Key: <invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item group="api" dense expand-separator label="Fetch Invoice">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/invoices/api/v1/invoice/{invoice_id}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{invoice_object}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key:
|
||||
<invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item group="api" dense expand-separator label="Create Invoice">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">POST</span> /invoices/api/v1/invoice</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{invoice_object}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}invoices/api/v1/invoice -H
|
||||
"X-Api-Key: <invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item group="api" dense expand-separator label="Update Invoice">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">POST</span>
|
||||
/invoices/api/v1/invoice/{invoice_id}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{invoice_object}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url
|
||||
}}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key:
|
||||
<invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Create Invoice Payment"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">POST</span>
|
||||
/invoices/api/v1/invoice/{invoice_id}/payments</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{payment_object}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url
|
||||
}}invoices/api/v1/invoice/{invoice_id}/payments -H "X-Api-Key:
|
||||
<invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Check Invoice Payment Status"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash} -H
|
||||
"X-Api-Key: <invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,571 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn unelevated color="primary" @click="formDialog.show = true"
|
||||
>New Invoice</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Invoices</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="invoices"
|
||||
row-key="id"
|
||||
:columns="invoicesTable.columns"
|
||||
:pagination.sync="invoicesTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="edit"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="showEditModal(props.row)"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="launch"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="'pay/' + props.row.id"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">
|
||||
{{SITE_TITLE}} Invoices extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "invoices/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="saveInvoice" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
></q-select>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.currency"
|
||||
:options="currencyOptions"
|
||||
label="Currency *"
|
||||
></q-select>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.status"
|
||||
:options="['draft', 'open', 'paid', 'canceled']"
|
||||
label="Status *"
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.company_name"
|
||||
label="Company Name"
|
||||
placeholder="LNBits Labs"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.first_name"
|
||||
label="First Name"
|
||||
placeholder="Satoshi"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.last_name"
|
||||
label="Last Name"
|
||||
placeholder="Nakamoto"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.email"
|
||||
label="Email"
|
||||
placeholder="satoshi@gmail.com"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.phone"
|
||||
label="Phone"
|
||||
placeholder="+81 (012)-345-6789"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.address"
|
||||
label="Address"
|
||||
placeholder="1600 Pennsylvania Ave."
|
||||
type="textarea"
|
||||
></q-input>
|
||||
|
||||
<q-list bordered separator>
|
||||
<q-item
|
||||
clickable
|
||||
v-ripple
|
||||
v-for="(item, index) in formDialog.invoiceItems"
|
||||
:key="index"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
label="Item"
|
||||
placeholder="Jelly Beans"
|
||||
v-model="formDialog.invoiceItems[index].description"
|
||||
></q-input>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
label="Amount"
|
||||
placeholder="4.20"
|
||||
v-model="formDialog.invoiceItems[index].amount"
|
||||
></q-input>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="delete"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="formDialog.invoiceItems.splice(index, 1)"
|
||||
></q-btn>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-btn flat icon="add" @click="formDialog.invoiceItems.push({})">
|
||||
Add Line Item
|
||||
</q-btn>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="formDialog.data.wallet == null || formDialog.data.currency == null"
|
||||
type="submit"
|
||||
v-if="typeof formDialog.data.id == 'undefined'"
|
||||
>Create Invoice</q-btn
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="formDialog.data.wallet == null || formDialog.data.currency == null"
|
||||
type="submit"
|
||||
v-if="typeof formDialog.data.id !== 'undefined'"
|
||||
>Save Invoice</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
var mapInvoice = function (obj) {
|
||||
obj.time = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
var mapInvoiceItems = function (obj) {
|
||||
obj.amount = parseFloat(obj.amount / 100).toFixed(2)
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
invoices: [],
|
||||
currencyOptions: [
|
||||
'USD',
|
||||
'EUR',
|
||||
'GBP',
|
||||
'AED',
|
||||
'AFN',
|
||||
'ALL',
|
||||
'AMD',
|
||||
'ANG',
|
||||
'AOA',
|
||||
'ARS',
|
||||
'AUD',
|
||||
'AWG',
|
||||
'AZN',
|
||||
'BAM',
|
||||
'BBD',
|
||||
'BDT',
|
||||
'BGN',
|
||||
'BHD',
|
||||
'BIF',
|
||||
'BMD',
|
||||
'BND',
|
||||
'BOB',
|
||||
'BRL',
|
||||
'BSD',
|
||||
'BTN',
|
||||
'BWP',
|
||||
'BYN',
|
||||
'BZD',
|
||||
'CAD',
|
||||
'CDF',
|
||||
'CHF',
|
||||
'CLF',
|
||||
'CLP',
|
||||
'CNH',
|
||||
'CNY',
|
||||
'COP',
|
||||
'CRC',
|
||||
'CUC',
|
||||
'CUP',
|
||||
'CVE',
|
||||
'CZK',
|
||||
'DJF',
|
||||
'DKK',
|
||||
'DOP',
|
||||
'DZD',
|
||||
'EGP',
|
||||
'ERN',
|
||||
'ETB',
|
||||
'EUR',
|
||||
'FJD',
|
||||
'FKP',
|
||||
'GBP',
|
||||
'GEL',
|
||||
'GGP',
|
||||
'GHS',
|
||||
'GIP',
|
||||
'GMD',
|
||||
'GNF',
|
||||
'GTQ',
|
||||
'GYD',
|
||||
'HKD',
|
||||
'HNL',
|
||||
'HRK',
|
||||
'HTG',
|
||||
'HUF',
|
||||
'IDR',
|
||||
'ILS',
|
||||
'IMP',
|
||||
'INR',
|
||||
'IQD',
|
||||
'IRR',
|
||||
'IRT',
|
||||
'ISK',
|
||||
'JEP',
|
||||
'JMD',
|
||||
'JOD',
|
||||
'JPY',
|
||||
'KES',
|
||||
'KGS',
|
||||
'KHR',
|
||||
'KMF',
|
||||
'KPW',
|
||||
'KRW',
|
||||
'KWD',
|
||||
'KYD',
|
||||
'KZT',
|
||||
'LAK',
|
||||
'LBP',
|
||||
'LKR',
|
||||
'LRD',
|
||||
'LSL',
|
||||
'LYD',
|
||||
'MAD',
|
||||
'MDL',
|
||||
'MGA',
|
||||
'MKD',
|
||||
'MMK',
|
||||
'MNT',
|
||||
'MOP',
|
||||
'MRO',
|
||||
'MUR',
|
||||
'MVR',
|
||||
'MWK',
|
||||
'MXN',
|
||||
'MYR',
|
||||
'MZN',
|
||||
'NAD',
|
||||
'NGN',
|
||||
'NIO',
|
||||
'NOK',
|
||||
'NPR',
|
||||
'NZD',
|
||||
'OMR',
|
||||
'PAB',
|
||||
'PEN',
|
||||
'PGK',
|
||||
'PHP',
|
||||
'PKR',
|
||||
'PLN',
|
||||
'PYG',
|
||||
'QAR',
|
||||
'RON',
|
||||
'RSD',
|
||||
'RUB',
|
||||
'RWF',
|
||||
'SAR',
|
||||
'SBD',
|
||||
'SCR',
|
||||
'SDG',
|
||||
'SEK',
|
||||
'SGD',
|
||||
'SHP',
|
||||
'SLL',
|
||||
'SOS',
|
||||
'SRD',
|
||||
'SSP',
|
||||
'STD',
|
||||
'SVC',
|
||||
'SYP',
|
||||
'SZL',
|
||||
'THB',
|
||||
'TJS',
|
||||
'TMT',
|
||||
'TND',
|
||||
'TOP',
|
||||
'TRY',
|
||||
'TTD',
|
||||
'TWD',
|
||||
'TZS',
|
||||
'UAH',
|
||||
'UGX',
|
||||
'USD',
|
||||
'UYU',
|
||||
'UZS',
|
||||
'VEF',
|
||||
'VES',
|
||||
'VND',
|
||||
'VUV',
|
||||
'WST',
|
||||
'XAF',
|
||||
'XAG',
|
||||
'XAU',
|
||||
'XCD',
|
||||
'XDR',
|
||||
'XOF',
|
||||
'XPD',
|
||||
'XPF',
|
||||
'XPT',
|
||||
'YER',
|
||||
'ZAR',
|
||||
'ZMW',
|
||||
'ZWL'
|
||||
],
|
||||
invoicesTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'status', align: 'left', label: 'Status', field: 'status'},
|
||||
{name: 'time', align: 'left', label: 'Created', field: 'time'},
|
||||
{name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'},
|
||||
{
|
||||
name: 'currency',
|
||||
align: 'left',
|
||||
label: 'Currency',
|
||||
field: 'currency'
|
||||
},
|
||||
{
|
||||
name: 'company_name',
|
||||
align: 'left',
|
||||
label: 'Company Name',
|
||||
field: 'company_name'
|
||||
},
|
||||
{
|
||||
name: 'first_name',
|
||||
align: 'left',
|
||||
label: 'First Name',
|
||||
field: 'first_name'
|
||||
},
|
||||
{
|
||||
name: 'last_name',
|
||||
align: 'left',
|
||||
label: 'Last Name',
|
||||
field: 'last_name'
|
||||
},
|
||||
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
||||
{name: 'phone', align: 'left', label: 'Phone', field: 'phone'},
|
||||
{name: 'address', align: 'left', label: 'Address', field: 'address'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {},
|
||||
invoiceItems: []
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = {}
|
||||
this.formDialog.invoiceItems = []
|
||||
},
|
||||
showEditModal: function (obj) {
|
||||
this.formDialog.data = obj
|
||||
this.formDialog.show = true
|
||||
|
||||
this.getInvoice(obj.id)
|
||||
},
|
||||
getInvoice: function (invoice_id) {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request('GET', '/invoices/api/v1/invoice/' + invoice_id)
|
||||
.then(function (response) {
|
||||
self.formDialog.invoiceItems = response.data.items.map(function (
|
||||
obj
|
||||
) {
|
||||
return mapInvoiceItems(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
getInvoices: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/invoices/api/v1/invoices?all_wallets=true',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.invoices = response.data.map(function (obj) {
|
||||
return mapInvoice(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
saveInvoice: function () {
|
||||
var data = this.formDialog.data
|
||||
data.items = this.formDialog.invoiceItems
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/invoices/api/v1/invoice' + (data.id ? '/' + data.id : ''),
|
||||
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
|
||||
.inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
if (!data.id) {
|
||||
self.invoices.push(mapInvoice(response.data))
|
||||
} else {
|
||||
self.getInvoices()
|
||||
}
|
||||
|
||||
self.formDialog.invoiceItems = []
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteTPoS: function (tposId) {
|
||||
var self = this
|
||||
var tpos = _.findWhere(this.tposs, {id: tposId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this TPoS?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/tpos/api/v1/tposs/' + tposId,
|
||||
_.findWhere(self.g.user.wallets, {id: tpos.wallet}).adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.tposs = _.reject(self.tposs, function (obj) {
|
||||
return obj.id == tposId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(this.invoicesTable.columns, this.invoices)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getInvoices()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,430 @@
|
||||
{% extends "public.html" %} {% block toolbar_title %} Invoice
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="md"
|
||||
@click.prevent="urlDialog.show = true"
|
||||
icon="share"
|
||||
color="white"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="md"
|
||||
@click.prevent="printInvoice()"
|
||||
icon="print"
|
||||
color="white"
|
||||
></q-btn>
|
||||
{% endblock %} {% from "macros.jinja" import window_vars with context %} {%
|
||||
block page %}
|
||||
<link rel="stylesheet" href="/invoices/static/css/pay.css" />
|
||||
<div id="invoicePage">
|
||||
<div class="row q-gutter-y-md">
|
||||
<div class="col-md-6 col-sm-12 col-xs-12">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
<b>Invoice</b>
|
||||
</p>
|
||||
|
||||
<q-list bordered separator>
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>ID</b></q-item-section>
|
||||
<q-item-section style="word-break: break-all"
|
||||
>{{ invoice_id }}</q-item-section
|
||||
>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Created At</b></q-item-section>
|
||||
<q-item-section
|
||||
>{{ datetime.utcfromtimestamp(invoice.time).strftime('%Y-%m-%d
|
||||
%H:%M') }}</q-item-section
|
||||
>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Status</b></q-item-section>
|
||||
<q-item-section>
|
||||
<span>
|
||||
<q-badge color=""> {{ invoice.status }} </q-badge>
|
||||
</span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Total</b></q-item-section>
|
||||
<q-item-section>
|
||||
{{ "{:0,.2f}".format(invoice_total / 100) }} {{ invoice.currency
|
||||
}}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Paid</b></q-item-section>
|
||||
<q-item-section>
|
||||
<div class="row" style="align-items: center">
|
||||
<div class="col-sm-6">
|
||||
{{ "{:0,.2f}".format(payments_total / 100) }} {{
|
||||
invoice.currency }}
|
||||
</div>
|
||||
<div class="col-sm-6" id="payButtonContainer">
|
||||
{% if payments_total < invoice_total %}
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="formDialog.show = true"
|
||||
v-if="status == 'open'"
|
||||
>
|
||||
Pay Invoice
|
||||
</q-btn>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12 col-xs-12">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
<b>Bill To</b>
|
||||
</p>
|
||||
|
||||
<q-list bordered separator>
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Company Name</b></q-item-section>
|
||||
<q-item-section>{{ invoice.company_name }}</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Name</b></q-item-section>
|
||||
<q-item-section
|
||||
>{{ invoice.first_name }} {{ invoice.last_name
|
||||
}}</q-item-section
|
||||
>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Address</b></q-item-section>
|
||||
<q-item-section>{{ invoice.address }}</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Email</b></q-item-section>
|
||||
<q-item-section>{{ invoice.email }}</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Phone</b></q-item-section>
|
||||
<q-item-section>{{ invoice.phone }}</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clear"></div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
<b>Items</b>
|
||||
</p>
|
||||
|
||||
<q-list bordered separator>
|
||||
{% if invoice_items %}
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Item</b></q-item-section>
|
||||
<q-item-section side><b>Amount</b></q-item-section>
|
||||
</q-item>
|
||||
{% endif %} {% for item in invoice_items %}
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>{{item.description}}</b></q-item-section>
|
||||
<q-item-section side>
|
||||
{{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency
|
||||
}}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
{% endfor %} {% if not invoice_items %} No Invoice Items {% endif %}
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clear"></div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
<b>Payments</b>
|
||||
</p>
|
||||
|
||||
<q-list bordered separator>
|
||||
{% if invoice_payments %}
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Date</b></q-item-section>
|
||||
<q-item-section side><b>Amount</b></q-item-section>
|
||||
</q-item>
|
||||
{% endif %} {% for item in invoice_payments %}
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section
|
||||
><b
|
||||
>{{ datetime.utcfromtimestamp(item.time).strftime('%Y-%m-%d
|
||||
%H:%M') }}</b
|
||||
></q-item-section
|
||||
>
|
||||
<q-item-section side>
|
||||
{{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency
|
||||
}}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
{% endfor %} {% if not invoice_payments %} No Invoice Payments {%
|
||||
endif %}
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clear"></div>
|
||||
|
||||
<div class="row q-gutter-y-md q-gutter-md" id="printQrCode">
|
||||
<div class="col-12 col-md">
|
||||
<div class="text-center">
|
||||
<p><b>Scan to View & Pay Online!</b></p>
|
||||
<qrcode
|
||||
value="{{ request.url }}"
|
||||
:options="{width: 200}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="createPayment" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.payment_amount"
|
||||
:rules="[val => val >= 0.01 || 'Minimum amount is 0.01']"
|
||||
min="0.01"
|
||||
label="Payment Amount"
|
||||
placeholder="4.20"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<span style="font-size: 12px"> {{ invoice.currency }} </span>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="formDialog.data.payment_amount == null"
|
||||
type="submit"
|
||||
>Create Payment</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog
|
||||
v-model="qrCodeDialog.show"
|
||||
position="top"
|
||||
@hide="closeQrCodeDialog"
|
||||
>
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card text-center">
|
||||
<a :href="'lightning:' + qrCodeDialog.data.payment_request">
|
||||
<q-responsive :ratio="1" class="q-mx-xs">
|
||||
<qrcode
|
||||
:value="qrCodeDialog.data.payment_request"
|
||||
:options="{width: 400}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<br />
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText('lightning:' + qrCodeDialog.data.payment_request, 'Invoice copied to clipboard!')"
|
||||
>Copy Invoice</q-btn
|
||||
>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="urlDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||
<qrcode
|
||||
value="{{ request.url }}"
|
||||
:options="{width: 400}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<div class="text-center q-mb-xl">
|
||||
<p style="word-break: break-all">{{ request.url }}</p>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText('{{ request.url }}', 'Invoice Pay URL copied to clipboard!')"
|
||||
>Copy URL</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
var mapInvoice = function (obj) {
|
||||
obj.time = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
var mapInvoiceItems = function (obj) {
|
||||
obj.amount = parseFloat(obj.amount / 100).toFixed(2)
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
invoice_id: '{{ invoice.id }}',
|
||||
wallet: '{{ invoice.wallet }}',
|
||||
currency: '{{ invoice.currency }}',
|
||||
status: '{{ invoice.status }}',
|
||||
qrCodeDialog: {
|
||||
data: {
|
||||
payment_request: null,
|
||||
},
|
||||
show: false,
|
||||
},
|
||||
formDialog: {
|
||||
data: {
|
||||
payment_amount: parseFloat({{invoice_total - payments_total}} / 100).toFixed(2)
|
||||
},
|
||||
show: false,
|
||||
},
|
||||
urlDialog: {
|
||||
show: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
printInvoice: function() {
|
||||
window.print()
|
||||
},
|
||||
closeFormDialog: function() {
|
||||
this.formDialog.show = false
|
||||
},
|
||||
closeQrCodeDialog: function() {
|
||||
this.qrCodeDialog.show = false
|
||||
},
|
||||
createPayment: function () {
|
||||
var self = this
|
||||
var qrCodeDialog = this.qrCodeDialog
|
||||
var formDialog = this.formDialog
|
||||
var famount = parseInt(formDialog.data.payment_amount * 100)
|
||||
|
||||
axios
|
||||
.post('/invoices/api/v1/invoice/' + this.invoice_id + '/payments', null, {
|
||||
params: {
|
||||
famount: famount,
|
||||
}
|
||||
})
|
||||
.then(function (response) {
|
||||
formDialog.show = false
|
||||
formDialog.data = {}
|
||||
|
||||
qrCodeDialog.data = response.data
|
||||
qrCodeDialog.show = true
|
||||
|
||||
console.log(qrCodeDialog.data)
|
||||
|
||||
qrCodeDialog.dismissMsg = self.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Waiting for payment...'
|
||||
})
|
||||
|
||||
qrCodeDialog.paymentChecker = setInterval(function () {
|
||||
axios
|
||||
.get(
|
||||
'/invoices/api/v1/invoice/' +
|
||||
self.invoice_id +
|
||||
'/payments/' +
|
||||
response.data.payment_hash
|
||||
)
|
||||
.then(function (res) {
|
||||
if (res.data.paid) {
|
||||
clearInterval(qrCodeDialog.paymentChecker)
|
||||
qrCodeDialog.dismissMsg()
|
||||
qrCodeDialog.show = false
|
||||
|
||||
setTimeout(function () {
|
||||
window.location.reload()
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
}, 3000)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
statusBadgeColor: function() {
|
||||
switch(this.status) {
|
||||
case 'draft':
|
||||
return 'gray'
|
||||
break
|
||||
|
||||
case 'open':
|
||||
return 'blue'
|
||||
break
|
||||
|
||||
case 'paid':
|
||||
return 'green'
|
||||
break
|
||||
|
||||
case 'canceled':
|
||||
return 'red'
|
||||
break
|
||||
}
|
||||
},
|
||||
},
|
||||
created: function () {
|
||||
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
||||
from . import invoices_ext, invoices_renderer
|
||||
from .crud import (
|
||||
get_invoice,
|
||||
get_invoice_items,
|
||||
get_invoice_payments,
|
||||
get_invoice_total,
|
||||
get_payments_total,
|
||||
)
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@invoices_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
return invoices_renderer().TemplateResponse(
|
||||
"invoices/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
|
||||
|
||||
@invoices_ext.get("/pay/{invoice_id}", response_class=HTMLResponse)
|
||||
async def index(request: Request, invoice_id: str):
|
||||
invoice = await get_invoice(invoice_id)
|
||||
|
||||
if not invoice:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
|
||||
)
|
||||
|
||||
invoice_items = await get_invoice_items(invoice_id)
|
||||
invoice_total = await get_invoice_total(invoice_items)
|
||||
|
||||
invoice_payments = await get_invoice_payments(invoice_id)
|
||||
payments_total = await get_payments_total(invoice_payments)
|
||||
|
||||
return invoices_renderer().TemplateResponse(
|
||||
"invoices/pay.html",
|
||||
{
|
||||
"request": request,
|
||||
"invoice_id": invoice_id,
|
||||
"invoice": invoice.dict(),
|
||||
"invoice_items": invoice_items,
|
||||
"invoice_total": invoice_total,
|
||||
"invoice_payments": invoice_payments,
|
||||
"payments_total": payments_total,
|
||||
"datetime": datetime,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
|
||||
from . import invoices_ext
|
||||
from .crud import (
|
||||
create_invoice_internal,
|
||||
create_invoice_items,
|
||||
get_invoice,
|
||||
get_invoice_items,
|
||||
get_invoice_payments,
|
||||
get_invoice_total,
|
||||
get_invoices,
|
||||
get_payments_total,
|
||||
update_invoice_internal,
|
||||
update_invoice_items,
|
||||
)
|
||||
from .models import CreateInvoiceData, UpdateInvoiceData
|
||||
|
||||
|
||||
@invoices_ext.get("/api/v1/invoices", status_code=HTTPStatus.OK)
|
||||
async def api_invoices(
|
||||
all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||
|
||||
return [invoice.dict() for invoice in await get_invoices(wallet_ids)]
|
||||
|
||||
|
||||
@invoices_ext.get("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK)
|
||||
async def api_invoice(invoice_id: str):
|
||||
invoice = await get_invoice(invoice_id)
|
||||
if not invoice:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
|
||||
)
|
||||
invoice_items = await get_invoice_items(invoice_id)
|
||||
|
||||
invoice_payments = await get_invoice_payments(invoice_id)
|
||||
payments_total = await get_payments_total(invoice_payments)
|
||||
|
||||
invoice_dict = invoice.dict()
|
||||
invoice_dict["items"] = invoice_items
|
||||
invoice_dict["payments"] = payments_total
|
||||
return invoice_dict
|
||||
|
||||
|
||||
@invoices_ext.post("/api/v1/invoice", status_code=HTTPStatus.CREATED)
|
||||
async def api_invoice_create(
|
||||
data: CreateInvoiceData, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
invoice = await create_invoice_internal(wallet_id=wallet.wallet.id, data=data)
|
||||
items = await create_invoice_items(invoice_id=invoice.id, data=data.items)
|
||||
invoice_dict = invoice.dict()
|
||||
invoice_dict["items"] = items
|
||||
return invoice_dict
|
||||
|
||||
|
||||
@invoices_ext.post("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK)
|
||||
async def api_invoice_update(
|
||||
data: UpdateInvoiceData,
|
||||
invoice_id: str,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
):
|
||||
invoice = await update_invoice_internal(wallet_id=wallet.wallet.id, data=data)
|
||||
items = await update_invoice_items(invoice_id=invoice.id, data=data.items)
|
||||
invoice_dict = invoice.dict()
|
||||
invoice_dict["items"] = items
|
||||
return invoice_dict
|
||||
|
||||
|
||||
@invoices_ext.post(
|
||||
"/api/v1/invoice/{invoice_id}/payments", status_code=HTTPStatus.CREATED
|
||||
)
|
||||
async def api_invoices_create_payment(
|
||||
famount: int = Query(..., ge=1), invoice_id: str = None
|
||||
):
|
||||
invoice = await get_invoice(invoice_id)
|
||||
invoice_items = await get_invoice_items(invoice_id)
|
||||
invoice_total = await get_invoice_total(invoice_items)
|
||||
|
||||
invoice_payments = await get_invoice_payments(invoice_id)
|
||||
payments_total = await get_payments_total(invoice_payments)
|
||||
|
||||
if payments_total + famount > invoice_total:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Amount exceeds invoice due."
|
||||
)
|
||||
|
||||
if not invoice:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
|
||||
)
|
||||
|
||||
price_in_sats = await fiat_amount_as_satoshis(famount / 100, invoice.currency)
|
||||
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=invoice.wallet,
|
||||
amount=price_in_sats,
|
||||
memo=f"Payment for invoice {invoice_id}",
|
||||
extra={"tag": "invoices", "invoice_id": invoice_id, "famount": famount},
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
||||
|
||||
|
||||
@invoices_ext.get(
|
||||
"/api/v1/invoice/{invoice_id}/payments/{payment_hash}", status_code=HTTPStatus.OK
|
||||
)
|
||||
async def api_invoices_check_payment(invoice_id: str, payment_hash: str):
|
||||
invoice = await get_invoice(invoice_id)
|
||||
if not invoice:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
|
||||
)
|
||||
try:
|
||||
status = await api_payment(payment_hash)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(exc)
|
||||
return {"paid": False}
|
||||
return status
|
||||
@@ -12,7 +12,7 @@ db = Database("ext_jukebox")
|
||||
jukebox_static_files = [
|
||||
{
|
||||
"path": "/jukebox/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/jukebox/static"),
|
||||
"app": StaticFiles(packages=[("lnbits", "extensions/jukebox/static")]),
|
||||
"name": "jukebox_static",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/jukebox"></q-btn>
|
||||
|
||||
<q-expansion-item group="api" dense expand-separator label="List jukeboxes">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
>
|
||||
<q-step
|
||||
:name="1"
|
||||
title="Pick wallet, price"
|
||||
title="1. Pick Wallet and Price"
|
||||
icon="account_balance_wallet"
|
||||
:done="step > 1"
|
||||
>
|
||||
@@ -170,16 +170,25 @@
|
||||
<br />
|
||||
</q-step>
|
||||
|
||||
<q-step :name="2" title="Add api keys" icon="vpn_key" :done="step > 2">
|
||||
<q-step
|
||||
:name="2"
|
||||
title="2. Add API keys"
|
||||
icon="vpn_key"
|
||||
:done="step > 2"
|
||||
>
|
||||
<img src="/jukebox/static/spotapi.gif" />
|
||||
To use this extension you need a Spotify client ID and client secret.
|
||||
You get these by creating an app in the Spotify developers dashboard
|
||||
<a
|
||||
You get these by creating an app in the Spotify Developer Dashboard
|
||||
<br />
|
||||
<br />
|
||||
<q-btn
|
||||
type="a"
|
||||
target="_blank"
|
||||
style="color: #43a047"
|
||||
color="primary"
|
||||
href="https://developer.spotify.com/dashboard/applications"
|
||||
>here</a
|
||||
>.
|
||||
>Open the Spotify Developer Dashboard</q-btn
|
||||
>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
class="q-pb-md q-pt-md"
|
||||
@@ -231,28 +240,39 @@
|
||||
<br />
|
||||
</q-step>
|
||||
|
||||
<q-step :name="3" title="Add Redirect URI" icon="link" :done="step > 3">
|
||||
<q-step
|
||||
:name="3"
|
||||
title="3. Add Redirect URI"
|
||||
icon="link"
|
||||
:done="step > 3"
|
||||
>
|
||||
<img src="/jukebox/static/spotapi1.gif" />
|
||||
In the app go to edit-settings, set the redirect URI to this link
|
||||
<p>
|
||||
In the app go to edit-settings, set the redirect URI to this link
|
||||
</p>
|
||||
<q-card
|
||||
class="cursor-pointer word-break"
|
||||
@click="copyText(locationcb + jukeboxDialog.data.sp_id, 'Link copied to clipboard!')"
|
||||
>
|
||||
<q-card-section style="word-break: break-all">
|
||||
{% raw %}{{ locationcb }}{{ jukeboxDialog.data.sp_id }}{% endraw
|
||||
%}
|
||||
</q-card-section>
|
||||
<q-tooltip> Click to copy URL </q-tooltip>
|
||||
</q-card>
|
||||
<br />
|
||||
<q-btn
|
||||
dense
|
||||
outline
|
||||
unelevated
|
||||
color="primary"
|
||||
size="xs"
|
||||
@click="copyText(locationcb + jukeboxDialog.data.sp_id, 'Link copied to clipboard!')"
|
||||
>{% raw %}{{ locationcb }}{{ jukeboxDialog.data.sp_id }}{% endraw
|
||||
%}<q-tooltip> Click to copy URL </q-tooltip>
|
||||
</q-btn>
|
||||
<br />
|
||||
Settings can be found
|
||||
<a
|
||||
type="a"
|
||||
target="_blank"
|
||||
style="color: #43a047"
|
||||
color="primary"
|
||||
href="https://developer.spotify.com/dashboard/applications"
|
||||
>here</a
|
||||
>.
|
||||
>Open the Spotify Application Settings</q-btn
|
||||
>
|
||||
<br /><br />
|
||||
<p>
|
||||
After adding the redirect URI, click the "Authorise access" button
|
||||
below.
|
||||
</p>
|
||||
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-4">
|
||||
@@ -281,7 +301,7 @@
|
||||
|
||||
<q-step
|
||||
:name="4"
|
||||
title="Select playlists"
|
||||
title="4. Select Device and Playlists"
|
||||
icon="queue_music"
|
||||
active-color="primary"
|
||||
:done="step > 4"
|
||||
|
||||
@@ -12,7 +12,7 @@ db = Database("ext_livestream")
|
||||
livestream_static_files = [
|
||||
{
|
||||
"path": "/livestream/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/livestream/static"),
|
||||
"app": StaticFiles(packages=[("lnbits", "extensions/livestream/static")]),
|
||||
"name": "livestream_static",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -90,9 +90,7 @@ async def lnurl_callback(
|
||||
wallet_id=ls.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=await track.fullname(),
|
||||
description_hash=hashlib.sha256(
|
||||
(await track.lnurlpay_metadata()).encode("utf-8")
|
||||
).digest(),
|
||||
unhashed_description=(await track.lnurlpay_metadata()).encode("utf-8"),
|
||||
extra={"tag": "livestream", "track": track.id, "comment": comment},
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/livestream"></q-btn>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
|
||||
@@ -70,11 +70,9 @@ async def lnurl_callback(address_id, amount: int = Query(...)):
|
||||
json={
|
||||
"out": False,
|
||||
"amount": int(amount_received / 1000),
|
||||
"description_hash": hashlib.sha256(
|
||||
(await address.lnurlpay_metadata(domain=domain.domain)).encode(
|
||||
"utf-8"
|
||||
)
|
||||
).hexdigest(),
|
||||
"description_hash": (
|
||||
await address.lnurlpay_metadata(domain=domain.domain)
|
||||
).encode("utf-8"),
|
||||
"extra": {"tag": f"Payment to {address.username}@{domain.domain}"},
|
||||
},
|
||||
timeout=40,
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/lnaddress"></q-btn>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET domains">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi.params import Depends, Query
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.core.services import check_invoice_status, create_invoice
|
||||
from lnbits.core.services import check_transaction_status, create_invoice
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||
from lnbits.extensions.lnaddress.models import CreateAddress, CreateDomain
|
||||
|
||||
@@ -229,7 +229,7 @@ async def api_address_send_address(payment_hash):
|
||||
address = await get_address(payment_hash)
|
||||
domain = await get_domain(address.domain)
|
||||
try:
|
||||
status = await check_invoice_status(domain.wallet, payment_hash)
|
||||
status = await check_transaction_status(domain.wallet, payment_hash)
|
||||
is_paid = not status.pending
|
||||
except Exception as e:
|
||||
return {"paid": False, "error": str(e)}
|
||||
|
||||
@@ -31,5 +31,6 @@
|
||||
</li>
|
||||
</ul>
|
||||
</q-card-section>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/lndhub"></q-btn>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
@@ -19,4 +19,5 @@
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/lnticket"></q-btn>
|
||||
</q-expansion-item>
|
||||
|
||||
@@ -281,7 +281,13 @@
|
||||
</q-card-section>
|
||||
{% endraw %}
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="CLOSE" color="primary" v-close-popup />
|
||||
<q-btn
|
||||
flat
|
||||
label="CLOSE"
|
||||
color="primary"
|
||||
v-close-popup
|
||||
@click="resetForm"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
@@ -371,6 +377,9 @@
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
resetForm() {
|
||||
this.formDialog.data = {flatrate: false}
|
||||
},
|
||||
getTickets: function () {
|
||||
var self = this
|
||||
|
||||
@@ -463,7 +472,7 @@
|
||||
.then(function (response) {
|
||||
self.forms.push(mapLNTicket(response.data))
|
||||
self.formDialog.show = false
|
||||
self.formDialog.data = {}
|
||||
self.resetForm()
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
@@ -497,7 +506,7 @@
|
||||
})
|
||||
self.forms.push(mapLNTicket(response.data))
|
||||
self.formDialog.show = false
|
||||
self.formDialog.data = {}
|
||||
self.resetForm()
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
|
||||
@@ -205,9 +205,7 @@ async def lnurl_callback(
|
||||
wallet_id=device.wallet,
|
||||
amount=lnurldevicepayment.sats / 1000,
|
||||
memo=device.title,
|
||||
description_hash=hashlib.sha256(
|
||||
(await device.lnurlpay_metadata()).encode("utf-8")
|
||||
).digest(),
|
||||
unhashed_description=(await device.lnurlpay_metadata()).encode("utf-8"),
|
||||
extra={"tag": "PoS"},
|
||||
)
|
||||
lnurldevicepayment = await update_lnurldevicepayment(
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn
|
||||
flat
|
||||
label="Swagger API"
|
||||
type="a"
|
||||
href="../docs#/lnurldevice"
|
||||
></q-btn>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
|
||||
@@ -12,7 +12,7 @@ db = Database("ext_lnurlp")
|
||||
lnurlp_static_files = [
|
||||
{
|
||||
"path": "/lnurlp/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/lnurlp/static"),
|
||||
"app": StaticFiles(packages=[("lnbits", "extensions/lnurlp/static")]),
|
||||
"name": "lnurlp_static",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -87,9 +87,7 @@ async def api_lnurl_callback(request: Request, link_id):
|
||||
wallet_id=link.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=link.description,
|
||||
description_hash=hashlib.sha256(
|
||||
link.lnurlpay_metadata.encode("utf-8")
|
||||
).digest(),
|
||||
unhashed_description=link.lnurlpay_metadata.encode("utf-8"),
|
||||
extra={
|
||||
"tag": "lnurlp",
|
||||
"link": link.id,
|
||||
|
||||
@@ -35,6 +35,7 @@ new Vue({
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
nfcTagWriting: false,
|
||||
formDialog: {
|
||||
show: false,
|
||||
fixedAmount: true,
|
||||
@@ -205,6 +206,42 @@ new Vue({
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
writeNfcTag: async function (lnurl) {
|
||||
try {
|
||||
if (typeof NDEFReader == 'undefined') {
|
||||
throw {
|
||||
toString: function () {
|
||||
return 'NFC not supported on this device or browser.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ndef = new NDEFReader()
|
||||
|
||||
this.nfcTagWriting = true
|
||||
this.$q.notify({
|
||||
message: 'Tap your NFC tag to write the LNURL-pay link to it.'
|
||||
})
|
||||
|
||||
await ndef.write({
|
||||
records: [{recordType: 'url', data: 'lightning:' + lnurl, lang: 'en'}]
|
||||
})
|
||||
|
||||
this.nfcTagWriting = false
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'NFC tag written successfully.'
|
||||
})
|
||||
} catch (error) {
|
||||
this.nfcTagWriting = false
|
||||
this.$q.notify({
|
||||
type: 'negative',
|
||||
message: error
|
||||
? error.toString()
|
||||
: 'An unexpected error has occurred.'
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/lnurlp"></q-btn>
|
||||
<q-expansion-item group="api" dense expand-separator label="List pay links">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
@@ -51,6 +52,7 @@
|
||||
expand-separator
|
||||
label="Create a pay link"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/lnurlp"></q-btn>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-green">POST</span> /lnurlp/api/v1/links</code>
|
||||
|
||||
@@ -14,10 +14,17 @@
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<div class="row q-mt-lg q-gutter-sm">
|
||||
<q-btn outline color="grey" @click="copyText('{{ lnurl }}')"
|
||||
>Copy LNURL</q-btn
|
||||
>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="nfc"
|
||||
@click="writeNfcTag(' {{ lnurl }} ')"
|
||||
:disable="nfcTagWriting"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
@click="openUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
@@ -153,7 +154,8 @@
|
||||
v-model.trim="formDialog.data.description"
|
||||
type="text"
|
||||
label="Item description *"
|
||||
></q-input>
|
||||
>
|
||||
</q-input>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<q-input
|
||||
filled
|
||||
@@ -171,7 +173,8 @@
|
||||
type="number"
|
||||
:step="formDialog.data.currency && formDialog.data.currency !== 'satoshis' ? '0.01' : '1'"
|
||||
label="Max *"
|
||||
></q-input>
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col">
|
||||
@@ -200,7 +203,8 @@
|
||||
type="number"
|
||||
label="Comment maximum characters"
|
||||
hint="Tell wallets to prompt users for a comment that will be sent along with the payment. LNURLp will store the comment and send it in the webhook."
|
||||
></q-input>
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
@@ -224,7 +228,8 @@
|
||||
type="text"
|
||||
label="Success URL (optional)"
|
||||
hint="Will be shown as a clickable link to the user in his wallet after a successful payment, appended by the payment_hash as a query string."
|
||||
></q-input>
|
||||
>
|
||||
</q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialog.data.id"
|
||||
@@ -294,6 +299,14 @@
|
||||
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
|
||||
>Shareable link</q-btn
|
||||
>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="nfc"
|
||||
@click="writeNfcTag(qrCodeDialog.data.lnurl)"
|
||||
:disable="nfcTagWriting"
|
||||
>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
|
||||
@@ -96,7 +96,7 @@ async def api_link_create_or_update(
|
||||
data.min *= data.fiat_base_multiplier
|
||||
data.max *= data.fiat_base_multiplier
|
||||
|
||||
if "success_url" in data and data.success_url[:8] != "https://":
|
||||
if data.success_url is not None and not data.success_url.startswith("https://"):
|
||||
raise HTTPException(
|
||||
detail="Success URL must be secure https://...",
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
@@ -121,7 +121,7 @@ async def api_link_create_or_update(
|
||||
return {**link.dict(), "lnurl": link.lnurl(request)}
|
||||
|
||||
|
||||
@lnurlp_ext.delete("/api/v1/links/{link_id}")
|
||||
@lnurlp_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
link = await get_pay_link(link_id)
|
||||
|
||||
@@ -136,7 +136,7 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type
|
||||
)
|
||||
|
||||
await delete_pay_link(link_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
"name": "LNURLPayout",
|
||||
"short_description": "Autodump wallet funds to LNURLpay",
|
||||
"icon": "exit_to_app",
|
||||
"contributors": ["arcbtc"]
|
||||
"contributors": ["arcbtc","talvasconcelos"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/lnurlpayout"></q-btn>
|
||||
<q-expansion-item group="api" dense expand-separator label="List lnurlpayout">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
|
||||
@@ -9,7 +9,7 @@ db = Database("ext_offlineshop")
|
||||
offlineshop_static_files = [
|
||||
{
|
||||
"path": "/offlineshop/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/offlineshop/static"),
|
||||
"app": StaticFiles(packages=[("lnbits", "extensions/offlineshop/static")]),
|
||||
"name": "offlineshop_static",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -52,14 +52,20 @@ async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Sho
|
||||
|
||||
|
||||
async def add_item(
|
||||
shop: int, name: str, description: str, image: Optional[str], price: int, unit: str
|
||||
shop: int,
|
||||
name: str,
|
||||
description: str,
|
||||
image: Optional[str],
|
||||
price: int,
|
||||
unit: str,
|
||||
fiat_base_multiplier: int,
|
||||
) -> int:
|
||||
result = await db.execute(
|
||||
"""
|
||||
INSERT INTO offlineshop.items (shop, name, description, image, price, unit)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO offlineshop.items (shop, name, description, image, price, unit, fiat_base_multiplier)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(shop, name, description, image, price, unit),
|
||||
(shop, name, description, image, price, unit, fiat_base_multiplier),
|
||||
)
|
||||
return result._result_proxy.lastrowid
|
||||
|
||||
@@ -72,6 +78,7 @@ async def update_item(
|
||||
image: Optional[str],
|
||||
price: int,
|
||||
unit: str,
|
||||
fiat_base_multiplier: int,
|
||||
) -> int:
|
||||
await db.execute(
|
||||
"""
|
||||
@@ -80,10 +87,11 @@ async def update_item(
|
||||
description = ?,
|
||||
image = ?,
|
||||
price = ?,
|
||||
unit = ?
|
||||
unit = ?,
|
||||
fiat_base_multiplier = ?
|
||||
WHERE shop = ? AND id = ?
|
||||
""",
|
||||
(name, description, image, price, unit, shop, item_id),
|
||||
(name, description, image, price, unit, fiat_base_multiplier, shop, item_id),
|
||||
)
|
||||
return item_id
|
||||
|
||||
@@ -92,12 +100,12 @@ async def get_item(id: int) -> Optional[Item]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM offlineshop.items WHERE id = ? LIMIT 1", (id,)
|
||||
)
|
||||
return Item(**dict(row)) if row else None
|
||||
return Item.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_items(shop: int) -> List[Item]:
|
||||
rows = await db.fetchall("SELECT * FROM offlineshop.items WHERE shop = ?", (shop,))
|
||||
return [Item(**dict(row)) for row in rows]
|
||||
return [Item.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def delete_item_from_shop(shop: int, item_id: int):
|
||||
|
||||
@@ -73,9 +73,7 @@ async def lnurl_callback(request: Request, item_id: int):
|
||||
wallet_id=shop.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=item.name,
|
||||
description_hash=hashlib.sha256(
|
||||
(await item.lnurlpay_metadata()).encode("utf-8")
|
||||
).digest(),
|
||||
unhashed_description=(await item.lnurlpay_metadata()).encode("utf-8"),
|
||||
extra={"tag": "offlineshop", "item": item.id},
|
||||
)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -27,3 +27,13 @@ async def m001_initial(db):
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m002_fiat_base_multiplier(db):
|
||||
"""
|
||||
Store the multiplier for fiat prices. We store the price in cents and
|
||||
remember to multiply by 100 when we use it to convert to Dollars.
|
||||
"""
|
||||
await db.execute(
|
||||
"ALTER TABLE offlineshop.items ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import base64
|
||||
import hashlib
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from sqlite3 import Row
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from lnurl import encode as lnurl_encode # type: ignore
|
||||
@@ -87,8 +88,16 @@ class Item(BaseModel):
|
||||
description: str
|
||||
image: Optional[str]
|
||||
enabled: bool
|
||||
price: int
|
||||
price: float
|
||||
unit: str
|
||||
fiat_base_multiplier: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Item":
|
||||
data = dict(row)
|
||||
if data["unit"] != "sat" and data["fiat_base_multiplier"]:
|
||||
data["price"] /= data["fiat_base_multiplier"]
|
||||
return cls(**data)
|
||||
|
||||
def lnurl(self, req: Request) -> str:
|
||||
return lnurl_encode(req.url_for("offlineshop.lnurl_response", item_id=self.id))
|
||||
|
||||
@@ -124,7 +124,8 @@ new Vue({
|
||||
description,
|
||||
image,
|
||||
price,
|
||||
unit
|
||||
unit,
|
||||
fiat_base_multiplier: unit == 'sat' ? 1 : 100
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/offlineshop"></q-btn>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
||||
from pydantic.main import BaseModel
|
||||
@@ -34,7 +35,6 @@ async def api_shop_from_wallet(
|
||||
):
|
||||
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
||||
items = await get_items(shop.id)
|
||||
|
||||
try:
|
||||
return {
|
||||
**shop.dict(),
|
||||
@@ -51,8 +51,9 @@ class CreateItemsData(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
image: Optional[str]
|
||||
price: int
|
||||
price: float
|
||||
unit: str
|
||||
fiat_base_multiplier: int = Query(100, ge=1)
|
||||
|
||||
|
||||
@offlineshop_ext.post("/api/v1/offlineshop/items")
|
||||
@@ -61,9 +62,18 @@ async def api_add_or_update_item(
|
||||
data: CreateItemsData, item_id=None, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
||||
if data.unit != "sat":
|
||||
data.price = data.price * 100
|
||||
if item_id == None:
|
||||
|
||||
await add_item(
|
||||
shop.id, data.name, data.description, data.image, data.price, data.unit
|
||||
shop.id,
|
||||
data.name,
|
||||
data.description,
|
||||
data.image,
|
||||
data.price,
|
||||
data.unit,
|
||||
data.fiat_base_multiplier,
|
||||
)
|
||||
return HTMLResponse(status_code=HTTPStatus.CREATED)
|
||||
else:
|
||||
@@ -75,6 +85,7 @@ async def api_add_or_update_item(
|
||||
data.image,
|
||||
data.price,
|
||||
data.unit,
|
||||
data.fiat_base_multiplier,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/paywall"></q-btn>
|
||||
<q-expansion-item group="api" dense expand-separator label="List paywalls">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
|
||||
@@ -4,7 +4,7 @@ from fastapi import Depends, Query
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_user, get_wallet
|
||||
from lnbits.core.services import check_invoice_status, create_invoice
|
||||
from lnbits.core.services import check_transaction_status, create_invoice
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||
|
||||
from . import paywall_ext
|
||||
@@ -87,7 +87,7 @@ async def api_paywal_check_invoice(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Paywall does not exist."
|
||||
)
|
||||
try:
|
||||
status = await check_invoice_status(paywall.wallet, payment_hash)
|
||||
status = await check_transaction_status(paywall.wallet, payment_hash)
|
||||
is_paid = not status.pending
|
||||
except Exception:
|
||||
return {"paid": False}
|
||||
|
||||
@@ -77,9 +77,7 @@ async def api_lnurlp_callback(
|
||||
wallet_id=link.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo="Satsdice bet",
|
||||
description_hash=hashlib.sha256(
|
||||
link.lnurlpay_metadata.encode("utf-8")
|
||||
).digest(),
|
||||
unhashed_description=link.lnurlpay_metadata.encode("utf-8"),
|
||||
extra={"tag": "satsdice", "link": link.id, "comment": "comment"},
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/satsdice"></q-btn>
|
||||
<q-expansion-item group="api" dense expand-separator label="List satsdices">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
|
||||
@@ -421,7 +421,13 @@
|
||||
this.formDialog = {
|
||||
show: false,
|
||||
fixedAmount: true,
|
||||
data: {}
|
||||
data: {
|
||||
haircut: 0,
|
||||
min_bet: 10,
|
||||
max_bet: 1000,
|
||||
currency: 'satoshis',
|
||||
comment_chars: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
updatePayLink(wallet, data) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
@@ -11,6 +12,14 @@ db = Database("ext_satspay")
|
||||
|
||||
satspay_ext: APIRouter = APIRouter(prefix="/satspay", tags=["satspay"])
|
||||
|
||||
satspay_static_files = [
|
||||
{
|
||||
"path": "/satspay/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/satspay/static"),
|
||||
"name": "satspay_static",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def satspay_renderer():
|
||||
return template_renderer(["lnbits/extensions/satspay/templates"])
|
||||
|
||||
@@ -6,7 +6,7 @@ from lnbits.core.services import create_invoice
|
||||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from ..watchonly.crud import get_fresh_address, get_mempool, get_watch_wallet
|
||||
from ..watchonly.crud import get_config, get_fresh_address
|
||||
|
||||
# from lnbits.db import open_ext_db
|
||||
from . import db
|
||||
@@ -18,7 +18,6 @@ from .models import Charges, CreateCharge
|
||||
async def create_charge(user: str, data: CreateCharge) -> Charges:
|
||||
charge_id = urlsafe_short_hash()
|
||||
if data.onchainwallet:
|
||||
wallet = await get_watch_wallet(data.onchainwallet)
|
||||
onchain = await get_fresh_address(data.onchainwallet)
|
||||
onchainaddress = onchain.address
|
||||
else:
|
||||
@@ -89,7 +88,8 @@ async def get_charge(charge_id: str) -> Charges:
|
||||
|
||||
async def get_charges(user: str) -> List[Charges]:
|
||||
rows = await db.fetchall(
|
||||
"""SELECT * FROM satspay.charges WHERE "user" = ?""", (user,)
|
||||
"""SELECT * FROM satspay.charges WHERE "user" = ? ORDER BY "timestamp" DESC """,
|
||||
(user,),
|
||||
)
|
||||
return [Charges.from_row(row) for row in rows]
|
||||
|
||||
@@ -102,14 +102,16 @@ async def check_address_balance(charge_id: str) -> List[Charges]:
|
||||
charge = await get_charge(charge_id)
|
||||
if not charge.paid:
|
||||
if charge.onchainaddress:
|
||||
mempool = await get_mempool(charge.user)
|
||||
config = await get_config(charge.user)
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(
|
||||
mempool.endpoint + "/api/address/" + charge.onchainaddress
|
||||
config.mempool_endpoint
|
||||
+ "/api/address/"
|
||||
+ charge.onchainaddress
|
||||
)
|
||||
respAmount = r.json()["chain_stats"]["funded_txo_sum"]
|
||||
if respAmount >= charge.balance:
|
||||
if respAmount > charge.balance:
|
||||
await update_charge(charge_id=charge_id, balance=respAmount)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from sqlite3 import Row
|
||||
from typing import Optional
|
||||
|
||||
@@ -38,12 +38,16 @@ class Charges(BaseModel):
|
||||
def from_row(cls, row: Row) -> "Charges":
|
||||
return cls(**dict(row))
|
||||
|
||||
@property
|
||||
def time_left(self):
|
||||
now = datetime.utcnow().timestamp()
|
||||
start = datetime.fromtimestamp(self.timestamp)
|
||||
expiration = (start + timedelta(minutes=self.time)).timestamp()
|
||||
return (expiration - now) / 60
|
||||
|
||||
@property
|
||||
def time_elapsed(self):
|
||||
if (self.timestamp + (self.time * 60)) >= time.time():
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
return self.time_left < 0
|
||||
|
||||
@property
|
||||
def paid(self):
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
const retryWithDelay = async function (fn, retryCount = 0) {
|
||||
try {
|
||||
await sleep(25)
|
||||
// Do not return the call directly, use result.
|
||||
// Otherwise the error will not be cought in this try-catch block.
|
||||
const result = await fn()
|
||||
return result
|
||||
} catch (err) {
|
||||
if (retryCount > 100) throw err
|
||||
await sleep((retryCount + 1) * 1000)
|
||||
return retryWithDelay(fn, retryCount + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const mapCharge = (obj, oldObj = {}) => {
|
||||
const charge = _.clone(obj)
|
||||
|
||||
charge.progress = obj.time_left < 0 ? 1 : 1 - obj.time_left / obj.time
|
||||
charge.time = minutesToTime(obj.time)
|
||||
charge.timeLeft = minutesToTime(obj.time_left)
|
||||
|
||||
charge.expanded = false
|
||||
charge.displayUrl = ['/satspay/', obj.id].join('')
|
||||
charge.expanded = oldObj.expanded
|
||||
charge.pendingBalance = oldObj.pendingBalance || 0
|
||||
return charge
|
||||
}
|
||||
|
||||
const minutesToTime = min =>
|
||||
min > 0 ? new Date(min * 1000).toISOString().substring(14, 19) : ''
|
||||
@@ -8,171 +8,10 @@
|
||||
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
||||
>
|
||||
</p>
|
||||
<br />
|
||||
<br />
|
||||
<a target="_blank" href="/docs#/satspay" class="text-white"
|
||||
>Swagger REST API Documentation</a
|
||||
>
|
||||
</q-card-section>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-expansion-item group="api" dense expand-separator label="Create charge">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">POST</span> /satspay/api/v1/charge</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}satspay/api/v1/charge -d
|
||||
'{"onchainwallet": <string, watchonly_wallet_id>,
|
||||
"description": <string>, "webhook":<string>, "time":
|
||||
<integer>, "amount": <integer>, "lnbitswallet":
|
||||
<string, lnbits_wallet_id>}' -H "Content-type:
|
||||
application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Update charge">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">PUT</span>
|
||||
/satspay/api/v1/charge/<charge_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url
|
||||
}}satspay/api/v1/charge/<charge_id> -d '{"onchainwallet":
|
||||
<string, watchonly_wallet_id>, "description": <string>,
|
||||
"webhook":<string>, "time": <integer>, "amount":
|
||||
<integer>, "lnbitswallet": <string, lnbits_wallet_id>}'
|
||||
-H "Content-type: application/json" -H "X-Api-Key:
|
||||
{{user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item group="api" dense expand-separator label="Get charge">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/satspay/api/v1/charge/<charge_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}satspay/api/v1/charge/<charge_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Get charges">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span> /satspay/api/v1/charges</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}satspay/api/v1/charges -H
|
||||
"X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Delete a pay link"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/satspay/api/v1/charge/<charge_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.base_url
|
||||
}}satspay/api/v1/charge/<charge_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get balances"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/satspay/api/v1/charges/balance/<charge_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}satspay/api/v1/charges/balance/<charge_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
</q-card>
|
||||
|
||||
@@ -1,223 +1,299 @@
|
||||
{% extends "public.html" %} {% block page %}
|
||||
<div class="q-pa-sm theCard">
|
||||
<q-card class="my-card">
|
||||
<div class="column">
|
||||
<center>
|
||||
<div class="col theHeading">{{ charge.description }}</div>
|
||||
</center>
|
||||
<div class="col">
|
||||
<div
|
||||
class="col"
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-if="timetoComplete < 1"
|
||||
>
|
||||
<center>Time elapsed</center>
|
||||
</div>
|
||||
<div
|
||||
class="col"
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-else-if="charge_paid == 'True'"
|
||||
>
|
||||
<center>Charge paid</center>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-linear-progress size="30px" :value="newProgress" color="grey">
|
||||
<q-item-section>
|
||||
<q-item style="padding: 3px">
|
||||
<q-spinner color="white" size="0.8em"></q-spinner
|
||||
><span style="font-size: 15px; color: white"
|
||||
><span class="q-pr-xl q-pl-md"> Awaiting payment...</span>
|
||||
<span class="q-pl-xl" style="color: white">
|
||||
{% raw %} {{ newTimeLeft }} {% endraw %}</span
|
||||
></span
|
||||
>
|
||||
</q-item>
|
||||
</q-item-section>
|
||||
</q-linear-progress>
|
||||
<div class="row items-center q-mt-md">
|
||||
<div class="col-lg-4 col-md-3 col-sm-1"></div>
|
||||
<div class="col-lg-4 col-md-6 col-sm-10">
|
||||
<q-card>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col text-center q-mt-md">
|
||||
<span class="text-h4" v-text="charge.description"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col" style="margin: 2px 15px; max-height: 100px">
|
||||
<center>
|
||||
<q-btn flat dense outline @click="copyText('{{ charge.id }}')"
|
||||
>Charge ID: {{ charge.id }}</q-btn
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<div
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-if="!charge.timeLeft"
|
||||
>
|
||||
</center>
|
||||
<span
|
||||
><small
|
||||
>{% raw %} Total to pay: {{ charge_amount }}sats<br />
|
||||
Amount paid: {{ charge_balance }}</small
|
||||
><br />
|
||||
Amount due: {{ charge_amount - charge_balance }}sats {% endraw %}
|
||||
</span>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="'{{ charge.lnbitswallet }}' == 'None' || charge_time_elapsed == 'True'"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin lightning payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payLN"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
<q-tooltip> pay with lightning </q-tooltip>
|
||||
</q-btn>
|
||||
Time elapsed
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="'{{ charge.onchainwallet }}' == 'None' || charge_time_elapsed == 'True'"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin onchain payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payON"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
<q-tooltip> pay onchain </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
</div>
|
||||
</div>
|
||||
<q-card class="q-pa-lg" v-if="lnbtc">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="text-center q-pt-md">
|
||||
<div v-if="timetoComplete < 1 && charge_paid == 'False'">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge_paid == 'True'">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
v-if="'{{ charge.webhook }}' != 'None'"
|
||||
type="a"
|
||||
href="{{ charge.completelink }}"
|
||||
label="{{ charge.completelinktext }}"
|
||||
></q-btn>
|
||||
<div
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-else-if="charge.paid"
|
||||
>
|
||||
Charge paid
|
||||
</div>
|
||||
<div v-else>
|
||||
<center>
|
||||
<span class="text-subtitle2"
|
||||
>Pay this <br />
|
||||
lightning-network invoice</span
|
||||
<q-linear-progress
|
||||
size="30px"
|
||||
:value="charge.progress"
|
||||
color="secondary"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item style="padding: 3px">
|
||||
<q-spinner color="white" size="0.8em"></q-spinner
|
||||
><span style="font-size: 15px; color: white"
|
||||
><span class="q-pr-xl q-pl-md"> Awaiting payment...</span>
|
||||
<span class="q-pl-xl" style="color: white">
|
||||
{% raw %} {{ charge.timeLeft }} {% endraw %}</span
|
||||
></span
|
||||
>
|
||||
</q-item>
|
||||
</q-item-section>
|
||||
</q-linear-progress>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-ml-md q-mt-md q-mb-lg">
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col-4 q-pr-lg">Charge Id:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-btn flat dense outline @click="copyText(charge.id)"
|
||||
><span v-text="charge.id"></span
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center">
|
||||
<div class="col-4 q-pr-lg">Total to pay:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge color="blue">
|
||||
<span v-text="charge.amount" class="text-subtitle2"></span> sat
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-sm">
|
||||
<div class="col-4 q-pr-lg">Amount paid:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge color="orange">
|
||||
<span v-text="charge.balance" class="text-subtitle2"></span>
|
||||
sat</q-badge
|
||||
>
|
||||
</center>
|
||||
<a href="lightning:{{ charge.payment_request }}">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="'{{ charge.payment_request }}'"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText('{{ charge.payment_request }}')"
|
||||
>Copy invoice</q-btn
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pendingFunds" class="row items-center q-mt-sm">
|
||||
<div class="col-4 q-pr-lg">Amount pending:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge color="gray">
|
||||
<span v-text="pendingFunds" class="text-subtitle2"></span> sat
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-sm">
|
||||
<div class="col-4 q-pr-lg">Amount due:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge v-if="charge.amount - charge.balance > 0" color="green">
|
||||
<span
|
||||
v-text="charge.amount - charge.balance"
|
||||
class="text-subtitle2"
|
||||
></span>
|
||||
sat
|
||||
</q-badge>
|
||||
<q-badge
|
||||
v-else="charge.amount - charge.balance <= 0"
|
||||
color="green"
|
||||
class="text-subtitle2"
|
||||
>
|
||||
none</q-badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="!charge.lnbitswallet || charge.time_elapsed"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin lightning payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payInvoice"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
<q-tooltip> pay with lightning </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="!charge.onchainwallet || charge.time_elapsed"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin onchain payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payOnchain"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
<q-tooltip> pay onchain </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
<q-card class="q-pa-lg" v-if="lnbtc">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="row items-center q-mt-sm">
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<div v-if="!charge.timeLeft && !charge.paid">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge.paid">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
v-if="charge.webhook"
|
||||
type="a"
|
||||
:href="charge.completelink"
|
||||
:label="charge.completelinktext"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="row text-center q-mb-sm">
|
||||
<div class="col text-center">
|
||||
<span class="text-subtitle2"
|
||||
>Pay this lightning-network invoice:</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a :href="'lightning:'+charge.payment_request">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="charge.payment_request"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<div class="row text-center q-mt-lg">
|
||||
<div class="col text-center">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(charge.payment_request)"
|
||||
>Copy invoice</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card class="q-pa-lg" v-if="onbtc">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="text-center q-pt-md">
|
||||
<div v-if="timetoComplete < 1 && charge_paid == 'False'">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge_paid == 'True'">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
v-if="'{{ charge.webhook }}' != None"
|
||||
type="a"
|
||||
href="{{ charge.completelink }}"
|
||||
label="{{ charge.completelinktext }}"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<center>
|
||||
<span class="text-subtitle2"
|
||||
>Send {{ charge.amount }}sats<br />
|
||||
to this onchain address</span
|
||||
>
|
||||
</center>
|
||||
<a href="bitcoin:{{ charge.onchainaddress }}">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="'{{ charge.onchainaddress }}'"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<div v-if="charge.timeLeft && !charge.paid" class="row items-center">
|
||||
<div class="col text-center">
|
||||
<a
|
||||
style="color: unset"
|
||||
:href="mempool_endpoint + '/address/' + charge.onchainaddress"
|
||||
target="_blank"
|
||||
><span
|
||||
class="text-subtitle1"
|
||||
v-text="charge.onchainaddress"
|
||||
></span>
|
||||
</a>
|
||||
<div class="row q-mt-lg">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-md">
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
<div class="col-md-8 col-sm-12 text-center">
|
||||
<div v-if="!charge.timeLeft && !charge.paid">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge.paid">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText('{{ charge.onchainaddress }}')"
|
||||
>Copy address</q-btn
|
||||
>
|
||||
v-if="charge.webhook"
|
||||
type="a"
|
||||
:href="charge.completelink"
|
||||
:label="charge.completelinktext"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="row items-center q-mb-sm">
|
||||
<div class="col text-center">
|
||||
<span class="text-subtitle2"
|
||||
>Send
|
||||
|
||||
<span v-text="charge.amount"></span>
|
||||
sats to this onchain address</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a :href="'bitcoin:'+charge.onchainaddress">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="charge.onchainaddress"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<div class="row items-center q-mt-lg">
|
||||
<div class="col text-center">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(charge.onchainaddress)"
|
||||
>Copy address</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-lg- 4 col-md-3 col-sm-1"></div>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
|
||||
<style>
|
||||
.theCard {
|
||||
width: 360px;
|
||||
margin: 10px auto;
|
||||
}
|
||||
.theHeading {
|
||||
margin: 15px;
|
||||
font-size: 25px;
|
||||
}
|
||||
</style>
|
||||
<script src="https://mempool.space/mempool.js"></script>
|
||||
<script src="{{ url_for('satspay_static', path='js/utils.js') }}"></script>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
@@ -226,16 +302,14 @@
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
charge: JSON.parse('{{charge_data | tojson}}'),
|
||||
mempool_endpoint: '{{mempool_endpoint}}',
|
||||
pendingFunds: 0,
|
||||
ws: null,
|
||||
newProgress: 0.4,
|
||||
counter: 1,
|
||||
newTimeLeft: '',
|
||||
timetoComplete: 100,
|
||||
lnbtc: true,
|
||||
onbtc: false,
|
||||
charge_time_elapsed: '{{charge.time_elapsed}}',
|
||||
charge_amount: '{{charge.amount}}',
|
||||
charge_balance: '{{charge.balance}}',
|
||||
charge_paid: '{{charge.paid}}',
|
||||
wallet: {
|
||||
inkey: ''
|
||||
},
|
||||
@@ -245,90 +319,141 @@
|
||||
methods: {
|
||||
startPaymentNotifier() {
|
||||
this.cancelListener()
|
||||
|
||||
this.cancelListener = LNbits.event.onInvoicePaid(
|
||||
if (!this.lnbitswallet) return
|
||||
this.cancelListener = LNbits.events.onInvoicePaid(
|
||||
this.wallet,
|
||||
payment => {
|
||||
this.checkBalance()
|
||||
this.checkInvoiceBalance()
|
||||
}
|
||||
)
|
||||
},
|
||||
checkBalance: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
checkBalances: async function () {
|
||||
if (!this.charge.hasStaleBalance) await this.refreshCharge()
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges/balance/{{ charge.id }}',
|
||||
'filla'
|
||||
`/satspay/api/v1/charge/balance/${this.charge.id}`
|
||||
)
|
||||
.then(function (response) {
|
||||
self.charge_time_elapsed = response.data.time_elapsed
|
||||
self.charge_amount = response.data.amount
|
||||
self.charge_balance = response.data.balance
|
||||
if (self.charge_balance >= self.charge_amount) {
|
||||
self.charge_paid = 'True'
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
this.charge = mapCharge(data, this.charge)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
payLN: function () {
|
||||
refreshCharge: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/satspay/api/v1/charge/${this.charge.id}`
|
||||
)
|
||||
this.charge = mapCharge(data, this.charge)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
checkPendingOnchain: async function () {
|
||||
const {
|
||||
bitcoin: {addresses: addressesAPI}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.mempool_endpoint).hostname
|
||||
})
|
||||
|
||||
try {
|
||||
const utxos = await addressesAPI.getAddressTxsUtxo({
|
||||
address: this.charge.onchainaddress
|
||||
})
|
||||
const newBalance = utxos.reduce((t, u) => t + u.value, 0)
|
||||
this.charge.hasStaleBalance = this.charge.balance === newBalance
|
||||
|
||||
this.pendingFunds = utxos
|
||||
.filter(u => !u.status.confirmed)
|
||||
.reduce((t, u) => t + u.value, 0)
|
||||
} catch (error) {
|
||||
console.error('cannot check pending funds')
|
||||
}
|
||||
},
|
||||
payInvoice: function () {
|
||||
this.lnbtc = true
|
||||
this.onbtc = false
|
||||
},
|
||||
payON: function () {
|
||||
payOnchain: function () {
|
||||
this.lnbtc = false
|
||||
this.onbtc = true
|
||||
},
|
||||
getTheTime: function () {
|
||||
var timeToComplete =
|
||||
parseInt('{{ charge.time }}') * 60 -
|
||||
(Date.now() / 1000 - parseInt('{{ charge.timestamp }}'))
|
||||
this.timetoComplete = timeToComplete
|
||||
var timeLeft = Quasar.utils.date.formatDate(
|
||||
new Date((timeToComplete - 3600) * 1000),
|
||||
'HH:mm:ss'
|
||||
)
|
||||
this.newTimeLeft = timeLeft
|
||||
},
|
||||
getThePercentage: function () {
|
||||
var timeToComplete =
|
||||
parseInt('{{ charge.time }}') * 60 -
|
||||
(Date.now() / 1000 - parseInt('{{ charge.timestamp }}'))
|
||||
this.newProgress =
|
||||
1 - timeToComplete / (parseInt('{{ charge.time }}') * 60)
|
||||
},
|
||||
|
||||
timerCount: function () {
|
||||
self = this
|
||||
var refreshIntervalId = setInterval(function () {
|
||||
if (self.charge_paid == 'True' || self.timetoComplete < 1) {
|
||||
loopRefresh: function () {
|
||||
// invoice only
|
||||
const refreshIntervalId = setInterval(async () => {
|
||||
if (this.charge.paid || !this.charge.timeLeft) {
|
||||
clearInterval(refreshIntervalId)
|
||||
}
|
||||
self.getTheTime()
|
||||
self.getThePercentage()
|
||||
self.counter++
|
||||
if (self.counter % 10 === 0) {
|
||||
self.checkBalance()
|
||||
if (this.counter % 10 === 0) {
|
||||
await this.checkBalances()
|
||||
await this.checkPendingOnchain()
|
||||
}
|
||||
this.counter++
|
||||
}, 1000)
|
||||
},
|
||||
initWs: async function () {
|
||||
const {
|
||||
bitcoin: {websocket}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.mempool_endpoint).hostname
|
||||
})
|
||||
|
||||
this.ws = new WebSocket('wss://mempool.space/api/v1/ws')
|
||||
this.ws.addEventListener('open', x => {
|
||||
if (this.charge.onchainaddress) {
|
||||
this.trackAddress(this.charge.onchainaddress)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.addEventListener('message', async ({data}) => {
|
||||
const res = JSON.parse(data.toString())
|
||||
if (res['address-transactions']) {
|
||||
await this.checkBalances()
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'New payment received!',
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
loopPingWs: function () {
|
||||
setInterval(() => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) this.initWs()
|
||||
this.ws.send(JSON.stringify({action: 'ping'}))
|
||||
}, 30 * 1000)
|
||||
},
|
||||
trackAddress: async function (address, retry = 0) {
|
||||
try {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) this.initWs()
|
||||
this.ws.send(JSON.stringify({'track-address': address}))
|
||||
} catch (error) {
|
||||
await sleep(1000)
|
||||
if (retry > 10) throw error
|
||||
this.trackAddress(address, retry + 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
console.log('{{ charge.onchainaddress }}' == 'None')
|
||||
if ('{{ charge.lnbitswallet }}' == 'None') {
|
||||
this.lnbtc = false
|
||||
this.onbtc = true
|
||||
}
|
||||
created: async function () {
|
||||
if (this.charge.lnbitswallet) this.payInvoice()
|
||||
else this.payOnchain()
|
||||
await this.checkBalances()
|
||||
|
||||
// empty for onchain
|
||||
this.wallet.inkey = '{{ wallet_inkey }}'
|
||||
this.getTheTime()
|
||||
this.getThePercentage()
|
||||
var timerCount = this.timerCount
|
||||
if ('{{ charge.paid }}' == 'False') {
|
||||
timerCount()
|
||||
}
|
||||
this.startPaymentNotifier()
|
||||
|
||||
if (!this.charge.paid) {
|
||||
this.loopRefresh()
|
||||
}
|
||||
|
||||
if (this.charge.onchainaddress) {
|
||||
this.loopPingWs()
|
||||
this.checkPendingOnchain()
|
||||
this.trackAddress(this.charge.onchainaddress)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -18,46 +18,54 @@
|
||||
<h5 class="text-subtitle1 q-my-none">Charges</h5>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<div class="col q-pr-lg">
|
||||
<q-input
|
||||
borderless
|
||||
dense
|
||||
debounce="300"
|
||||
v-model="filter"
|
||||
placeholder="Search"
|
||||
class="float-right"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon name="search"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-btn flat color="grey" @click="exportchargeCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn outline color="grey" label="...">
|
||||
<q-menu auto-close>
|
||||
<q-list style="min-width: 100px">
|
||||
<q-item clickable>
|
||||
<q-item-section @click="exportchargeCSV"
|
||||
>Export to CSV</q-item-section
|
||||
>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
flat
|
||||
dense
|
||||
:data="ChargeLinks"
|
||||
:data="chargeLinks"
|
||||
row-key="id"
|
||||
:columns="ChargesTable.columns"
|
||||
:pagination.sync="ChargesTable.pagination"
|
||||
:columns="chargesTable.columns"
|
||||
:pagination.sync="chargesTable.pagination"
|
||||
:filter="filter"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
auto-width
|
||||
>
|
||||
<div v-if="col.name == 'id'"></div>
|
||||
<div v-else>{{ col.label }}</div>
|
||||
</q-th>
|
||||
<q-th auto-width>Status </q-th>
|
||||
<q-th auto-width>Title</q-th>
|
||||
<q-th auto-width>Time Left (hh:mm)</q-th>
|
||||
<q-th auto-width>Time To Pay (hh:mm)</q-th>
|
||||
<q-th auto-width>Amount To Pay</q-th>
|
||||
<q-th auto-width>Balance</q-th>
|
||||
<q-th auto-width>Pending Balance</q-th>
|
||||
<q-th auto-width>Onchain Address</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
@@ -66,73 +74,179 @@
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
size="sm"
|
||||
color="accent"
|
||||
round
|
||||
dense
|
||||
size="xs"
|
||||
icon="link"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
@click="props.row.expanded= !props.row.expanded"
|
||||
:icon="props.row.expanded? 'remove' : 'add'"
|
||||
/>
|
||||
</q-td>
|
||||
|
||||
<q-td auto-width>
|
||||
<q-badge
|
||||
v-if="props.row.time_elapsed && props.row.balance < props.row.amount"
|
||||
color="red"
|
||||
>
|
||||
<a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>expired</a
|
||||
>
|
||||
</q-badge>
|
||||
|
||||
<q-badge
|
||||
v-else-if="props.row.balance >= props.row.amount"
|
||||
color="green"
|
||||
>
|
||||
<a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>paid</a
|
||||
>
|
||||
</q-badge>
|
||||
|
||||
<q-badge v-else color="blue"
|
||||
><a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>waiting</a
|
||||
>
|
||||
</q-badge>
|
||||
</q-td>
|
||||
<q-td key="description" :props="props" :class="">
|
||||
<a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>{{props.row.description}}</a
|
||||
>
|
||||
<q-tooltip> Payment link </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
v-if="props.row.time_elapsed && props.row.balance < props.row.amount"
|
||||
unelevated
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
icon="error"
|
||||
:color="($q.dark.isActive) ? 'red' : 'red'"
|
||||
<q-td key="timeLeft" :props="props" :class="">
|
||||
<div>{{props.row.timeLeft}}</div>
|
||||
<q-linear-progress
|
||||
v-if="props.row.timeLeft"
|
||||
:value="props.row.progress"
|
||||
color="secondary"
|
||||
>
|
||||
<q-tooltip> Time elapsed </q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
v-else-if="props.row.balance >= props.row.amount"
|
||||
unelevated
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
icon="check"
|
||||
:color="($q.dark.isActive) ? 'green' : 'green'"
|
||||
>
|
||||
<q-tooltip> PAID! </q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="cached"
|
||||
flat
|
||||
:color="($q.dark.isActive) ? 'blue' : 'blue'"
|
||||
>
|
||||
<q-tooltip> Processing </q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteChargeLink(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
>
|
||||
<q-tooltip> Delete charge </q-tooltip>
|
||||
</q-btn>
|
||||
</q-linear-progress>
|
||||
</q-td>
|
||||
<q-td
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
auto-width
|
||||
>
|
||||
<div v-if="col.name == 'id'"></div>
|
||||
<div v-else>{{ col.value }}</div>
|
||||
<q-td key="time to pay" :props="props" :class="">
|
||||
<div>{{props.row.time}}</div>
|
||||
</q-td>
|
||||
<q-td key="amount" :props="props" :class="">
|
||||
<div>{{props.row.amount}}</div>
|
||||
</q-td>
|
||||
<q-td key="balance" :props="props" :class="">
|
||||
<div>{{props.row.balance}}</div>
|
||||
</q-td>
|
||||
<q-td key="pendingBalance" :props="props" :class="">
|
||||
<div>
|
||||
{{props.row.pendingBalance ? props.row.pendingBalance : ''}}
|
||||
</div>
|
||||
</q-td>
|
||||
<q-td key="onchain address" :props="props" :class="">
|
||||
<a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>{{props.row.onchainaddress}}</a
|
||||
>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
<q-tr v-show="props.row.expanded" :props="props">
|
||||
<q-td colspan="100%">
|
||||
<div
|
||||
v-if="props.row.onchainwallet"
|
||||
class="row items-center q-mt-md q-mb-lg"
|
||||
>
|
||||
<div class="col-2 q-pr-lg">Onchain Wallet:</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
{{getOnchainWalletName(props.row.onchainwallet)}}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.row.lnbitswallet"
|
||||
class="row items-center q-mt-md q-mb-lg"
|
||||
>
|
||||
<div class="col-2 q-pr-lg">LNbits Wallet:</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
{{getLNbitsWalletName(props.row.lnbitswallet)}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.row.completelink || props.row.completelinktext"
|
||||
class="row items-center q-mt-md q-mb-lg"
|
||||
>
|
||||
<div class="col-2 q-pr-lg">Completed Link:</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<a
|
||||
:href="props.row.completelink"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>{{props.row.completelinktext ||
|
||||
props.row.completelink}}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.row.webhook"
|
||||
class="row items-center q-mt-md q-mb-lg"
|
||||
>
|
||||
<div class="col-2 q-pr-lg">Webhook:</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<a
|
||||
:href="props.row.webhook"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>{{props.row.webhook || props.row.webhook}}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-md q-mb-lg">
|
||||
<div class="col-2 q-pr-lg">ID:</div>
|
||||
<div class="col-4 q-pr-lg">{{props.row.id}}</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-md q-mb-lg">
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
<div class="col-6 q-pr-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="gray"
|
||||
outline
|
||||
type="a"
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
class="float-left q-mr-lg"
|
||||
>Details</q-btn
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="gray"
|
||||
outline
|
||||
type="a"
|
||||
@click="refreshBalance(props.row)"
|
||||
target="_blank"
|
||||
class="float-left"
|
||||
>Refresh Balance</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="pink"
|
||||
icon="cancel"
|
||||
@click="deleteChargeLink(props.row.id)"
|
||||
>Delete</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-4"></div>
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
</div>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
@@ -155,11 +269,7 @@
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<q-dialog
|
||||
v-model="formDialogCharge.show"
|
||||
position="top"
|
||||
@hide="closeFormDialog"
|
||||
>
|
||||
<q-dialog v-model="formDialogCharge.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormDataCharge" class="q-gutter-md">
|
||||
<q-input
|
||||
@@ -225,7 +335,8 @@
|
||||
<div v-else>
|
||||
<q-checkbox :value="false" label="Onchain" disabled>
|
||||
<q-tooltip>
|
||||
Watch-Only extension MUST be activated and have a wallet
|
||||
Onchain Wallet (watch-only) extension MUST be activated and
|
||||
have a wallet
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
@@ -245,7 +356,7 @@
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialogCharge.data.onchainwallet"
|
||||
v-model="onchainwallet"
|
||||
:options="walletLinks"
|
||||
label="Onchain Wallet"
|
||||
/>
|
||||
@@ -283,49 +394,28 @@
|
||||
<!-- lnbits/static/vendor
|
||||
<script src="/vendor/vue-qrcode@1.0.2/vue-qrcode.min.js"></script> -->
|
||||
<style></style>
|
||||
<!-- todo: use config mempool -->
|
||||
<script src="https://mempool.space/mempool.js"></script>
|
||||
<script src="{{ url_for('satspay_static', path='js/utils.js') }}"></script>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var mapCharge = obj => {
|
||||
obj._data = _.clone(obj)
|
||||
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
|
||||
obj.time = obj.time + 'mins'
|
||||
|
||||
if (obj.time_elapsed) {
|
||||
obj.date = 'Time elapsed'
|
||||
} else {
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date((obj.theTime - 3600) * 1000),
|
||||
'HH:mm:ss'
|
||||
)
|
||||
}
|
||||
obj.displayUrl = ['/satspay/', obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
filter: '',
|
||||
watchonlyactive: false,
|
||||
balance: null,
|
||||
checker: null,
|
||||
walletLinks: [],
|
||||
ChargeLinks: [],
|
||||
ChargeLinksObj: [],
|
||||
chargeLinks: [],
|
||||
onchainwallet: '',
|
||||
currentaddress: '',
|
||||
Addresses: {
|
||||
show: false,
|
||||
data: null
|
||||
},
|
||||
rescanning: false,
|
||||
mempool: {
|
||||
endpoint: ''
|
||||
},
|
||||
|
||||
ChargesTable: {
|
||||
chargesTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'theId',
|
||||
@@ -340,10 +430,10 @@
|
||||
field: 'description'
|
||||
},
|
||||
{
|
||||
name: 'timeleft',
|
||||
name: 'timeLeft',
|
||||
align: 'left',
|
||||
label: 'Time left',
|
||||
field: 'date'
|
||||
field: 'timeLeft'
|
||||
},
|
||||
{
|
||||
name: 'time to pay',
|
||||
@@ -363,6 +453,12 @@
|
||||
label: 'Balance',
|
||||
field: 'balance'
|
||||
},
|
||||
{
|
||||
name: 'pendingBalance',
|
||||
align: 'left',
|
||||
label: 'Pending Balance',
|
||||
field: 'pendingBalance'
|
||||
},
|
||||
{
|
||||
name: 'onchain address',
|
||||
align: 'left',
|
||||
@@ -392,172 +488,218 @@
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
formDialogCharge: {
|
||||
show: false,
|
||||
data: {
|
||||
onchain: false,
|
||||
onchainwallet: '',
|
||||
lnbits: false,
|
||||
description: '',
|
||||
time: null,
|
||||
amount: null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancelCharge: function (data) {
|
||||
this.formDialogCharge.data.description = ''
|
||||
this.formDialogCharge.data.onchainwallet = ''
|
||||
this.formDialogCharge.data.lnbitswallet = ''
|
||||
this.formDialogCharge.data.time = null
|
||||
this.formDialogCharge.data.amount = null
|
||||
this.formDialogCharge.data.webhook = ''
|
||||
this.formDialogCharge.data.completelink = ''
|
||||
this.formDialogCharge.show = false
|
||||
},
|
||||
|
||||
getWalletLinks: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/wallet',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.walletLinks = data.map(w => ({
|
||||
id: w.id,
|
||||
label: w.title + ' - ' + w.id
|
||||
}))
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
|
||||
getWalletConfig: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/config',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.mempool.endpoint = data.mempool_endpoint
|
||||
const url = new URL(this.mempool.endpoint)
|
||||
this.mempool.hostname = url.hostname
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
getOnchainWalletName: function (walletId) {
|
||||
const wallet = this.walletLinks.find(w => w.id === walletId)
|
||||
if (!wallet) return 'unknown'
|
||||
return wallet.label
|
||||
},
|
||||
getLNbitsWalletName: function (walletId) {
|
||||
const wallet = this.g.user.walletOptions.find(w => w.value === walletId)
|
||||
if (!wallet) return 'unknown'
|
||||
return wallet.label
|
||||
},
|
||||
|
||||
getCharges: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.chargeLinks = data.map(c =>
|
||||
mapCharge(
|
||||
c,
|
||||
this.chargeLinks.find(old => old.id === c.id)
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
sendFormDataCharge: function () {
|
||||
const wallet = this.g.user.wallets[0].inkey
|
||||
const data = this.formDialogCharge.data
|
||||
data.amount = parseInt(data.amount)
|
||||
data.time = parseInt(data.time)
|
||||
data.onchainwallet = this.onchainwallet?.id
|
||||
this.createCharge(wallet, data)
|
||||
},
|
||||
refreshActiveChargesBalance: async function () {
|
||||
try {
|
||||
const activeLinkIds = this.chargeLinks
|
||||
.filter(c => !c.paid && !c.time_elapsed && !c.hasStaleBalance)
|
||||
.map(c => c.id)
|
||||
.join(',')
|
||||
if (activeLinkIds) {
|
||||
await LNbits.api.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges/balance/' + activeLinkIds,
|
||||
'filla'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
await this.getCharges()
|
||||
}
|
||||
},
|
||||
refreshBalance: async function (charge) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charge/balance/' + charge.id,
|
||||
'filla'
|
||||
)
|
||||
charge.balance = data.balance
|
||||
} catch (error) {}
|
||||
},
|
||||
rescanOnchainAddresses: async function () {
|
||||
if (this.rescanning) return
|
||||
this.rescanning = true
|
||||
|
||||
const {
|
||||
bitcoin: {addresses: addressesAPI}
|
||||
} = mempoolJS({hostname: this.mempool.hostname})
|
||||
|
||||
try {
|
||||
const onchainActiveCharges = this.chargeLinks.filter(
|
||||
c => c.onchainaddress && !c.paid && !c.time_elapsed
|
||||
)
|
||||
for (const charge of onchainActiveCharges) {
|
||||
const fn = async () =>
|
||||
addressesAPI.getAddressTxsUtxo({
|
||||
address: charge.onchainaddress
|
||||
})
|
||||
|
||||
const utxos = await retryWithDelay(fn)
|
||||
const newBalance = utxos.reduce((t, u) => t + u.value, 0)
|
||||
|
||||
charge.pendingBalance = utxos
|
||||
.filter(u => !u.status.confirmed)
|
||||
.reduce((t, u) => t + u.value, 0)
|
||||
|
||||
charge.hasStaleBalance = charge.balance === newBalance
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
this.rescanning = false
|
||||
}
|
||||
},
|
||||
createCharge: async function (wallet, data) {
|
||||
try {
|
||||
const resp = await LNbits.api.request(
|
||||
'POST',
|
||||
'/satspay/api/v1/charge',
|
||||
wallet,
|
||||
data
|
||||
)
|
||||
|
||||
this.chargeLinks.unshift(mapCharge(resp.data))
|
||||
this.formDialogCharge.show = false
|
||||
this.formDialogCharge.data = {
|
||||
onchain: false,
|
||||
lnbits: false,
|
||||
description: '',
|
||||
time: null,
|
||||
amount: null
|
||||
}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancelCharge: function (data) {
|
||||
var self = this
|
||||
self.formDialogCharge.data.description = ''
|
||||
self.formDialogCharge.data.onchainwallet = ''
|
||||
self.formDialogCharge.data.lnbitswallet = ''
|
||||
self.formDialogCharge.data.time = null
|
||||
self.formDialogCharge.data.amount = null
|
||||
self.formDialogCharge.data.webhook = ''
|
||||
self.formDialogCharge.data.completelink = ''
|
||||
self.formDialogCharge.show = false
|
||||
},
|
||||
|
||||
getWalletLinks: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/wallet',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
for (i = 0; i < response.data.length; i++) {
|
||||
self.walletLinks.push(response.data[i].id)
|
||||
}
|
||||
return
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = {
|
||||
is_unique: false
|
||||
}
|
||||
},
|
||||
openQrCodeDialog: function (linkId) {
|
||||
var self = this
|
||||
var getAddresses = this.getAddresses
|
||||
getAddresses(linkId)
|
||||
self.current = linkId
|
||||
self.Addresses.show = true
|
||||
},
|
||||
getCharges: function () {
|
||||
var self = this
|
||||
var getAddressBalance = this.getAddressBalance
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.ChargeLinks = response.data.map(mapCharge)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
sendFormDataCharge: function () {
|
||||
var self = this
|
||||
var wallet = this.g.user.wallets[0].inkey
|
||||
var data = this.formDialogCharge.data
|
||||
data.amount = parseInt(data.amount)
|
||||
data.time = parseInt(data.time)
|
||||
this.createCharge(wallet, data)
|
||||
},
|
||||
timerCount: function () {
|
||||
self = this
|
||||
var refreshIntervalId = setInterval(function () {
|
||||
for (i = 0; i < self.ChargeLinks.length - 1; i++) {
|
||||
if (self.ChargeLinks[i]['paid'] == 'True') {
|
||||
setTimeout(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges/balance/' +
|
||||
self.ChargeLinks[i]['id'],
|
||||
'filla'
|
||||
)
|
||||
.then(function (response) {})
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
self.getCharges()
|
||||
}, 20000)
|
||||
},
|
||||
createCharge: function (wallet, data) {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request('POST', '/satspay/api/v1/charge', wallet, data)
|
||||
.then(function (response) {
|
||||
self.ChargeLinks.push(mapCharge(response.data))
|
||||
self.formDialogCharge.show = false
|
||||
self.formDialogCharge.data = {
|
||||
onchain: false,
|
||||
lnbits: false,
|
||||
description: '',
|
||||
time: null,
|
||||
amount: null
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
|
||||
deleteChargeLink: function (chargeId) {
|
||||
var self = this
|
||||
var link = _.findWhere(this.ChargeLinks, {id: chargeId})
|
||||
const link = _.findWhere(this.chargeLinks, {id: chargeId})
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
.onOk(async () => {
|
||||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'DELETE',
|
||||
'/satspay/api/v1/charge/' + chargeId,
|
||||
self.g.user.wallets[0].adminkey
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.ChargeLinks = _.reject(self.ChargeLinks, function (obj) {
|
||||
return obj.id === chargeId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
|
||||
this.chargeLinks = _.reject(this.chargeLinks, function (obj) {
|
||||
return obj.id === chargeId
|
||||
})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
})
|
||||
},
|
||||
exportchargeCSV: function () {
|
||||
var self = this
|
||||
LNbits.utils.exportCSV(self.ChargesTable.columns, this.ChargeLinks)
|
||||
LNbits.utils.exportCSV(
|
||||
this.chargesTable.columns,
|
||||
this.chargeLinks,
|
||||
'charges'
|
||||
)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
console.log(this.g.user)
|
||||
var self = this
|
||||
var getCharges = this.getCharges
|
||||
getCharges()
|
||||
var getWalletLinks = this.getWalletLinks
|
||||
getWalletLinks()
|
||||
var timerCount = this.timerCount
|
||||
timerCount()
|
||||
created: async function () {
|
||||
await this.getCharges()
|
||||
await this.getWalletLinks()
|
||||
await this.getWalletConfig()
|
||||
setInterval(() => this.refreshActiveChargesBalance(), 10 * 2000)
|
||||
await this.rescanOnchainAddresses()
|
||||
setInterval(() => this.rescanOnchainAddresses(), 10 * 1000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -9,6 +9,7 @@ from starlette.responses import HTMLResponse
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.extensions.watchonly.crud import get_config
|
||||
|
||||
from . import satspay_ext, satspay_renderer
|
||||
from .crud import get_charge
|
||||
@@ -24,14 +25,24 @@ async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
|
||||
|
||||
@satspay_ext.get("/{charge_id}", response_class=HTMLResponse)
|
||||
async def display(request: Request, charge_id):
|
||||
async def display(request: Request, charge_id: str):
|
||||
charge = await get_charge(charge_id)
|
||||
if not charge:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist."
|
||||
)
|
||||
wallet = await get_wallet(charge.lnbitswallet)
|
||||
onchainwallet_config = await get_config(charge.user)
|
||||
inkey = wallet.inkey if wallet else None
|
||||
mempool_endpoint = (
|
||||
onchainwallet_config.mempool_endpoint if onchainwallet_config else None
|
||||
)
|
||||
return satspay_renderer().TemplateResponse(
|
||||
"satspay/display.html",
|
||||
{"request": request, "charge": charge, "wallet_key": wallet.inkey},
|
||||
{
|
||||
"request": request,
|
||||
"charge_data": charge.dict(),
|
||||
"wallet_inkey": inkey,
|
||||
"mempool_endpoint": mempool_endpoint,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
import httpx
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
@@ -31,7 +30,12 @@ async def api_charge_create(
|
||||
data: CreateCharge, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||
):
|
||||
charge = await create_charge(user=wallet.wallet.user, data=data)
|
||||
return charge.dict()
|
||||
return {
|
||||
**charge.dict(),
|
||||
**{"time_elapsed": charge.time_elapsed},
|
||||
**{"time_left": charge.time_left},
|
||||
**{"paid": charge.paid},
|
||||
}
|
||||
|
||||
|
||||
@satspay_ext.put("/api/v1/charge/{charge_id}")
|
||||
@@ -51,6 +55,7 @@ async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
{
|
||||
**charge.dict(),
|
||||
**{"time_elapsed": charge.time_elapsed},
|
||||
**{"time_left": charge.time_left},
|
||||
**{"paid": charge.paid},
|
||||
}
|
||||
for charge in await get_charges(wallet.wallet.user)
|
||||
@@ -73,6 +78,7 @@ async def api_charge_retrieve(
|
||||
return {
|
||||
**charge.dict(),
|
||||
**{"time_elapsed": charge.time_elapsed},
|
||||
**{"time_left": charge.time_left},
|
||||
**{"paid": charge.paid},
|
||||
}
|
||||
|
||||
@@ -93,9 +99,18 @@ async def api_charge_delete(charge_id, wallet: WalletTypeInfo = Depends(get_key_
|
||||
#############################BALANCE##########################
|
||||
|
||||
|
||||
@satspay_ext.get("/api/v1/charges/balance/{charge_id}")
|
||||
async def api_charges_balance(charge_id):
|
||||
@satspay_ext.get("/api/v1/charges/balance/{charge_ids}")
|
||||
async def api_charges_balance(charge_ids):
|
||||
charge_id_list = charge_ids.split(",")
|
||||
charges = []
|
||||
for charge_id in charge_id_list:
|
||||
charge = await api_charge_balance(charge_id)
|
||||
charges.append(charge)
|
||||
return charges
|
||||
|
||||
|
||||
@satspay_ext.get("/api/v1/charge/balance/{charge_id}")
|
||||
async def api_charge_balance(charge_id):
|
||||
charge = await check_address_balance(charge_id)
|
||||
|
||||
if not charge:
|
||||
@@ -125,23 +140,9 @@ async def api_charges_balance(charge_id):
|
||||
)
|
||||
except AssertionError:
|
||||
charge.webhook = None
|
||||
return charge.dict()
|
||||
|
||||
|
||||
#############################MEMPOOL##########################
|
||||
|
||||
|
||||
@satspay_ext.put("/api/v1/mempool")
|
||||
async def api_update_mempool(
|
||||
endpoint: str = Query(...), wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
mempool = await update_mempool(endpoint, user=wallet.wallet.user)
|
||||
return mempool.dict()
|
||||
|
||||
|
||||
@satspay_ext.route("/api/v1/mempool")
|
||||
async def api_get_mempool(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
mempool = await get_mempool(wallet.wallet.user)
|
||||
if not mempool:
|
||||
mempool = await create_mempool(user=wallet.wallet.user)
|
||||
return mempool.dict()
|
||||
return {
|
||||
**charge.dict(),
|
||||
**{"time_elapsed": charge.time_elapsed},
|
||||
**{"time_left": charge.time_left},
|
||||
**{"paid": charge.paid},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Scrub
|
||||
|
||||
## Automatically forward funds (Scrub) that get paid to the wallet to an LNURLpay or Lightning Address
|
||||
|
||||
SCRUB is a small but handy extension that allows a user to take advantage of all the functionalities inside **LNbits** and upon a payment received to your LNbits wallet, automatically forward it to your desired wallet via LNURL or LNAddress!
|
||||
|
||||
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
|
||||
|
||||
## Usage
|
||||
|
||||
1. Create an scrub (New Scrub link)\
|
||||

|
||||
|
||||
- select the wallet to be _scrubbed_
|
||||
- make a small description
|
||||
- enter either an LNURL pay or a lightning address
|
||||
|
||||
Make sure your LNURL or LNaddress is correct!
|
||||
|
||||
2. A new scrub will show on the _Scrub links_ section\
|
||||

|
||||
|
||||
- only one scrub can be created for each wallet!
|
||||
- You can _edit_ or _delete_ the Scrub at any time\
|
||||

|
||||
|
||||
3. On your wallet, you'll see a transaction of a payment received and another right after it as apayment sent, marked with **#scrubed**\
|
||||

|
||||
@@ -0,0 +1,34 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_scrub")
|
||||
|
||||
scrub_static_files = [
|
||||
{
|
||||
"path": "/scrub/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/scrub/static"),
|
||||
"name": "scrub_static",
|
||||
}
|
||||
]
|
||||
|
||||
scrub_ext: APIRouter = APIRouter(prefix="/scrub", tags=["scrub"])
|
||||
|
||||
|
||||
def scrub_renderer():
|
||||
return template_renderer(["lnbits/extensions/scrub/templates"])
|
||||
|
||||
|
||||
from .tasks import wait_for_paid_invoices
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
|
||||
|
||||
def scrub_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Scrub",
|
||||
"short_description": "Pass payments to LNURLp/LNaddress",
|
||||
"icon": "send",
|
||||
"contributors": ["arcbtc", "talvasconcelos"]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import CreateScrubLink, ScrubLink
|
||||
|
||||
|
||||
async def create_scrub_link(data: CreateScrubLink) -> ScrubLink:
|
||||
scrub_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO scrub.scrub_links (
|
||||
id,
|
||||
wallet,
|
||||
description,
|
||||
payoraddress
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
scrub_id,
|
||||
data.wallet,
|
||||
data.description,
|
||||
data.payoraddress,
|
||||
),
|
||||
)
|
||||
link = await get_scrub_link(scrub_id)
|
||||
assert link, "Newly created link couldn't be retrieved"
|
||||
return link
|
||||
|
||||
|
||||
async def get_scrub_link(link_id: str) -> Optional[ScrubLink]:
|
||||
row = await db.fetchone("SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,))
|
||||
return ScrubLink(**row) if row else None
|
||||
|
||||
|
||||
async def get_scrub_links(wallet_ids: Union[str, List[str]]) -> List[ScrubLink]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"""
|
||||
SELECT * FROM scrub.scrub_links WHERE wallet IN ({q})
|
||||
ORDER BY id
|
||||
""",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
return [ScrubLink(**row) for row in rows]
|
||||
|
||||
|
||||
async def update_scrub_link(link_id: int, **kwargs) -> Optional[ScrubLink]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE scrub.scrub_links SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), link_id),
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,))
|
||||
return ScrubLink(**row) if row else None
|
||||
|
||||
|
||||
async def delete_scrub_link(link_id: int) -> None:
|
||||
await db.execute("DELETE FROM scrub.scrub_links WHERE id = ?", (link_id,))
|
||||
|
||||
|
||||
async def get_scrub_by_wallet(wallet_id) -> Optional[ScrubLink]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * from scrub.scrub_links WHERE wallet = ?",
|
||||
(wallet_id,),
|
||||
)
|
||||
return ScrubLink(**row) if row else None
|
||||
|
||||
|
||||
async def unique_scrubed_wallet(wallet_id):
|
||||
(row,) = await db.fetchone(
|
||||
"SELECT COUNT(wallet) FROM scrub.scrub_links WHERE wallet = ?",
|
||||
(wallet_id,),
|
||||
)
|
||||
return row
|
||||
@@ -0,0 +1,14 @@
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial scrub table.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE scrub.scrub_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
payoraddress TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
from sqlite3 import Row
|
||||
|
||||
from pydantic import BaseModel
|
||||
from starlette.requests import Request
|
||||
|
||||
from lnbits.lnurl import encode as lnurl_encode # type: ignore
|
||||
|
||||
|
||||
class CreateScrubLink(BaseModel):
|
||||
wallet: str
|
||||
description: str
|
||||
payoraddress: str
|
||||
|
||||
|
||||
class ScrubLink(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
description: str
|
||||
payoraddress: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "ScrubLink":
|
||||
data = dict(row)
|
||||
return cls(**data)
|
||||
|
||||
def lnurl(self, req: Request) -> str:
|
||||
url = req.url_for("scrub.api_lnurl_response", link_id=self.id)
|
||||
return lnurl_encode(url)
|
||||
@@ -0,0 +1,143 @@
|
||||
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var locationPath = [
|
||||
window.location.protocol,
|
||||
'//',
|
||||
window.location.host,
|
||||
window.location.pathname
|
||||
].join('')
|
||||
|
||||
var mapScrubLink = obj => {
|
||||
obj._data = _.clone(obj)
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
||||
obj.print_url = [locationPath, 'print/', obj.id].join('')
|
||||
obj.pay_url = [locationPath, obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
checker: null,
|
||||
payLinks: [],
|
||||
payLinksTable: {
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getScrubLinks() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/scrub/api/v1/links?all_wallets=true',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(response => {
|
||||
this.payLinks = response.data.map(mapScrubLink)
|
||||
})
|
||||
.catch(err => {
|
||||
clearInterval(this.checker)
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
closeFormDialog() {
|
||||
this.resetFormData()
|
||||
},
|
||||
openUpdateDialog(linkId) {
|
||||
const link = _.findWhere(this.payLinks, {id: linkId})
|
||||
|
||||
this.formDialog.data = _.clone(link._data)
|
||||
this.formDialog.show = true
|
||||
},
|
||||
sendFormData() {
|
||||
const wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
let data = Object.freeze(this.formDialog.data)
|
||||
console.log(wallet, data)
|
||||
|
||||
if (data.id) {
|
||||
this.updateScrubLink(wallet, data)
|
||||
} else {
|
||||
this.createScrubLink(wallet, data)
|
||||
}
|
||||
},
|
||||
resetFormData() {
|
||||
this.formDialog = {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
},
|
||||
updateScrubLink(wallet, data) {
|
||||
LNbits.api
|
||||
.request('PUT', '/scrub/api/v1/links/' + data.id, wallet.adminkey, data)
|
||||
.then(response => {
|
||||
this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id)
|
||||
this.payLinks.push(mapScrubLink(response.data))
|
||||
this.formDialog.show = false
|
||||
this.resetFormData()
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
createScrubLink(wallet, data) {
|
||||
LNbits.api
|
||||
.request('POST', '/scrub/api/v1/links', wallet.adminkey, data)
|
||||
.then(response => {
|
||||
console.log('RES', response)
|
||||
this.getScrubLinks()
|
||||
this.formDialog.show = false
|
||||
this.resetFormData()
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
deleteScrubLink(linkId) {
|
||||
var link = _.findWhere(this.payLinks, {id: linkId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/scrub/api/v1/links/' + linkId,
|
||||
_.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
|
||||
)
|
||||
.then(response => {
|
||||
this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId)
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.g.user.wallets.length) {
|
||||
var getScrubLinks = this.getScrubLinks
|
||||
getScrubLinks()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import asyncio
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import pay_invoice
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import get_scrub_by_wallet
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
invoice_queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue)
|
||||
|
||||
while True:
|
||||
payment = await invoice_queue.get()
|
||||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
# (avoid loops)
|
||||
if "scrubed" == payment.extra.get("tag"):
|
||||
# already scrubbed
|
||||
return
|
||||
|
||||
scrub_link = await get_scrub_by_wallet(payment.wallet_id)
|
||||
|
||||
if not scrub_link:
|
||||
return
|
||||
|
||||
from lnbits.core.views.api import api_lnurlscan
|
||||
|
||||
# DECODE LNURLP OR LNADDRESS
|
||||
data = await api_lnurlscan(scrub_link.payoraddress)
|
||||
|
||||
# I REALLY HATE THIS DUPLICATION OF CODE!! CORE/VIEWS/API.PY, LINE 267
|
||||
domain = urlparse(data["callback"]).netloc
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.get(
|
||||
data["callback"],
|
||||
params={"amount": payment.amount},
|
||||
timeout=40,
|
||||
)
|
||||
if r.is_error:
|
||||
raise httpx.ConnectError
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Failed to connect to {domain}.",
|
||||
)
|
||||
|
||||
params = json.loads(r.text)
|
||||
if params.get("status") == "ERROR":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} said: '{params.get('reason', '')}'",
|
||||
)
|
||||
|
||||
invoice = bolt11.decode(params["pr"])
|
||||
if invoice.amount_msat != payment.amount:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} returned an invalid invoice. Expected {payment.amount} msat, got {invoice.amount_msat}.",
|
||||
)
|
||||
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=payment.wallet_id,
|
||||
payment_request=params["pr"],
|
||||
description=data["description"],
|
||||
extra={"tag": "scrubed"},
|
||||
)
|
||||
|
||||
return {
|
||||
"payment_hash": payment_hash,
|
||||
# maintain backwards compatibility with API clients:
|
||||
"checking_id": payment_hash,
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-expansion-item group="api" dense expand-separator label="List scrubs">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">GET</span> /scrub/api/v1/links</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<pay_link_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}scrub/api/v1/links?all_wallets=true
|
||||
-H "X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Get a scrub">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/scrub/api/v1/links/<scrub_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"id": <string>, "wallet": <string>, "description":
|
||||
<string>, "payoraddress": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}scrub/api/v1/links/<pay_id>
|
||||
-H "X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Create a scrub">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-green">POST</span> /scrub/api/v1/links</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"wallet": <string>, "description": <string>,
|
||||
"payoraddress": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"id": <string>, "wallet": <string>, "description":
|
||||
<string>, "payoraddress": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}scrub/api/v1/links -d '{"wallet":
|
||||
<string>, "description": <string>, "payoraddress":
|
||||
<string>}' -H "Content-type: application/json" -H "X-Api-Key: {{
|
||||
user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Update a scrub">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">PUT</span>
|
||||
/scrub/api/v1/links/<pay_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"wallet": <string>, "description": <string>,
|
||||
"payoraddress": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"id": <string>, "wallet": <string>, "description":
|
||||
<string>, "payoraddress": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X PUT {{ request.base_url }}scrub/api/v1/links/<pay_id>
|
||||
-d '{"wallet": <string>, "description": <string>,
|
||||
"payoraddress": <string>}' -H "Content-type: application/json"
|
||||
-H "X-Api-Key: {{ user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Delete a scrub"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/scrub/api/v1/links/<pay_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.base_url
|
||||
}}scrub/api/v1/links/<pay_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,28 @@
|
||||
<q-expansion-item group="extras" icon="info" label="Powered by LNURL">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
<b>WARNING: LNURL must be used over https or TOR</b><br />
|
||||
LNURL is a range of lightning-network standards that allow us to use
|
||||
lightning-network differently. An LNURL-pay is a link that wallets use
|
||||
to fetch an invoice from a server on-demand. The link or QR code is
|
||||
fixed, but each time it is read by a compatible wallet a new QR code is
|
||||
issued by the service. It can be used to activate machines without them
|
||||
having to maintain an electronic screen to generate and show invoices
|
||||
locally, or to sell any predefined good or service automatically.
|
||||
</p>
|
||||
<p>
|
||||
Exploring LNURL and finding use cases, is really helping inform
|
||||
lightning protocol development, rather than the protocol dictating how
|
||||
lightning-network should be engaged with.
|
||||
</p>
|
||||
<small
|
||||
>Check
|
||||
<a href="https://github.com/fiatjaf/awesome-lnurl" target="_blank"
|
||||
>Awesome LNURL</a
|
||||
>
|
||||
for further information.</small
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,140 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn unelevated color="primary" @click="formDialog.show = true"
|
||||
>New scrub link</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Scrub links</h5>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="payLinks"
|
||||
row-key="id"
|
||||
:pagination.sync="payLinksTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props" style="text-align: left">
|
||||
<q-th>Wallet</q-th>
|
||||
<q-th>Description</q-th>
|
||||
<q-th>LNURLPay/Address</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td>{{ props.row.wallet }}</q-td>
|
||||
<q-td>{{ props.row.description }}</q-td>
|
||||
<q-td>{{ props.row.payoraddress }}</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteScrubLink(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">{{SITE_TITLE}} Scrub extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
{% include "scrub/_api_docs.html" %}
|
||||
<q-separator></q-separator>
|
||||
{% include "scrub/_lnurl.html" %}
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.show" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.description"
|
||||
type="text"
|
||||
label="Description *"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.payoraddress"
|
||||
type="text"
|
||||
label="LNURLPay or LNAdress *"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update pay link</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="
|
||||
formDialog.data.wallet == null ||
|
||||
formDialog.data.description == null ||
|
||||
formDialog.data.payoraddress == null
|
||||
"
|
||||
type="submit"
|
||||
>Create pay link</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="/scrub/static/js/index.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
from fastapi import Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
||||
from . import scrub_ext, scrub_renderer
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@scrub_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
return scrub_renderer().TemplateResponse(
|
||||
"scrub/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
|
||||
from . import scrub_ext
|
||||
from .crud import (
|
||||
create_scrub_link,
|
||||
delete_scrub_link,
|
||||
get_scrub_link,
|
||||
get_scrub_links,
|
||||
unique_scrubed_wallet,
|
||||
update_scrub_link,
|
||||
)
|
||||
from .models import CreateScrubLink
|
||||
|
||||
|
||||
@scrub_ext.get("/api/v1/links", status_code=HTTPStatus.OK)
|
||||
async def api_links(
|
||||
req: Request,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
all_wallets: bool = Query(False),
|
||||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||
|
||||
try:
|
||||
return [link.dict() for link in await get_scrub_links(wallet_ids)]
|
||||
|
||||
except:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="No SCRUB links made yet",
|
||||
)
|
||||
|
||||
|
||||
@scrub_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||
async def api_link_retrieve(
|
||||
r: Request, link_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
link = await get_scrub_link(link_id)
|
||||
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
detail="Scrub link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
if link.wallet != wallet.wallet.id:
|
||||
raise HTTPException(
|
||||
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||
)
|
||||
|
||||
return link
|
||||
|
||||
|
||||
@scrub_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED)
|
||||
@scrub_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||
async def api_scrub_create_or_update(
|
||||
data: CreateScrubLink,
|
||||
link_id=None,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
if link_id:
|
||||
link = await get_scrub_link(link_id)
|
||||
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
detail="Scrub link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
if link.wallet != wallet.wallet.id:
|
||||
raise HTTPException(
|
||||
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||
)
|
||||
|
||||
link = await update_scrub_link(**data.dict(), link_id=link_id)
|
||||
else:
|
||||
wallet_has_scrub = await unique_scrubed_wallet(wallet_id=data.wallet)
|
||||
if wallet_has_scrub > 0:
|
||||
raise HTTPException(
|
||||
detail="Wallet is already being Scrubbed",
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
link = await create_scrub_link(data=data)
|
||||
|
||||
return link
|
||||
|
||||
|
||||
@scrub_ext.delete("/api/v1/links/{link_id}")
|
||||
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
|
||||
link = await get_scrub_link(link_id)
|
||||
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
detail="Scrub link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
if link.wallet != wallet.wallet.id:
|
||||
raise HTTPException(
|
||||
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||
)
|
||||
|
||||
await delete_scrub_link(link_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
@@ -12,7 +12,7 @@ db = Database("ext_splitpayments")
|
||||
splitpayments_static_files = [
|
||||
{
|
||||
"path": "/splitpayments/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/splitpayments/static"),
|
||||
"app": StaticFiles(packages=[("lnbits", "extensions/splitpayments/static")]),
|
||||
"name": "splitpayments_static",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -28,6 +28,12 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn
|
||||
flat
|
||||
label="Swagger API"
|
||||
type="a"
|
||||
href="../docs#/splitpayments"
|
||||
></q-btn>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
|
||||
@@ -18,7 +18,7 @@ In the "Whitelist Users" field, input the username of a Twitch account you contr
|
||||
For now, simply set the "Redirect URI" to `http://localhost`, you will change this soon.
|
||||
Then, hit create:
|
||||

|
||||
1. In LNbits, enable the Stream Alerts extension and optionally the SatsPayServer (to observe donations directly) and Watch Only (to accept on-chain donations) extenions:
|
||||
1. In LNbits, enable the Stream Alerts extension and optionally the SatsPayServer (to observe donations directly) and Onchain Wallet (watch-only) (to accept on-chain donations) extenions:
|
||||

|
||||
1. Create a "NEW SERVICE" using the button. Fill in all the information (you get your Client ID and Secret from the Streamlabs App page):
|
||||

|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user