mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-03-10 09:19:42 +01:00
track fiat value of payments (#1789)
* save fiat_amounts on payment creation * show fiat amount in frontend * add lnbits_default_accounting_currency * extract fiat calculation logic into service * move all currency conversions to calc_fiat_amounts move all conversion logic into create_invoice so extensions can benefit aswell * show user-defined and wallet-defined currency in frontend * remove sat from fiat units * make bundle * improve tests * debug log
This commit is contained in:
parent
7a37e72915
commit
2623e9247a
14 changed files with 233 additions and 42 deletions
|
@ -238,15 +238,25 @@ async def create_wallet(
|
|||
|
||||
|
||||
async def update_wallet(
|
||||
wallet_id: str, new_name: str, conn: Optional[Connection] = None
|
||||
wallet_id: str,
|
||||
name: Optional[str] = None,
|
||||
currency: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[Wallet]:
|
||||
set_clause = []
|
||||
values = []
|
||||
if name:
|
||||
set_clause.append("name = ?")
|
||||
values.append(name)
|
||||
if currency is not None:
|
||||
set_clause.append("currency = ?")
|
||||
values.append(currency)
|
||||
values.append(wallet_id)
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
UPDATE wallets SET
|
||||
name = ?
|
||||
WHERE id = ?
|
||||
f"""
|
||||
UPDATE wallets SET {', '.join(set_clause)} WHERE id = ?
|
||||
""",
|
||||
(new_name, wallet_id),
|
||||
tuple(values),
|
||||
)
|
||||
wallet = await get_wallet(wallet_id=wallet_id, conn=conn)
|
||||
assert wallet, "updated created wallet couldn't be retrieved"
|
||||
|
@ -519,8 +529,8 @@ async def create_payment(
|
|||
conn: Optional[Connection] = None,
|
||||
) -> Payment:
|
||||
# todo: add this when tests are fixed
|
||||
# previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||
# assert previous_payment is None, "Payment already exists"
|
||||
previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||
assert previous_payment is None, "Payment already exists"
|
||||
|
||||
try:
|
||||
invoice = bolt11.decode(payment_request)
|
||||
|
|
|
@ -319,3 +319,11 @@ async def m011_optimize_balances_view(db):
|
|||
GROUP BY wallet
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m012_add_currency_to_wallet(db):
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE wallets ADD COLUMN currency TEXT
|
||||
"""
|
||||
)
|
||||
|
|
|
@ -27,6 +27,7 @@ class Wallet(BaseModel):
|
|||
user: str
|
||||
adminkey: str
|
||||
inkey: str
|
||||
currency: Optional[str]
|
||||
balance_msat: int
|
||||
|
||||
@property
|
||||
|
|
|
@ -21,6 +21,7 @@ from lnbits.settings import (
|
|||
send_admin_user_to_saas,
|
||||
settings,
|
||||
)
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
|
||||
from lnbits.wallets import FAKE_WALLET, get_wallet_class, set_wallet_class
|
||||
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
||||
|
||||
|
@ -55,10 +56,47 @@ class InvoiceFailure(Exception):
|
|||
pass
|
||||
|
||||
|
||||
async def calculate_fiat_amounts(
|
||||
amount: float,
|
||||
wallet_id: str,
|
||||
currency: Optional[str] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Tuple[int, Optional[Dict]]:
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
assert wallet, "invalid wallet_id"
|
||||
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
|
||||
|
||||
if currency and currency != "sat":
|
||||
amount_sat = await fiat_amount_as_satoshis(amount, currency)
|
||||
extra = extra or {}
|
||||
if currency != wallet_currency:
|
||||
extra["fiat_currency"] = currency
|
||||
extra["fiat_amount"] = round(amount, ndigits=3)
|
||||
extra["fiat_rate"] = amount_sat / amount
|
||||
else:
|
||||
amount_sat = int(amount)
|
||||
|
||||
if wallet_currency:
|
||||
if wallet_currency == currency:
|
||||
fiat_amount = amount
|
||||
else:
|
||||
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
|
||||
extra = extra or {}
|
||||
extra["wallet_fiat_currency"] = wallet_currency
|
||||
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
|
||||
extra["wallet_fiat_rate"] = amount_sat / fiat_amount
|
||||
|
||||
logger.debug(f"Calculated fiat amounts for {wallet}: {extra=}")
|
||||
|
||||
return amount_sat, extra
|
||||
|
||||
|
||||
async def create_invoice(
|
||||
*,
|
||||
wallet_id: str,
|
||||
amount: int, # in satoshis
|
||||
amount: float,
|
||||
currency: Optional[str] = "sat",
|
||||
memo: str,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
|
@ -76,8 +114,12 @@ async def create_invoice(
|
|||
# use the fake wallet if the invoice is for internal use only
|
||||
wallet = FAKE_WALLET if internal else get_wallet_class()
|
||||
|
||||
amount_sat, extra = await calculate_fiat_amounts(
|
||||
amount, wallet_id, currency=currency, extra=extra, conn=conn
|
||||
)
|
||||
|
||||
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
|
||||
amount=amount,
|
||||
amount=amount_sat,
|
||||
memo=invoice_memo,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
|
@ -88,7 +130,7 @@ async def create_invoice(
|
|||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
|
||||
amount_msat = amount * 1000
|
||||
amount_msat = 1000 * amount_sat
|
||||
await create_payment(
|
||||
wallet_id=wallet_id,
|
||||
checking_id=checking_id,
|
||||
|
@ -134,6 +176,10 @@ async def pay_invoice(
|
|||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
|
||||
_, extra = await calculate_fiat_amounts(
|
||||
invoice.amount_msat, wallet_id, extra=extra, conn=conn
|
||||
)
|
||||
|
||||
# put all parameters that don't change here
|
||||
class PaymentKwargs(TypedDict):
|
||||
wallet_id: str
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// update cache version every time there is a new deployment
|
||||
// so the service worker reinitializes the cache
|
||||
const CACHE_VERSION = 52
|
||||
const CACHE_VERSION = 53
|
||||
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
||||
|
||||
const getApiKey = request => {
|
||||
|
|
|
@ -256,7 +256,10 @@ new Vue({
|
|||
},
|
||||
balance: 0,
|
||||
credit: 0,
|
||||
newName: ''
|
||||
update: {
|
||||
name: null,
|
||||
currency: null
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
@ -717,16 +720,12 @@ new Vue({
|
|||
}
|
||||
})
|
||||
},
|
||||
updateWalletName: function () {
|
||||
let newName = this.newName
|
||||
let adminkey = this.g.wallet.adminkey
|
||||
if (!newName || !newName.length) return
|
||||
updateWallet: function (data) {
|
||||
LNbits.api
|
||||
.request('PUT', '/api/v1/wallet/' + newName, adminkey, {})
|
||||
.request('PATCH', '/api/v1/wallet', this.g.wallet.adminkey, data)
|
||||
.then(res => {
|
||||
this.newName = ''
|
||||
this.$q.notify({
|
||||
message: `Wallet named updated.`,
|
||||
message: `Wallet updated.`,
|
||||
type: 'positive',
|
||||
timeout: 3500
|
||||
})
|
||||
|
@ -737,7 +736,6 @@ new Vue({
|
|||
)
|
||||
})
|
||||
.catch(err => {
|
||||
this.newName = ''
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
|
@ -811,6 +809,9 @@ new Vue({
|
|||
this.fetchBalance()
|
||||
this.fetchPayments()
|
||||
|
||||
this.update.name = this.g.wallet.name
|
||||
this.update.currency = this.g.wallet.currency
|
||||
|
||||
LNbits.api
|
||||
.request('GET', '/api/v1/currencies')
|
||||
.then(response => {
|
||||
|
|
|
@ -67,6 +67,18 @@
|
|||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Default Accounting Currency</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_default_accounting_currency"
|
||||
clearable
|
||||
hint="Default currency for accounting"
|
||||
label="Currency"
|
||||
:options="formData.lnbits_allowed_currencies.length ? formData.lnbits_allowed_currencies : {{ currencies }}"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
|
|
|
@ -211,10 +211,17 @@
|
|||
parseFloat(String(props.row.fsat).replaceAll(",", "")) / 100
|
||||
}}
|
||||
</q-td>
|
||||
|
||||
<q-td auto-width key="amount" v-else :props="props">
|
||||
{{ props.row.fsat }}<br />
|
||||
<i>fee {{ props.row.fee/1000 }}</i>
|
||||
<i v-if="props.row.extra.wallet_fiat_currency">
|
||||
{{ props.row.extra.wallet_fiat_currency }} {{
|
||||
props.row.extra.wallet_fiat_amount }}
|
||||
<br />
|
||||
</i>
|
||||
<i v-if="props.row.extra.fiat_currency">
|
||||
{{ props.row.extra.fiat_currency }} {{
|
||||
props.row.extra.fiat_amount }}
|
||||
</i>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
|
||||
|
@ -361,23 +368,54 @@
|
|||
<div class="" style="max-width: 320px">
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="newName"
|
||||
label="Label"
|
||||
dense="dense"
|
||||
v-model.trim="update.name"
|
||||
label="Name"
|
||||
dense
|
||||
@update:model-value="(e) => console.log(e)"
|
||||
/>
|
||||
</div>
|
||||
<q-btn
|
||||
:disable="!newName.length"
|
||||
:disable="!update.name.length"
|
||||
unelevated
|
||||
class="q-mt-sm"
|
||||
color="primary"
|
||||
:label="$t('update_name')"
|
||||
@click="updateWalletName()"
|
||||
@click="updateWallet({ name: update.name })"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="attach_money"
|
||||
:label="$t('fiat_tracking')"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div style="max-width: 360px">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="update.currency"
|
||||
type="text"
|
||||
:label="$t('currency')"
|
||||
:options="receive.units.filter((u) => u !== 'sat')"
|
||||
></q-select>
|
||||
</div>
|
||||
<q-btn
|
||||
:disable="!update.name.length"
|
||||
unelevated
|
||||
class="q-mt-sm"
|
||||
color="primary"
|
||||
:label="$t('update_currency')"
|
||||
@click="updateWallet({ currency: update.currency || '' })"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
|
|
|
@ -114,7 +114,7 @@ async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
|||
|
||||
|
||||
@core_app.put("/api/v1/wallet/{new_name}")
|
||||
async def api_update_wallet(
|
||||
async def api_update_wallet_name(
|
||||
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
await update_wallet(wallet.wallet.id, new_name)
|
||||
|
@ -125,6 +125,15 @@ async def api_update_wallet(
|
|||
}
|
||||
|
||||
|
||||
@core_app.patch("/api/v1/wallet", response_model=Wallet)
|
||||
async def api_update_wallet(
|
||||
name: Optional[str] = Body(None),
|
||||
currency: Optional[str] = Body(None),
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
return await update_wallet(wallet.wallet.id, name, currency)
|
||||
|
||||
|
||||
@core_app.get(
|
||||
"/api/v1/payments",
|
||||
name="Payment List",
|
||||
|
@ -187,7 +196,6 @@ async def api_payments_paginated(
|
|||
|
||||
|
||||
async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||
data.extra = data.extra or {}
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or settings.lnbits_site_title
|
||||
|
@ -211,20 +219,13 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
|||
# do not save memo if description_hash or unhashed_description is set
|
||||
memo = ""
|
||||
|
||||
if data.unit == "sat":
|
||||
amount = int(data.amount)
|
||||
else:
|
||||
assert data.unit is not None, "unit not set"
|
||||
price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
amount = price_in_sats
|
||||
data.extra.update({"fiat_amount": data.amount, "fiat_currency": data.unit})
|
||||
|
||||
async with db.connect() as conn:
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=wallet.id,
|
||||
amount=amount,
|
||||
amount=data.amount,
|
||||
memo=memo,
|
||||
currency=data.unit,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
|
|
|
@ -90,6 +90,7 @@ class ThemesSettings(LNbitsSettings):
|
|||
) # sneaky sneaky
|
||||
lnbits_ad_space_enabled: bool = Field(default=False)
|
||||
lnbits_allowed_currencies: List[str] = Field(default=[])
|
||||
lnbits_default_accounting_currency: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class OpsSettings(LNbitsSettings):
|
||||
|
|
2
lnbits/static/bundle.min.js
vendored
2
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
|
@ -29,6 +29,9 @@ window.localisation.en = {
|
|||
'This whole wallet will be deleted, the funds will be UNRECOVERABLE.',
|
||||
rename_wallet: 'Rename wallet',
|
||||
update_name: 'Update name',
|
||||
fiat_tracking: 'Fiat tracking',
|
||||
currency: 'Currency',
|
||||
update_currency: 'Update currency',
|
||||
press_to_claim: 'Press to claim bitcoin',
|
||||
donate: 'Donate',
|
||||
view_github: 'View on GitHub',
|
||||
|
|
|
@ -178,7 +178,8 @@ window.LNbits = {
|
|||
id: data.id,
|
||||
name: data.name,
|
||||
adminkey: data.adminkey,
|
||||
inkey: data.inkey
|
||||
inkey: data.inkey,
|
||||
currency: data.currency
|
||||
}
|
||||
newWallet.msat = data.balance_msat
|
||||
newWallet.sat = Math.round(data.balance_msat / 1000)
|
||||
|
@ -203,7 +204,9 @@ window.LNbits = {
|
|||
extra: data.extra,
|
||||
wallet_id: data.wallet_id,
|
||||
webhook: data.webhook,
|
||||
webhook_status: data.webhook_status
|
||||
webhook_status: data.webhook_status,
|
||||
fiat_amount: data.fiat_amount,
|
||||
fiat_currency: data.fiat_currency
|
||||
}
|
||||
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
|
|
|
@ -10,6 +10,7 @@ from lnbits.core.models import Payment
|
|||
from lnbits.core.views.admin_api import api_auditor
|
||||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.db import DB_TYPE, SQLITE
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_wallet_class
|
||||
from tests.conftest import CreateInvoice, api_payments_create_invoice
|
||||
|
||||
|
@ -96,6 +97,13 @@ async def test_create_invoice_fiat_amount(client, inkey_headers_to):
|
|||
decode = bolt11.decode(invoice["payment_request"])
|
||||
assert decode.amount_msat != data["amount"] * 1000
|
||||
|
||||
response = await client.get("/api/v1/payments?limit=1", headers=inkey_headers_to)
|
||||
assert response.is_success
|
||||
extra = response.json()[0]["extra"]
|
||||
assert extra["fiat_amount"] == data["amount"]
|
||||
assert extra["fiat_currency"] == data["unit"]
|
||||
assert extra["fiat_rate"]
|
||||
|
||||
|
||||
# check POST /api/v1/payments: invoice creation for internal payments only
|
||||
@pytest.mark.asyncio
|
||||
|
@ -357,6 +365,65 @@ async def test_create_invoice_with_unhashed_description(client, inkey_headers_to
|
|||
return invoice
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_wallet(client, adminkey_headers_from):
|
||||
name = "new name"
|
||||
currency = "EUR"
|
||||
|
||||
response = await client.patch(
|
||||
"/api/v1/wallet", json={"name": name}, headers=adminkey_headers_from
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == name
|
||||
|
||||
response = await client.patch(
|
||||
"/api/v1/wallet", json={"currency": currency}, headers=adminkey_headers_from
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["currency"] == currency
|
||||
# name is not changed because updates are partial
|
||||
assert response.json()["name"] == name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fiat_tracking(client, adminkey_headers_from):
|
||||
async def create_invoice():
|
||||
data = await get_random_invoice_data()
|
||||
response = await client.post(
|
||||
"/api/v1/payments", json=data, headers=adminkey_headers_from
|
||||
)
|
||||
assert response.is_success
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/payments/{response.json()['payment_hash']}",
|
||||
headers=adminkey_headers_from,
|
||||
)
|
||||
assert response.is_success
|
||||
return response.json()["details"]
|
||||
|
||||
async def update_currency(currency):
|
||||
response = await client.patch(
|
||||
"/api/v1/wallet", json={"currency": currency}, headers=adminkey_headers_from
|
||||
)
|
||||
assert response.is_success
|
||||
assert response.json()["currency"] == currency
|
||||
|
||||
await update_currency("")
|
||||
|
||||
settings.lnbits_default_accounting_currency = "USD"
|
||||
payment = await create_invoice()
|
||||
assert payment["extra"]["wallet_fiat_currency"] == "USD"
|
||||
assert payment["extra"]["wallet_fiat_amount"] != payment["amount"]
|
||||
assert payment["extra"]["wallet_fiat_rate"]
|
||||
|
||||
await update_currency("EUR")
|
||||
|
||||
payment = await create_invoice()
|
||||
assert payment["extra"]["wallet_fiat_currency"] == "EUR"
|
||||
assert payment["extra"]["wallet_fiat_amount"] != payment["amount"]
|
||||
assert payment["extra"]["wallet_fiat_rate"]
|
||||
|
||||
|
||||
async def get_node_balance_sats():
|
||||
audit = await api_auditor()
|
||||
return audit["node_balance_msats"] / 1000
|
||||
|
|
Loading…
Add table
Reference in a new issue