Add a payments page for admin (#2910)

This commit is contained in:
Tiago Vasconcelos 2025-02-06 12:48:54 +00:00 committed by GitHub
parent c1d26bb274
commit 34a959f0bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1416 additions and 54 deletions

View File

@ -1,26 +1,23 @@
from time import time
from typing import Literal, Optional
from typing import Optional, Tuple
from lnbits.core.crud.wallets import get_total_balance, get_wallet
from lnbits.core.db import db
from lnbits.core.models import PaymentState
from lnbits.core.models.payments import PaymentsStatusCount
from lnbits.db import DB_TYPE, SQLITE, Connection, Filters, Page
from lnbits.db import Connection, DateTrunc, Filters, Page
from ..models import (
CreatePayment,
Payment,
PaymentCountField,
PaymentCountStat,
PaymentDailyStats,
PaymentFilters,
PaymentHistoryPoint,
PaymentsStatusCount,
PaymentWalletStats,
)
DateTrunc = Literal["hour", "day", "month"]
sqlite_formats = {
"hour": "%Y-%m-%d %H:00:00",
"day": "%Y-%m-%d 00:00:00",
"month": "%Y-%m-01 00:00:00",
}
def update_payment_extra():
pass
@ -304,12 +301,7 @@ async def get_payments_history(
if not filters:
filters = Filters()
if DB_TYPE == SQLITE and group in sqlite_formats:
date_trunc = f"strftime('{sqlite_formats[group]}', time, 'unixepoch')"
elif group in ("day", "hour", "month"):
date_trunc = f"date_trunc('{group}', time)"
else:
raise ValueError(f"Invalid group value: {group}")
date_trunc = db.datetime_grouping(group)
values = {
"wallet_id": wallet_id,
@ -361,6 +353,114 @@ async def get_payments_history(
return results
async def get_payment_count_stats(
field: PaymentCountField,
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
) -> list[PaymentCountStat]:
if not filters:
filters = Filters()
clause = filters.where()
data = await (conn or db).fetchall(
query=f"""
SELECT {field} as field, count(*) as total
FROM apipayments
{clause}
GROUP BY {field}
ORDER BY {field}
""",
values=filters.values(),
model=PaymentCountStat,
)
return data
async def get_daily_stats(
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
) -> Tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters:
filters = Filters()
in_clause = filters.where(
["(apipayments.status = 'success' AND apipayments.amount > 0)"]
)
out_clause = filters.where(
["(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"]
)
date_trunc = db.datetime_grouping("day")
query = """
SELECT {date_trunc} date,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance,
ABS(SUM(apipayments.fee)) as fee,
COUNT(*) as payments_count
FROM apipayments
RIGHT JOIN wallets ON apipayments.wallet_id = wallets.id
{clause}
AND (wallets.deleted = false OR wallets.deleted is NULL)
GROUP BY date
ORDER BY date ASC
"""
data_in = await (conn or db).fetchall(
query=query.format(date_trunc=date_trunc, clause=in_clause),
values=filters.values(),
model=PaymentDailyStats,
)
data_out = await (conn or db).fetchall(
query=query.format(date_trunc=date_trunc, clause=out_clause),
values=filters.values(),
model=PaymentDailyStats,
)
return data_in, data_out
async def get_wallets_stats(
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
) -> list[PaymentWalletStats]:
if not filters:
filters = Filters()
where_stmts = [
"(wallets.deleted = false OR wallets.deleted is NULL)",
"""
(
(apipayments.status = 'success' AND apipayments.amount > 0)
OR (
apipayments.status IN ('success', 'pending')
AND apipayments.amount < 0
)
)
""",
]
clauses = filters.where(where_stmts)
data = await (conn or db).fetchall(
query=f"""
SELECT apipayments.wallet_id,
MAX(wallets.name) AS wallet_name,
MAX(wallets.user) AS user_id,
COUNT(*) as payments_count,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet_id = wallets.id
{clauses}
GROUP BY apipayments.wallet_id
ORDER BY payments_count
""",
values=filters.values(),
model=PaymentWalletStats,
)
return data
async def delete_wallet_payment(
checking_id: str, wallet_id: str, conn: Optional[Connection] = None
) -> None:

View File

@ -14,10 +14,15 @@ from .payments import (
DecodePayment,
PayInvoice,
Payment,
PaymentCountField,
PaymentCountStat,
PaymentDailyStats,
PaymentExtra,
PaymentFilters,
PaymentHistoryPoint,
PaymentsStatusCount,
PaymentState,
PaymentWalletStats,
)
from .tinyurl import TinyURL
from .users import (
@ -63,6 +68,11 @@ __all__ = [
"DecodePayment",
"PayInvoice",
"Payment",
"PaymentCountField",
"PaymentCountStat",
"PaymentDailyStats",
"PaymentsStatusCount",
"PaymentWalletStats",
"PaymentExtra",
"PaymentFilters",
"PaymentHistoryPoint",

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from typing import Literal, Optional
from fastapi import Query
from pydantic import BaseModel, Field, validator
@ -125,22 +125,60 @@ class Payment(BaseModel):
class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount"]
__search_fields__ = ["memo", "amount", "wallet_id", "tag"]
status: str
checking_id: str
__sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
status: Optional[str]
tag: Optional[str]
checking_id: Optional[str]
amount: int
fee: int
memo: Optional[str]
time: datetime
bolt11: str
preimage: str
payment_hash: str
expiry: Optional[datetime]
extra: dict = {}
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
preimage: Optional[str]
payment_hash: Optional[str]
wallet_id: Optional[str]
class PaymentDataPoint(BaseModel):
date: datetime
count: int
max_amount: int
min_amount: int
average_amount: int
total_amount: int
max_fee: int
min_fee: int
average_fee: int
total_fee: int
PaymentCountField = Literal["status", "tag", "extension", "wallet_id"]
class PaymentCountStat(BaseModel):
field: str = ""
total: float = 0
class PaymentWalletStats(BaseModel):
wallet_id: str = ""
wallet_name: str = ""
user_id: str = ""
payments_count: int
balance: float = 0
class PaymentDailyStats(BaseModel):
date: datetime
balance: float = 0
balance_in: Optional[float] = 0
balance_out: Optional[float] = 0
payments_count: int = 0
count_in: Optional[int] = 0
count_out: Optional[int] = 0
fee: float = 0
class PaymentHistoryPoint(BaseModel):

View File

@ -1,5 +1,6 @@
import json
import time
from datetime import datetime, timedelta
from typing import Optional
from bolt11 import Bolt11, MilliSatoshi, Tags
@ -7,10 +8,12 @@ from bolt11 import decode as bolt11_decode
from bolt11 import encode as bolt11_encode
from loguru import logger
from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.notifications import enqueue_notification
from lnbits.db import Connection
from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.settings import settings
@ -427,6 +430,48 @@ async def check_transaction_status(
return await payment.check_status()
async def get_payments_daily_stats(
filters: Filters[PaymentFilters],
) -> list[PaymentDailyStats]:
data_in, data_out = await get_daily_stats(filters)
balance_total: float = 0
_none = PaymentDailyStats(date=datetime.now())
if len(data_in) == 0:
data_in = [_none]
if len(data_out) == 0:
data_out = [_none]
data: list[PaymentDailyStats] = []
start_date = min(data_in[0].date, data_out[0].date)
end_date = max(data_in[-1].date, data_out[-1].date)
delta = timedelta(days=1)
while start_date <= end_date:
data_in_point = next((x for x in data_in if x.date == start_date), _none)
data_out_point = next((x for x in data_out if x.date == start_date), _none)
balance_total += data_in_point.balance + data_out_point.balance
data.append(
PaymentDailyStats(
date=start_date,
balance=balance_total // 1000,
balance_in=data_in_point.balance // 1000,
balance_out=data_out_point.balance // 1000,
payments_count=data_in_point.payments_count
+ data_out_point.payments_count,
count_in=data_in_point.payments_count,
count_out=data_out_point.payments_count,
fee=(data_in_point.fee + data_out_point.fee) // 1000,
)
)
start_date += delta
return data
async def _pay_invoice(wallet, create_payment_model, conn):
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
if not payment:

View File

@ -0,0 +1,396 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm q-pl-lg">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<div class="float-left">
<q-chip
v-if="searchDate.timeFrom"
removable
@remove="removeCreatedFrom()"
:label="searchDate.timeFrom"
class="ellipsis"
>
</q-chip>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy
cover
transition-show="scale"
transition-hide="scale"
>
<q-date v-model="searchDate.timeFrom" mask="YYYY-MM-DD">
<div class="row">
<q-btn
label="Search"
color="primary"
flat
@click="fetchPayments()"
class="float-left"
v-close-popup
/>
</div>
</q-date>
<q-date v-model="searchDate.timeTo" mask="YYYY-MM-DD">
<div class="row items-center justify-end">
<q-btn v-close-popup label="Close" color="primary" flat />
</div>
</q-date>
</q-popup-proxy>
</q-icon>
<q-chip
removable
v-if="searchDate.timeTo"
@remove="removeCreatedTo()"
:label="searchDate.timeTo"
class="ellipsis"
>
</q-chip>
</div>
</div>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showPaymentStatus"
:label="$t('payments_status_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showPaymentTags"
:label="$t('payments_tag_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showBalance"
:label="$t('payments_balance_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showWalletsSize"
:label="$t('payments_wallets_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showBalanceInOut"
:label="$t('payments_balance_in_out_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div class="float-left">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartData.showPaymentCountInOut"
:label="$t('payments_count_in_out_chart')"
>
</q-checkbox>
</div>
<q-separator vertical class="q-ma-sm"></q-separator>
<div>
<q-btn
v-if="g.user.admin"
flat
round
icon="settings"
to="/admin#server"
>
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
</q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div v-show="!showDetails">
<div class="row q-col-gutter-md justify-center q-mb-md">
<div
v-show="chartData.showPaymentStatus"
class="col-lg-3 col-md-6 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payment_chart_status')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsStatusChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showPaymentStatus"
class="col-lg-3 col-md-6 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payment_chart_tags')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsTagsChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showBalance"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong
v-text="$t('lnbits_balance', {balance: (lnbitsBalance || 0).toLocaleString()})"
></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsDailyChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showWalletsSize"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payment_chart_tx_per_wallet')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsWalletsChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showBalanceInOut"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payments_balance_in_out')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsBalanceInOutChart"></canvas>
</div>
</q-card>
</div>
<div
v-show="chartData.showPaymentCountInOut"
class="col-lg-6 col-md-12 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
<strong v-text="$t('payments_count_in_out')"></strong>
<div style="height: 300px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="paymentsCountInOutChart"></canvas>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col">
<q-card class="q-pa-md">
<q-table
row-key="payment_hash"
:rows="payments"
:columns="paymentsTable.columns"
v-model:pagination="paymentsTable.pagination"
:filter="paymentsTable.search"
:loading="paymentsTable.loading"
@request="fetchPayments"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="['wallet_id', 'payment_hash', 'memo'].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchPaymentsBy()"
@update:model-value="searchPaymentsBy()"
dense
type="text"
filled
clearable
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchPaymentsBy()"
class="cursor-pointer"
/>
</template>
</q-input>
<q-select
v-else-if="['status'].includes(col.name)"
v-model="searchData[col.name]"
:options="searchOptions[col.name]"
@update:model-value="searchPaymentsBy()"
:label="col.label"
clearable
style="width: 100px"
></q-select>
<q-select
v-else-if="['tag'].includes(col.name)"
v-model="searchData[col.name]"
:options="searchOptions[col.name]"
@update:model-value="searchPaymentsBy()"
:label="col.label"
clearable
style="width: 100px"
></q-select>
<span v-else v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<div v-if="col.name == 'status'">
<q-tooltip
><span v-text="$t('payment_details')"></span
></q-tooltip>
<q-icon
@click="showDetailsToggle(props.row)"
v-if="props.row.status === 'success'"
size="14px"
:name="props.row.amount < 0 ? 'call_made' : 'call_received'"
:color="props.row.amount < 0 ? 'pink' : 'green'"
class="cursor-pointer"
></q-icon>
<q-icon
v-else-if="props.row.status === 'pending'"
@click="showDetailsToggle(props.row)"
name="settings_ethernet"
color="grey"
class="cursor-pointer"
></q-icon>
<q-icon
v-else
@click="showDetailsToggle(props.row)"
name="warning"
color="yellow"
class="cursor-pointer"
></q-icon>
</div>
<div v-else-if="col.name == 'created_at'">
<div>
<q-tooltip anchor="top middle">
<span v-text="formatDate(props.row.created_at)"></span>
</q-tooltip>
<span v-text="props.row.timeFrom"> </span>
</div>
</div>
<div
v-else-if="['wallet_id', 'payment_hash', 'memo'].includes(col.name)"
>
<q-btn
v-if="props.row[col.name]"
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row[col.name])"
>
<q-tooltip anchor="top middle">Copy</q-tooltip>
</q-btn>
<span v-text="shortify(props.row[col.name], col.max_length)">
</span>
<q-tooltip>
<span v-text="props.row[col.name]"></span>
</q-tooltip>
</div>
<span
v-else
v-text="props.row[col.name]"
class="cursor-pointer"
></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</div>
</div>
<div v-show="showDetails">
<q-card>
<q-card-section class="flex">
<div>
<q-btn
flat
round
icon="arrow_back"
class="q-mr-md"
@click="showDetailsToggle(null)"
></q-btn>
</div>
<div class="self-center text-h6 text-weight-bolder text-grey-5">
<span v-text="$t('payment_details_back')"></span>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="text-h6">
<q-item>
<q-item-section avatar class="">
<q-icon color="primary" name="receipt" size="44px"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label>
<div class="text-h6">
<span v-text="$t('payment_details')"></span>
</div>
</q-item-label>
<q-item-label caption v-text="$t('payment_details_desc')">
</q-item-label>
</q-item-section>
</q-item>
</q-card-section>
<q-card-section>
<q-list separator>
<q-item v-for="(value, key) in paymentDetails" :key="key">
<q-item-section>
<q-item-label v-text="key"></q-item-label>
<q-item-label
caption
v-text="value"
style="word-wrap: break-word"
></q-item-label>
</q-item-section>
<q-item-section side>
<q-btn
v-show="value"
icon="content_copy"
flat
class="cursor-pointer q-ml-sm"
@click="copyText(value)"
>
<q-tooltip>Copy</q-tooltip>
</q-btn>
</q-item-section>
<!-- <q-separator></q-separator> -->
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
{% endblock %}

View File

@ -209,7 +209,6 @@ async def account(
request: Request,
user: User = Depends(check_user_exists),
):
return template_renderer().TemplateResponse(
request,
"core/account.html",
@ -405,6 +404,18 @@ async def audit_index(request: Request, user: User = Depends(check_admin)):
)
@generic_router.get("/payments", response_class=HTMLResponse)
async def payments_index(request: Request, user: User = Depends(check_admin)):
return template_renderer().TemplateResponse(
"payments/index.html",
{
"request": request,
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/uuidv4/{hex_value}")
async def hex_to_uuid4(hex_value: str):
try:
@ -433,7 +444,6 @@ async def lnurlwallet(request: Request):
lnurl = lnurl_decode(lightning_param)
async with httpx.AsyncClient() as client:
res1 = await client.get(lnurl, timeout=2)
res1.raise_for_status()
data1 = res1.json()

View File

@ -16,6 +16,10 @@ from fastapi.responses import JSONResponse
from loguru import logger
from lnbits import bolt11
from lnbits.core.crud.payments import (
get_payment_count_stats,
get_wallets_stats,
)
from lnbits.core.models import (
CreateInvoice,
CreateLnurl,
@ -23,13 +27,19 @@ from lnbits.core.models import (
KeyType,
PayLnurlWData,
Payment,
PaymentCountField,
PaymentCountStat,
PaymentDailyStats,
PaymentFilters,
PaymentHistoryPoint,
PaymentWalletStats,
Wallet,
)
from lnbits.core.services.payments import get_payments_daily_stats
from lnbits.db import Filters, Page
from lnbits.decorators import (
WalletTypeInfo,
check_admin,
parse_filters,
require_admin_key,
require_invoice_key,
@ -93,6 +103,48 @@ async def api_payments_history(
return await get_payments_history(key_info.wallet.id, group, filters)
@payment_router.get(
"/stats/count",
name="Get payments history for all users",
dependencies=[Depends(check_admin)],
response_model=List[PaymentCountStat],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_counting_stats(
count_by: PaymentCountField = Query("tag"),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
):
return await get_payment_count_stats(count_by, filters)
@payment_router.get(
"/stats/wallets",
name="Get payments history for all users",
dependencies=[Depends(check_admin)],
response_model=List[PaymentWalletStats],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_wallets_stats(
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
):
return await get_wallets_stats(filters)
@payment_router.get(
"/stats/daily",
name="Get payments history per day",
dependencies=[Depends(check_admin)],
response_model=List[PaymentDailyStats],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_daily_stats(
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
):
return await get_payments_daily_stats(filters)
@payment_router.get(
"/paginated",
name="Payment List",
@ -175,6 +227,23 @@ async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
return payment
@payment_router.get(
"/all/paginated",
name="Payment List",
summary="get paginated list of payments",
response_description="list of payments",
response_model=Page[Payment],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
dependencies=[Depends(check_admin)],
)
async def api_all_payments_paginated(
filters: Filters = Depends(parse_filters(PaymentFilters)),
):
return await get_payments_paginated(
filters=filters,
)
@payment_router.post(
"",
summary="Create or pay an invoice",
@ -366,7 +435,6 @@ async def api_payment_pay_with_nfc(
payment_request: str,
lnurl_data: PayLnurlWData,
) -> JSONResponse:
lnurl = lnurl_data.lnurl_w.lower()
# Follow LUD-17 -> https://github.com/lnurl/luds/blob/luds/17.md

View File

@ -22,6 +22,13 @@ POSTGRES = "POSTGRES"
COCKROACH = "COCKROACH"
SQLITE = "SQLITE"
DateTrunc = Literal["hour", "day", "month"]
sqlite_formats = {
"hour": "%Y-%m-%d %H:00:00",
"day": "%Y-%m-%d 00:00:00",
"month": "%Y-%m-01 00:00:00",
}
if settings.lnbits_database_url:
database_uri = settings.lnbits_database_url
if database_uri.startswith("cockroachdb://"):
@ -75,6 +82,13 @@ class Compat:
return time.mktime(date.timetuple())
return "<nothing>"
def datetime_grouping(self, group: DateTrunc):
if self.type in {POSTGRES, COCKROACH}:
return f"date_trunc('{group}', time)"
elif self.type == SQLITE:
return f"unixepoch(strftime('{sqlite_formats[group]}', time, 'unixepoch'))"
return "<bad grouping>"
@property
def timestamp_now(self) -> str:
if self.type in {POSTGRES, COCKROACH}:
@ -534,12 +548,11 @@ class Filters(BaseModel, Generic[TFilterModel]):
if self.filters:
for page_filter in self.filters:
where_stmts.append(page_filter.statement)
if self.search and self.model:
fields = self.model.__search_fields__
if DB_TYPE == POSTGRES:
where_stmts.append(f"lower(concat({', '.join(fields)})) LIKE :search")
elif DB_TYPE == SQLITE:
where_stmts.append(f"lower({'||'.join(fields)}) LIKE :search")
if self.search and self.model and self.model.__search_fields__:
where_stmts.append(
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE :search"
)
if where_stmts:
return "WHERE " + " AND ".join(where_stmts)
return ""

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,6 +6,7 @@ window.localisation.en = {
funding: 'Funding',
users: 'Users',
audit: 'Audit',
api_watch: 'Api Watch',
apps: 'Apps',
channels: 'Channels',
transactions: 'Transactions',
@ -487,5 +488,22 @@ window.localisation.en = {
http_request_methods: 'HTTP Request Methods',
http_response_codes: 'HTTP Response Codes',
request_details: 'Request Details',
http_request_details: 'HTTP Request Details'
http_request_details: 'HTTP Request Details',
payment_details: 'Payment Details',
payment_details_desc: 'Detailed information about the payment',
payments: 'Payments',
payment_show_internal: 'Show Internal Payments',
payment_chart_flow: 'Monthly Payment Flow',
payment_chart_status: 'Payment Status',
payment_chart_tx_per_wallet: 'Transactions per Wallet (balance/count)',
payment_details_back: 'Back to Payments',
payment_chart_tags: 'Payments by Tags',
payments_balance_in_out: 'Balance In/Out',
payments_count_in_out: 'Count In/Out',
payments_status_chart: 'Status Chart',
payments_tag_chart: 'Tag Chart',
payments_balance_chart: 'Balance Chart',
payments_wallets_chart: 'Wallets Chart',
payments_balance_in_out_chart: 'Balance In/Out Chart',
payments_count_in_out_chart: 'Count In/Out Chart'
}

View File

@ -103,7 +103,14 @@ window.app.component('lnbits-extension-list', {
window.app.component('lnbits-manage', {
mixins: [window.windowMixin],
template: '#lnbits-manage',
props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers', 'showAudit'],
props: [
'showAdmin',
'showNode',
'showExtensions',
'showUsers',
'showAudit',
'showPayments'
],
methods: {
isActive(path) {
return window.location.pathname === path

View File

@ -1,7 +1,7 @@
window.app.component('payment-list', {
name: 'payment-list',
template: '#payment-list',
props: ['update', 'lazy'],
props: ['update', 'lazy', 'wallet'],
mixins: [window.windowMixin],
data() {
return {
@ -116,8 +116,8 @@ window.app.component('payment-list', {
}
},
computed: {
wallet() {
return this.g.wallet
currentWallet() {
return this.wallet || this.g.wallet
},
filteredPayments() {
const q = this.paymentsTable.search
@ -139,7 +139,7 @@ window.app.component('payment-list', {
fetchPayments(props) {
const params = LNbits.utils.prepareFilterQuery(this.paymentsTable, props)
return LNbits.api
.getPayments(this.g.wallet, params)
.getPayments(this.currentWallet, params)
.then(response => {
this.paymentsTable.loading = false
this.paymentsTable.pagination.rowsNumber = response.data.total

View File

@ -163,6 +163,15 @@ const routes = [
scripts: ['/static/js/audit.js']
}
},
{
path: '/payments',
name: 'Payments',
component: DynamicComponent,
props: {
fetchUrl: '/payments',
scripts: ['/static/js/payments.js']
}
},
{
path: '/extensions',
name: 'Extensions',

View File

@ -0,0 +1,636 @@
window.PaymentsPageLogic = {
mixins: [window.windowMixin],
data() {
return {
payments: [],
dailyChartData: [],
searchDate: {
timeFrom: null,
timeTo: null
},
searchData: {
wallet_id: null,
payment_hash: null,
status: null,
memo: null
},
chartData: {
showPaymentStatus: true,
showPaymentTags: true,
showBalance: true,
showWalletsSize: false,
showBalanceInOut: false,
showPaymentCountInOut: false
},
searchOptions: {
status: []
// tag: [] // not used, payments don't have tag, only the extra
},
paymentsTable: {
columns: [
{
name: 'status',
align: 'left',
label: 'Status',
field: 'status',
sortable: false
},
{
name: 'created_at',
align: 'left',
label: 'Created At',
field: 'created_at',
sortable: true
},
{
name: 'amount',
align: 'right',
label: 'Amount',
field: 'amount',
sortable: true
},
{
name: 'amountFiat',
align: 'right',
label: 'Fiat',
field: 'amountFiat',
sortable: false
},
{
name: 'fee',
align: 'left',
label: 'Fee',
field: 'fee',
sortable: true
},
{
name: 'tag',
align: 'left',
label: 'Tag',
field: 'tag',
sortable: false
},
{
name: 'memo',
align: 'left',
label: 'Memo',
field: 'memo',
sortable: false,
max_length: 20
},
{
name: 'wallet_id',
align: 'left',
label: 'Wallet (ID)',
field: 'wallet_id',
sortable: false
},
{
name: 'payment_hash',
align: 'left',
label: 'Payment Hash',
field: 'payment_hash',
sortable: false
}
],
pagination: {
sortBy: 'created_at',
rowsPerPage: 25,
page: 1,
descending: true,
rowsNumber: 10
},
search: null,
hideEmpty: true,
loading: true
},
chartsReady: false,
showDetails: false,
paymentDetails: null,
lnbitsBalance: 0
}
},
async mounted() {
this.chartsReady = true
await this.$nextTick()
this.initCharts()
this.searchDate.timeFrom = moment()
.subtract(1, 'month')
.format('YYYY-MM-DD')
this.searchDate.timeTo = moment().format('YYYY-MM-DD')
await this.fetchPayments()
},
computed: {},
methods: {
async fetchPayments(props) {
const filter = Object.entries(this.searchData).reduce(
(a, [k, v]) => (v ? ((a[k] = v), a) : a),
{}
)
delete filter['time[ge]']
delete filter['time[le]']
if (this.searchDate.timeFrom) {
filter['time[ge]'] = this.searchDate.timeFrom + 'T00:00:00'
}
if (this.searchDate.timeTo) {
filter['time[le]'] = this.searchDate.timeTo + 'T23:59:59'
}
this.paymentsTable.filter = filter
try {
const params = LNbits.utils.prepareFilterQuery(
this.paymentsTable,
props
)
const {data} = await LNbits.api.request(
'GET',
`/api/v1/payments/all/paginated?${params}`
)
this.paymentsTable.pagination.rowsNumber = data.total
this.payments = data.data.map(p => {
if (p.extra && p.extra.tag) {
p.tag = p.extra.tag
}
p.timeFrom = moment(p.created_at).fromNow()
p.amount =
new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) +
' sats'
if (p.extra?.wallet_fiat_amount) {
p.amountFiat = this.formatCurrency(
p.extra.wallet_fiat_amount,
p.extra.wallet_fiat_currency
)
}
return p
})
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
} finally {
this.paymentsTable.loading = false
this.updateCharts(props)
}
},
async searchPaymentsBy(fieldName, fieldValue) {
if (fieldName) {
this.searchData[fieldName] = fieldValue
}
await this.fetchPayments()
},
async removeCreatedFrom() {
this.searchDate.timeFrom = null
await this.fetchPayments()
},
async removeCreatedTo() {
this.searchDate.timeTo = null
await this.fetchPayments()
},
showDetailsToggle(payment) {
this.paymentDetails = payment
return (this.showDetails = !this.showDetails)
},
formatDate(dateString) {
return LNbits.utils.formatDateString(dateString)
},
formatCurrency(amount, currency) {
try {
return LNbits.utils.formatCurrency(amount, currency)
} catch (e) {
console.error(e)
return `${amount} ???`
}
},
shortify(value, max_length = 10) {
valueLength = (value || '').length
if (valueLength <= max_length) {
return value
}
return `${value.substring(0, 5)}...${value.substring(valueLength - 5, valueLength)}`
},
async updateCharts(props) {
let params = LNbits.utils.prepareFilterQuery(this.paymentsTable, props)
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/payments/stats/count?${params}&count_by=status`
)
data.sort((a, b) => a.field - b.field)
this.searchOptions.status = data
.map(s => s.field)
.sort()
.reverse()
this.paymentsStatusChart.data.datasets[0].data = data.map(s => s.total)
this.paymentsStatusChart.data.labels = [...this.searchOptions.status]
this.paymentsStatusChart.update()
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/payments/stats/wallets?${params}`
)
const counts = data.map(w => w.balance / w.payments_count)
const min = Math.min(...counts)
const max = Math.max(...counts)
const scale = val => {
return Math.floor(3 + ((val - min) * (25 - 3)) / (max - min))
}
const colors = this.randomColors(20)
const walletsData = data.map((w, i) => {
return {
data: [
{
x: w.payments_count,
y: w.balance,
r: scale(Math.max(w.balance / w.payments_count, 5))
}
],
label: w.wallet_name,
wallet_id: w.wallet_id,
backgroundColor: colors[i % 100],
hoverOffset: 4
}
})
this.paymentsWalletsChart.data.datasets = walletsData
this.paymentsWalletsChart.update()
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/payments/stats/count?${params}&count_by=tag`
)
this.searchOptions.tag = data.map(s => s.field)
this.searchOptions.status.sort()
this.paymentsTagsChart.data.datasets[0].data = data.map(rm => rm.total)
this.paymentsTagsChart.data.labels = data.map(rm => rm.field || 'core')
this.paymentsTagsChart.update()
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
try {
const filter = Object.entries(this.searchData).reduce(
(a, [k, v]) => (v ? ((a[k] = v), a) : a),
{}
)
const paymentsTable = {...this.paymentsTable, filter: filter}
const noTimeParams = LNbits.utils.prepareFilterQuery(
paymentsTable,
props
)
let {data} = await LNbits.api.request(
'GET',
`/api/v1/payments/stats/daily?${noTimeParams}`
)
const timeFrom = this.searchDate.timeFrom + 'T00:00:00'
const timeTo = this.searchDate.timeTo + 'T00:00:00'
this.lnbitsBalance = data[data.length - 1].balance
data = data.filter(p => {
if (this.searchDate.timeFrom && this.searchDate.timeTo) {
return p.date >= timeFrom && p.date <= timeTo
}
if (this.searchDate.timeFrom) {
return p.date >= timeFrom
}
if (this.searchDate.timeTo) {
return p.date <= timeTo
}
return true
})
this.paymentsDailyChart.data.datasets = [
{
label: 'Balance',
data: data.map(s => s.balance),
pointStyle: false,
borderWidth: 2,
tension: 0.7,
fill: 1
},
{
label: 'Fees',
data: data.map(s => s.fee),
pointStyle: false,
borderWidth: 1,
tension: 0.4,
fill: 1
}
]
this.paymentsDailyChart.data.labels = data.map(s =>
s.date.substring(0, 10)
)
this.paymentsDailyChart.update()
this.paymentsBalanceInOutChart.data.datasets = [
{
label: 'Incoming Payments Balance',
data: data.map(s => s.balance_in)
},
{
label: 'Outgoing Payments Balance',
data: data.map(s => s.balance_out)
}
]
this.paymentsBalanceInOutChart.data.labels = data.map(s =>
s.date.substring(0, 10)
)
this.paymentsBalanceInOutChart.update()
this.paymentsCountInOutChart.data.datasets = [
{
label: 'Incoming Payments Count',
data: data.map(s => s.count_in)
},
{
label: 'Outgoing Payments Count',
data: data.map(s => -s.count_out)
}
]
this.paymentsCountInOutChart.data.labels = data.map(s =>
s.date.substring(0, 10)
)
this.paymentsCountInOutChart.update()
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
async initCharts() {
const chartData =
this.$q.localStorage.getItem('lnbits.payments.chartData') || {}
this.chartData = {...this.chartData, ...chartData}
if (!this.chartsReady) {
console.warn('Charts are not ready yet. Initialization delayed.')
return
}
this.paymentsStatusChart = new Chart(
this.$refs.paymentsStatusChart.getContext('2d'),
{
type: 'doughnut',
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: false
}
},
onClick: (_, elements, chart) => {
if (elements[0]) {
const i = elements[0].index
this.searchPaymentsBy('status', chart.data.labels[i])
}
}
},
data: {
datasets: [
{
label: '',
data: [],
backgroundColor: [
'rgb(0, 205, 86)',
'rgb(54, 162, 235)',
'rgb(255, 99, 132)',
'rgb(255, 5, 86)',
'rgb(25, 205, 86)',
'rgb(255, 205, 250)'
],
hoverOffset: 4
}
]
}
}
)
this.paymentsWalletsChart = new Chart(
this.$refs.paymentsWalletsChart.getContext('2d'),
{
type: 'bubble',
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
title: {
display: false
}
},
onClick: (_, elements, chart) => {
if (elements[0]) {
const i = elements[0].datasetIndex
this.searchPaymentsBy(
'wallet_id',
chart.data.datasets[i].wallet_id
)
}
}
},
data: {
datasets: [
{
label: '',
data: [],
backgroundColor: this.randomColors(20),
hoverOffset: 4
}
]
}
}
)
this.paymentsTagsChart = new Chart(
this.$refs.paymentsTagsChart.getContext('2d'),
{
type: 'pie',
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: false
},
legend: {
display: false,
title: {
display: false,
text: 'Tags'
}
}
},
onClick: (_, elements, chart) => {
if (elements[0]) {
const i = elements[0].index
this.searchPaymentsBy('tag', chart.data.labels[i])
}
}
},
data: {
datasets: [
{
label: '',
data: [],
backgroundColor: this.randomColors(10),
hoverOffset: 4
}
]
}
}
)
this.paymentsDailyChart = new Chart(
this.$refs.paymentsDailyChart.getContext('2d'),
{
type: 'line',
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: false
},
legend: {
display: true,
title: {
display: false,
text: 'Tags'
}
}
}
},
data: {
datasets: [
{
label: '',
data: [],
backgroundColor: this.randomColors(10),
hoverOffset: 4
}
]
}
}
)
this.paymentsBalanceInOutChart = new Chart(
this.$refs.paymentsBalanceInOutChart.getContext('2d'),
{
type: 'bar',
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: false
},
legend: {
display: true,
title: {
display: false,
text: 'Tags'
}
}
},
scales: {
x: {
stacked: true
},
y: {
stacked: true
}
}
},
data: {
datasets: [
{
label: '',
data: [],
backgroundColor: this.randomColors(50),
hoverOffset: 4
}
]
}
}
)
this.paymentsCountInOutChart = new Chart(
this.$refs.paymentsCountInOutChart.getContext('2d'),
{
type: 'bar',
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: false
},
legend: {
display: true,
title: {
display: false,
text: ''
}
}
},
scales: {
x: {
stacked: true
},
y: {
stacked: true
}
}
},
data: {
datasets: [
{
label: '',
data: [],
backgroundColor: this.randomColors(80),
hoverOffset: 4
}
]
}
}
)
},
saveChartsPreferences() {
this.$q.localStorage.set('lnbits.payments.chartData', this.chartData)
},
randomColors(seed = 1) {
const colors = []
for (let i = 1; i <= 10; i++) {
for (let j = 1; j <= 10; j++) {
colors.push(
`rgb(${(j * seed * 33) % 200}, ${(71 * (i + j + seed)) % 255}, ${(i + seed * 30) % 255})`
)
}
}
return colors
}
}
}

View File

@ -191,6 +191,7 @@
:show-admin="'{{LNBITS_ADMIN_UI}}' == 'True'"
:show-users="'{{LNBITS_ADMIN_UI}}' == 'True'"
:show-audit="'{{LNBITS_AUDIT_ENABLED}}' == 'True'"
:show-payments="'{{LNBITS_ADMIN_UI}}' == 'True'"
:show-node="'{{LNBITS_NODE_UI}}' == 'True'"
:show-extensions="'{{LNBITS_EXTENSIONS_DEACTIVATE_ALL}}' == 'False'"
></lnbits-manage>

View File

@ -176,13 +176,25 @@
<q-item v-if="showAudit" to="/audit">
<q-item-section side>
<q-icon
name="query_stats"
name="playlist_add_check_circle"
:color="isActive('/audit') ? 'primary' : 'grey-5'"
size="md"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('server')"></q-item-label>
<q-item-label lines="1" v-text="$t('api_watch')"></q-item-label>
</q-item-section>
</q-item>
<q-item v-if="showPayments" to="/payments">
<q-item-section side>
<q-icon
name="query_stats"
:color="isActive('/payments') ? 'primary' : 'grey-5'"
size="md"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('payments')"></q-item-label>
</q-item-section>
</q-item>
</div>

View File

@ -337,10 +337,10 @@ async def test_get_payments(client, inkey_fresh_headers_to, fake_payments):
payments = await get_payments({"sortby": "amount", "direction": "asc"})
assert payments[-1].amount > payments[0].amount
payments = await get_payments({"search": "aaa"})
payments = await get_payments({"search": "xxx"})
assert len(payments) == 1
payments = await get_payments({"search": "aa"})
payments = await get_payments({"search": "xx"})
assert len(payments) == 2
# amount is in msat

View File

@ -117,7 +117,6 @@ async def test_login_usr_not_allowed_for_admin_without_credentials(
response = await http_client.get(
f"/admin/api/v1/settings?usr={settings.super_user}"
)
print("### response", response.text)
assert response.status_code == 403
assert (
response.json().get("detail") == "User id only access for admins is forbidden."

View File

@ -276,9 +276,9 @@ async def fake_payments(client, inkey_fresh_headers_to):
await asyncio.sleep(1)
fake_data = [
CreateInvoice(amount=10, memo="aaaa", out=False),
CreateInvoice(amount=100, memo="bbbb", out=False),
CreateInvoice(amount=1000, memo="aabb", out=False),
CreateInvoice(amount=10, memo="xxxx", out=False),
CreateInvoice(amount=100, memo="yyyy", out=False),
CreateInvoice(amount=1000, memo="xxyy", out=False),
]
for invoice in fake_data: