Merge branch 'main' into secure_communication
This commit is contained in:
+31
-11
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
import warnings
|
||||
@@ -75,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."},
|
||||
@@ -101,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."
|
||||
)
|
||||
@@ -185,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}
|
||||
)
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
+3
-1
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -141,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)
|
||||
|
||||
|
||||
+15
-8
@@ -21,7 +21,7 @@ from lnbits.decorators import (
|
||||
)
|
||||
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
|
||||
@@ -54,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,
|
||||
@@ -65,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.")
|
||||
@@ -156,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.")
|
||||
|
||||
@@ -182,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(
|
||||
@@ -196,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)
|
||||
@@ -337,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:
|
||||
@@ -359,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))
|
||||
|
||||
@@ -668,7 +668,17 @@ new Vue({
|
||||
})
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(this.paymentsTable.columns, this.payments)
|
||||
// status is important for export but it is not in paymentsTable
|
||||
// because it is manually added with payment detail link and icons
|
||||
// and would cause duplication in the list
|
||||
let columns = this.paymentsTable.columns
|
||||
columns.unshift({
|
||||
name: 'pending',
|
||||
align: 'left',
|
||||
label: 'Pending',
|
||||
field: 'pending'
|
||||
})
|
||||
LNbits.utils.exportCSV(columns, this.payments)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
||||
+26
-11
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
from binascii import unhexlify
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
@@ -48,7 +48,7 @@ from ..crud import (
|
||||
from ..services import (
|
||||
InvoiceFailure,
|
||||
PaymentFailure,
|
||||
check_invoice_status,
|
||||
check_transaction_status,
|
||||
create_invoice,
|
||||
pay_invoice,
|
||||
perform_lnurlauth,
|
||||
@@ -123,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(
|
||||
@@ -141,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
|
||||
@@ -151,10 +152,28 @@ class CreateInvoiceData(BaseModel):
|
||||
|
||||
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
if data.description_hash:
|
||||
description_hash = unhexlify(data.description_hash)
|
||||
try:
|
||||
description_hash = binascii.unhexlify(data.description_hash)
|
||||
except binascii.Error:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'description_hash' must be a valid hex string",
|
||||
)
|
||||
unhashed_description = b""
|
||||
memo = ""
|
||||
elif data.unhashed_description:
|
||||
try:
|
||||
unhashed_description = binascii.unhexlify(data.unhashed_description)
|
||||
except binascii.Error:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'unhashed_description' must be a valid hex string",
|
||||
)
|
||||
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)
|
||||
@@ -170,6 +189,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 +206,6 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
if data.lnurl_callback:
|
||||
if data.lnurl_balance_check is not None:
|
||||
await save_balance_check(wallet.id, data.lnurl_balance_check)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="lnurl_balance_check not set.",
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
@@ -267,7 +282,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.",
|
||||
)
|
||||
|
||||
@@ -407,7 +422,7 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(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
|
||||
)
|
||||
|
||||
@@ -148,7 +148,9 @@ async def wallet(
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
logger.debug(f"Access wallet {wallet_name}{'of user '+ user.id if user else ''}")
|
||||
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(
|
||||
|
||||
+22
-5
@@ -130,10 +130,13 @@ async def get_key_type(
|
||||
# 2: invalid
|
||||
pathname = r["path"].split("/")[1]
|
||||
|
||||
if not api_key_header and not api_key_query:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
|
||||
token = api_key_header or api_key_query
|
||||
|
||||
token = api_key_header if api_key_header else api_key_query
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
try:
|
||||
admin_checker = WalletAdminKeyChecker(api_key=token)
|
||||
@@ -180,7 +183,14 @@ async def require_admin_key(
|
||||
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 not token:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Admin key required.",
|
||||
)
|
||||
|
||||
wallet = await get_key_type(r, token)
|
||||
|
||||
@@ -199,7 +209,14 @@ async def require_invoice_key(
|
||||
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 not token:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
wallet = await get_key_type(r, token)
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ async def lnurl_callback(
|
||||
wallet_id=cp.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=cp.lnurl_title,
|
||||
description_hash=(
|
||||
unhashed_description=(
|
||||
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
|
||||
).encode("utf-8"),
|
||||
extra={"tag": "copilot", "copilotid": cp.id, "comment": comment},
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Invoices
|
||||
|
||||
## Create invoices that you can send to your client to pay online over Lightning.
|
||||
|
||||
This extension allows users to create "traditional" invoices (not in the lightning sense) that contain one or more line items. Line items are denominated in a user-configurable fiat currency. Each invoice contains one or more payments up to the total of the invoice. Each invoice creates a public link that can be shared with a customer that they can use to (partially or in full) pay the invoice.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Create an invoice by clicking "NEW INVOICE"\
|
||||

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

|
||||
@@ -0,0 +1,36 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_invoices")
|
||||
|
||||
invoices_static_files = [
|
||||
{
|
||||
"path": "/invoices/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/invoices/static"),
|
||||
"name": "invoices_static",
|
||||
}
|
||||
]
|
||||
|
||||
invoices_ext: APIRouter = APIRouter(prefix="/invoices", tags=["invoices"])
|
||||
|
||||
|
||||
def invoices_renderer():
|
||||
return template_renderer(["lnbits/extensions/invoices/templates"])
|
||||
|
||||
|
||||
from .tasks import wait_for_paid_invoices
|
||||
|
||||
|
||||
def invoices_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||
|
||||
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Invoices",
|
||||
"short_description": "Create invoices for your clients.",
|
||||
"icon": "request_quote",
|
||||
"contributors": ["leesalminen"]
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import (
|
||||
CreateInvoiceData,
|
||||
CreateInvoiceItemData,
|
||||
CreatePaymentData,
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
Payment,
|
||||
UpdateInvoiceData,
|
||||
UpdateInvoiceItemData,
|
||||
)
|
||||
|
||||
|
||||
async def get_invoice(invoice_id: str) -> Optional[Invoice]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM invoices.invoices WHERE id = ?", (invoice_id,)
|
||||
)
|
||||
return Invoice.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_invoice_items(invoice_id: str) -> List[InvoiceItem]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM invoices.invoice_items WHERE invoice_id = ?", (invoice_id,)
|
||||
)
|
||||
|
||||
return [InvoiceItem.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def get_invoice_item(item_id: str) -> InvoiceItem:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM invoices.invoice_items WHERE id = ?", (item_id,)
|
||||
)
|
||||
return InvoiceItem.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_invoice_total(items: List[InvoiceItem]) -> int:
|
||||
return sum(item.amount for item in items)
|
||||
|
||||
|
||||
async def get_invoices(wallet_ids: Union[str, List[str]]) -> List[Invoice]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM invoices.invoices WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Invoice.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def get_invoice_payments(invoice_id: str) -> List[Payment]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM invoices.payments WHERE invoice_id = ?", (invoice_id,)
|
||||
)
|
||||
|
||||
return [Payment.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def get_invoice_payment(payment_id: str) -> Payment:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM invoices.payments WHERE id = ?", (payment_id,)
|
||||
)
|
||||
return Payment.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_payments_total(payments: List[Payment]) -> int:
|
||||
return sum(item.amount for item in payments)
|
||||
|
||||
|
||||
async def create_invoice_internal(wallet_id: str, data: CreateInvoiceData) -> Invoice:
|
||||
invoice_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO invoices.invoices (id, wallet, status, currency, company_name, first_name, last_name, email, phone, address)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
invoice_id,
|
||||
wallet_id,
|
||||
data.status,
|
||||
data.currency,
|
||||
data.company_name,
|
||||
data.first_name,
|
||||
data.last_name,
|
||||
data.email,
|
||||
data.phone,
|
||||
data.address,
|
||||
),
|
||||
)
|
||||
|
||||
invoice = await get_invoice(invoice_id)
|
||||
assert invoice, "Newly created invoice couldn't be retrieved"
|
||||
return invoice
|
||||
|
||||
|
||||
async def create_invoice_items(
|
||||
invoice_id: str, data: List[CreateInvoiceItemData]
|
||||
) -> List[InvoiceItem]:
|
||||
for item in data:
|
||||
item_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO invoices.invoice_items (id, invoice_id, description, amount)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
item_id,
|
||||
invoice_id,
|
||||
item.description,
|
||||
int(item.amount * 100),
|
||||
),
|
||||
)
|
||||
|
||||
invoice_items = await get_invoice_items(invoice_id)
|
||||
return invoice_items
|
||||
|
||||
|
||||
async def update_invoice_internal(wallet_id: str, data: UpdateInvoiceData) -> Invoice:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE invoices.invoices
|
||||
SET wallet = ?, currency = ?, status = ?, company_name = ?, first_name = ?, last_name = ?, email = ?, phone = ?, address = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
wallet_id,
|
||||
data.currency,
|
||||
data.status,
|
||||
data.company_name,
|
||||
data.first_name,
|
||||
data.last_name,
|
||||
data.email,
|
||||
data.phone,
|
||||
data.address,
|
||||
data.id,
|
||||
),
|
||||
)
|
||||
|
||||
invoice = await get_invoice(data.id)
|
||||
assert invoice, "Newly updated invoice couldn't be retrieved"
|
||||
return invoice
|
||||
|
||||
|
||||
async def update_invoice_items(
|
||||
invoice_id: str, data: List[UpdateInvoiceItemData]
|
||||
) -> List[InvoiceItem]:
|
||||
updated_items = []
|
||||
for item in data:
|
||||
if item.id:
|
||||
updated_items.append(item.id)
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE invoices.invoice_items
|
||||
SET description = ?, amount = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(item.description, int(item.amount * 100), item.id),
|
||||
)
|
||||
|
||||
placeholders = ",".join("?" for i in range(len(updated_items)))
|
||||
if not placeholders:
|
||||
placeholders = "?"
|
||||
updated_items = ("skip",)
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
DELETE FROM invoices.invoice_items
|
||||
WHERE invoice_id = ?
|
||||
AND id NOT IN ({placeholders})
|
||||
""",
|
||||
(
|
||||
invoice_id,
|
||||
*tuple(updated_items),
|
||||
),
|
||||
)
|
||||
|
||||
for item in data:
|
||||
if not item.id:
|
||||
await create_invoice_items(invoice_id=invoice_id, data=[item])
|
||||
|
||||
invoice_items = await get_invoice_items(invoice_id)
|
||||
return invoice_items
|
||||
|
||||
|
||||
async def create_invoice_payment(invoice_id: str, amount: int) -> Payment:
|
||||
payment_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO invoices.payments (id, invoice_id, amount)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
payment_id,
|
||||
invoice_id,
|
||||
amount,
|
||||
),
|
||||
)
|
||||
|
||||
payment = await get_invoice_payment(payment_id)
|
||||
assert payment, "Newly created payment couldn't be retrieved"
|
||||
return payment
|
||||
@@ -0,0 +1,55 @@
|
||||
async def m001_initial_invoices(db):
|
||||
|
||||
# STATUS COLUMN OPTIONS: 'draft', 'open', 'paid', 'canceled'
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE invoices.invoices (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
|
||||
currency TEXT NOT NULL,
|
||||
|
||||
company_name TEXT DEFAULT NULL,
|
||||
first_name TEXT DEFAULT NULL,
|
||||
last_name TEXT DEFAULT NULL,
|
||||
email TEXT DEFAULT NULL,
|
||||
phone TEXT DEFAULT NULL,
|
||||
address TEXT DEFAULT NULL,
|
||||
|
||||
|
||||
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE invoices.invoice_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
invoice_id TEXT NOT NULL,
|
||||
|
||||
description TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
|
||||
FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE invoices.payments (
|
||||
id TEXT PRIMARY KEY,
|
||||
invoice_id TEXT NOT NULL,
|
||||
|
||||
amount INT NOT NULL,
|
||||
|
||||
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
|
||||
FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
from enum import Enum
|
||||
from sqlite3 import Row
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi.param_functions import Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class InvoiceStatusEnum(str, Enum):
|
||||
draft = "draft"
|
||||
open = "open"
|
||||
paid = "paid"
|
||||
canceled = "canceled"
|
||||
|
||||
|
||||
class CreateInvoiceItemData(BaseModel):
|
||||
description: str
|
||||
amount: float = Query(..., ge=0.01)
|
||||
|
||||
|
||||
class CreateInvoiceData(BaseModel):
|
||||
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
|
||||
currency: str
|
||||
company_name: Optional[str]
|
||||
first_name: Optional[str]
|
||||
last_name: Optional[str]
|
||||
email: Optional[str]
|
||||
phone: Optional[str]
|
||||
address: Optional[str]
|
||||
items: List[CreateInvoiceItemData]
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
class UpdateInvoiceItemData(BaseModel):
|
||||
id: Optional[str]
|
||||
description: str
|
||||
amount: float = Query(..., ge=0.01)
|
||||
|
||||
|
||||
class UpdateInvoiceData(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
|
||||
currency: str
|
||||
company_name: Optional[str]
|
||||
first_name: Optional[str]
|
||||
last_name: Optional[str]
|
||||
email: Optional[str]
|
||||
phone: Optional[str]
|
||||
address: Optional[str]
|
||||
items: List[UpdateInvoiceItemData]
|
||||
|
||||
|
||||
class Invoice(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
|
||||
currency: str
|
||||
company_name: Optional[str]
|
||||
first_name: Optional[str]
|
||||
last_name: Optional[str]
|
||||
email: Optional[str]
|
||||
phone: Optional[str]
|
||||
address: Optional[str]
|
||||
time: int
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Invoice":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class InvoiceItem(BaseModel):
|
||||
id: str
|
||||
invoice_id: str
|
||||
description: str
|
||||
amount: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "InvoiceItem":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class Payment(BaseModel):
|
||||
id: str
|
||||
invoice_id: str
|
||||
amount: int
|
||||
time: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Payment":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class CreatePaymentData(BaseModel):
|
||||
invoice_id: str
|
||||
amount: int
|
||||
@@ -0,0 +1,65 @@
|
||||
#invoicePage>.row:first-child>.col-md-6 {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#invoicePage>.row:first-child>.col-md-6>.q-card {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#invoicePage .clear {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
#printQrCode {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
#invoicePage>.row:first-child>.col-md-6:first-child>div {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
#invoicePage>.row:first-child>.col-md-6:nth-child(2)>div {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media print {
|
||||
* {
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
header, button, #payButtonContainer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
main, .q-page-container {
|
||||
padding-top: 0px !important;
|
||||
}
|
||||
|
||||
.q-card {
|
||||
box-shadow: none !important;
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
.q-item {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.q-card__section {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#printQrCode {
|
||||
display: block;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 0px !important;
|
||||
}
|
||||
|
||||
#invoicePage .clear {
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.tasks import internal_invoice_queue, register_invoice_listener
|
||||
|
||||
from .crud import (
|
||||
create_invoice_payment,
|
||||
get_invoice,
|
||||
get_invoice_items,
|
||||
get_invoice_payments,
|
||||
get_invoice_total,
|
||||
get_payments_total,
|
||||
update_invoice_internal,
|
||||
)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
invoice_queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue)
|
||||
|
||||
while True:
|
||||
payment = await invoice_queue.get()
|
||||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if payment.extra.get("tag") != "invoices":
|
||||
# not relevant
|
||||
return
|
||||
|
||||
invoice_id = payment.extra.get("invoice_id")
|
||||
|
||||
payment = await create_invoice_payment(
|
||||
invoice_id=invoice_id, amount=payment.extra.get("famount")
|
||||
)
|
||||
|
||||
invoice = await get_invoice(invoice_id)
|
||||
|
||||
invoice_items = await get_invoice_items(invoice_id)
|
||||
invoice_total = await get_invoice_total(invoice_items)
|
||||
|
||||
invoice_payments = await get_invoice_payments(invoice_id)
|
||||
payments_total = await get_payments_total(invoice_payments)
|
||||
|
||||
if payments_total >= invoice_total:
|
||||
invoice.status = "paid"
|
||||
await update_invoice_internal(invoice.wallet, invoice)
|
||||
|
||||
return
|
||||
@@ -0,0 +1,153 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-expansion-item group="api" dense expand-separator label="List Invoices">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span> /invoices/api/v1/invoices</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<invoice_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}invoices/api/v1/invoices -H
|
||||
"X-Api-Key: <invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item group="api" dense expand-separator label="Fetch Invoice">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/invoices/api/v1/invoice/{invoice_id}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{invoice_object}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key:
|
||||
<invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item group="api" dense expand-separator label="Create Invoice">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">POST</span> /invoices/api/v1/invoice</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{invoice_object}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}invoices/api/v1/invoice -H
|
||||
"X-Api-Key: <invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item group="api" dense expand-separator label="Update Invoice">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">POST</span>
|
||||
/invoices/api/v1/invoice/{invoice_id}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{invoice_object}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url
|
||||
}}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key:
|
||||
<invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Create Invoice Payment"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">POST</span>
|
||||
/invoices/api/v1/invoice/{invoice_id}/payments</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{payment_object}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url
|
||||
}}invoices/api/v1/invoice/{invoice_id}/payments -H "X-Api-Key:
|
||||
<invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Check Invoice Payment Status"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash} -H
|
||||
"X-Api-Key: <invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,571 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn unelevated color="primary" @click="formDialog.show = true"
|
||||
>New Invoice</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Invoices</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="invoices"
|
||||
row-key="id"
|
||||
:columns="invoicesTable.columns"
|
||||
:pagination.sync="invoicesTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="edit"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="showEditModal(props.row)"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="launch"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="'pay/' + props.row.id"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">
|
||||
{{SITE_TITLE}} Invoices extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "invoices/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="saveInvoice" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
></q-select>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.currency"
|
||||
:options="currencyOptions"
|
||||
label="Currency *"
|
||||
></q-select>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.status"
|
||||
:options="['draft', 'open', 'paid', 'canceled']"
|
||||
label="Status *"
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.company_name"
|
||||
label="Company Name"
|
||||
placeholder="LNBits Labs"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.first_name"
|
||||
label="First Name"
|
||||
placeholder="Satoshi"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.last_name"
|
||||
label="Last Name"
|
||||
placeholder="Nakamoto"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.email"
|
||||
label="Email"
|
||||
placeholder="satoshi@gmail.com"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.phone"
|
||||
label="Phone"
|
||||
placeholder="+81 (012)-345-6789"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.address"
|
||||
label="Address"
|
||||
placeholder="1600 Pennsylvania Ave."
|
||||
type="textarea"
|
||||
></q-input>
|
||||
|
||||
<q-list bordered separator>
|
||||
<q-item
|
||||
clickable
|
||||
v-ripple
|
||||
v-for="(item, index) in formDialog.invoiceItems"
|
||||
:key="index"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
label="Item"
|
||||
placeholder="Jelly Beans"
|
||||
v-model="formDialog.invoiceItems[index].description"
|
||||
></q-input>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
label="Amount"
|
||||
placeholder="4.20"
|
||||
v-model="formDialog.invoiceItems[index].amount"
|
||||
></q-input>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="delete"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="formDialog.invoiceItems.splice(index, 1)"
|
||||
></q-btn>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-btn flat icon="add" @click="formDialog.invoiceItems.push({})">
|
||||
Add Line Item
|
||||
</q-btn>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="formDialog.data.wallet == null || formDialog.data.currency == null"
|
||||
type="submit"
|
||||
v-if="typeof formDialog.data.id == 'undefined'"
|
||||
>Create Invoice</q-btn
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="formDialog.data.wallet == null || formDialog.data.currency == null"
|
||||
type="submit"
|
||||
v-if="typeof formDialog.data.id !== 'undefined'"
|
||||
>Save Invoice</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
var mapInvoice = function (obj) {
|
||||
obj.time = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
var mapInvoiceItems = function (obj) {
|
||||
obj.amount = parseFloat(obj.amount / 100).toFixed(2)
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
invoices: [],
|
||||
currencyOptions: [
|
||||
'USD',
|
||||
'EUR',
|
||||
'GBP',
|
||||
'AED',
|
||||
'AFN',
|
||||
'ALL',
|
||||
'AMD',
|
||||
'ANG',
|
||||
'AOA',
|
||||
'ARS',
|
||||
'AUD',
|
||||
'AWG',
|
||||
'AZN',
|
||||
'BAM',
|
||||
'BBD',
|
||||
'BDT',
|
||||
'BGN',
|
||||
'BHD',
|
||||
'BIF',
|
||||
'BMD',
|
||||
'BND',
|
||||
'BOB',
|
||||
'BRL',
|
||||
'BSD',
|
||||
'BTN',
|
||||
'BWP',
|
||||
'BYN',
|
||||
'BZD',
|
||||
'CAD',
|
||||
'CDF',
|
||||
'CHF',
|
||||
'CLF',
|
||||
'CLP',
|
||||
'CNH',
|
||||
'CNY',
|
||||
'COP',
|
||||
'CRC',
|
||||
'CUC',
|
||||
'CUP',
|
||||
'CVE',
|
||||
'CZK',
|
||||
'DJF',
|
||||
'DKK',
|
||||
'DOP',
|
||||
'DZD',
|
||||
'EGP',
|
||||
'ERN',
|
||||
'ETB',
|
||||
'EUR',
|
||||
'FJD',
|
||||
'FKP',
|
||||
'GBP',
|
||||
'GEL',
|
||||
'GGP',
|
||||
'GHS',
|
||||
'GIP',
|
||||
'GMD',
|
||||
'GNF',
|
||||
'GTQ',
|
||||
'GYD',
|
||||
'HKD',
|
||||
'HNL',
|
||||
'HRK',
|
||||
'HTG',
|
||||
'HUF',
|
||||
'IDR',
|
||||
'ILS',
|
||||
'IMP',
|
||||
'INR',
|
||||
'IQD',
|
||||
'IRR',
|
||||
'IRT',
|
||||
'ISK',
|
||||
'JEP',
|
||||
'JMD',
|
||||
'JOD',
|
||||
'JPY',
|
||||
'KES',
|
||||
'KGS',
|
||||
'KHR',
|
||||
'KMF',
|
||||
'KPW',
|
||||
'KRW',
|
||||
'KWD',
|
||||
'KYD',
|
||||
'KZT',
|
||||
'LAK',
|
||||
'LBP',
|
||||
'LKR',
|
||||
'LRD',
|
||||
'LSL',
|
||||
'LYD',
|
||||
'MAD',
|
||||
'MDL',
|
||||
'MGA',
|
||||
'MKD',
|
||||
'MMK',
|
||||
'MNT',
|
||||
'MOP',
|
||||
'MRO',
|
||||
'MUR',
|
||||
'MVR',
|
||||
'MWK',
|
||||
'MXN',
|
||||
'MYR',
|
||||
'MZN',
|
||||
'NAD',
|
||||
'NGN',
|
||||
'NIO',
|
||||
'NOK',
|
||||
'NPR',
|
||||
'NZD',
|
||||
'OMR',
|
||||
'PAB',
|
||||
'PEN',
|
||||
'PGK',
|
||||
'PHP',
|
||||
'PKR',
|
||||
'PLN',
|
||||
'PYG',
|
||||
'QAR',
|
||||
'RON',
|
||||
'RSD',
|
||||
'RUB',
|
||||
'RWF',
|
||||
'SAR',
|
||||
'SBD',
|
||||
'SCR',
|
||||
'SDG',
|
||||
'SEK',
|
||||
'SGD',
|
||||
'SHP',
|
||||
'SLL',
|
||||
'SOS',
|
||||
'SRD',
|
||||
'SSP',
|
||||
'STD',
|
||||
'SVC',
|
||||
'SYP',
|
||||
'SZL',
|
||||
'THB',
|
||||
'TJS',
|
||||
'TMT',
|
||||
'TND',
|
||||
'TOP',
|
||||
'TRY',
|
||||
'TTD',
|
||||
'TWD',
|
||||
'TZS',
|
||||
'UAH',
|
||||
'UGX',
|
||||
'USD',
|
||||
'UYU',
|
||||
'UZS',
|
||||
'VEF',
|
||||
'VES',
|
||||
'VND',
|
||||
'VUV',
|
||||
'WST',
|
||||
'XAF',
|
||||
'XAG',
|
||||
'XAU',
|
||||
'XCD',
|
||||
'XDR',
|
||||
'XOF',
|
||||
'XPD',
|
||||
'XPF',
|
||||
'XPT',
|
||||
'YER',
|
||||
'ZAR',
|
||||
'ZMW',
|
||||
'ZWL'
|
||||
],
|
||||
invoicesTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'status', align: 'left', label: 'Status', field: 'status'},
|
||||
{name: 'time', align: 'left', label: 'Created', field: 'time'},
|
||||
{name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'},
|
||||
{
|
||||
name: 'currency',
|
||||
align: 'left',
|
||||
label: 'Currency',
|
||||
field: 'currency'
|
||||
},
|
||||
{
|
||||
name: 'company_name',
|
||||
align: 'left',
|
||||
label: 'Company Name',
|
||||
field: 'company_name'
|
||||
},
|
||||
{
|
||||
name: 'first_name',
|
||||
align: 'left',
|
||||
label: 'First Name',
|
||||
field: 'first_name'
|
||||
},
|
||||
{
|
||||
name: 'last_name',
|
||||
align: 'left',
|
||||
label: 'Last Name',
|
||||
field: 'last_name'
|
||||
},
|
||||
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
||||
{name: 'phone', align: 'left', label: 'Phone', field: 'phone'},
|
||||
{name: 'address', align: 'left', label: 'Address', field: 'address'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {},
|
||||
invoiceItems: []
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = {}
|
||||
this.formDialog.invoiceItems = []
|
||||
},
|
||||
showEditModal: function (obj) {
|
||||
this.formDialog.data = obj
|
||||
this.formDialog.show = true
|
||||
|
||||
this.getInvoice(obj.id)
|
||||
},
|
||||
getInvoice: function (invoice_id) {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request('GET', '/invoices/api/v1/invoice/' + invoice_id)
|
||||
.then(function (response) {
|
||||
self.formDialog.invoiceItems = response.data.items.map(function (
|
||||
obj
|
||||
) {
|
||||
return mapInvoiceItems(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
getInvoices: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/invoices/api/v1/invoices?all_wallets=true',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.invoices = response.data.map(function (obj) {
|
||||
return mapInvoice(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
saveInvoice: function () {
|
||||
var data = this.formDialog.data
|
||||
data.items = this.formDialog.invoiceItems
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/invoices/api/v1/invoice' + (data.id ? '/' + data.id : ''),
|
||||
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
|
||||
.inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
if (!data.id) {
|
||||
self.invoices.push(mapInvoice(response.data))
|
||||
} else {
|
||||
self.getInvoices()
|
||||
}
|
||||
|
||||
self.formDialog.invoiceItems = []
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteTPoS: function (tposId) {
|
||||
var self = this
|
||||
var tpos = _.findWhere(this.tposs, {id: tposId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this TPoS?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/tpos/api/v1/tposs/' + tposId,
|
||||
_.findWhere(self.g.user.wallets, {id: tpos.wallet}).adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.tposs = _.reject(self.tposs, function (obj) {
|
||||
return obj.id == tposId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(this.invoicesTable.columns, this.invoices)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getInvoices()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,430 @@
|
||||
{% extends "public.html" %} {% block toolbar_title %} Invoice
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="md"
|
||||
@click.prevent="urlDialog.show = true"
|
||||
icon="share"
|
||||
color="white"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="md"
|
||||
@click.prevent="printInvoice()"
|
||||
icon="print"
|
||||
color="white"
|
||||
></q-btn>
|
||||
{% endblock %} {% from "macros.jinja" import window_vars with context %} {%
|
||||
block page %}
|
||||
<link rel="stylesheet" href="/invoices/static/css/pay.css" />
|
||||
<div id="invoicePage">
|
||||
<div class="row q-gutter-y-md">
|
||||
<div class="col-md-6 col-sm-12 col-xs-12">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
<b>Invoice</b>
|
||||
</p>
|
||||
|
||||
<q-list bordered separator>
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>ID</b></q-item-section>
|
||||
<q-item-section style="word-break: break-all"
|
||||
>{{ invoice_id }}</q-item-section
|
||||
>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Created At</b></q-item-section>
|
||||
<q-item-section
|
||||
>{{ datetime.utcfromtimestamp(invoice.time).strftime('%Y-%m-%d
|
||||
%H:%M') }}</q-item-section
|
||||
>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Status</b></q-item-section>
|
||||
<q-item-section>
|
||||
<span>
|
||||
<q-badge color=""> {{ invoice.status }} </q-badge>
|
||||
</span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Total</b></q-item-section>
|
||||
<q-item-section>
|
||||
{{ "{:0,.2f}".format(invoice_total / 100) }} {{ invoice.currency
|
||||
}}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Paid</b></q-item-section>
|
||||
<q-item-section>
|
||||
<div class="row" style="align-items: center">
|
||||
<div class="col-sm-6">
|
||||
{{ "{:0,.2f}".format(payments_total / 100) }} {{
|
||||
invoice.currency }}
|
||||
</div>
|
||||
<div class="col-sm-6" id="payButtonContainer">
|
||||
{% if payments_total < invoice_total %}
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="formDialog.show = true"
|
||||
v-if="status == 'open'"
|
||||
>
|
||||
Pay Invoice
|
||||
</q-btn>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12 col-xs-12">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
<b>Bill To</b>
|
||||
</p>
|
||||
|
||||
<q-list bordered separator>
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Company Name</b></q-item-section>
|
||||
<q-item-section>{{ invoice.company_name }}</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Name</b></q-item-section>
|
||||
<q-item-section
|
||||
>{{ invoice.first_name }} {{ invoice.last_name
|
||||
}}</q-item-section
|
||||
>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Address</b></q-item-section>
|
||||
<q-item-section>{{ invoice.address }}</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Email</b></q-item-section>
|
||||
<q-item-section>{{ invoice.email }}</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Phone</b></q-item-section>
|
||||
<q-item-section>{{ invoice.phone }}</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clear"></div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
<b>Items</b>
|
||||
</p>
|
||||
|
||||
<q-list bordered separator>
|
||||
{% if invoice_items %}
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Item</b></q-item-section>
|
||||
<q-item-section side><b>Amount</b></q-item-section>
|
||||
</q-item>
|
||||
{% endif %} {% for item in invoice_items %}
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>{{item.description}}</b></q-item-section>
|
||||
<q-item-section side>
|
||||
{{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency
|
||||
}}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
{% endfor %} {% if not invoice_items %} No Invoice Items {% endif %}
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clear"></div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
<b>Payments</b>
|
||||
</p>
|
||||
|
||||
<q-list bordered separator>
|
||||
{% if invoice_payments %}
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section><b>Date</b></q-item-section>
|
||||
<q-item-section side><b>Amount</b></q-item-section>
|
||||
</q-item>
|
||||
{% endif %} {% for item in invoice_payments %}
|
||||
<q-item clickable v-ripple>
|
||||
<q-item-section
|
||||
><b
|
||||
>{{ datetime.utcfromtimestamp(item.time).strftime('%Y-%m-%d
|
||||
%H:%M') }}</b
|
||||
></q-item-section
|
||||
>
|
||||
<q-item-section side>
|
||||
{{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency
|
||||
}}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
{% endfor %} {% if not invoice_payments %} No Invoice Payments {%
|
||||
endif %}
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clear"></div>
|
||||
|
||||
<div class="row q-gutter-y-md q-gutter-md" id="printQrCode">
|
||||
<div class="col-12 col-md">
|
||||
<div class="text-center">
|
||||
<p><b>Scan to View & Pay Online!</b></p>
|
||||
<qrcode
|
||||
value="{{ request.url }}"
|
||||
:options="{width: 200}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="createPayment" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.payment_amount"
|
||||
:rules="[val => val >= 0.01 || 'Minimum amount is 0.01']"
|
||||
min="0.01"
|
||||
label="Payment Amount"
|
||||
placeholder="4.20"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<span style="font-size: 12px"> {{ invoice.currency }} </span>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="formDialog.data.payment_amount == null"
|
||||
type="submit"
|
||||
>Create Payment</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog
|
||||
v-model="qrCodeDialog.show"
|
||||
position="top"
|
||||
@hide="closeQrCodeDialog"
|
||||
>
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card text-center">
|
||||
<a :href="'lightning:' + qrCodeDialog.data.payment_request">
|
||||
<q-responsive :ratio="1" class="q-mx-xs">
|
||||
<qrcode
|
||||
:value="qrCodeDialog.data.payment_request"
|
||||
:options="{width: 400}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<br />
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText('lightning:' + qrCodeDialog.data.payment_request, 'Invoice copied to clipboard!')"
|
||||
>Copy Invoice</q-btn
|
||||
>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="urlDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||
<qrcode
|
||||
value="{{ request.url }}"
|
||||
:options="{width: 400}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<div class="text-center q-mb-xl">
|
||||
<p style="word-break: break-all">{{ request.url }}</p>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText('{{ request.url }}', 'Invoice Pay URL copied to clipboard!')"
|
||||
>Copy URL</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
var mapInvoice = function (obj) {
|
||||
obj.time = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
var mapInvoiceItems = function (obj) {
|
||||
obj.amount = parseFloat(obj.amount / 100).toFixed(2)
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
invoice_id: '{{ invoice.id }}',
|
||||
wallet: '{{ invoice.wallet }}',
|
||||
currency: '{{ invoice.currency }}',
|
||||
status: '{{ invoice.status }}',
|
||||
qrCodeDialog: {
|
||||
data: {
|
||||
payment_request: null,
|
||||
},
|
||||
show: false,
|
||||
},
|
||||
formDialog: {
|
||||
data: {
|
||||
payment_amount: parseFloat({{invoice_total - payments_total}} / 100).toFixed(2)
|
||||
},
|
||||
show: false,
|
||||
},
|
||||
urlDialog: {
|
||||
show: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
printInvoice: function() {
|
||||
window.print()
|
||||
},
|
||||
closeFormDialog: function() {
|
||||
this.formDialog.show = false
|
||||
},
|
||||
closeQrCodeDialog: function() {
|
||||
this.qrCodeDialog.show = false
|
||||
},
|
||||
createPayment: function () {
|
||||
var self = this
|
||||
var qrCodeDialog = this.qrCodeDialog
|
||||
var formDialog = this.formDialog
|
||||
var famount = parseInt(formDialog.data.payment_amount * 100)
|
||||
|
||||
axios
|
||||
.post('/invoices/api/v1/invoice/' + this.invoice_id + '/payments', null, {
|
||||
params: {
|
||||
famount: famount,
|
||||
}
|
||||
})
|
||||
.then(function (response) {
|
||||
formDialog.show = false
|
||||
formDialog.data = {}
|
||||
|
||||
qrCodeDialog.data = response.data
|
||||
qrCodeDialog.show = true
|
||||
|
||||
console.log(qrCodeDialog.data)
|
||||
|
||||
qrCodeDialog.dismissMsg = self.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Waiting for payment...'
|
||||
})
|
||||
|
||||
qrCodeDialog.paymentChecker = setInterval(function () {
|
||||
axios
|
||||
.get(
|
||||
'/invoices/api/v1/invoice/' +
|
||||
self.invoice_id +
|
||||
'/payments/' +
|
||||
response.data.payment_hash
|
||||
)
|
||||
.then(function (res) {
|
||||
if (res.data.paid) {
|
||||
clearInterval(qrCodeDialog.paymentChecker)
|
||||
qrCodeDialog.dismissMsg()
|
||||
qrCodeDialog.show = false
|
||||
|
||||
setTimeout(function () {
|
||||
window.location.reload()
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
}, 3000)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
statusBadgeColor: function() {
|
||||
switch(this.status) {
|
||||
case 'draft':
|
||||
return 'gray'
|
||||
break
|
||||
|
||||
case 'open':
|
||||
return 'blue'
|
||||
break
|
||||
|
||||
case 'paid':
|
||||
return 'green'
|
||||
break
|
||||
|
||||
case 'canceled':
|
||||
return 'red'
|
||||
break
|
||||
}
|
||||
},
|
||||
},
|
||||
created: function () {
|
||||
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
||||
from . import invoices_ext, invoices_renderer
|
||||
from .crud import (
|
||||
get_invoice,
|
||||
get_invoice_items,
|
||||
get_invoice_payments,
|
||||
get_invoice_total,
|
||||
get_payments_total,
|
||||
)
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@invoices_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
return invoices_renderer().TemplateResponse(
|
||||
"invoices/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
|
||||
|
||||
@invoices_ext.get("/pay/{invoice_id}", response_class=HTMLResponse)
|
||||
async def index(request: Request, invoice_id: str):
|
||||
invoice = await get_invoice(invoice_id)
|
||||
|
||||
if not invoice:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
|
||||
)
|
||||
|
||||
invoice_items = await get_invoice_items(invoice_id)
|
||||
invoice_total = await get_invoice_total(invoice_items)
|
||||
|
||||
invoice_payments = await get_invoice_payments(invoice_id)
|
||||
payments_total = await get_payments_total(invoice_payments)
|
||||
|
||||
return invoices_renderer().TemplateResponse(
|
||||
"invoices/pay.html",
|
||||
{
|
||||
"request": request,
|
||||
"invoice_id": invoice_id,
|
||||
"invoice": invoice.dict(),
|
||||
"invoice_items": invoice_items,
|
||||
"invoice_total": invoice_total,
|
||||
"invoice_payments": invoice_payments,
|
||||
"payments_total": payments_total,
|
||||
"datetime": datetime,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
|
||||
from . import invoices_ext
|
||||
from .crud import (
|
||||
create_invoice_internal,
|
||||
create_invoice_items,
|
||||
get_invoice,
|
||||
get_invoice_items,
|
||||
get_invoice_payments,
|
||||
get_invoice_total,
|
||||
get_invoices,
|
||||
get_payments_total,
|
||||
update_invoice_internal,
|
||||
update_invoice_items,
|
||||
)
|
||||
from .models import CreateInvoiceData, UpdateInvoiceData
|
||||
|
||||
|
||||
@invoices_ext.get("/api/v1/invoices", status_code=HTTPStatus.OK)
|
||||
async def api_invoices(
|
||||
all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||
|
||||
return [invoice.dict() for invoice in await get_invoices(wallet_ids)]
|
||||
|
||||
|
||||
@invoices_ext.get("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK)
|
||||
async def api_invoice(invoice_id: str):
|
||||
invoice = await get_invoice(invoice_id)
|
||||
if not invoice:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
|
||||
)
|
||||
invoice_items = await get_invoice_items(invoice_id)
|
||||
|
||||
invoice_payments = await get_invoice_payments(invoice_id)
|
||||
payments_total = await get_payments_total(invoice_payments)
|
||||
|
||||
invoice_dict = invoice.dict()
|
||||
invoice_dict["items"] = invoice_items
|
||||
invoice_dict["payments"] = payments_total
|
||||
return invoice_dict
|
||||
|
||||
|
||||
@invoices_ext.post("/api/v1/invoice", status_code=HTTPStatus.CREATED)
|
||||
async def api_invoice_create(
|
||||
data: CreateInvoiceData, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
invoice = await create_invoice_internal(wallet_id=wallet.wallet.id, data=data)
|
||||
items = await create_invoice_items(invoice_id=invoice.id, data=data.items)
|
||||
invoice_dict = invoice.dict()
|
||||
invoice_dict["items"] = items
|
||||
return invoice_dict
|
||||
|
||||
|
||||
@invoices_ext.post("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK)
|
||||
async def api_invoice_update(
|
||||
data: UpdateInvoiceData,
|
||||
invoice_id: str,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
):
|
||||
invoice = await update_invoice_internal(wallet_id=wallet.wallet.id, data=data)
|
||||
items = await update_invoice_items(invoice_id=invoice.id, data=data.items)
|
||||
invoice_dict = invoice.dict()
|
||||
invoice_dict["items"] = items
|
||||
return invoice_dict
|
||||
|
||||
|
||||
@invoices_ext.post(
|
||||
"/api/v1/invoice/{invoice_id}/payments", status_code=HTTPStatus.CREATED
|
||||
)
|
||||
async def api_invoices_create_payment(
|
||||
famount: int = Query(..., ge=1), invoice_id: str = None
|
||||
):
|
||||
invoice = await get_invoice(invoice_id)
|
||||
invoice_items = await get_invoice_items(invoice_id)
|
||||
invoice_total = await get_invoice_total(invoice_items)
|
||||
|
||||
invoice_payments = await get_invoice_payments(invoice_id)
|
||||
payments_total = await get_payments_total(invoice_payments)
|
||||
|
||||
if payments_total + famount > invoice_total:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Amount exceeds invoice due."
|
||||
)
|
||||
|
||||
if not invoice:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
|
||||
)
|
||||
|
||||
price_in_sats = await fiat_amount_as_satoshis(famount / 100, invoice.currency)
|
||||
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=invoice.wallet,
|
||||
amount=price_in_sats,
|
||||
memo=f"Payment for invoice {invoice_id}",
|
||||
extra={"tag": "invoices", "invoice_id": invoice_id, "famount": famount},
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
||||
|
||||
|
||||
@invoices_ext.get(
|
||||
"/api/v1/invoice/{invoice_id}/payments/{payment_hash}", status_code=HTTPStatus.OK
|
||||
)
|
||||
async def api_invoices_check_payment(invoice_id: str, payment_hash: str):
|
||||
invoice = await get_invoice(invoice_id)
|
||||
if not invoice:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
|
||||
)
|
||||
try:
|
||||
status = await api_payment(payment_hash)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(exc)
|
||||
return {"paid": False}
|
||||
return status
|
||||
@@ -90,7 +90,7 @@ async def lnurl_callback(
|
||||
wallet_id=ls.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=await track.fullname(),
|
||||
description_hash=(await track.lnurlpay_metadata()).encode("utf-8"),
|
||||
unhashed_description=(await track.lnurlpay_metadata()).encode("utf-8"),
|
||||
extra={"tag": "livestream", "track": track.id, "comment": comment},
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
Charge people for using your domain name...<br />
|
||||
|
||||
<a
|
||||
href="https://github.com/lnbits/lnbits/tree/master/lnbits/extensions/lnaddress"
|
||||
href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/lnaddress"
|
||||
>More details</a
|
||||
>
|
||||
<br />
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -205,7 +205,7 @@ async def lnurl_callback(
|
||||
wallet_id=device.wallet,
|
||||
amount=lnurldevicepayment.sats / 1000,
|
||||
memo=device.title,
|
||||
description_hash=(await device.lnurlpay_metadata()).encode("utf-8"),
|
||||
unhashed_description=(await device.lnurlpay_metadata()).encode("utf-8"),
|
||||
extra={"tag": "PoS"},
|
||||
)
|
||||
lnurldevicepayment = await update_lnurldevicepayment(
|
||||
|
||||
@@ -87,7 +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=link.lnurlpay_metadata.encode("utf-8"),
|
||||
unhashed_description=link.lnurlpay_metadata.encode("utf-8"),
|
||||
extra={
|
||||
"tag": "lnurlp",
|
||||
"link": link.id,
|
||||
|
||||
@@ -296,16 +296,17 @@
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="link"
|
||||
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
|
||||
>Shareable link</q-btn
|
||||
>
|
||||
><q-tooltip>Copy sharable link</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="nfc"
|
||||
@click="writeNfcTag(qrCodeDialog.data.lnurl)"
|
||||
:disable="nfcTagWriting"
|
||||
>
|
||||
><q-tooltip>Write to NFC</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
outline
|
||||
@@ -314,7 +315,8 @@
|
||||
type="a"
|
||||
:href="qrCodeDialog.data.print_url"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
><q-tooltip>Print</q-tooltip></q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
|
||||
@@ -96,7 +96,7 @@ async def api_link_create_or_update(
|
||||
data.min *= data.fiat_base_multiplier
|
||||
data.max *= data.fiat_base_multiplier
|
||||
|
||||
if data.success_url is not None and data.success_url.startswith("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)
|
||||
|
||||
@@ -73,7 +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=(await item.lnurlpay_metadata()).encode("utf-8"),
|
||||
unhashed_description=(await item.lnurlpay_metadata()).encode("utf-8"),
|
||||
extra={"tag": "offlineshop", "item": item.id},
|
||||
)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -4,7 +4,7 @@ from fastapi import Depends, Query
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_user, get_wallet
|
||||
from lnbits.core.services import check_invoice_status, create_invoice
|
||||
from lnbits.core.services import check_transaction_status, create_invoice
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||
|
||||
from . import paywall_ext
|
||||
@@ -87,7 +87,7 @@ async def api_paywal_check_invoice(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Paywall does not exist."
|
||||
)
|
||||
try:
|
||||
status = await check_invoice_status(paywall.wallet, payment_hash)
|
||||
status = await check_transaction_status(paywall.wallet, payment_hash)
|
||||
is_paid = not status.pending
|
||||
except Exception:
|
||||
return {"paid": False}
|
||||
|
||||
@@ -77,7 +77,7 @@ async def api_lnurlp_callback(
|
||||
wallet_id=link.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo="Satsdice bet",
|
||||
description_hash=link.lnurlpay_metadata.encode("utf-8"),
|
||||
unhashed_description=link.lnurlpay_metadata.encode("utf-8"),
|
||||
extra={"tag": "satsdice", "link": link.id, "comment": "comment"},
|
||||
)
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="share"
|
||||
icon="link"
|
||||
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
|
||||
><q-tooltip>Copy shareable link</q-tooltip></q-btn
|
||||
>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
Charge people for using your subdomain name...<br />
|
||||
|
||||
<a
|
||||
href="https://github.com/lnbits/lnbits/tree/master/lnbits/extensions/subdomains"
|
||||
href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/subdomains"
|
||||
>More details</a
|
||||
>
|
||||
<br />
|
||||
|
||||
@@ -5,7 +5,7 @@ from fastapi.params import Depends
|
||||
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.subdomains.models import CreateDomain, CreateSubdomain
|
||||
|
||||
@@ -161,7 +161,7 @@ async def api_subdomain_make_subdomain(domain_id, data: CreateSubdomain):
|
||||
async def api_subdomain_send_subdomain(payment_hash):
|
||||
subdomain = await get_subdomain(payment_hash)
|
||||
try:
|
||||
status = await check_invoice_status(subdomain.wallet, payment_hash)
|
||||
status = await check_transaction_status(subdomain.wallet, payment_hash)
|
||||
is_paid = not status.pending
|
||||
except Exception:
|
||||
return {"paid": False}
|
||||
|
||||
@@ -76,10 +76,10 @@ async def get_tipjars(wallet_id: str) -> Optional[list]:
|
||||
|
||||
async def delete_tipjar(tipjar_id: int) -> None:
|
||||
"""Delete a TipJar and all corresponding Tips"""
|
||||
await db.execute("DELETE FROM tipjar.TipJars WHERE id = ?", (tipjar_id,))
|
||||
rows = await db.fetchall("SELECT * FROM tipjar.Tips WHERE tipjar = ?", (tipjar_id,))
|
||||
for row in rows:
|
||||
await delete_tip(row["id"])
|
||||
await db.execute("DELETE FROM tipjar.TipJars WHERE id = ?", (tipjar_id,))
|
||||
|
||||
|
||||
async def get_tip(tip_id: str) -> Optional[Tip]:
|
||||
|
||||
@@ -23,3 +23,7 @@ class TPoS(BaseModel):
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "TPoS":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class PayLnurlWData(BaseModel):
|
||||
lnurl: str
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="row justify-center full-width">
|
||||
<div class="col-12 col-sm-8 col-md-6 col-lg-4 text-center">
|
||||
<h3 class="q-mb-md">{% raw %}{{ famount }}{% endraw %}</h3>
|
||||
<h5 class="q-mt-none">
|
||||
<h5 class="q-mt-none q-mb-sm">
|
||||
{% raw %}{{ fsat }}{% endraw %} <small>sat</small>
|
||||
</h5>
|
||||
</div>
|
||||
@@ -174,6 +174,13 @@
|
||||
>
|
||||
{% endraw %}
|
||||
</h5>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="nfc"
|
||||
@click="readNfcTag()"
|
||||
:disable="nfcTagReading"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
@@ -281,6 +288,7 @@
|
||||
exchangeRate: null,
|
||||
stack: [],
|
||||
tipAmount: 0.0,
|
||||
nfcTagReading: false,
|
||||
invoiceDialog: {
|
||||
show: false,
|
||||
data: null,
|
||||
@@ -356,7 +364,7 @@
|
||||
this.showInvoice()
|
||||
},
|
||||
submitForm: function () {
|
||||
if (this.tip_options) {
|
||||
if (this.tip_options.length) {
|
||||
this.showTipModal()
|
||||
} else {
|
||||
this.showInvoice()
|
||||
@@ -410,6 +418,98 @@
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
readNfcTag: function () {
|
||||
try {
|
||||
const self = this
|
||||
|
||||
if (typeof NDEFReader == 'undefined') {
|
||||
throw {
|
||||
toString: function () {
|
||||
return 'NFC not supported on this device or browser.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ndef = new NDEFReader()
|
||||
|
||||
const readerAbortController = new AbortController()
|
||||
readerAbortController.signal.onabort = event => {
|
||||
console.log('All NFC Read operations have been aborted.')
|
||||
}
|
||||
|
||||
this.nfcTagReading = true
|
||||
this.$q.notify({
|
||||
message: 'Tap your NFC tag to pay this invoice with LNURLw.'
|
||||
})
|
||||
|
||||
return ndef.scan({signal: readerAbortController.signal}).then(() => {
|
||||
ndef.onreadingerror = () => {
|
||||
self.nfcTagReading = false
|
||||
|
||||
this.$q.notify({
|
||||
type: 'negative',
|
||||
message: 'There was an error reading this NFC tag.'
|
||||
})
|
||||
|
||||
readerAbortController.abort()
|
||||
}
|
||||
|
||||
ndef.onreading = ({message}) => {
|
||||
//Decode NDEF data from tag
|
||||
const textDecoder = new TextDecoder('utf-8')
|
||||
|
||||
const record = message.records.find(el => {
|
||||
const payload = textDecoder.decode(el.data)
|
||||
return payload.toUpperCase().indexOf('LNURL') !== -1
|
||||
})
|
||||
|
||||
const lnurl = textDecoder.decode(record.data)
|
||||
|
||||
//User feedback, show loader icon
|
||||
self.nfcTagReading = false
|
||||
self.payInvoice(lnurl, readerAbortController)
|
||||
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'NFC tag read successfully.'
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
this.nfcTagReading = false
|
||||
this.$q.notify({
|
||||
type: 'negative',
|
||||
message: error
|
||||
? error.toString()
|
||||
: 'An unexpected error has occurred.'
|
||||
})
|
||||
}
|
||||
},
|
||||
payInvoice: function (lnurl, readerAbortController) {
|
||||
const self = this
|
||||
|
||||
return axios
|
||||
.post(
|
||||
'/tpos/api/v1/tposs/' +
|
||||
self.tposId +
|
||||
'/invoices/' +
|
||||
self.invoiceDialog.data.payment_request +
|
||||
'/pay',
|
||||
{
|
||||
lnurl: lnurl
|
||||
}
|
||||
)
|
||||
.then(response => {
|
||||
if (!response.data.success) {
|
||||
this.$q.notify({
|
||||
type: 'negative',
|
||||
message: response.data.detail
|
||||
})
|
||||
}
|
||||
|
||||
readerAbortController.abort()
|
||||
})
|
||||
},
|
||||
getRates: function () {
|
||||
var self = this
|
||||
axios.get('https://api.opennode.co/v1/rates').then(function (response) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
import httpx
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from lnurl import decode as decode_lnurl
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
@@ -12,7 +14,7 @@ from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
|
||||
from . import tpos_ext
|
||||
from .crud import create_tpos, delete_tpos, get_tpos, get_tposs
|
||||
from .models import CreateTposData
|
||||
from .models import CreateTposData, PayLnurlWData
|
||||
|
||||
|
||||
@tpos_ext.get("/api/v1/tposs", status_code=HTTPStatus.OK)
|
||||
@@ -79,6 +81,66 @@ async def api_tpos_create_invoice(
|
||||
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
||||
|
||||
|
||||
@tpos_ext.post(
|
||||
"/api/v1/tposs/{tpos_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK
|
||||
)
|
||||
async def api_tpos_pay_invoice(
|
||||
lnurl_data: PayLnurlWData, payment_request: str = None, tpos_id: str = None
|
||||
):
|
||||
tpos = await get_tpos(tpos_id)
|
||||
|
||||
if not tpos:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
|
||||
)
|
||||
|
||||
lnurl = (
|
||||
lnurl_data.lnurl.replace("lnurlw://", "")
|
||||
.replace("lightning://", "")
|
||||
.replace("LIGHTNING://", "")
|
||||
.replace("lightning:", "")
|
||||
.replace("LIGHTNING:", "")
|
||||
)
|
||||
|
||||
if lnurl.lower().startswith("lnurl"):
|
||||
lnurl = decode_lnurl(lnurl)
|
||||
else:
|
||||
lnurl = "https://" + lnurl
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.get(lnurl, follow_redirects=True)
|
||||
if r.is_error:
|
||||
lnurl_response = {"success": False, "detail": "Error loading"}
|
||||
else:
|
||||
resp = r.json()
|
||||
if resp["tag"] != "withdrawRequest":
|
||||
lnurl_response = {"success": False, "detail": "Wrong tag type"}
|
||||
else:
|
||||
r2 = await client.get(
|
||||
resp["callback"],
|
||||
follow_redirects=True,
|
||||
params={
|
||||
"k1": resp["k1"],
|
||||
"pr": payment_request,
|
||||
},
|
||||
)
|
||||
resp2 = r2.json()
|
||||
if r2.is_error:
|
||||
lnurl_response = {
|
||||
"success": False,
|
||||
"detail": "Error loading callback",
|
||||
}
|
||||
elif resp2["status"] == "ERROR":
|
||||
lnurl_response = {"success": False, "detail": resp2["reason"]}
|
||||
else:
|
||||
lnurl_response = {"success": True, "detail": resp2}
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
lnurl_response = {"success": False, "detail": "Unexpected error occurred"}
|
||||
|
||||
return lnurl_response
|
||||
|
||||
|
||||
@tpos_ext.get(
|
||||
"/api/v1/tposs/{tpos_id}/invoices/{payment_hash}", status_code=HTTPStatus.OK
|
||||
)
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
<q-td colspan="100%">
|
||||
<div class="row items-center q-mt-md q-mb-lg">
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<div class="col-2 q-pr-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
@@ -123,6 +123,16 @@
|
||||
QR Code</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-2 q-pr-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="content_copy"
|
||||
@click="copyText(props.row.address)"
|
||||
class="q-ml-sm"
|
||||
>Copy</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-2 q-pr-lg">
|
||||
<q-btn
|
||||
outline
|
||||
|
||||
@@ -74,6 +74,16 @@ async function addressList(path) {
|
||||
satBtc(val, showUnit = true) {
|
||||
return satOrBtc(val, showUnit, this.satsDenominated)
|
||||
},
|
||||
// todo: bad. base.js not present in custom components
|
||||
copyText: function (text, message, position) {
|
||||
var notify = this.$q.notify
|
||||
Quasar.utils.copyToClipboard(text).then(function () {
|
||||
notify({
|
||||
message: message || 'Copied to clipboard!',
|
||||
position: position || 'bottom'
|
||||
})
|
||||
})
|
||||
},
|
||||
getWalletName: function (walletId) {
|
||||
const wallet = (this.accounts || []).find(wl => wl.id === walletId)
|
||||
return wallet ? wallet.title : 'unknown'
|
||||
|
||||
@@ -123,7 +123,6 @@
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="hwwConfigAndConnect" class="q-gutter-md">
|
||||
<span>Enter Config</span>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
@@ -131,6 +130,7 @@
|
||||
label="Name (optional)"
|
||||
></q-input>
|
||||
<q-separator></q-separator>
|
||||
|
||||
<serial-port-config
|
||||
ref="serialPortConfig"
|
||||
:config="hww.config"
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<div>
|
||||
<q-card>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col-2 q-ml-lg">
|
||||
|
||||
<div class="col-md-2 col-xs-4 q-ml-lg">
|
||||
<q-btn unelevated @click="show = true" color="primary" icon="settings">
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<div class="col-md-8 col-xs-4">
|
||||
<div class="row justify-center q-gutter-x-md items-center">
|
||||
<div class="text-h3">{{satBtc(total)}}</div>
|
||||
<div :class="{'text-h4': $q.screen.gt.md}">{{satBtc(total)}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2 float-right">
|
||||
<div class="col-md-2 col-xs-4 q-pr-lg">
|
||||
<slot name="serial"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,124 +58,10 @@ async function walletConfig(path) {
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
deriveSecretKey: async function (privateKey, publicKey) {
|
||||
return window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'ECDH',
|
||||
public: publicKey
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
length: 256
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
},
|
||||
old: async function () {
|
||||
// const alicesKeyPair = await window.crypto.subtle.generateKey(
|
||||
// {
|
||||
// name: 'ECDH',
|
||||
// namedCurve: 'P-256'
|
||||
// },
|
||||
// true,
|
||||
// ['deriveKey']
|
||||
// )
|
||||
// const bobsKeyPair = await window.crypto.subtle.generateKey(
|
||||
// {
|
||||
// name: 'ECDH',
|
||||
// namedCurve: 'P-256'
|
||||
// },
|
||||
// true,
|
||||
// ['deriveKey']
|
||||
// )
|
||||
// console.log('### alicesKeyPair', alicesKeyPair)
|
||||
// console.log('### alicesKeyPair.privateKey', alicesKeyPair.privateKey)
|
||||
// console.log('### alicesKeyPair.publicKey', alicesKeyPair.publicKey)
|
||||
// const alicesPriveKey = await window.crypto.subtle.exportKey(
|
||||
// 'jwk',
|
||||
// alicesKeyPair.privateKey
|
||||
// )
|
||||
// console.log('### alicesPriveKey', alicesPriveKey)
|
||||
// const alicesPriveKeyBase64 = toStdBase64(alicesPriveKey.d)
|
||||
// console.log(
|
||||
// '### alicesPriveKeyBase64',
|
||||
// alicesPriveKeyBase64,
|
||||
// base64ToHex(alicesPriveKeyBase64)
|
||||
// )
|
||||
// const bobPublicKey = await window.crypto.subtle.exportKey(
|
||||
// 'raw',
|
||||
// bobsKeyPair.publicKey
|
||||
// )
|
||||
// console.log('### bobPublicKey hex', buf2hex(bobPublicKey))
|
||||
// const sharedSecret01 = await this.deriveSecretKey(alicesKeyPair.privateKey, bobsKeyPair.publicKey)
|
||||
// console.log('### sharedSecret01', sharedSecret01)
|
||||
// const sharedSecret01Raw = await window.crypto.subtle.exportKey('jwk', sharedSecret01)
|
||||
// console.log('### sharedSecret01Raw', sharedSecret01Raw)
|
||||
// const sharedSecret02 = await this.deriveSecretKey(bobsKeyPair.privateKey, alicesKeyPair.publicKey)
|
||||
// console.log('### sharedSecret02', sharedSecret02)
|
||||
// const sharedSecret02Raw = await window.crypto.subtle.exportKey('jwk', sharedSecret02)
|
||||
// console.log('### sharedSecret02Raw', sharedSecret02Raw)
|
||||
// const sharedSecret = nobleSecp256k1.getSharedSecret(
|
||||
// alicesPriveKey,
|
||||
// buf2hex(bobPublicKey)
|
||||
// )
|
||||
// console.log('###', getSharedSecret)
|
||||
},
|
||||
importSecretKey: async function (rawKey) {
|
||||
return window.crypto.subtle.importKey('raw', rawKey, 'AES-GCM', true, [
|
||||
'encrypt',
|
||||
'decrypt'
|
||||
])
|
||||
}
|
||||
},
|
||||
|
||||
created: async function () {
|
||||
await this.getConfig()
|
||||
// ### in: 6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e5130c81c46a35ce411e5fbc1191a0a52eff69f2445df4f9b17ad2b417be66c3710
|
||||
// ### in2: 073059e23605fe508919d892501974905f8b5e13728db63b1b7b9326951abbe4f722c104bc82a4bcf3f1e15ede8ab7d5eb00be8c46271e1f65867f984e9cb5f1
|
||||
|
||||
// const alicesPrivateKey =
|
||||
// '359A8CA1418C49DD26DC7D92C789AC33347F64C6B7789C666098805AF3CC60E5'
|
||||
|
||||
// const bobsPrivateKey =
|
||||
// 'AB52F1F981F639BD83F884703BC690B10DB709FF48806680A0D3FBC6475E6093'
|
||||
|
||||
// const alicesPublicKey = nobleSecp256k1.Point.fromPrivateKey(
|
||||
// alicesPrivateKey
|
||||
// )
|
||||
// console.log('### alicesPublicKey', alicesPublicKey.toHex())
|
||||
|
||||
// const bobsPublicKey = nobleSecp256k1.Point.fromPrivateKey(bobsPrivateKey)
|
||||
// console.log('### bobsPublicKey', bobsPublicKey.toHex())
|
||||
|
||||
// const sharedSecret = nobleSecp256k1.getSharedSecret(
|
||||
// alicesPrivateKey,
|
||||
// bobsPublicKey
|
||||
// )
|
||||
|
||||
// console.log('### sharedSecret naked', sharedSecret)
|
||||
|
||||
// console.log(
|
||||
// '### sharedSecret a',
|
||||
// nobleSecp256k1.utils.bytesToHex(sharedSecret)
|
||||
// )
|
||||
// console.log(
|
||||
// '### sharedSecret b',
|
||||
// nobleSecp256k1.Point.fromHex(sharedSecret)
|
||||
// )
|
||||
// console.log(
|
||||
// '### sharedSecret b',
|
||||
// nobleSecp256k1.Point.fromHex(sharedSecret).toHex(true)
|
||||
// )
|
||||
|
||||
// const alicesPrivateKeyBytes = nobleSecp256k1.utils.hexToBytes(
|
||||
// alicesPrivateKey
|
||||
// )
|
||||
// const x = await this.importSecretKey(alicesPrivateKeyBytes)
|
||||
// console.log('### x', x)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<div class="col-4">
|
||||
<q-btn-dropdown
|
||||
split
|
||||
unelevated
|
||||
@@ -30,9 +30,8 @@
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="col-auto q-pr-lg"></div>
|
||||
<div class="col-auto q-pl-lg">
|
||||
<div class="col-4 q-pl-lg"></div>
|
||||
<div class="col-4 q-pl-lg">
|
||||
<q-input
|
||||
borderless
|
||||
dense
|
||||
|
||||
@@ -19,6 +19,7 @@ const COMMAND_CHECK_PAIRING = '/check-pairing'
|
||||
const DEFAULT_RECEIVE_GAP_LIMIT = 20
|
||||
const PAIRING_CONTROL_TEXT = 'lnbits'
|
||||
|
||||
|
||||
const blockTimeToDate = blockTime =>
|
||||
blockTime ? moment(blockTime * 1000).format('LLL') : ''
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
:network="config.network"
|
||||
:sats-denominated="config.sats_denominated"
|
||||
@signed:psbt="updateSignedPsbt"
|
||||
class="q-pr-lg float-right"
|
||||
></serial-signer>
|
||||
</template>
|
||||
</wallet-config>
|
||||
@@ -33,7 +34,7 @@
|
||||
{% raw %}
|
||||
<q-card>
|
||||
<div class="row q-pt-sm q-pb-sm items-center no-wrap q-mb-md">
|
||||
<div class="col-3 q-pl-md">
|
||||
<div class="col-md-3 col-sm-5 q-pl-md">
|
||||
<q-btn
|
||||
unelevated
|
||||
class="btn-full"
|
||||
@@ -43,14 +44,14 @@
|
||||
>Scan Blockchain</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="col-md-6 col-sm-2 q-pl-md">
|
||||
<q-spinner
|
||||
v-if="scan.scanning == true"
|
||||
color="primary"
|
||||
size="2.55em"
|
||||
></q-spinner>
|
||||
</div>
|
||||
<div class="col-3 q-pr-md">
|
||||
<div class="col-md-3 col-sm-5 q-pr-md">
|
||||
<q-btn
|
||||
v-if="!showPayment"
|
||||
unelevated
|
||||
@@ -170,14 +171,25 @@
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<p v-if="currentAddress">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="ms"
|
||||
icon="content_copy"
|
||||
@click="copyText(props.row.address)"
|
||||
class="q-ml-sm"
|
||||
></q-btn>
|
||||
|
||||
{{ currentAddress.address }}
|
||||
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="ms"
|
||||
icon="launch"
|
||||
type="a"
|
||||
:href="mempoolHostname + '/address/' + currentAddress.address"
|
||||
:href="'https://' + mempoolHostname + '/address/' + currentAddress.address"
|
||||
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</p>
|
||||
@@ -239,5 +251,6 @@
|
||||
<script src="{{ url_for('watchonly_static', path='components/serial-port-config/serial-port-config.js') }}"></script>
|
||||
<script src="{{ url_for('watchonly_static', path='js/crypto/noble-secp256k1.js') }}"></script>
|
||||
<script src="{{ url_for('watchonly_static', path='js/crypto/aes.js') }}"></script>
|
||||
|
||||
<script src="{{ url_for('watchonly_static', path='js/index.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -70,7 +70,7 @@ new Vue({
|
||||
show: false,
|
||||
data: {
|
||||
is_unique: true,
|
||||
use_custom: true,
|
||||
use_custom: false,
|
||||
title: 'Vouchers',
|
||||
min_withdrawable: 0,
|
||||
wait_time: 1
|
||||
@@ -125,7 +125,6 @@ new Vue({
|
||||
var link = _.findWhere(this.withdrawLinks, {id: linkId})
|
||||
|
||||
this.qrCodeDialog.data = _.clone(link)
|
||||
console.log(this.qrCodeDialog.data)
|
||||
this.qrCodeDialog.data.url =
|
||||
window.location.protocol + '//' + window.location.host
|
||||
this.qrCodeDialog.show = true
|
||||
@@ -140,6 +139,11 @@ new Vue({
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
var data = _.omit(this.formDialog.data, 'wallet')
|
||||
|
||||
if (!data.use_custom) {
|
||||
data.custom_url = null
|
||||
}
|
||||
|
||||
if (data.use_custom && !data?.custom_url) {
|
||||
data.custom_url = CUSTOM_URL
|
||||
}
|
||||
@@ -168,6 +172,10 @@ new Vue({
|
||||
data.title = 'vouchers'
|
||||
data.is_unique = true
|
||||
|
||||
if (!data.use_custom) {
|
||||
data.custom_url = null
|
||||
}
|
||||
|
||||
if (data.use_custom && !data?.custom_url) {
|
||||
data.custom_url = '/static/images/default_voucher.png'
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@
|
||||
v-model="formDialog.data.custom_url"
|
||||
type="text"
|
||||
label="Custom design .png (optional)"
|
||||
hint="Enter a URL if you want to use a custom design or leave blank for showing only the QR"
|
||||
hint="Enter a URL if you want to use a custom design or leave blank for LNbits designed vouchers!"
|
||||
></q-input>
|
||||
<q-list>
|
||||
<q-item tag="label" class="rounded-borders">
|
||||
@@ -353,7 +353,7 @@
|
||||
v-model="simpleformDialog.data.custom_url"
|
||||
type="text"
|
||||
label="Custom design .png (optional)"
|
||||
hint="Enter a URL if you want to use a custom design or leave blank for showing only the QR"
|
||||
hint="Enter a URL if you want to use a custom design or leave blank for LNbits designed vouchers!"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
@@ -418,16 +418,18 @@
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="link"
|
||||
@click="copyText(qrCodeDialog.data.withdraw_url, 'Link copied to clipboard!')"
|
||||
>Shareable link</q-btn
|
||||
>
|
||||
><q-tooltip>Copy sharable link</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="nfc"
|
||||
@click="writeNfcTag(qrCodeDialog.data.lnurl)"
|
||||
:disable="nfcTagWriting"
|
||||
></q-btn>
|
||||
><q-tooltip>Write to NFC</q-tooltip></q-btn
|
||||
>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@@ -435,7 +437,8 @@
|
||||
type="a"
|
||||
:href="qrCodeDialog.data.print_url"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
><q-tooltip>Print</q-tooltip></q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
|
||||
@@ -113,7 +113,7 @@ async def api_link_create_or_update(
|
||||
return {**link.dict(), **{"lnurl": link.lnurl(req)}}
|
||||
|
||||
|
||||
@withdraw_ext.delete("/api/v1/links/{link_id}")
|
||||
@withdraw_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
|
||||
link = await get_withdraw_link(link_id)
|
||||
|
||||
@@ -128,7 +128,7 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admi
|
||||
)
|
||||
|
||||
await delete_withdraw_link(link_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@withdraw_ext.get("/api/v1/links/{the_hash}/{lnurl_id}", status_code=HTTPStatus.OK)
|
||||
|
||||
+40
-9
@@ -1,18 +1,49 @@
|
||||
import time
|
||||
|
||||
import click
|
||||
import uvicorn
|
||||
|
||||
from lnbits.settings import HOST, PORT
|
||||
|
||||
@click.command()
|
||||
@click.option("--port", default="5000", help="Port to run LNBits on")
|
||||
@click.option("--host", default="127.0.0.1", help="Host to run LNBits on")
|
||||
def main(port, host):
|
||||
|
||||
@click.command(
|
||||
context_settings=dict(
|
||||
ignore_unknown_options=True,
|
||||
allow_extra_args=True,
|
||||
)
|
||||
)
|
||||
@click.option("--port", default=PORT, help="Port to listen on")
|
||||
@click.option("--host", default=HOST, help="Host to run LNBits on")
|
||||
@click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile")
|
||||
@click.option("--ssl-certfile", default=None, help="Path to SSL certificate")
|
||||
@click.pass_context
|
||||
def main(ctx, port: int, host: str, ssl_keyfile: str, ssl_certfile: str):
|
||||
"""Launched with `poetry run lnbits` at root level"""
|
||||
uvicorn.run("lnbits.__main__:app", port=port, host=host)
|
||||
# this beautiful beast parses all command line arguments and passes them to the uvicorn server
|
||||
d = dict()
|
||||
for a in ctx.args:
|
||||
item = a.split("=")
|
||||
if len(item) > 1: # argument like --key=value
|
||||
print(a, item)
|
||||
d[item[0].strip("--").replace("-", "_")] = (
|
||||
int(item[1]) # need to convert to int if it's a number
|
||||
if item[1].isdigit()
|
||||
else item[1]
|
||||
)
|
||||
else:
|
||||
d[a.strip("--")] = True # argument like --key
|
||||
|
||||
config = uvicorn.Config(
|
||||
"lnbits.__main__:app",
|
||||
port=port,
|
||||
host=host,
|
||||
ssl_keyfile=ssl_keyfile,
|
||||
ssl_certfile=ssl_certfile,
|
||||
**d
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
server.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
# def main():
|
||||
# """Launched with `poetry run start` at root level"""
|
||||
# uvicorn.run("lnbits.__main__:app")
|
||||
|
||||
@@ -55,6 +55,8 @@ FAKE_WALLET = getattr(wallets_module, "FakeWallet")()
|
||||
DEFAULT_WALLET_NAME = env.str("LNBITS_DEFAULT_WALLET_NAME", default="LNbits wallet")
|
||||
PREFER_SECURE_URLS = env.bool("LNBITS_FORCE_HTTPS", default=True)
|
||||
|
||||
RESERVE_FEE_MIN = env.int("LNBITS_RESERVE_FEE_MIN", default=2000)
|
||||
RESERVE_FEE_PERCENT = env.float("LNBITS_RESERVE_FEE_PERCENT", default=1.0)
|
||||
SERVICE_FEE = env.float("LNBITS_SERVICE_FEE", default=0.0)
|
||||
|
||||
try:
|
||||
|
||||
@@ -179,6 +179,11 @@ Vue.component('lnbits-extension-list', {
|
||||
|
||||
Vue.component('lnbits-payment-details', {
|
||||
props: ['payment'],
|
||||
data: function () {
|
||||
return {
|
||||
LNBITS_DENOMINATION: LNBITS_DENOMINATION
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="q-py-md" style="text-align: left">
|
||||
<div class="row justify-center q-mb-md">
|
||||
|
||||
@@ -6,6 +6,7 @@ from .cln import CoreLightningWallet as CLightningWallet
|
||||
from .eclair import EclairWallet
|
||||
from .fake import FakeWallet
|
||||
from .lnbits import LNbitsWallet
|
||||
from .lndgrpc import LndWallet
|
||||
from .lndrest import LndRestWallet
|
||||
from .lnpay import LNPayWallet
|
||||
from .lntxbot import LntxbotWallet
|
||||
|
||||
@@ -46,12 +46,19 @@ class ClicheWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
) -> InvoiceResponse:
|
||||
if description_hash:
|
||||
description_hash_hashed = hashlib.sha256(description_hash).hexdigest()
|
||||
if unhashed_description or description_hash:
|
||||
description_hash_str = (
|
||||
description_hash.hex()
|
||||
if description_hash
|
||||
else hashlib.sha256(unhashed_description).hexdigest()
|
||||
if unhashed_description
|
||||
else None
|
||||
)
|
||||
ws = create_connection(self.endpoint)
|
||||
ws.send(
|
||||
f"create-invoice --msatoshi {amount*1000} --description_hash {description_hash_hashed}"
|
||||
f"create-invoice --msatoshi {amount*1000} --description_hash {description_hash_str}"
|
||||
)
|
||||
r = ws.recv()
|
||||
else:
|
||||
|
||||
+11
-6
@@ -82,21 +82,26 @@ class CoreLightningWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
) -> InvoiceResponse:
|
||||
label = "lbl{}".format(random.random())
|
||||
msat = amount * 1000
|
||||
msat: int = int(amount * 1000)
|
||||
try:
|
||||
if description_hash and not self.supports_description_hash:
|
||||
raise Unsupported("description_hash")
|
||||
if description_hash and not unhashed_description:
|
||||
raise Unsupported(
|
||||
"'description_hash' unsupported by CLN, provide 'unhashed_description'"
|
||||
)
|
||||
if unhashed_description and not self.supports_description_hash:
|
||||
raise Unsupported("unhashed_description")
|
||||
r = self.ln.invoice(
|
||||
msatoshi=msat,
|
||||
label=label,
|
||||
description=description_hash.decode("utf-8")
|
||||
if description_hash
|
||||
description=unhashed_description.decode("utf-8")
|
||||
if unhashed_description
|
||||
else memo,
|
||||
exposeprivatechannels=True,
|
||||
deschashonly=True
|
||||
if description_hash
|
||||
if unhashed_description
|
||||
else False, # we can't pass None here
|
||||
)
|
||||
|
||||
|
||||
@@ -69,11 +69,14 @@ class EclairWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
) -> InvoiceResponse:
|
||||
|
||||
data: Dict = {"amountMsat": amount * 1000}
|
||||
if description_hash:
|
||||
data["description_hash"] = hashlib.sha256(description_hash).hexdigest()
|
||||
data["description_hash"] = description_hash.hex()
|
||||
elif unhashed_description:
|
||||
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
|
||||
else:
|
||||
data["description"] = memo or ""
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ class FakeWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
) -> InvoiceResponse:
|
||||
# we set a default secret since FakeWallet is used for internal=True invoices
|
||||
# and the user might not have configured a secret yet
|
||||
@@ -61,7 +62,10 @@ class FakeWallet(Wallet):
|
||||
data["timestamp"] = datetime.now().timestamp()
|
||||
if description_hash:
|
||||
data["tags_set"] = ["h"]
|
||||
data["description_hash"] = description_hash.decode("utf-8")
|
||||
data["description_hash"] = description_hash
|
||||
elif unhashed_description:
|
||||
data["tags_set"] = ["d"]
|
||||
data["description_hash"] = hashlib.sha256(unhashed_description).digest()
|
||||
else:
|
||||
data["tags_set"] = ["d"]
|
||||
data["memo"] = memo
|
||||
|
||||
@@ -57,10 +57,13 @@ class LNbitsWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict = {"out": False, "amount": amount}
|
||||
if description_hash:
|
||||
data["description_hash"] = hashlib.sha256(description_hash).hexdigest()
|
||||
data["description_hash"] = description_hash.hex()
|
||||
elif unhashed_description:
|
||||
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
|
||||
else:
|
||||
data["memo"] = memo or ""
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,871 @@
|
||||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
|
||||
import lnbits.wallets.lnd_grpc_files.lightning_pb2 as lightning__pb2
|
||||
import lnbits.wallets.lnd_grpc_files.router_pb2 as router__pb2
|
||||
|
||||
|
||||
class RouterStub(object):
|
||||
"""Router is a service that offers advanced interaction with the router
|
||||
subsystem of the daemon.
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.SendPaymentV2 = channel.unary_stream(
|
||||
"/routerrpc.Router/SendPaymentV2",
|
||||
request_serializer=router__pb2.SendPaymentRequest.SerializeToString,
|
||||
response_deserializer=lightning__pb2.Payment.FromString,
|
||||
)
|
||||
self.TrackPaymentV2 = channel.unary_stream(
|
||||
"/routerrpc.Router/TrackPaymentV2",
|
||||
request_serializer=router__pb2.TrackPaymentRequest.SerializeToString,
|
||||
response_deserializer=lightning__pb2.Payment.FromString,
|
||||
)
|
||||
self.EstimateRouteFee = channel.unary_unary(
|
||||
"/routerrpc.Router/EstimateRouteFee",
|
||||
request_serializer=router__pb2.RouteFeeRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.RouteFeeResponse.FromString,
|
||||
)
|
||||
self.SendToRoute = channel.unary_unary(
|
||||
"/routerrpc.Router/SendToRoute",
|
||||
request_serializer=router__pb2.SendToRouteRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.SendToRouteResponse.FromString,
|
||||
)
|
||||
self.SendToRouteV2 = channel.unary_unary(
|
||||
"/routerrpc.Router/SendToRouteV2",
|
||||
request_serializer=router__pb2.SendToRouteRequest.SerializeToString,
|
||||
response_deserializer=lightning__pb2.HTLCAttempt.FromString,
|
||||
)
|
||||
self.ResetMissionControl = channel.unary_unary(
|
||||
"/routerrpc.Router/ResetMissionControl",
|
||||
request_serializer=router__pb2.ResetMissionControlRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.ResetMissionControlResponse.FromString,
|
||||
)
|
||||
self.QueryMissionControl = channel.unary_unary(
|
||||
"/routerrpc.Router/QueryMissionControl",
|
||||
request_serializer=router__pb2.QueryMissionControlRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.QueryMissionControlResponse.FromString,
|
||||
)
|
||||
self.XImportMissionControl = channel.unary_unary(
|
||||
"/routerrpc.Router/XImportMissionControl",
|
||||
request_serializer=router__pb2.XImportMissionControlRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.XImportMissionControlResponse.FromString,
|
||||
)
|
||||
self.GetMissionControlConfig = channel.unary_unary(
|
||||
"/routerrpc.Router/GetMissionControlConfig",
|
||||
request_serializer=router__pb2.GetMissionControlConfigRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.GetMissionControlConfigResponse.FromString,
|
||||
)
|
||||
self.SetMissionControlConfig = channel.unary_unary(
|
||||
"/routerrpc.Router/SetMissionControlConfig",
|
||||
request_serializer=router__pb2.SetMissionControlConfigRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.SetMissionControlConfigResponse.FromString,
|
||||
)
|
||||
self.QueryProbability = channel.unary_unary(
|
||||
"/routerrpc.Router/QueryProbability",
|
||||
request_serializer=router__pb2.QueryProbabilityRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.QueryProbabilityResponse.FromString,
|
||||
)
|
||||
self.BuildRoute = channel.unary_unary(
|
||||
"/routerrpc.Router/BuildRoute",
|
||||
request_serializer=router__pb2.BuildRouteRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.BuildRouteResponse.FromString,
|
||||
)
|
||||
self.SubscribeHtlcEvents = channel.unary_stream(
|
||||
"/routerrpc.Router/SubscribeHtlcEvents",
|
||||
request_serializer=router__pb2.SubscribeHtlcEventsRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.HtlcEvent.FromString,
|
||||
)
|
||||
self.SendPayment = channel.unary_stream(
|
||||
"/routerrpc.Router/SendPayment",
|
||||
request_serializer=router__pb2.SendPaymentRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.PaymentStatus.FromString,
|
||||
)
|
||||
self.TrackPayment = channel.unary_stream(
|
||||
"/routerrpc.Router/TrackPayment",
|
||||
request_serializer=router__pb2.TrackPaymentRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.PaymentStatus.FromString,
|
||||
)
|
||||
self.HtlcInterceptor = channel.stream_stream(
|
||||
"/routerrpc.Router/HtlcInterceptor",
|
||||
request_serializer=router__pb2.ForwardHtlcInterceptResponse.SerializeToString,
|
||||
response_deserializer=router__pb2.ForwardHtlcInterceptRequest.FromString,
|
||||
)
|
||||
self.UpdateChanStatus = channel.unary_unary(
|
||||
"/routerrpc.Router/UpdateChanStatus",
|
||||
request_serializer=router__pb2.UpdateChanStatusRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.UpdateChanStatusResponse.FromString,
|
||||
)
|
||||
|
||||
|
||||
class RouterServicer(object):
|
||||
"""Router is a service that offers advanced interaction with the router
|
||||
subsystem of the daemon.
|
||||
"""
|
||||
|
||||
def SendPaymentV2(self, request, context):
|
||||
"""
|
||||
SendPaymentV2 attempts to route a payment described by the passed
|
||||
PaymentRequest to the final destination. The call returns a stream of
|
||||
payment updates.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def TrackPaymentV2(self, request, context):
|
||||
"""
|
||||
TrackPaymentV2 returns an update stream for the payment identified by the
|
||||
payment hash.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def EstimateRouteFee(self, request, context):
|
||||
"""
|
||||
EstimateRouteFee allows callers to obtain a lower bound w.r.t how much it
|
||||
may cost to send an HTLC to the target end destination.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def SendToRoute(self, request, context):
|
||||
"""
|
||||
Deprecated, use SendToRouteV2. SendToRoute attempts to make a payment via
|
||||
the specified route. This method differs from SendPayment in that it
|
||||
allows users to specify a full route manually. This can be used for
|
||||
things like rebalancing, and atomic swaps. It differs from the newer
|
||||
SendToRouteV2 in that it doesn't return the full HTLC information.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def SendToRouteV2(self, request, context):
|
||||
"""
|
||||
SendToRouteV2 attempts to make a payment via the specified route. This
|
||||
method differs from SendPayment in that it allows users to specify a full
|
||||
route manually. This can be used for things like rebalancing, and atomic
|
||||
swaps.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def ResetMissionControl(self, request, context):
|
||||
"""
|
||||
ResetMissionControl clears all mission control state and starts with a clean
|
||||
slate.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def QueryMissionControl(self, request, context):
|
||||
"""
|
||||
QueryMissionControl exposes the internal mission control state to callers.
|
||||
It is a development feature.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def XImportMissionControl(self, request, context):
|
||||
"""
|
||||
XImportMissionControl is an experimental API that imports the state provided
|
||||
to the internal mission control's state, using all results which are more
|
||||
recent than our existing values. These values will only be imported
|
||||
in-memory, and will not be persisted across restarts.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def GetMissionControlConfig(self, request, context):
|
||||
"""
|
||||
GetMissionControlConfig returns mission control's current config.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def SetMissionControlConfig(self, request, context):
|
||||
"""
|
||||
SetMissionControlConfig will set mission control's config, if the config
|
||||
provided is valid.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def QueryProbability(self, request, context):
|
||||
"""
|
||||
QueryProbability returns the current success probability estimate for a
|
||||
given node pair and amount.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def BuildRoute(self, request, context):
|
||||
"""
|
||||
BuildRoute builds a fully specified route based on a list of hop public
|
||||
keys. It retrieves the relevant channel policies from the graph in order to
|
||||
calculate the correct fees and time locks.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def SubscribeHtlcEvents(self, request, context):
|
||||
"""
|
||||
SubscribeHtlcEvents creates a uni-directional stream from the server to
|
||||
the client which delivers a stream of htlc events.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def SendPayment(self, request, context):
|
||||
"""
|
||||
Deprecated, use SendPaymentV2. SendPayment attempts to route a payment
|
||||
described by the passed PaymentRequest to the final destination. The call
|
||||
returns a stream of payment status updates.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def TrackPayment(self, request, context):
|
||||
"""
|
||||
Deprecated, use TrackPaymentV2. TrackPayment returns an update stream for
|
||||
the payment identified by the payment hash.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def HtlcInterceptor(self, request_iterator, context):
|
||||
"""*
|
||||
HtlcInterceptor dispatches a bi-directional streaming RPC in which
|
||||
Forwarded HTLC requests are sent to the client and the client responds with
|
||||
a boolean that tells LND if this htlc should be intercepted.
|
||||
In case of interception, the htlc can be either settled, cancelled or
|
||||
resumed later by using the ResolveHoldForward endpoint.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def UpdateChanStatus(self, request, context):
|
||||
"""
|
||||
UpdateChanStatus attempts to manually set the state of a channel
|
||||
(enabled, disabled, or auto). A manual "disable" request will cause the
|
||||
channel to stay disabled until a subsequent manual request of either
|
||||
"enable" or "auto".
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
|
||||
def add_RouterServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
"SendPaymentV2": grpc.unary_stream_rpc_method_handler(
|
||||
servicer.SendPaymentV2,
|
||||
request_deserializer=router__pb2.SendPaymentRequest.FromString,
|
||||
response_serializer=lightning__pb2.Payment.SerializeToString,
|
||||
),
|
||||
"TrackPaymentV2": grpc.unary_stream_rpc_method_handler(
|
||||
servicer.TrackPaymentV2,
|
||||
request_deserializer=router__pb2.TrackPaymentRequest.FromString,
|
||||
response_serializer=lightning__pb2.Payment.SerializeToString,
|
||||
),
|
||||
"EstimateRouteFee": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.EstimateRouteFee,
|
||||
request_deserializer=router__pb2.RouteFeeRequest.FromString,
|
||||
response_serializer=router__pb2.RouteFeeResponse.SerializeToString,
|
||||
),
|
||||
"SendToRoute": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.SendToRoute,
|
||||
request_deserializer=router__pb2.SendToRouteRequest.FromString,
|
||||
response_serializer=router__pb2.SendToRouteResponse.SerializeToString,
|
||||
),
|
||||
"SendToRouteV2": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.SendToRouteV2,
|
||||
request_deserializer=router__pb2.SendToRouteRequest.FromString,
|
||||
response_serializer=lightning__pb2.HTLCAttempt.SerializeToString,
|
||||
),
|
||||
"ResetMissionControl": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.ResetMissionControl,
|
||||
request_deserializer=router__pb2.ResetMissionControlRequest.FromString,
|
||||
response_serializer=router__pb2.ResetMissionControlResponse.SerializeToString,
|
||||
),
|
||||
"QueryMissionControl": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.QueryMissionControl,
|
||||
request_deserializer=router__pb2.QueryMissionControlRequest.FromString,
|
||||
response_serializer=router__pb2.QueryMissionControlResponse.SerializeToString,
|
||||
),
|
||||
"XImportMissionControl": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.XImportMissionControl,
|
||||
request_deserializer=router__pb2.XImportMissionControlRequest.FromString,
|
||||
response_serializer=router__pb2.XImportMissionControlResponse.SerializeToString,
|
||||
),
|
||||
"GetMissionControlConfig": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetMissionControlConfig,
|
||||
request_deserializer=router__pb2.GetMissionControlConfigRequest.FromString,
|
||||
response_serializer=router__pb2.GetMissionControlConfigResponse.SerializeToString,
|
||||
),
|
||||
"SetMissionControlConfig": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.SetMissionControlConfig,
|
||||
request_deserializer=router__pb2.SetMissionControlConfigRequest.FromString,
|
||||
response_serializer=router__pb2.SetMissionControlConfigResponse.SerializeToString,
|
||||
),
|
||||
"QueryProbability": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.QueryProbability,
|
||||
request_deserializer=router__pb2.QueryProbabilityRequest.FromString,
|
||||
response_serializer=router__pb2.QueryProbabilityResponse.SerializeToString,
|
||||
),
|
||||
"BuildRoute": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.BuildRoute,
|
||||
request_deserializer=router__pb2.BuildRouteRequest.FromString,
|
||||
response_serializer=router__pb2.BuildRouteResponse.SerializeToString,
|
||||
),
|
||||
"SubscribeHtlcEvents": grpc.unary_stream_rpc_method_handler(
|
||||
servicer.SubscribeHtlcEvents,
|
||||
request_deserializer=router__pb2.SubscribeHtlcEventsRequest.FromString,
|
||||
response_serializer=router__pb2.HtlcEvent.SerializeToString,
|
||||
),
|
||||
"SendPayment": grpc.unary_stream_rpc_method_handler(
|
||||
servicer.SendPayment,
|
||||
request_deserializer=router__pb2.SendPaymentRequest.FromString,
|
||||
response_serializer=router__pb2.PaymentStatus.SerializeToString,
|
||||
),
|
||||
"TrackPayment": grpc.unary_stream_rpc_method_handler(
|
||||
servicer.TrackPayment,
|
||||
request_deserializer=router__pb2.TrackPaymentRequest.FromString,
|
||||
response_serializer=router__pb2.PaymentStatus.SerializeToString,
|
||||
),
|
||||
"HtlcInterceptor": grpc.stream_stream_rpc_method_handler(
|
||||
servicer.HtlcInterceptor,
|
||||
request_deserializer=router__pb2.ForwardHtlcInterceptResponse.FromString,
|
||||
response_serializer=router__pb2.ForwardHtlcInterceptRequest.SerializeToString,
|
||||
),
|
||||
"UpdateChanStatus": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.UpdateChanStatus,
|
||||
request_deserializer=router__pb2.UpdateChanStatusRequest.FromString,
|
||||
response_serializer=router__pb2.UpdateChanStatusResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
"routerrpc.Router", rpc_method_handlers
|
||||
)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class Router(object):
|
||||
"""Router is a service that offers advanced interaction with the router
|
||||
subsystem of the daemon.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def SendPaymentV2(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_stream(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/SendPaymentV2",
|
||||
router__pb2.SendPaymentRequest.SerializeToString,
|
||||
lightning__pb2.Payment.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def TrackPaymentV2(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_stream(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/TrackPaymentV2",
|
||||
router__pb2.TrackPaymentRequest.SerializeToString,
|
||||
lightning__pb2.Payment.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def EstimateRouteFee(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/EstimateRouteFee",
|
||||
router__pb2.RouteFeeRequest.SerializeToString,
|
||||
router__pb2.RouteFeeResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def SendToRoute(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/SendToRoute",
|
||||
router__pb2.SendToRouteRequest.SerializeToString,
|
||||
router__pb2.SendToRouteResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def SendToRouteV2(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/SendToRouteV2",
|
||||
router__pb2.SendToRouteRequest.SerializeToString,
|
||||
lightning__pb2.HTLCAttempt.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def ResetMissionControl(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/ResetMissionControl",
|
||||
router__pb2.ResetMissionControlRequest.SerializeToString,
|
||||
router__pb2.ResetMissionControlResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def QueryMissionControl(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/QueryMissionControl",
|
||||
router__pb2.QueryMissionControlRequest.SerializeToString,
|
||||
router__pb2.QueryMissionControlResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def XImportMissionControl(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/XImportMissionControl",
|
||||
router__pb2.XImportMissionControlRequest.SerializeToString,
|
||||
router__pb2.XImportMissionControlResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def GetMissionControlConfig(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/GetMissionControlConfig",
|
||||
router__pb2.GetMissionControlConfigRequest.SerializeToString,
|
||||
router__pb2.GetMissionControlConfigResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def SetMissionControlConfig(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/SetMissionControlConfig",
|
||||
router__pb2.SetMissionControlConfigRequest.SerializeToString,
|
||||
router__pb2.SetMissionControlConfigResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def QueryProbability(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/QueryProbability",
|
||||
router__pb2.QueryProbabilityRequest.SerializeToString,
|
||||
router__pb2.QueryProbabilityResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def BuildRoute(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/BuildRoute",
|
||||
router__pb2.BuildRouteRequest.SerializeToString,
|
||||
router__pb2.BuildRouteResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def SubscribeHtlcEvents(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_stream(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/SubscribeHtlcEvents",
|
||||
router__pb2.SubscribeHtlcEventsRequest.SerializeToString,
|
||||
router__pb2.HtlcEvent.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def SendPayment(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_stream(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/SendPayment",
|
||||
router__pb2.SendPaymentRequest.SerializeToString,
|
||||
router__pb2.PaymentStatus.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def TrackPayment(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_stream(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/TrackPayment",
|
||||
router__pb2.TrackPaymentRequest.SerializeToString,
|
||||
router__pb2.PaymentStatus.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def HtlcInterceptor(
|
||||
request_iterator,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.stream_stream(
|
||||
request_iterator,
|
||||
target,
|
||||
"/routerrpc.Router/HtlcInterceptor",
|
||||
router__pb2.ForwardHtlcInterceptResponse.SerializeToString,
|
||||
router__pb2.ForwardHtlcInterceptRequest.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def UpdateChanStatus(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/UpdateChanStatus",
|
||||
router__pb2.UpdateChanStatusRequest.SerializeToString,
|
||||
router__pb2.UpdateChanStatusResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
+93
-29
@@ -2,10 +2,11 @@ imports_ok = True
|
||||
try:
|
||||
import grpc
|
||||
from google import protobuf
|
||||
from grpc import RpcError
|
||||
except ImportError: # pragma: nocover
|
||||
imports_ok = False
|
||||
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
@@ -19,6 +20,8 @@ from .macaroon import AESCipher, load_macaroon
|
||||
if imports_ok:
|
||||
import lnbits.wallets.lnd_grpc_files.lightning_pb2 as ln
|
||||
import lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc as lnrpc
|
||||
import lnbits.wallets.lnd_grpc_files.router_pb2 as router
|
||||
import lnbits.wallets.lnd_grpc_files.router_pb2_grpc as routerrpc
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
@@ -111,6 +114,7 @@ class LndWallet(Wallet):
|
||||
f"{self.endpoint}:{self.port}", composite_creds
|
||||
)
|
||||
self.rpc = lnrpc.LightningStub(channel)
|
||||
self.routerpc = routerrpc.RouterStub(channel)
|
||||
|
||||
def metadata_callback(self, _, callback):
|
||||
callback([("macaroon", self.macaroon)], None)
|
||||
@@ -118,6 +122,8 @@ class LndWallet(Wallet):
|
||||
async def status(self) -> StatusResponse:
|
||||
try:
|
||||
resp = await self.rpc.ChannelBalance(ln.ChannelBalanceRequest())
|
||||
except RpcError as exc:
|
||||
return StatusResponse(str(exc._details), 0)
|
||||
except Exception as exc:
|
||||
return StatusResponse(str(exc), 0)
|
||||
|
||||
@@ -128,13 +134,15 @@ class LndWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
) -> InvoiceResponse:
|
||||
params: Dict = {"value": amount, "expiry": 600, "private": True}
|
||||
|
||||
if description_hash:
|
||||
params["description_hash"] = base64.b64encode(
|
||||
hashlib.sha256(description_hash).digest()
|
||||
) # as bytes directly
|
||||
params["description_hash"] = description_hash
|
||||
elif unhashed_description:
|
||||
params["description_hash"] = hashlib.sha256(
|
||||
unhashed_description
|
||||
).digest() # as bytes directly
|
||||
else:
|
||||
params["memo"] = memo or ""
|
||||
|
||||
@@ -150,18 +158,39 @@ class LndWallet(Wallet):
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000)
|
||||
req = ln.SendRequest(payment_request=bolt11, fee_limit=fee_limit_fixed)
|
||||
resp = await self.rpc.SendPaymentSync(req)
|
||||
# fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000)
|
||||
req = router.SendPaymentRequest(
|
||||
payment_request=bolt11,
|
||||
fee_limit_msat=fee_limit_msat,
|
||||
timeout_seconds=30,
|
||||
no_inflight_updates=True,
|
||||
)
|
||||
try:
|
||||
resp = await self.routerpc.SendPaymentV2(req).read()
|
||||
except RpcError as exc:
|
||||
return PaymentResponse(False, "", 0, None, exc._details)
|
||||
except Exception as exc:
|
||||
return PaymentResponse(False, "", 0, None, str(exc))
|
||||
|
||||
if resp.payment_error:
|
||||
return PaymentResponse(False, "", 0, None, resp.payment_error)
|
||||
# PaymentStatus from https://github.com/lightningnetwork/lnd/blob/master/channeldb/payments.go#L178
|
||||
statuses = {
|
||||
0: None, # NON_EXISTENT
|
||||
1: None, # IN_FLIGHT
|
||||
2: True, # SUCCEEDED
|
||||
3: False, # FAILED
|
||||
}
|
||||
|
||||
r_hash = hashlib.sha256(resp.payment_preimage).digest()
|
||||
checking_id = stringify_checking_id(r_hash)
|
||||
fee_msat = resp.payment_route.total_fees_msat
|
||||
preimage = resp.payment_preimage.hex()
|
||||
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
|
||||
if resp.status in [0, 1, 3]:
|
||||
fee_msat = 0
|
||||
preimage = ""
|
||||
checking_id = ""
|
||||
elif resp.status == 2: # SUCCEEDED
|
||||
fee_msat = resp.htlcs[-1].route.total_fees_msat
|
||||
preimage = resp.payment_preimage
|
||||
checking_id = resp.payment_hash
|
||||
return PaymentResponse(
|
||||
statuses[resp.status], checking_id, fee_msat, preimage, None
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
@@ -180,20 +209,55 @@ class LndWallet(Wallet):
|
||||
return PaymentStatus(None)
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
return PaymentStatus(True)
|
||||
"""
|
||||
This routine checks the payment status using routerpc.TrackPaymentV2.
|
||||
"""
|
||||
try:
|
||||
r_hash = parse_checking_id(checking_id)
|
||||
if len(r_hash) != 32:
|
||||
raise binascii.Error
|
||||
except binascii.Error:
|
||||
# this may happen if we switch between backend wallets
|
||||
# that use different checking_id formats
|
||||
return PaymentStatus(None)
|
||||
|
||||
# for some reason our checking_ids are in base64 but the payment hashes
|
||||
# returned here are in hex, lnd is weird
|
||||
checking_id = checking_id.replace("_", "/")
|
||||
checking_id = base64.b64decode(checking_id).hex()
|
||||
|
||||
resp = self.routerpc.TrackPaymentV2(
|
||||
router.TrackPaymentRequest(payment_hash=r_hash)
|
||||
)
|
||||
|
||||
# HTLCAttempt.HTLCStatus:
|
||||
# https://github.com/lightningnetwork/lnd/blob/master/lnrpc/lightning.proto#L3641
|
||||
statuses = {
|
||||
0: None, # IN_FLIGHT
|
||||
1: True, # "SUCCEEDED"
|
||||
2: False, # "FAILED"
|
||||
}
|
||||
|
||||
try:
|
||||
async for payment in resp:
|
||||
return PaymentStatus(statuses[payment.htlcs[-1].status])
|
||||
except: # most likely the payment wasn't found
|
||||
return PaymentStatus(None)
|
||||
|
||||
return PaymentStatus(None)
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
request = ln.InvoiceSubscription()
|
||||
try:
|
||||
async for i in self.rpc.SubscribeInvoices(request):
|
||||
if not i.settled:
|
||||
continue
|
||||
while True:
|
||||
request = ln.InvoiceSubscription()
|
||||
try:
|
||||
async for i in self.rpc.SubscribeInvoices(request):
|
||||
if not i.settled:
|
||||
continue
|
||||
|
||||
checking_id = stringify_checking_id(i.r_hash)
|
||||
yield checking_id
|
||||
except error:
|
||||
logger.error(error)
|
||||
|
||||
logger.error(
|
||||
"lost connection to lnd InvoiceSubscription, please restart lnbits."
|
||||
)
|
||||
checking_id = stringify_checking_id(i.r_hash)
|
||||
yield checking_id
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"lost connection to lnd invoices stream: '{exc}', retrying in 5 seconds"
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
+37
-25
@@ -73,11 +73,17 @@ class LndRestWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict = {"value": amount, "private": True}
|
||||
if description_hash:
|
||||
data["description_hash"] = base64.b64encode(description_hash).decode(
|
||||
"ascii"
|
||||
)
|
||||
elif unhashed_description:
|
||||
data["description_hash"] = base64.b64encode(
|
||||
hashlib.sha256(description_hash).digest()
|
||||
hashlib.sha256(unhashed_description).digest()
|
||||
).decode("ascii")
|
||||
else:
|
||||
data["memo"] = memo or ""
|
||||
@@ -142,15 +148,10 @@ class LndRestWallet(Wallet):
|
||||
return PaymentStatus(True)
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
async with httpx.AsyncClient(verify=self.cert) as client:
|
||||
r = await client.get(
|
||||
url=f"{self.endpoint}/v1/payments",
|
||||
headers=self.auth,
|
||||
params={"max_payments": "20", "reversed": True},
|
||||
)
|
||||
|
||||
if r.is_error:
|
||||
return PaymentStatus(None)
|
||||
"""
|
||||
This routine checks the payment status using routerpc.TrackPaymentV2.
|
||||
"""
|
||||
url = f"{self.endpoint}/v2/router/track/{checking_id}"
|
||||
|
||||
# check payment.status:
|
||||
# https://api.lightning.community/rest/index.html?python#peersynctype
|
||||
@@ -161,14 +162,27 @@ class LndRestWallet(Wallet):
|
||||
"FAILED": False,
|
||||
}
|
||||
|
||||
# for some reason our checking_ids are in base64 but the payment hashes
|
||||
# returned here are in hex, lnd is weird
|
||||
checking_id = checking_id.replace("_", "/")
|
||||
checking_id = base64.b64decode(checking_id).hex()
|
||||
|
||||
for p in r.json()["payments"]:
|
||||
if p["payment_hash"] == checking_id:
|
||||
return PaymentStatus(statuses[p["status"]])
|
||||
async with httpx.AsyncClient(
|
||||
timeout=None, headers=self.auth, verify=self.cert
|
||||
) as client:
|
||||
async with client.stream("GET", url) as r:
|
||||
async for l in r.aiter_lines():
|
||||
try:
|
||||
line = json.loads(l)
|
||||
if line.get("error"):
|
||||
logger.error(
|
||||
line["error"]["message"]
|
||||
if "message" in line["error"]
|
||||
else line["error"]
|
||||
)
|
||||
return PaymentStatus(None)
|
||||
payment = line.get("result")
|
||||
if payment is not None and payment.get("status"):
|
||||
return PaymentStatus(statuses[payment["status"]])
|
||||
else:
|
||||
return PaymentStatus(None)
|
||||
except:
|
||||
continue
|
||||
|
||||
return PaymentStatus(None)
|
||||
|
||||
@@ -191,10 +205,8 @@ class LndRestWallet(Wallet):
|
||||
|
||||
payment_hash = base64.b64decode(inv["r_hash"]).hex()
|
||||
yield payment_hash
|
||||
except (OSError, httpx.ConnectError, httpx.ReadError):
|
||||
pass
|
||||
|
||||
logger.error(
|
||||
"lost connection to lnd invoices stream, retrying in 5 seconds"
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"lost connection to lnd invoices stream: '{exc}', retrying in 5 seconds"
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
@@ -52,10 +52,14 @@ class LNPayWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict = {"num_satoshis": f"{amount}"}
|
||||
if description_hash:
|
||||
data["description_hash"] = hashlib.sha256(description_hash).hexdigest()
|
||||
data["description_hash"] = description_hash.hex()
|
||||
elif unhashed_description:
|
||||
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
|
||||
else:
|
||||
data["memo"] = memo or ""
|
||||
|
||||
|
||||
@@ -52,10 +52,14 @@ class LntxbotWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict = {"amt": str(amount)}
|
||||
if description_hash:
|
||||
data["description_hash"] = hashlib.sha256(description_hash).hexdigest()
|
||||
data["description_hash"] = description_hash.hex()
|
||||
elif unhashed_description:
|
||||
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
|
||||
else:
|
||||
data["memo"] = memo or ""
|
||||
|
||||
|
||||
@@ -54,8 +54,10 @@ class OpenNodeWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
if description_hash:
|
||||
if description_hash or unhashed_description:
|
||||
raise Unsupported("description_hash")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
|
||||
@@ -93,6 +93,8 @@ class SparkWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
label = "lbs{}".format(random.random())
|
||||
checking_id = label
|
||||
@@ -102,7 +104,13 @@ class SparkWallet(Wallet):
|
||||
r = await self.invoicewithdescriptionhash(
|
||||
msatoshi=amount * 1000,
|
||||
label=label,
|
||||
description_hash=hashlib.sha256(description_hash).hexdigest(),
|
||||
description_hash=description_hash.hex(),
|
||||
)
|
||||
elif unhashed_description:
|
||||
r = await self.invoicewithdescriptionhash(
|
||||
msatoshi=amount * 1000,
|
||||
label=label,
|
||||
description_hash=hashlib.sha256(unhashed_description).hexdigest(),
|
||||
)
|
||||
else:
|
||||
r = await self.invoice(
|
||||
|
||||
@@ -18,6 +18,7 @@ class VoidWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
raise Unsupported("")
|
||||
|
||||
@@ -31,10 +32,10 @@ class VoidWallet(Wallet):
|
||||
raise Unsupported("")
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
raise Unsupported("")
|
||||
return PaymentStatus(None)
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
raise Unsupported("")
|
||||
return PaymentStatus(None)
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
yield ""
|
||||
|
||||
Reference in New Issue
Block a user