This commit is contained in:
Arc
2026-06-26 13:04:35 +01:00
parent c680fd647d
commit 0275b66a89
8 changed files with 350 additions and 4 deletions
+39
View File
@@ -16,6 +16,7 @@ from ..models import (
PaymentFilters, PaymentFilters,
PaymentHistoryPoint, PaymentHistoryPoint,
PaymentsStatusCount, PaymentsStatusCount,
PaymentTotalBreakdown,
PaymentWalletStats, PaymentWalletStats,
) )
@@ -428,6 +429,44 @@ async def get_payment_count_stats(
return data 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
OR checking_id LIKE 'fiat_%'
OR extra LIKE '%"fiat_payment_request"%'
OR extra LIKE '%"fiat_amount"%'
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( async def get_daily_stats(
filters: Filters[PaymentFilters] | None = None, filters: Filters[PaymentFilters] | None = None,
user_id: str | None = None, user_id: str | None = None,
+2
View File
@@ -21,6 +21,7 @@ from .payments import (
PaymentExtra, PaymentExtra,
PaymentFilters, PaymentFilters,
PaymentHistoryPoint, PaymentHistoryPoint,
PaymentTotalBreakdown,
PaymentsStatusCount, PaymentsStatusCount,
PaymentState, PaymentState,
PaymentWalletStats, PaymentWalletStats,
@@ -82,6 +83,7 @@ __all__ = [
"PaymentExtra", "PaymentExtra",
"PaymentFilters", "PaymentFilters",
"PaymentHistoryPoint", "PaymentHistoryPoint",
"PaymentTotalBreakdown",
"PaymentState", "PaymentState",
"PaymentWalletStats", "PaymentWalletStats",
"PaymentsStatusCount", "PaymentsStatusCount",
+7
View File
@@ -220,6 +220,13 @@ class PaymentWalletStats(BaseModel):
balance: float = 0 balance: float = 0
class PaymentTotalBreakdown(BaseModel):
tag: str | None = None
is_fiat: bool = False
payments_count: int = 0
total: int = 0
class PaymentDailyStats(BaseModel): class PaymentDailyStats(BaseModel):
date: datetime date: datetime
balance: float = 0 balance: float = 0
+13
View File
@@ -14,6 +14,7 @@ from lnurl import url_decode
from lnbits import bolt11 from lnbits import bolt11
from lnbits.core.crud.payments import ( from lnbits.core.crud.payments import (
get_payment_count_stats, get_payment_count_stats,
get_wallet_payment_total_breakdown,
get_wallets_stats, get_wallets_stats,
update_payment, update_payment,
) )
@@ -31,6 +32,7 @@ from lnbits.core.models import (
PaymentDailyStats, PaymentDailyStats,
PaymentFilters, PaymentFilters,
PaymentHistoryPoint, PaymentHistoryPoint,
PaymentTotalBreakdown,
PaymentWalletStats, PaymentWalletStats,
SettleInvoice, SettleInvoice,
SimpleStatus, SimpleStatus,
@@ -131,6 +133,17 @@ async def api_payments_counting_stats(
return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id) 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( @payment_router.get(
"/stats/wallets", "/stats/wallets",
name="Get payments history for all users", name="Get payments history for all users",
+3
View File
@@ -175,6 +175,9 @@ window._lnbitsApi = {
wallet.inkey wallet.inkey
) )
}, },
getPaymentTotalBreakdown(wallet) {
return this.request('get', '/api/v1/payments/stats/breakdown', wallet.inkey)
},
getPayment(wallet, paymentHash) { getPayment(wallet, paymentHash) {
return this.request('get', '/api/v1/payments/' + paymentHash, wallet.inkey) return this.request('get', '/api/v1/payments/' + paymentHash, wallet.inkey)
}, },
+95
View File
@@ -48,6 +48,13 @@ window.PageWallet = {
hasNfc: false, hasNfc: false,
nfcReaderAbortController: null, nfcReaderAbortController: null,
formattedFiatAmount: 0, formattedFiatAmount: 0,
totalBreakdown: {
show: false,
loading: false,
rows: [],
selectedTypes: ['bitcoin', 'fiat'],
selectedTags: []
},
paymentFilter: { paymentFilter: {
'status[ne]': 'failed' 'status[ne]': 'failed'
}, },
@@ -84,9 +91,97 @@ window.PageWallet = {
}, },
formattedSatAmount() { formattedSatAmount() {
return LNbits.utils.formatMsat(this.receive.amountMsat) + ' sat' 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
)
},
selectedTotalBreakdownCount() {
return this.selectedTotalBreakdownRows.reduce(
(total, row) => total + row.payments_count,
0
)
},
formattedTotalBreakdown() {
return this.utils.formatBalance(
this.selectedTotalBreakdownMsat / 1000,
this.g.denomination
)
},
formattedTotalBreakdownFiat() {
if (!this.g.fiatTracking) return null
const sats = this.selectedTotalBreakdownMsat / 1000
const amount = (sats / 100000000) * this.g.exchangeRate
return LNbits.utils.formatCurrency(amount, this.g.wallet.currency)
} }
}, },
methods: { 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(msat / 1000, this.g.denomination)
},
handleSendLnurl(lnurl) { handleSendLnurl(lnurl) {
this.parse.data.request = lnurl this.parse.data.request = lnurl
this.parse.show = true this.parse.show = true
+119 -2
View File
@@ -28,7 +28,13 @@
<div class="col-7"> <div class="col-7">
<div class="row"> <div class="row">
<div class="col-auto"> <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 <strong
v-text=" v-text="
utils.formatBalance(g.wallet.sat, g.denomination) utils.formatBalance(g.wallet.sat, g.denomination)
@@ -40,6 +46,7 @@
maxWidth: '100%' maxWidth: '100%'
}" }"
></strong> ></strong>
<q-tooltip>Total breakdown</q-tooltip>
</div> </div>
</div> </div>
<div class="col-auto"> <div class="col-auto">
@@ -77,9 +84,14 @@
<div class="col-auto"> <div class="col-auto">
<div <div
v-if="g.fiatTracking" 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> <strong v-text="formattedFiatAmount"></strong>
<q-tooltip>Total breakdown</q-tooltip>
</div> </div>
</div> </div>
<div class="col-auto"> <div class="col-auto">
@@ -228,6 +240,111 @@
</div> </div>
</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="formattedTotalBreakdown"
></div>
<div
v-if="formattedTotalBreakdownFiat"
class="text-h6 text-italic"
style="opacity: 0.75"
v-text="formattedTotalBreakdownFiat"
></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>
<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 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-dialog v-model="receive.show" position="top" @hide="onReceiveDialogHide">
<q-card <q-card
v-if="!receive.paymentReq" v-if="!receive.paymentReq"
+72 -2
View File
@@ -6,7 +6,12 @@ import pytest
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import ValidationError 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 import Account, CreateInvoice, PaymentFilters, PaymentState
from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice
from lnbits.core.models.users import AccountId from lnbits.core.models.users import AccountId
@@ -20,6 +25,7 @@ from lnbits.core.views.payment_api import (
api_payments_daily_stats, api_payments_daily_stats,
api_payments_fee_reserve, api_payments_fee_reserve,
api_payments_settle, api_payments_settle,
api_payments_total_breakdown,
api_payments_wallets_stats, api_payments_wallets_stats,
) )
from lnbits.db import Filter, Filters 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") 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(
WalletTypeInfo(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 @pytest.mark.anyio
async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker): async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
user = await create_user_account( user = await create_user_account(
@@ -383,8 +444,13 @@ async def _create_payment(
status: PaymentState = PaymentState.SUCCESS, status: PaymentState = PaymentState.SUCCESS,
payment_hash: str | None = None, payment_hash: str | None = None,
tag: str | None = None, tag: str | None = None,
fiat_provider: str | None = None,
extra: dict | None = None,
) -> str: ) -> str:
checking_id = f"checking_{uuid4().hex[:8]}" checking_id = f"checking_{uuid4().hex[:8]}"
payment_extra = extra or {}
if tag:
payment_extra["tag"] = tag
await create_payment( await create_payment(
checking_id=checking_id, checking_id=checking_id,
data=CreatePayment( data=CreatePayment(
@@ -393,8 +459,12 @@ async def _create_payment(
bolt11=f"bolt11_{checking_id}", bolt11=f"bolt11_{checking_id}",
amount_msat=amount_msat, amount_msat=amount_msat,
memo=f"payment_{checking_id}", memo=f"payment_{checking_id}",
extra={"tag": tag} if tag else {}, extra=payment_extra,
), ),
status=status, status=status,
) )
if fiat_provider:
payment = await get_payment(checking_id)
payment.fiat_provider = fiat_provider
await update_payment(payment)
return checking_id return checking_id