Add a payments page for admin (#2910)
This commit is contained in:
parent
c1d26bb274
commit
34a959f0bc
20 changed files with 1416 additions and 54 deletions
|
|
@ -1,26 +1,23 @@
|
||||||
from time import time
|
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.crud.wallets import get_total_balance, get_wallet
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
from lnbits.core.models import PaymentState
|
from lnbits.core.models import PaymentState
|
||||||
from lnbits.core.models.payments import PaymentsStatusCount
|
from lnbits.db import Connection, DateTrunc, Filters, Page
|
||||||
from lnbits.db import DB_TYPE, SQLITE, Connection, Filters, Page
|
|
||||||
|
|
||||||
from ..models import (
|
from ..models import (
|
||||||
CreatePayment,
|
CreatePayment,
|
||||||
Payment,
|
Payment,
|
||||||
|
PaymentCountField,
|
||||||
|
PaymentCountStat,
|
||||||
|
PaymentDailyStats,
|
||||||
PaymentFilters,
|
PaymentFilters,
|
||||||
PaymentHistoryPoint,
|
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():
|
def update_payment_extra():
|
||||||
pass
|
pass
|
||||||
|
|
@ -304,12 +301,7 @@ async def get_payments_history(
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = Filters()
|
filters = Filters()
|
||||||
|
|
||||||
if DB_TYPE == SQLITE and group in sqlite_formats:
|
date_trunc = db.datetime_grouping(group)
|
||||||
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}")
|
|
||||||
|
|
||||||
values = {
|
values = {
|
||||||
"wallet_id": wallet_id,
|
"wallet_id": wallet_id,
|
||||||
|
|
@ -361,6 +353,114 @@ async def get_payments_history(
|
||||||
return results
|
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(
|
async def delete_wallet_payment(
|
||||||
checking_id: str, wallet_id: str, conn: Optional[Connection] = None
|
checking_id: str, wallet_id: str, conn: Optional[Connection] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,15 @@ from .payments import (
|
||||||
DecodePayment,
|
DecodePayment,
|
||||||
PayInvoice,
|
PayInvoice,
|
||||||
Payment,
|
Payment,
|
||||||
|
PaymentCountField,
|
||||||
|
PaymentCountStat,
|
||||||
|
PaymentDailyStats,
|
||||||
PaymentExtra,
|
PaymentExtra,
|
||||||
PaymentFilters,
|
PaymentFilters,
|
||||||
PaymentHistoryPoint,
|
PaymentHistoryPoint,
|
||||||
|
PaymentsStatusCount,
|
||||||
PaymentState,
|
PaymentState,
|
||||||
|
PaymentWalletStats,
|
||||||
)
|
)
|
||||||
from .tinyurl import TinyURL
|
from .tinyurl import TinyURL
|
||||||
from .users import (
|
from .users import (
|
||||||
|
|
@ -63,6 +68,11 @@ __all__ = [
|
||||||
"DecodePayment",
|
"DecodePayment",
|
||||||
"PayInvoice",
|
"PayInvoice",
|
||||||
"Payment",
|
"Payment",
|
||||||
|
"PaymentCountField",
|
||||||
|
"PaymentCountStat",
|
||||||
|
"PaymentDailyStats",
|
||||||
|
"PaymentsStatusCount",
|
||||||
|
"PaymentWalletStats",
|
||||||
"PaymentExtra",
|
"PaymentExtra",
|
||||||
"PaymentFilters",
|
"PaymentFilters",
|
||||||
"PaymentHistoryPoint",
|
"PaymentHistoryPoint",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from fastapi import Query
|
from fastapi import Query
|
||||||
from pydantic import BaseModel, Field, validator
|
from pydantic import BaseModel, Field, validator
|
||||||
|
|
@ -125,22 +125,60 @@ class Payment(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class PaymentFilters(FilterModel):
|
class PaymentFilters(FilterModel):
|
||||||
__search_fields__ = ["memo", "amount"]
|
__search_fields__ = ["memo", "amount", "wallet_id", "tag"]
|
||||||
|
|
||||||
status: str
|
__sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
|
||||||
checking_id: str
|
|
||||||
|
status: Optional[str]
|
||||||
|
tag: Optional[str]
|
||||||
|
checking_id: Optional[str]
|
||||||
amount: int
|
amount: int
|
||||||
fee: int
|
fee: int
|
||||||
memo: Optional[str]
|
memo: Optional[str]
|
||||||
time: datetime
|
time: datetime
|
||||||
bolt11: str
|
preimage: Optional[str]
|
||||||
preimage: str
|
payment_hash: Optional[str]
|
||||||
payment_hash: str
|
wallet_id: Optional[str]
|
||||||
expiry: Optional[datetime]
|
|
||||||
extra: dict = {}
|
|
||||||
wallet_id: str
|
class PaymentDataPoint(BaseModel):
|
||||||
webhook: Optional[str]
|
date: datetime
|
||||||
webhook_status: Optional[int]
|
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):
|
class PaymentHistoryPoint(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from bolt11 import Bolt11, MilliSatoshi, Tags
|
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 bolt11 import encode as bolt11_encode
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.core.crud.payments import get_daily_stats
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
|
from lnbits.core.models import PaymentDailyStats, PaymentFilters
|
||||||
from lnbits.core.models.notifications import NotificationType
|
from lnbits.core.models.notifications import NotificationType
|
||||||
from lnbits.core.services.notifications import enqueue_notification
|
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.decorators import check_user_extension_access
|
||||||
from lnbits.exceptions import InvoiceError, PaymentError
|
from lnbits.exceptions import InvoiceError, PaymentError
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
@ -427,6 +430,48 @@ async def check_transaction_status(
|
||||||
return await payment.check_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):
|
async def _pay_invoice(wallet, create_payment_model, conn):
|
||||||
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
|
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
|
||||||
if not payment:
|
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,
|
request: Request,
|
||||||
user: User = Depends(check_user_exists),
|
user: User = Depends(check_user_exists),
|
||||||
):
|
):
|
||||||
|
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"core/account.html",
|
"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}")
|
@generic_router.get("/uuidv4/{hex_value}")
|
||||||
async def hex_to_uuid4(hex_value: str):
|
async def hex_to_uuid4(hex_value: str):
|
||||||
try:
|
try:
|
||||||
|
|
@ -433,7 +444,6 @@ async def lnurlwallet(request: Request):
|
||||||
lnurl = lnurl_decode(lightning_param)
|
lnurl = lnurl_decode(lightning_param)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
|
|
||||||
res1 = await client.get(lnurl, timeout=2)
|
res1 = await client.get(lnurl, timeout=2)
|
||||||
res1.raise_for_status()
|
res1.raise_for_status()
|
||||||
data1 = res1.json()
|
data1 = res1.json()
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@ from fastapi.responses import JSONResponse
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
|
from lnbits.core.crud.payments import (
|
||||||
|
get_payment_count_stats,
|
||||||
|
get_wallets_stats,
|
||||||
|
)
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
CreateInvoice,
|
CreateInvoice,
|
||||||
CreateLnurl,
|
CreateLnurl,
|
||||||
|
|
@ -23,13 +27,19 @@ from lnbits.core.models import (
|
||||||
KeyType,
|
KeyType,
|
||||||
PayLnurlWData,
|
PayLnurlWData,
|
||||||
Payment,
|
Payment,
|
||||||
|
PaymentCountField,
|
||||||
|
PaymentCountStat,
|
||||||
|
PaymentDailyStats,
|
||||||
PaymentFilters,
|
PaymentFilters,
|
||||||
PaymentHistoryPoint,
|
PaymentHistoryPoint,
|
||||||
|
PaymentWalletStats,
|
||||||
Wallet,
|
Wallet,
|
||||||
)
|
)
|
||||||
|
from lnbits.core.services.payments import get_payments_daily_stats
|
||||||
from lnbits.db import Filters, Page
|
from lnbits.db import Filters, Page
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
|
check_admin,
|
||||||
parse_filters,
|
parse_filters,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
|
|
@ -93,6 +103,48 @@ async def api_payments_history(
|
||||||
return await get_payments_history(key_info.wallet.id, group, filters)
|
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(
|
@payment_router.get(
|
||||||
"/paginated",
|
"/paginated",
|
||||||
name="Payment List",
|
name="Payment List",
|
||||||
|
|
@ -175,6 +227,23 @@ async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||||
return payment
|
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(
|
@payment_router.post(
|
||||||
"",
|
"",
|
||||||
summary="Create or pay an invoice",
|
summary="Create or pay an invoice",
|
||||||
|
|
@ -366,7 +435,6 @@ async def api_payment_pay_with_nfc(
|
||||||
payment_request: str,
|
payment_request: str,
|
||||||
lnurl_data: PayLnurlWData,
|
lnurl_data: PayLnurlWData,
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
|
|
||||||
lnurl = lnurl_data.lnurl_w.lower()
|
lnurl = lnurl_data.lnurl_w.lower()
|
||||||
|
|
||||||
# Follow LUD-17 -> https://github.com/lnurl/luds/blob/luds/17.md
|
# 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"
|
COCKROACH = "COCKROACH"
|
||||||
SQLITE = "SQLITE"
|
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:
|
if settings.lnbits_database_url:
|
||||||
database_uri = settings.lnbits_database_url
|
database_uri = settings.lnbits_database_url
|
||||||
if database_uri.startswith("cockroachdb://"):
|
if database_uri.startswith("cockroachdb://"):
|
||||||
|
|
@ -75,6 +82,13 @@ class Compat:
|
||||||
return time.mktime(date.timetuple())
|
return time.mktime(date.timetuple())
|
||||||
return "<nothing>"
|
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
|
@property
|
||||||
def timestamp_now(self) -> str:
|
def timestamp_now(self) -> str:
|
||||||
if self.type in {POSTGRES, COCKROACH}:
|
if self.type in {POSTGRES, COCKROACH}:
|
||||||
|
|
@ -534,12 +548,11 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||||
if self.filters:
|
if self.filters:
|
||||||
for page_filter in self.filters:
|
for page_filter in self.filters:
|
||||||
where_stmts.append(page_filter.statement)
|
where_stmts.append(page_filter.statement)
|
||||||
if self.search and self.model:
|
if self.search and self.model and self.model.__search_fields__:
|
||||||
fields = self.model.__search_fields__
|
where_stmts.append(
|
||||||
if DB_TYPE == POSTGRES:
|
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE :search"
|
||||||
where_stmts.append(f"lower(concat({', '.join(fields)})) LIKE :search")
|
)
|
||||||
elif DB_TYPE == SQLITE:
|
|
||||||
where_stmts.append(f"lower({'||'.join(fields)}) LIKE :search")
|
|
||||||
if where_stmts:
|
if where_stmts:
|
||||||
return "WHERE " + " AND ".join(where_stmts)
|
return "WHERE " + " AND ".join(where_stmts)
|
||||||
return ""
|
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',
|
funding: 'Funding',
|
||||||
users: 'Users',
|
users: 'Users',
|
||||||
audit: 'Audit',
|
audit: 'Audit',
|
||||||
|
api_watch: 'Api Watch',
|
||||||
apps: 'Apps',
|
apps: 'Apps',
|
||||||
channels: 'Channels',
|
channels: 'Channels',
|
||||||
transactions: 'Transactions',
|
transactions: 'Transactions',
|
||||||
|
|
@ -487,5 +488,22 @@ window.localisation.en = {
|
||||||
http_request_methods: 'HTTP Request Methods',
|
http_request_methods: 'HTTP Request Methods',
|
||||||
http_response_codes: 'HTTP Response Codes',
|
http_response_codes: 'HTTP Response Codes',
|
||||||
request_details: 'Request Details',
|
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', {
|
window.app.component('lnbits-manage', {
|
||||||
mixins: [window.windowMixin],
|
mixins: [window.windowMixin],
|
||||||
template: '#lnbits-manage',
|
template: '#lnbits-manage',
|
||||||
props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers', 'showAudit'],
|
props: [
|
||||||
|
'showAdmin',
|
||||||
|
'showNode',
|
||||||
|
'showExtensions',
|
||||||
|
'showUsers',
|
||||||
|
'showAudit',
|
||||||
|
'showPayments'
|
||||||
|
],
|
||||||
methods: {
|
methods: {
|
||||||
isActive(path) {
|
isActive(path) {
|
||||||
return window.location.pathname === path
|
return window.location.pathname === path
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
window.app.component('payment-list', {
|
window.app.component('payment-list', {
|
||||||
name: 'payment-list',
|
name: 'payment-list',
|
||||||
template: '#payment-list',
|
template: '#payment-list',
|
||||||
props: ['update', 'lazy'],
|
props: ['update', 'lazy', 'wallet'],
|
||||||
mixins: [window.windowMixin],
|
mixins: [window.windowMixin],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -116,8 +116,8 @@ window.app.component('payment-list', {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
wallet() {
|
currentWallet() {
|
||||||
return this.g.wallet
|
return this.wallet || this.g.wallet
|
||||||
},
|
},
|
||||||
filteredPayments() {
|
filteredPayments() {
|
||||||
const q = this.paymentsTable.search
|
const q = this.paymentsTable.search
|
||||||
|
|
@ -139,7 +139,7 @@ window.app.component('payment-list', {
|
||||||
fetchPayments(props) {
|
fetchPayments(props) {
|
||||||
const params = LNbits.utils.prepareFilterQuery(this.paymentsTable, props)
|
const params = LNbits.utils.prepareFilterQuery(this.paymentsTable, props)
|
||||||
return LNbits.api
|
return LNbits.api
|
||||||
.getPayments(this.g.wallet, params)
|
.getPayments(this.currentWallet, params)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.paymentsTable.loading = false
|
this.paymentsTable.loading = false
|
||||||
this.paymentsTable.pagination.rowsNumber = response.data.total
|
this.paymentsTable.pagination.rowsNumber = response.data.total
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,15 @@ const routes = [
|
||||||
scripts: ['/static/js/audit.js']
|
scripts: ['/static/js/audit.js']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/payments',
|
||||||
|
name: 'Payments',
|
||||||
|
component: DynamicComponent,
|
||||||
|
props: {
|
||||||
|
fetchUrl: '/payments',
|
||||||
|
scripts: ['/static/js/payments.js']
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/extensions',
|
path: '/extensions',
|
||||||
name: '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-admin="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
||||||
:show-users="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
:show-users="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
||||||
:show-audit="'{{LNBITS_AUDIT_ENABLED}}' == 'True'"
|
:show-audit="'{{LNBITS_AUDIT_ENABLED}}' == 'True'"
|
||||||
|
:show-payments="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
||||||
:show-node="'{{LNBITS_NODE_UI}}' == 'True'"
|
:show-node="'{{LNBITS_NODE_UI}}' == 'True'"
|
||||||
:show-extensions="'{{LNBITS_EXTENSIONS_DEACTIVATE_ALL}}' == 'False'"
|
:show-extensions="'{{LNBITS_EXTENSIONS_DEACTIVATE_ALL}}' == 'False'"
|
||||||
></lnbits-manage>
|
></lnbits-manage>
|
||||||
|
|
|
||||||
|
|
@ -176,13 +176,25 @@
|
||||||
<q-item v-if="showAudit" to="/audit">
|
<q-item v-if="showAudit" to="/audit">
|
||||||
<q-item-section side>
|
<q-item-section side>
|
||||||
<q-icon
|
<q-icon
|
||||||
name="query_stats"
|
name="playlist_add_check_circle"
|
||||||
:color="isActive('/audit') ? 'primary' : 'grey-5'"
|
:color="isActive('/audit') ? 'primary' : 'grey-5'"
|
||||||
size="md"
|
size="md"
|
||||||
></q-icon>
|
></q-icon>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<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-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</div>
|
</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"})
|
payments = await get_payments({"sortby": "amount", "direction": "asc"})
|
||||||
assert payments[-1].amount > payments[0].amount
|
assert payments[-1].amount > payments[0].amount
|
||||||
|
|
||||||
payments = await get_payments({"search": "aaa"})
|
payments = await get_payments({"search": "xxx"})
|
||||||
assert len(payments) == 1
|
assert len(payments) == 1
|
||||||
|
|
||||||
payments = await get_payments({"search": "aa"})
|
payments = await get_payments({"search": "xx"})
|
||||||
assert len(payments) == 2
|
assert len(payments) == 2
|
||||||
|
|
||||||
# amount is in msat
|
# amount is in msat
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,6 @@ async def test_login_usr_not_allowed_for_admin_without_credentials(
|
||||||
response = await http_client.get(
|
response = await http_client.get(
|
||||||
f"/admin/api/v1/settings?usr={settings.super_user}"
|
f"/admin/api/v1/settings?usr={settings.super_user}"
|
||||||
)
|
)
|
||||||
print("### response", response.text)
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert (
|
assert (
|
||||||
response.json().get("detail") == "User id only access for admins is forbidden."
|
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)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
fake_data = [
|
fake_data = [
|
||||||
CreateInvoice(amount=10, memo="aaaa", out=False),
|
CreateInvoice(amount=10, memo="xxxx", out=False),
|
||||||
CreateInvoice(amount=100, memo="bbbb", out=False),
|
CreateInvoice(amount=100, memo="yyyy", out=False),
|
||||||
CreateInvoice(amount=1000, memo="aabb", out=False),
|
CreateInvoice(amount=1000, memo="xxyy", out=False),
|
||||||
]
|
]
|
||||||
|
|
||||||
for invoice in fake_data:
|
for invoice in fake_data:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue