Merge branch 'main' into diagon-alley

This commit is contained in:
Tiago vasconcelos
2022-08-16 09:02:06 +01:00
237 changed files with 16064 additions and 26363 deletions
+1 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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))
+32
View File
@@ -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]
})
+1 -1
View File
@@ -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)
+14 -1
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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",
},
)
+38 -27
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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",
}
]
+3 -5
View File
@@ -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>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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">
+20 -21
View File
@@ -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")
+19
View File
@@ -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"\
![create new invoice](https://imgur.com/a/Dce3wrr.png)
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\
![invoice link](https://imgur.com/a/L0JOj4T.png)
+36
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Invoices",
"short_description": "Create invoices for your clients.",
"icon": "request_quote",
"contributors": ["leesalminen"]
}
+206
View File
@@ -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
+55
View File
@@ -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)
);
"""
)
+104
View File
@@ -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;
}
}
+51
View File
@@ -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": &lt;invoice_key&gt;}</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>[&lt;invoice_object&gt;, ...]</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: &lt;invoice_key&gt;"
</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": &lt;invoice_key&gt;}</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:
&lt;invoice_key&gt;"
</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": &lt;invoice_key&gt;}</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: &lt;invoice_key&gt;"
</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": &lt;invoice_key&gt;}</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:
&lt;invoice_key&gt;"
</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:
&lt;invoice_key&gt;"
</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: &lt;invoice_key&gt;"
</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 %}
+59
View File
@@ -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,
},
)
+136
View File
@@ -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
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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",
}
]
+1 -3
View File
@@ -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
+3 -5
View File
@@ -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>
+2 -2
View File
@@ -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)
+1 -3
View File
@@ -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
+1 -1
View File
@@ -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",
}
]
+1 -3
View File
@@ -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"
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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",
}
]
+16 -8
View File
@@ -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):
+1 -3
View File
@@ -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;"
)
+10 -1
View File
@@ -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
+14 -3
View File
@@ -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>
+2 -2
View File
@@ -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}
+1 -3
View File
@@ -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) {
+9
View File
@@ -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"])
+8 -6
View File
@@ -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
+9 -5
View File
@@ -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": &lt;admin_key&gt;}</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>[&lt;charge_object&gt;, ...]</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": &lt;string, watchonly_wallet_id&gt;,
"description": &lt;string&gt;, "webhook":&lt;string&gt;, "time":
&lt;integer&gt;, "amount": &lt;integer&gt;, "lnbitswallet":
&lt;string, lnbits_wallet_id&gt;}' -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/&lt;charge_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</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>[&lt;charge_object&gt;, ...]</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/&lt;charge_id&gt; -d '{"onchainwallet":
&lt;string, watchonly_wallet_id&gt;, "description": &lt;string&gt;,
"webhook":&lt;string&gt;, "time": &lt;integer&gt;, "amount":
&lt;integer&gt;, "lnbitswallet": &lt;string, lnbits_wallet_id&gt;}'
-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/&lt;charge_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</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>[&lt;charge_object&gt;, ...]</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/&lt;charge_id&gt; -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": &lt;invoice_key&gt;}</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>[&lt;charge_object&gt;, ...]</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/&lt;charge_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</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/&lt;charge_id&gt; -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/&lt;charge_id&gt;</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>[&lt;charge_object&gt;, ...]</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/&lt;charge_id&gt; -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>
+13 -2
View File
@@ -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,
},
)
+25 -24
View File
@@ -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},
}
+28
View File
@@ -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)\
![create scrub](https://i.imgur.com/LUeNkzM.jpg)
- 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\
![scrub](https://i.imgur.com/LNoFkeu.jpg)
- only one scrub can be created for each wallet!
- You can _edit_ or _delete_ the Scrub at any time\
![edit scrub](https://i.imgur.com/Qu65lGG.jpg)
3. On your wallet, you'll see a transaction of a payment received and another right after it as apayment sent, marked with **#scrubed**\
![wallet view](https://i.imgur.com/S6EWWCP.jpg)
+34
View File
@@ -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))
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Scrub",
"short_description": "Pass payments to LNURLp/LNaddress",
"icon": "send",
"contributors": ["arcbtc", "talvasconcelos"]
}
+80
View File
@@ -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
+14
View File
@@ -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
);
"""
)
+28
View File
@@ -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)
+143
View File
@@ -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()
}
}
})
+85
View File
@@ -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": &lt;invoice_key&gt;}</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>[&lt;pay_link_object&gt;, ...]</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/&lt;scrub_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</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": &lt;string&gt;, "wallet": &lt;string&gt;, "description":
&lt;string&gt;, "payoraddress": &lt;string&gt;}</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/&lt;pay_id&gt;
-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": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"wallet": &lt;string&gt;, "description": &lt;string&gt;,
"payoraddress": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"id": &lt;string&gt;, "wallet": &lt;string&gt;, "description":
&lt;string&gt;, "payoraddress": &lt;string&gt;}</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":
&lt;string&gt;, "description": &lt;string&gt;, "payoraddress":
&lt;string&gt;}' -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/&lt;pay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"wallet": &lt;string&gt;, "description": &lt;string&gt;,
"payoraddress": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code
>{"id": &lt;string&gt;, "wallet": &lt;string&gt;, "description":
&lt;string&gt;, "payoraddress": &lt;string&gt;}</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/&lt;pay_id&gt;
-d '{"wallet": &lt;string&gt;, "description": &lt;string&gt;,
"payoraddress": &lt;string&gt;}' -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/&lt;pay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</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/&lt;pay_id&gt; -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 %}
+18
View File
@@ -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()}
)
+112
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
![image](https://user-images.githubusercontent.com/28876473/127759264-ae91539a-5694-4096-a478-80eb02b7b594.png)
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:
![image](https://user-images.githubusercontent.com/28876473/127759486-0e3420c2-c498-4bf9-932e-0abfa17bd478.png)
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):
![image](https://user-images.githubusercontent.com/28876473/127759512-8e8b4e90-2a64-422a-bf0a-5508d0630bed.png)

Some files were not shown because too many files have changed in this diff Show More