diff --git a/lnbits/core/crud/payments.py b/lnbits/core/crud/payments.py index 05a9d341f..46aab1f1f 100644 --- a/lnbits/core/crud/payments.py +++ b/lnbits/core/crud/payments.py @@ -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, diff --git a/lnbits/core/models/__init__.py b/lnbits/core/models/__init__.py index f845e5486..ad060f741 100644 --- a/lnbits/core/models/__init__.py +++ b/lnbits/core/models/__init__.py @@ -21,6 +21,7 @@ from .payments import ( PaymentExtra, PaymentFilters, PaymentHistoryPoint, + PaymentTotalBreakdown, PaymentsStatusCount, PaymentState, PaymentWalletStats, @@ -82,6 +83,7 @@ __all__ = [ "PaymentExtra", "PaymentFilters", "PaymentHistoryPoint", + "PaymentTotalBreakdown", "PaymentState", "PaymentWalletStats", "PaymentsStatusCount", diff --git a/lnbits/core/models/payments.py b/lnbits/core/models/payments.py index 8b1460b09..41193c202 100644 --- a/lnbits/core/models/payments.py +++ b/lnbits/core/models/payments.py @@ -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 diff --git a/lnbits/core/views/payment_api.py b/lnbits/core/views/payment_api.py index f9bcb4de2..484d89231 100644 --- a/lnbits/core/views/payment_api.py +++ b/lnbits/core/views/payment_api.py @@ -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", diff --git a/lnbits/static/js/api.js b/lnbits/static/js/api.js index ba1936790..9ce36d034 100644 --- a/lnbits/static/js/api.js +++ b/lnbits/static/js/api.js @@ -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) }, diff --git a/lnbits/static/js/pages/wallet.js b/lnbits/static/js/pages/wallet.js index 02ffe19e5..18a704122 100644 --- a/lnbits/static/js/pages/wallet.js +++ b/lnbits/static/js/pages/wallet.js @@ -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,97 @@ 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 + ) + }, + 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: { + 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) { this.parse.data.request = lnurl this.parse.show = true diff --git a/lnbits/templates/pages/wallet.vue b/lnbits/templates/pages/wallet.vue index 2c4d4cb03..086511d16 100644 --- a/lnbits/templates/pages/wallet.vue +++ b/lnbits/templates/pages/wallet.vue @@ -28,7 +28,13 @@
-
+
+ Total breakdown
@@ -77,9 +84,14 @@
+ Total breakdown
@@ -228,6 +240,111 @@
+ + + +
+
+
+
+
+
+ + Refresh + +
+ + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + No completed payments. + +
+ +
+ +
+
+
+
+ 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