mirror of
https://github.com/lnbits/lnbits.git
synced 2025-04-07 19:38:13 +02:00
Add a payments page for admin (#2910)
This commit is contained in:
parent
c1d26bb274
commit
34a959f0bc
@ -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:
|
||||
|
@ -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",
|
||||
|
@ -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):
|
||||
|
@ -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:
|
||||
|
396
lnbits/core/templates/payments/index.html
Normal file
396
lnbits/core/templates/payments/index.html
Normal 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 %}
|
@ -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()
|
||||
|
@ -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
|
||||
|
25
lnbits/db.py
25
lnbits/db.py
@ -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 ""
|
||||
|
2
lnbits/static/bundle-components.min.js
vendored
2
lnbits/static/bundle-components.min.js
vendored
File diff suppressed because one or more lines are too long
2
lnbits/static/bundle.min.js
vendored
2
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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'
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
636
lnbits/static/js/payments.js
Normal file
636
lnbits/static/js/payments.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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."
|
||||
|
@ -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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user