Add a payments page for admin (#2910)

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

View file

@ -1,26 +1,23 @@
from time import time from 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:

View file

@ -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",

View file

@ -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):

View file

@ -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:

View file

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

View file

@ -209,7 +209,6 @@ async def account(
request: Request, 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()

View file

@ -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

View file

@ -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 ""

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -6,6 +6,7 @@ window.localisation.en = {
funding: 'Funding', 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'
} }

View file

@ -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

View file

@ -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

View file

@ -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',

View file

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

View file

@ -191,6 +191,7 @@
:show-admin="'{{LNBITS_ADMIN_UI}}' == 'True'" :show-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>

View file

@ -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>

View file

@ -337,10 +337,10 @@ async def test_get_payments(client, inkey_fresh_headers_to, fake_payments):
payments = await get_payments({"sortby": "amount", "direction": "asc"}) 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

View file

@ -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."

View file

@ -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: