Fix payments chart (#1851)

* feat: payment history endpoint

* test payment history

* use new endpoint in frontend

* refactor tests
This commit is contained in:
jackstar12
2023-09-12 14:38:30 +02:00
committed by GitHub
parent f526a93b6c
commit 4c16675b3b
7 changed files with 243 additions and 90 deletions

View File

@@ -1,6 +1,6 @@
import datetime import datetime
import json import json
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Literal, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@@ -9,7 +9,7 @@ import shortuuid
from lnbits import bolt11 from lnbits import bolt11
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import WalletType from lnbits.core.models import WalletType
from lnbits.db import Connection, Database, Filters, Page from lnbits.db import DB_TYPE, SQLITE, Connection, Database, Filters, Page
from lnbits.extension_manager import InstallableExtension from lnbits.extension_manager import InstallableExtension
from lnbits.settings import ( from lnbits.settings import (
AdminSettings, AdminSettings,
@@ -23,6 +23,7 @@ from .models import (
BalanceCheck, BalanceCheck,
Payment, Payment,
PaymentFilters, PaymentFilters,
PaymentHistoryPoint,
TinyURL, TinyURL,
User, User,
Wallet, Wallet,
@@ -655,6 +656,79 @@ async def update_payment_extra(
) )
async def update_pending_payments(wallet_id: str):
pending_payments = await get_payments(
wallet_id=wallet_id,
pending=True,
exclude_uncheckable=True,
)
for payment in pending_payments:
await payment.check_status()
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",
}
async def get_payments_history(
wallet_id: Optional[str] = None,
group: DateTrunc = "day",
filters: Optional[Filters] = None,
) -> List[PaymentHistoryPoint]:
if not filters:
filters = Filters()
where = ["(pending = False OR amount < 0)"]
values = []
if wallet_id:
where.append("wallet = ?")
values.append(wallet_id)
if DB_TYPE == SQLITE and group in sqlite_formats:
date_trunc = f"strftime('{sqlite_formats[group]}', time, 'unixepoch')"
elif group in ("day", "hour", "month"):
date_trunc = f"date_trunc('{group}', time)"
else:
raise ValueError(f"Invalid group value: {group}")
transactions = await db.fetchall(
f"""
SELECT {date_trunc} date,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
FROM apipayments
{filters.where(where)}
GROUP BY date
ORDER BY date DESC
""",
filters.values(values),
)
if wallet_id:
wallet = await get_wallet(wallet_id)
if wallet:
balance = wallet.balance_msat
else:
raise ValueError("Unknown wallet")
else:
balance = await get_total_balance()
# since we dont know the balance at the starting point,
# we take the current balance and walk backwards
results: list[PaymentHistoryPoint] = []
for row in transactions:
results.insert(
0,
PaymentHistoryPoint(
balance=balance, date=row[0], income=row[1], spending=row[2]
),
)
balance -= row.income - row.spending
return results
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

@@ -258,6 +258,13 @@ class PaymentFilters(FilterModel):
webhook_status: Optional[int] webhook_status: Optional[int]
class PaymentHistoryPoint(BaseModel):
date: datetime.datetime
income: int
spending: int
balance: int
class BalanceCheck(BaseModel): class BalanceCheck(BaseModel):
wallet: str wallet: str
service: str service: str

View File

@@ -3,45 +3,24 @@
Vue.component(VueQrcode.name, VueQrcode) Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader) Vue.use(VueQrcodeReader)
function generateChart(canvas, payments) { function generateChart(canvas, rawData) {
var txs = [] const data = rawData.reduce(
var n = 0 (previous, current) => {
var data = { previous.labels.push(current.date)
previous.income.push(current.income)
previous.spending.push(current.spending)
previous.cumulative.push(current.balance)
return previous
},
{
labels: [], labels: [],
income: [], income: [],
outcome: [], spending: [],
cumulative: [] cumulative: []
} }
_.each(
payments.filter(p => !p.pending).sort((a, b) => a.time - b.time),
tx => {
txs.push({
hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'),
sat: tx.sat
})
}
) )
_.each(_.groupBy(txs, 'hour'), (value, day) => { return new Chart(canvas.getContext('2d'), {
var income = _.reduce(
value,
(memo, tx) => (tx.sat >= 0 ? memo + tx.sat : memo),
0
)
var outcome = _.reduce(
value,
(memo, tx) => (tx.sat < 0 ? memo + Math.abs(tx.sat) : memo),
0
)
n = n + income - outcome
data.labels.push(day)
data.income.push(income)
data.outcome.push(outcome)
data.cumulative.push(n)
})
new Chart(canvas.getContext('2d'), {
type: 'bar', type: 'bar',
data: { data: {
labels: data.labels, labels: data.labels,
@@ -64,7 +43,7 @@ function generateChart(canvas, payments) {
backgroundColor: window.Color('rgb(76,175,80)').alpha(0.5).rgbString() // green backgroundColor: window.Color('rgb(76,175,80)').alpha(0.5).rgbString() // green
}, },
{ {
data: data.outcome, data: data.spending,
type: 'bar', type: 'bar',
label: 'out', label: 'out',
barPercentage: 0.75, barPercentage: 0.75,
@@ -85,7 +64,7 @@ function generateChart(canvas, payments) {
{ {
type: 'time', type: 'time',
display: true, display: true,
offset: true, //offset: true,
time: { time: {
minUnit: 'hour', minUnit: 'hour',
stepSize: 3 stepSize: 3
@@ -248,7 +227,14 @@ new Vue({
loading: false loading: false
}, },
paymentsChart: { paymentsChart: {
show: false show: false,
group: {value: 'hour', label: 'Hour'},
groupOptions: [
{value: 'month', label: 'Month'},
{value: 'day', label: 'Day'},
{value: 'hour', label: 'Hour'}
],
instance: null
}, },
disclaimerDialog: { disclaimerDialog: {
show: false, show: false,
@@ -301,8 +287,26 @@ new Vue({
}, },
showChart: function () { showChart: function () {
this.paymentsChart.show = true this.paymentsChart.show = true
LNbits.api
.request(
'GET',
'/api/v1/payments/history?group=' + this.paymentsChart.group.value,
this.g.wallet.adminkey
)
.then(response => {
this.$nextTick(() => { this.$nextTick(() => {
generateChart(this.$refs.canvas, this.payments) if (this.paymentsChart.instance) {
this.paymentsChart.instance.destroy()
}
this.paymentsChart.instance = generateChart(
this.$refs.canvas,
response.data
)
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
this.paymentsChart.show = false
}) })
}, },
focusInput(el) { focusInput(el) {
@@ -803,6 +807,9 @@ new Vue({
watch: { watch: {
payments: function () { payments: function () {
this.fetchBalance() this.fetchBalance()
},
'paymentsChart.group': function () {
this.showChart()
} }
}, },
created: function () { created: function () {

View File

@@ -827,6 +827,19 @@
<q-dialog v-model="paymentsChart.show"> <q-dialog v-model="paymentsChart.show">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset"> <q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section> <q-card-section>
<div class="row q-gutter-sm justify-between">
<div class="text-h6">Payments Chart</div>
<q-select
label="Group"
filled
dense
v-model="paymentsChart.group"
style="min-width: 120px"
:options="paymentsChart.groupOptions"
>
</q-select>
</div>
<canvas ref="canvas" width="600" height="400"></canvas> <canvas ref="canvas" width="600" height="400"></canvas>
</q-card-section> </q-card-section>
</q-card> </q-card>

View File

@@ -40,6 +40,8 @@ from lnbits.core.models import (
DecodePayment, DecodePayment,
Payment, Payment,
PaymentFilters, PaymentFilters,
PaymentHistoryPoint,
Query,
User, User,
Wallet, Wallet,
WalletType, WalletType,
@@ -71,6 +73,7 @@ from lnbits.utils.exchange_rates import (
) )
from ..crud import ( from ..crud import (
DateTrunc,
add_installed_extension, add_installed_extension,
create_tinyurl, create_tinyurl,
create_webpush_subscription, create_webpush_subscription,
@@ -81,6 +84,7 @@ from ..crud import (
drop_extension_db, drop_extension_db,
get_dbversions, get_dbversions,
get_payments, get_payments,
get_payments_history,
get_payments_paginated, get_payments_paginated,
get_standalone_payment, get_standalone_payment,
get_tinyurl, get_tinyurl,
@@ -88,6 +92,7 @@ from ..crud import (
get_wallet_for_key, get_wallet_for_key,
get_webpush_subscription, get_webpush_subscription,
save_balance_check, save_balance_check,
update_pending_payments,
update_wallet, update_wallet,
) )
from ..services import ( from ..services import (
@@ -155,16 +160,7 @@ async def api_payments(
wallet: WalletTypeInfo = Depends(get_key_type), wallet: WalletTypeInfo = Depends(get_key_type),
filters: Filters = Depends(parse_filters(PaymentFilters)), filters: Filters = Depends(parse_filters(PaymentFilters)),
): ):
pending_payments = await get_payments( await update_pending_payments(wallet.wallet.id)
wallet_id=wallet.wallet.id,
pending=True,
exclude_uncheckable=True,
filters=filters,
)
for payment in pending_payments:
await check_transaction_status(
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
)
return await get_payments( return await get_payments(
wallet_id=wallet.wallet.id, wallet_id=wallet.wallet.id,
pending=True, pending=True,
@@ -173,6 +169,21 @@ async def api_payments(
) )
@api_router.get(
"/api/v1/payments/history",
name="Get payments history",
response_model=List[PaymentHistoryPoint],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_history(
wallet: WalletTypeInfo = Depends(get_key_type),
group: DateTrunc = Query("day"),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
):
await update_pending_payments(wallet.wallet.id)
return await get_payments_history(wallet.wallet.id, group, filters)
@api_router.get( @api_router.get(
"/api/v1/payments/paginated", "/api/v1/payments/paginated",
name="Payment List", name="Payment List",
@@ -185,16 +196,7 @@ async def api_payments_paginated(
wallet: WalletTypeInfo = Depends(get_key_type), wallet: WalletTypeInfo = Depends(get_key_type),
filters: Filters = Depends(parse_filters(PaymentFilters)), filters: Filters = Depends(parse_filters(PaymentFilters)),
): ):
pending = await get_payments_paginated( await update_pending_payments(wallet.wallet.id)
wallet_id=wallet.wallet.id,
pending=True,
exclude_uncheckable=True,
filters=filters,
)
for payment in pending.data:
await check_transaction_status(
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
)
page = await get_payments_paginated( page = await get_payments_paginated(
wallet_id=wallet.wallet.id, wallet_id=wallet.wallet.id,
pending=True, pending=True,

View File

@@ -1,5 +1,6 @@
# ruff: noqa: E402 # ruff: noqa: E402
import asyncio import asyncio
from time import time
import uvloop import uvloop
@@ -11,11 +12,16 @@ from fastapi.testclient import TestClient
from httpx import AsyncClient from httpx import AsyncClient
from lnbits.app import create_app from lnbits.app import create_app
from lnbits.core.crud import create_account, create_wallet, get_user from lnbits.core.crud import (
create_account,
create_wallet,
get_user,
update_payment_status,
)
from lnbits.core.models import CreateInvoice from lnbits.core.models import CreateInvoice
from lnbits.core.services import update_wallet_balance from lnbits.core.services import update_wallet_balance
from lnbits.core.views.api import api_payments_create_invoice from lnbits.core.views.api import api_payments_create_invoice
from lnbits.db import Database from lnbits.db import DB_TYPE, SQLITE, Database
from lnbits.settings import settings from lnbits.settings import settings
from tests.helpers import ( from tests.helpers import (
clean_database, clean_database,
@@ -173,6 +179,31 @@ async def real_invoice():
del invoice del invoice
@pytest_asyncio.fixture(scope="session")
async def fake_payments(client, adminkey_headers_from):
# Because sqlite only stores timestamps with milliseconds
# we have to wait a second to ensure a different timestamp than previous invoices
if DB_TYPE == SQLITE:
await asyncio.sleep(1)
ts = time()
fake_data = [
CreateInvoice(amount=10, memo="aaaa", out=False),
CreateInvoice(amount=100, memo="bbbb", out=False),
CreateInvoice(amount=1000, memo="aabb", out=False),
]
for invoice in fake_data:
response = await client.post(
"/api/v1/payments", headers=adminkey_headers_from, json=invoice.dict()
)
assert response.is_success
await update_payment_status(response.json()["checking_id"], pending=False)
params = {"time[ge]": ts, "time[le]": time()}
return fake_data, params
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
async def hold_invoice(): async def hold_invoice():
invoice = get_hold_invoice(100) invoice = get_hold_invoice(100)

View File

@@ -1,23 +1,21 @@
import asyncio import asyncio
import hashlib import hashlib
from time import time
import pytest import pytest
from lnbits import bolt11 from lnbits import bolt11
from lnbits.core.crud import get_standalone_payment, update_payment_details from lnbits.core.crud import get_standalone_payment, update_payment_details
from lnbits.core.models import Payment from lnbits.core.models import CreateInvoice, Payment
from lnbits.core.views.admin_api import api_auditor from lnbits.core.views.admin_api import api_auditor
from lnbits.core.views.api import api_payment from lnbits.core.views.api import api_payment
from lnbits.db import DB_TYPE, SQLITE
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_wallet_class from lnbits.wallets import get_wallet_class
from tests.conftest import CreateInvoice, api_payments_create_invoice
from ...helpers import ( from ...helpers import (
cancel_invoice, cancel_invoice,
get_random_invoice_data, get_random_invoice_data,
is_fake, is_fake,
is_regtest,
pay_real_invoice, pay_real_invoice,
settle_invoice, settle_invoice,
) )
@@ -250,29 +248,13 @@ async def test_pay_invoice_adminkey(client, invoice, adminkey_headers_from):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_payments(client, from_wallet, adminkey_headers_from): async def test_get_payments(client, adminkey_headers_from, fake_payments):
# Because sqlite only stores timestamps with milliseconds we have to wait a second fake_data, filters = fake_payments
# to ensure a different timestamp than previous invoices due to this limitation
# both payments (normal and paginated) are tested at the same time as they are
# almost identical anyways
if DB_TYPE == SQLITE:
await asyncio.sleep(1)
ts = time()
fake_data = [
CreateInvoice(amount=10, memo="aaaa"),
CreateInvoice(amount=100, memo="bbbb"),
CreateInvoice(amount=1000, memo="aabb"),
]
for invoice in fake_data:
await api_payments_create_invoice(invoice, from_wallet)
async def get_payments(params: dict): async def get_payments(params: dict):
params["time[ge]"] = ts
response = await client.get( response = await client.get(
"/api/v1/payments", "/api/v1/payments",
params=params, params=filters | params,
headers=adminkey_headers_from, headers=adminkey_headers_from,
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -298,9 +280,14 @@ async def test_get_payments(client, from_wallet, adminkey_headers_from):
payments = await get_payments({"amount[gt]": 10000}) payments = await get_payments({"amount[gt]": 10000})
assert len(payments) == 2 assert len(payments) == 2
@pytest.mark.asyncio
async def test_get_payments_paginated(client, adminkey_headers_from, fake_payments):
fake_data, filters = fake_payments
response = await client.get( response = await client.get(
"/api/v1/payments/paginated", "/api/v1/payments/paginated",
params={"limit": 2, "time[ge]": ts}, params=filters | {"limit": 2},
headers=adminkey_headers_from, headers=adminkey_headers_from,
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -309,6 +296,38 @@ async def test_get_payments(client, from_wallet, adminkey_headers_from):
assert paginated["total"] == len(fake_data) assert paginated["total"] == len(fake_data)
@pytest.mark.asyncio
@pytest.mark.skipif(
is_regtest, reason="payments wont be confirmed rightaway in regtest"
)
async def test_get_payments_history(client, adminkey_headers_from, fake_payments):
fake_data, filters = fake_payments
response = await client.get(
"/api/v1/payments/history",
params=filters,
headers=adminkey_headers_from,
)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["spending"] == sum(
payment.amount * 1000 for payment in fake_data if payment.out
)
assert data[0]["income"] == sum(
payment.amount * 1000 for payment in fake_data if not payment.out
)
response = await client.get(
"/api/v1/payments/history?group=INVALID",
params=filters,
headers=adminkey_headers_from,
)
assert response.status_code == 400
# check POST /api/v1/payments/decode # check POST /api/v1/payments/decode
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_decode_invoice(client, invoice): async def test_decode_invoice(client, invoice):