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:
jackstar12 2023-08-28 12:00:59 +02:00 committed by GitHub
parent 7a37e72915
commit 2623e9247a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 233 additions and 42 deletions

View file

@ -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)

View file

@ -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
"""
)

View file

@ -27,6 +27,7 @@ class Wallet(BaseModel):
user: str
adminkey: str
inkey: str
currency: Optional[str]
balance_msat: int
@property

View file

@ -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

View file

@ -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 => {

View file

@ -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 => {

View file

@ -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">

View file

@ -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"

View file

@ -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,

View file

@ -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):

File diff suppressed because one or more lines are too long

View file

@ -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',

View file

@ -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(

View file

@ -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