Compare commits

...
10 Commits
Author SHA1 Message Date
alan c3c68bc9da chore: make bundle [skip ci] 2026-06-26 12:38:43 +00:00
Arc 95deb47765 make 2026-06-26 13:29:30 +01:00
Arc b25a5cfcf6 make 2026-06-26 13:25:59 +01:00
Arc 0e2397a813 remove tooltip 2026-06-26 13:24:22 +01:00
Arc f363418c05 tweak 2026-06-26 13:18:32 +01:00
Arc 0275b66a89 init 2026-06-26 13:04:35 +01:00
Arc c680fd647d Merge remote-tracking branch 'origin/dev' into dev 2026-06-26 12:59:58 +01:00
Arc 5708f0707d Merge remote-tracking branch 'origin/dev' into dev 2026-06-05 10:01:43 +01:00
Arc 7ba0bf5e70 Merge remote-tracking branch 'origin/dev' into dev 2026-06-03 20:49:03 +01:00
Arc aeeaed9609 revolut fix 2026-06-02 12:20:53 +01:00
10 changed files with 372 additions and 7 deletions
+39
View File
@@ -16,6 +16,7 @@ from ..models import (
PaymentFilters,
PaymentHistoryPoint,
PaymentsStatusCount,
PaymentTotalBreakdown,
PaymentWalletStats,
)
@@ -428,6 +429,44 @@ 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
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(
filters: Filters[PaymentFilters] | None = None,
user_id: str | None = None,
+2
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",
+7
View File
@@ -220,6 +220,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
+13
View File
@@ -14,6 +14,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,
)
@@ -31,6 +32,7 @@ from lnbits.core.models import (
PaymentDailyStats,
PaymentFilters,
PaymentHistoryPoint,
PaymentTotalBreakdown,
PaymentWalletStats,
SettleInvoice,
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)
@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
+1 -1
View File
File diff suppressed because one or more lines are too long
+3
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)
},
+114
View File
@@ -48,6 +48,13 @@ window.PageWallet = {
hasNfc: false,
nfcReaderAbortController: null,
formattedFiatAmount: 0,
totalBreakdown: {
show: false,
loading: false,
rows: [],
selectedTypes: ['bitcoin', 'fiat'],
selectedTags: []
},
paymentFilter: {
'status[ne]': 'failed'
},
@@ -84,9 +91,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
+119 -2
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"
+73 -3
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(
@@ -383,8 +444,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(
@@ -393,8 +459,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