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( 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]: ) -> 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( await (conn or db).execute(
""" f"""
UPDATE wallets SET UPDATE wallets SET {', '.join(set_clause)} WHERE id = ?
name = ?
WHERE id = ?
""", """,
(new_name, wallet_id), tuple(values),
) )
wallet = await get_wallet(wallet_id=wallet_id, conn=conn) wallet = await get_wallet(wallet_id=wallet_id, conn=conn)
assert wallet, "updated created wallet couldn't be retrieved" assert wallet, "updated created wallet couldn't be retrieved"
@ -519,8 +529,8 @@ async def create_payment(
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Payment: ) -> Payment:
# todo: add this when tests are fixed # todo: add this when tests are fixed
# previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn) previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
# assert previous_payment is None, "Payment already exists" assert previous_payment is None, "Payment already exists"
try: try:
invoice = bolt11.decode(payment_request) invoice = bolt11.decode(payment_request)

View file

@ -319,3 +319,11 @@ async def m011_optimize_balances_view(db):
GROUP BY wallet 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 user: str
adminkey: str adminkey: str
inkey: str inkey: str
currency: Optional[str]
balance_msat: int balance_msat: int
@property @property

View file

@ -21,6 +21,7 @@ from lnbits.settings import (
send_admin_user_to_saas, send_admin_user_to_saas,
settings, 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 import FAKE_WALLET, get_wallet_class, set_wallet_class
from lnbits.wallets.base import PaymentResponse, PaymentStatus from lnbits.wallets.base import PaymentResponse, PaymentStatus
@ -55,10 +56,47 @@ class InvoiceFailure(Exception):
pass 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( async def create_invoice(
*, *,
wallet_id: str, wallet_id: str,
amount: int, # in satoshis amount: float,
currency: Optional[str] = "sat",
memo: str, memo: str,
description_hash: Optional[bytes] = None, description_hash: Optional[bytes] = None,
unhashed_description: 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 # use the fake wallet if the invoice is for internal use only
wallet = FAKE_WALLET if internal else get_wallet_class() 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( ok, checking_id, payment_request, error_message = await wallet.create_invoice(
amount=amount, amount=amount_sat,
memo=invoice_memo, memo=invoice_memo,
description_hash=description_hash, description_hash=description_hash,
unhashed_description=unhashed_description, unhashed_description=unhashed_description,
@ -88,7 +130,7 @@ async def create_invoice(
invoice = bolt11.decode(payment_request) invoice = bolt11.decode(payment_request)
amount_msat = amount * 1000 amount_msat = 1000 * amount_sat
await create_payment( await create_payment(
wallet_id=wallet_id, wallet_id=wallet_id,
checking_id=checking_id, checking_id=checking_id,
@ -134,6 +176,10 @@ async def pay_invoice(
if max_sat and invoice.amount_msat > max_sat * 1000: if max_sat and invoice.amount_msat > max_sat * 1000:
raise ValueError("Amount in invoice is too high.") 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 # put all parameters that don't change here
class PaymentKwargs(TypedDict): class PaymentKwargs(TypedDict):
wallet_id: str wallet_id: str

View file

@ -1,6 +1,6 @@
// update cache version every time there is a new deployment // update cache version every time there is a new deployment
// so the service worker reinitializes the cache // so the service worker reinitializes the cache
const CACHE_VERSION = 52 const CACHE_VERSION = 53
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-` const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
const getApiKey = request => { const getApiKey = request => {

View file

@ -256,7 +256,10 @@ new Vue({
}, },
balance: 0, balance: 0,
credit: 0, credit: 0,
newName: '' update: {
name: null,
currency: null
}
} }
}, },
computed: { computed: {
@ -717,16 +720,12 @@ new Vue({
} }
}) })
}, },
updateWalletName: function () { updateWallet: function (data) {
let newName = this.newName
let adminkey = this.g.wallet.adminkey
if (!newName || !newName.length) return
LNbits.api LNbits.api
.request('PUT', '/api/v1/wallet/' + newName, adminkey, {}) .request('PATCH', '/api/v1/wallet', this.g.wallet.adminkey, data)
.then(res => { .then(res => {
this.newName = ''
this.$q.notify({ this.$q.notify({
message: `Wallet named updated.`, message: `Wallet updated.`,
type: 'positive', type: 'positive',
timeout: 3500 timeout: 3500
}) })
@ -737,7 +736,6 @@ new Vue({
) )
}) })
.catch(err => { .catch(err => {
this.newName = ''
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
}) })
}, },
@ -811,6 +809,9 @@ new Vue({
this.fetchBalance() this.fetchBalance()
this.fetchPayments() this.fetchPayments()
this.update.name = this.g.wallet.name
this.update.currency = this.g.wallet.currency
LNbits.api LNbits.api
.request('GET', '/api/v1/currencies') .request('GET', '/api/v1/currencies')
.then(response => { .then(response => {

View file

@ -67,6 +67,18 @@
></q-select> ></q-select>
<br /> <br />
</div> </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>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">

View file

@ -211,10 +211,17 @@
parseFloat(String(props.row.fsat).replaceAll(",", "")) / 100 parseFloat(String(props.row.fsat).replaceAll(",", "")) / 100
}} }}
</q-td> </q-td>
<q-td auto-width key="amount" v-else :props="props"> <q-td auto-width key="amount" v-else :props="props">
{{ props.row.fsat }}<br /> {{ 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-td>
</q-tr> </q-tr>
@ -361,23 +368,54 @@
<div class="" style="max-width: 320px"> <div class="" style="max-width: 320px">
<q-input <q-input
filled filled
v-model.trim="newName" v-model.trim="update.name"
label="Label" label="Name"
dense="dense" dense
@update:model-value="(e) => console.log(e)" @update:model-value="(e) => console.log(e)"
/> />
</div> </div>
<q-btn <q-btn
:disable="!newName.length" :disable="!update.name.length"
unelevated unelevated
class="q-mt-sm" class="q-mt-sm"
color="primary" color="primary"
:label="$t('update_name')" :label="$t('update_name')"
@click="updateWalletName()" @click="updateWallet({ name: update.name })"
></q-btn> ></q-btn>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-expansion-item> </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-separator></q-separator>
<q-expansion-item <q-expansion-item
group="extras" 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}") @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) new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
): ):
await update_wallet(wallet.wallet.id, new_name) 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( @core_app.get(
"/api/v1/payments", "/api/v1/payments",
name="Payment List", name="Payment List",
@ -187,7 +196,6 @@ async def api_payments_paginated(
async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet): async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
data.extra = data.extra or {}
description_hash = b"" description_hash = b""
unhashed_description = b"" unhashed_description = b""
memo = data.memo or settings.lnbits_site_title 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 # do not save memo if description_hash or unhashed_description is set
memo = "" 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: async with db.connect() as conn:
try: try:
payment_hash, payment_request = await create_invoice( payment_hash, payment_request = await create_invoice(
wallet_id=wallet.id, wallet_id=wallet.id,
amount=amount, amount=data.amount,
memo=memo, memo=memo,
currency=data.unit,
description_hash=description_hash, description_hash=description_hash,
unhashed_description=unhashed_description, unhashed_description=unhashed_description,
expiry=data.expiry, expiry=data.expiry,

View file

@ -90,6 +90,7 @@ class ThemesSettings(LNbitsSettings):
) # sneaky sneaky ) # sneaky sneaky
lnbits_ad_space_enabled: bool = Field(default=False) lnbits_ad_space_enabled: bool = Field(default=False)
lnbits_allowed_currencies: List[str] = Field(default=[]) lnbits_allowed_currencies: List[str] = Field(default=[])
lnbits_default_accounting_currency: Optional[str] = Field(default=None)
class OpsSettings(LNbitsSettings): 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.', 'This whole wallet will be deleted, the funds will be UNRECOVERABLE.',
rename_wallet: 'Rename wallet', rename_wallet: 'Rename wallet',
update_name: 'Update name', update_name: 'Update name',
fiat_tracking: 'Fiat tracking',
currency: 'Currency',
update_currency: 'Update currency',
press_to_claim: 'Press to claim bitcoin', press_to_claim: 'Press to claim bitcoin',
donate: 'Donate', donate: 'Donate',
view_github: 'View on GitHub', view_github: 'View on GitHub',

View file

@ -178,7 +178,8 @@ window.LNbits = {
id: data.id, id: data.id,
name: data.name, name: data.name,
adminkey: data.adminkey, adminkey: data.adminkey,
inkey: data.inkey inkey: data.inkey,
currency: data.currency
} }
newWallet.msat = data.balance_msat newWallet.msat = data.balance_msat
newWallet.sat = Math.round(data.balance_msat / 1000) newWallet.sat = Math.round(data.balance_msat / 1000)
@ -203,7 +204,9 @@ window.LNbits = {
extra: data.extra, extra: data.extra,
wallet_id: data.wallet_id, wallet_id: data.wallet_id,
webhook: data.webhook, 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( 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.admin_api import api_auditor
from lnbits.core.views.api import api_payment from lnbits.core.views.api import api_payment
from lnbits.db import DB_TYPE, SQLITE from lnbits.db import DB_TYPE, SQLITE
from lnbits.settings import settings
from lnbits.wallets import get_wallet_class from lnbits.wallets import get_wallet_class
from tests.conftest import CreateInvoice, api_payments_create_invoice 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"]) decode = bolt11.decode(invoice["payment_request"])
assert decode.amount_msat != data["amount"] * 1000 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 # check POST /api/v1/payments: invoice creation for internal payments only
@pytest.mark.asyncio @pytest.mark.asyncio
@ -357,6 +365,65 @@ async def test_create_invoice_with_unhashed_description(client, inkey_headers_to
return invoice 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(): async def get_node_balance_sats():
audit = await api_auditor() audit = await api_auditor()
return audit["node_balance_msats"] / 1000 return audit["node_balance_msats"] / 1000