feat: payments breakdown wallet ui + api (#4027)

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
Arc 2026-07-21 16:26:00 +01:00 committed by GitHub
parent 91c8aa77ed
commit f3f5c89113
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 369 additions and 7 deletions

View file

@ -16,6 +16,7 @@ from ..models import (
PaymentFilters,
PaymentHistoryPoint,
PaymentsStatusCount,
PaymentTotalBreakdown,
PaymentWalletStats,
)
@ -403,6 +404,41 @@ async def get_payment_count_stats(
return data
async def get_wallet_payment_total_breakdown(
wallet_id: str,
conn: Connection | None = None,
) -> list[PaymentTotalBreakdown]:
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not wallet.can_view_payments:
return []
values = {"wallet_id": wallet.source_wallet_id}
data = await (conn or db).fetchall(
query=f"""
SELECT tag,
CASE
WHEN fiat_provider IS NOT NULL
THEN true
ELSE false
END AS is_fiat,
COUNT(*) AS payments_count,
SUM(amount - ABS(fee)) AS total
FROM apipayments
WHERE wallet_id = :wallet_id
AND (
status = '{PaymentState.SUCCESS}'
OR (amount < 0 AND status = '{PaymentState.PENDING}')
)
GROUP BY tag, is_fiat
ORDER BY tag
""", # noqa: S608
values=values,
model=PaymentTotalBreakdown,
)
return data
async def get_daily_stats(
filters: Filters[PaymentFilters] | None = None,
user_id: str | None = None,

View file

@ -23,6 +23,7 @@ from .payments import (
PaymentHistoryPoint,
PaymentsStatusCount,
PaymentState,
PaymentTotalBreakdown,
PaymentWalletStats,
SettleInvoice,
UpdatePaymentExtra,
@ -83,6 +84,7 @@ __all__ = [
"PaymentFilters",
"PaymentHistoryPoint",
"PaymentState",
"PaymentTotalBreakdown",
"PaymentWalletStats",
"PaymentsStatusCount",
"RegisterUser",

View file

@ -221,6 +221,13 @@ class PaymentWalletStats(BaseModel):
balance: float = 0
class PaymentTotalBreakdown(BaseModel):
tag: str | None = None
is_fiat: bool = False
payments_count: int = 0
total: int = 0
class PaymentDailyStats(BaseModel):
date: datetime
balance: float = 0

View file

@ -15,6 +15,7 @@ from lnurl import url_decode
from lnbits import bolt11
from lnbits.core.crud.payments import (
get_payment_count_stats,
get_wallet_payment_total_breakdown,
get_wallets_stats,
update_payment,
)
@ -32,6 +33,7 @@ from lnbits.core.models import (
PaymentDailyStats,
PaymentFilters,
PaymentHistoryPoint,
PaymentTotalBreakdown,
PaymentWalletStats,
SettleInvoice,
SimpleStatus,
@ -134,6 +136,17 @@ async def api_payments_counting_stats(
return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id)
@payment_router.get(
"/stats/breakdown",
name="Get wallet payment total breakdown",
response_model=list[PaymentTotalBreakdown],
)
async def api_payments_total_breakdown(
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key),
):
return await get_wallet_payment_total_breakdown(key_info.wallet.id)
@payment_router.get(
"/stats/wallets",
name="Get payments history for all users",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -175,6 +175,9 @@ window._lnbitsApi = {
wallet.inkey
)
},
getPaymentTotalBreakdown(wallet) {
return this.request('get', '/api/v1/payments/stats/breakdown', wallet.inkey)
},
getPayment(wallet, paymentHash) {
return this.request('get', '/api/v1/payments/' + paymentHash, wallet.inkey)
},

View file

@ -49,6 +49,13 @@ window.PageWallet = {
hasNfc: false,
nfcReaderAbortController: null,
formattedFiatAmount: 0,
totalBreakdown: {
show: false,
loading: false,
rows: [],
selectedTypes: ['bitcoin', 'fiat'],
selectedTags: []
},
paymentFilter: {
'status[ne]': 'failed'
},
@ -85,9 +92,116 @@ window.PageWallet = {
},
formattedSatAmount() {
return LNbits.utils.formatMsat(this.receive.amountMsat) + ' sat'
},
totalBreakdownTags() {
const tags = this.totalBreakdown.rows.map(row => row.tag || null)
return [...new Set(tags)].sort((a, b) =>
this.totalBreakdownTagLabel(a).localeCompare(
this.totalBreakdownTagLabel(b)
)
)
},
hasFiatTotalBreakdown() {
return this.totalBreakdown.rows.some(row => row.is_fiat)
},
selectedTotalBreakdownRows() {
return this.totalBreakdown.rows.filter(row => {
const type = row.is_fiat ? 'fiat' : 'bitcoin'
return (
this.totalBreakdown.selectedTypes.includes(type) &&
this.totalBreakdown.selectedTags.includes(
this.totalBreakdownTagKey(row.tag)
)
)
})
},
selectedTotalBreakdownMsat() {
return this.selectedTotalBreakdownRows.reduce(
(total, row) => total + row.total,
0
)
},
selectedTotalBreakdownSat() {
return Math.round(this.selectedTotalBreakdownMsat / 1000)
},
selectedTotalBreakdownCount() {
return this.selectedTotalBreakdownRows.reduce(
(total, row) => total + row.payments_count,
0
)
},
formattedTotalBreakdown() {
return this.utils.formatBalance(
this.selectedTotalBreakdownSat,
this.g.denomination
)
},
formattedTotalBreakdownFiat() {
if (!this.g.fiatTracking) return null
const amount =
(this.selectedTotalBreakdownSat / 100000000) * this.g.exchangeRate
return LNbits.utils.formatCurrency(amount, this.g.wallet.currency)
},
primaryTotalBreakdownValue() {
if (this.g.isFiatPriority && this.g.fiatTracking) {
return this.formattedTotalBreakdownFiat || this.formattedTotalBreakdown
}
return this.formattedTotalBreakdown
},
secondaryTotalBreakdownValue() {
if (!this.g.fiatTracking) return null
if (this.g.isFiatPriority) {
return this.formattedTotalBreakdown
}
return this.formattedTotalBreakdownFiat
}
},
methods: {
showWalletTotalBreakdown() {
this.totalBreakdown.show = true
if (!this.totalBreakdown.rows.length) {
this.fetchTotalBreakdown()
}
},
fetchTotalBreakdown() {
this.totalBreakdown.loading = true
LNbits.api
.getPaymentTotalBreakdown(this.g.wallet)
.then(response => {
this.totalBreakdown.rows = response.data
this.totalBreakdown.selectedTypes = ['bitcoin', 'fiat']
this.totalBreakdown.selectedTags = this.totalBreakdownTags.map(
this.totalBreakdownTagKey
)
this.totalBreakdown.loading = false
})
.catch(err => {
this.totalBreakdown.loading = false
LNbits.utils.notifyApiError(err)
})
},
totalBreakdownTagLabel(tag) {
return tag || 'No tag'
},
totalBreakdownTagKey(tag) {
return tag || '__untagged__'
},
totalBreakdownTagCount(tag) {
return this.totalBreakdown.rows
.filter(row => (row.tag || null) === tag)
.reduce((total, row) => total + row.payments_count, 0)
},
totalBreakdownTagMsat(tag) {
return this.totalBreakdown.rows
.filter(row => (row.tag || null) === tag)
.reduce((total, row) => total + row.total, 0)
},
formatTotalBreakdownMsat(msat) {
return this.utils.formatBalance(
Math.round(msat / 1000),
this.g.denomination
)
},
handleSendLnurl(lnurl) {
this.parse.data.request = lnurl
this.parse.show = true

View file

@ -28,7 +28,13 @@
<div class="col-7">
<div class="row">
<div class="col-auto">
<div class="text-h3 q-my-none full-width">
<div
class="text-h3 q-my-none full-width cursor-pointer"
role="button"
tabindex="0"
@click="showWalletTotalBreakdown"
@keyup.enter="showWalletTotalBreakdown"
>
<strong
v-text="
utils.formatBalance(g.wallet.sat, g.denomination)
@ -77,7 +83,11 @@
<div class="col-auto">
<div
v-if="g.fiatTracking"
class="text-h3 q-my-none text-no-wrap"
class="text-h3 q-my-none text-no-wrap cursor-pointer"
role="button"
tabindex="0"
@click="showWalletTotalBreakdown"
@keyup.enter="showWalletTotalBreakdown"
>
<strong v-text="formattedFiatAmount"></strong>
</div>
@ -228,6 +238,113 @@
</div>
</div>
<q-dialog v-model="totalBreakdown.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-card-section>
<div class="row items-start q-mb-md">
<div class="col">
<div
class="text-h4 text-bold"
v-text="primaryTotalBreakdownValue"
></div>
<div
v-if="secondaryTotalBreakdownValue"
class="text-h6 text-italic"
style="opacity: 0.75"
v-text="secondaryTotalBreakdownValue"
></div>
<div
class="text-caption text-grey-5"
v-text="selectedTotalBreakdownCount + ' payments'"
></div>
</div>
<q-btn
flat
dense
round
color="grey"
icon="refresh"
:loading="totalBreakdown.loading"
@click="fetchTotalBreakdown"
>
<q-tooltip>Refresh</q-tooltip>
</q-btn>
</div>
<q-inner-loading :showing="totalBreakdown.loading"></q-inner-loading>
<div v-if="hasFiatTotalBreakdown" class="q-mb-md">
<q-checkbox
v-model="totalBreakdown.selectedTypes"
val="bitcoin"
label="Bitcoin"
></q-checkbox>
<q-checkbox
v-model="totalBreakdown.selectedTypes"
val="fiat"
label="Fiat"
></q-checkbox>
</div>
<q-separator v-if="hasFiatTotalBreakdown" class="q-mb-md"></q-separator>
<div style="max-height: min(52vh, 420px); overflow-y: auto">
<q-list dense>
<q-item
v-for="tag in totalBreakdownTags"
:key="totalBreakdownTagKey(tag)"
tag="label"
v-ripple
>
<q-item-section side>
<q-checkbox
v-model="totalBreakdown.selectedTags"
:val="totalBreakdownTagKey(tag)"
></q-checkbox>
</q-item-section>
<q-item-section>
<q-item-label>
<q-badge
v-if="tag"
color="yellow"
text-color="black"
v-text="'#' + tag"
></q-badge>
<span v-else v-text="totalBreakdownTagLabel(tag)"></span>
</q-item-label>
<q-item-label
caption
v-text="totalBreakdownTagCount(tag) + ' payments'"
></q-item-label>
</q-item-section>
<q-item-section side>
<span
v-text="formatTotalBreakdownMsat(totalBreakdownTagMsat(tag))"
></span>
</q-item-section>
</q-item>
</q-list>
</div>
<div v-if="!totalBreakdown.loading && !totalBreakdown.rows.length">
<q-banner class="bg-transparent text-grey-5">
No completed payments.
</q-banner>
</div>
<div class="row q-mt-md">
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</div>
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="receive.show" position="top" @hide="onReceiveDialogHide">
<q-card
v-if="!receive.paymentReq"

View file

@ -6,11 +6,16 @@ import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from lnbits.core.crud.payments import create_payment, get_payment, get_payments
from lnbits.core.crud.payments import (
create_payment,
get_payment,
get_payments,
update_payment,
)
from lnbits.core.models import Account, CreateInvoice, PaymentFilters, PaymentState
from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice
from lnbits.core.models.users import AccountId
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
from lnbits.core.models.wallets import BaseWalletTypeInfo, KeyType, WalletTypeInfo
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.payment_api import (
@ -20,6 +25,7 @@ from lnbits.core.views.payment_api import (
api_payments_daily_stats,
api_payments_fee_reserve,
api_payments_settle,
api_payments_total_breakdown,
api_payments_wallets_stats,
)
from lnbits.db import Filter, Filters
@ -146,6 +152,61 @@ async def test_payment_external_id_is_stored_and_validated():
CreateInvoice(out=False, amount=21, external_id="provider payment 123")
@pytest.mark.anyio
async def test_payment_api_total_breakdown_groups_wallet_tags_and_fiat():
first_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
second_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
first_wallet = first_user.wallets[0]
second_wallet = second_user.wallets[0]
await _create_payment(first_wallet.id, amount_msat=2_000, tag="coffee")
await _create_payment(
first_wallet.id,
amount_msat=4_000,
tag="coffee",
fiat_provider="stripe",
extra={"fiat_payment_request": "https://stripe.test/session"},
)
await _create_payment(first_wallet.id, amount_msat=-1_000)
await _create_payment(second_wallet.id, amount_msat=8_000, tag="books")
breakdown = await api_payments_total_breakdown(
BaseWalletTypeInfo(key_type=KeyType.invoice, wallet=first_wallet)
)
assert any(
item.tag == "coffee"
and item.is_fiat is False
and item.total == 2_000
and item.payments_count == 1
for item in breakdown
)
assert any(
item.tag == "coffee"
and item.is_fiat is True
and item.total == 4_000
and item.payments_count == 1
for item in breakdown
)
assert any(
item.tag is None and item.is_fiat is False and item.total == -1_000
for item in breakdown
)
assert all(item.tag != "books" for item in breakdown)
@pytest.mark.anyio
async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
user = await create_user_account(
@ -439,8 +500,13 @@ async def _create_payment(
status: PaymentState = PaymentState.SUCCESS,
payment_hash: str | None = None,
tag: str | None = None,
fiat_provider: str | None = None,
extra: dict | None = None,
) -> str:
checking_id = f"checking_{uuid4().hex[:8]}"
payment_extra = extra or {}
if tag:
payment_extra["tag"] = tag
await create_payment(
checking_id=checking_id,
data=CreatePayment(
@ -449,8 +515,12 @@ async def _create_payment(
bolt11=f"bolt11_{checking_id}",
amount_msat=amount_msat,
memo=f"payment_{checking_id}",
extra={"tag": tag} if tag else {},
extra=payment_extra,
),
status=status,
)
if fiat_provider:
payment = await get_payment(checking_id)
payment.fiat_provider = fiat_provider
await update_payment(payment)
return checking_id