diff --git a/crud.py b/crud.py index b914ae5..bca32d8 100644 --- a/crud.py +++ b/crud.py @@ -22,8 +22,9 @@ async def create_withdraw_link( created_at=datetime.now(), open_time=int(datetime.now().timestamp()) + data.wait_time, title=data.title, - min_withdrawable=data.min_withdrawable, - max_withdrawable=data.max_withdrawable, + currency=data.currency, + min_withdrawable=int(data.min_withdrawable), + max_withdrawable=int(data.max_withdrawable), uses=data.uses, wait_time=data.wait_time, is_unique=data.is_unique, @@ -85,12 +86,10 @@ async def get_withdraw_links( query_params, WithdrawLink, ) - result = await db.execute( - f""" + result = await db.execute(f""" SELECT COUNT(*) as total FROM withdraw.withdraw_link WHERE wallet IN ({q}) - """ - ) + """) result2 = result.mappings().first() return PaginatedWithdraws(data=links, total=int(result2.total)) @@ -141,7 +140,6 @@ async def create_hash_check(the_hash: str, lnurl_id: str) -> HashCheck: async def get_hash_check(the_hash: str, lnurl_id: str) -> HashCheck: - hash_check = await db.fetchone( """ SELECT id as hash, lnurl_id as lnurl diff --git a/helpers.py b/helpers.py index 51eb948..e475624 100644 --- a/helpers.py +++ b/helpers.py @@ -1,4 +1,5 @@ from fastapi import Request +from lnbits.utils.exchange_rates import get_fiat_rate_satoshis from lnurl import Lnurl from lnurl import encode as lnurl_encode from shortuuid import uuid @@ -26,3 +27,15 @@ def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl: f"Error creating LNURL with url: `{url!s}`, " "check your webserver proxy configuration." ) from e + + +async def min_max_withdrawable(link: WithdrawLink) -> tuple[int, int]: + min_withdrawable = link.min_withdrawable + max_withdrawable = link.max_withdrawable + + if link.currency: + rate = await get_fiat_rate_satoshis(link.currency) + min_withdrawable = round(min_withdrawable / 100 * rate) + max_withdrawable = round(max_withdrawable / 100 * rate) + + return min_withdrawable, max_withdrawable diff --git a/migrations.py b/migrations.py index e27af8a..dccccde 100644 --- a/migrations.py +++ b/migrations.py @@ -2,8 +2,7 @@ async def m001_initial(db): """ Creates an improved withdraw table and migrates the existing data. """ - await db.execute( - f""" + await db.execute(f""" CREATE TABLE withdraw.withdraw_links ( id TEXT PRIMARY KEY, wallet TEXT, @@ -19,16 +18,14 @@ async def m001_initial(db): used INTEGER DEFAULT 0, usescsv TEXT ); - """ - ) + """) async def m002_change_withdraw_table(db): """ Creates an improved withdraw table and migrates the existing data. """ - await db.execute( - f""" + await db.execute(f""" CREATE TABLE withdraw.withdraw_link ( id TEXT PRIMARY KEY, wallet TEXT, @@ -44,8 +41,7 @@ async def m002_change_withdraw_table(db): used INTEGER DEFAULT 0, usescsv TEXT ); - """ - ) + """) for row in [ list(row) for row in await db.fetchall("SELECT * FROM withdraw.withdraw_links") @@ -100,14 +96,12 @@ async def m003_make_hash_check(db): """ Creates a hash check table. """ - await db.execute( - """ + await db.execute(""" CREATE TABLE withdraw.hash_check ( id TEXT PRIMARY KEY, lnurl_id TEXT ); - """ - ) + """) async def m004_webhook_url(db): @@ -145,3 +139,7 @@ async def m008_add_enabled_column(db): await db.execute( "ALTER TABLE withdraw.withdraw_link ADD COLUMN enabled BOOLEAN DEFAULT true;" ) + + +async def m009_add_currency(db): + await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN currency TEXT;") diff --git a/models.py b/models.py index e888cdf..9c5cc86 100644 --- a/models.py +++ b/models.py @@ -6,8 +6,8 @@ class CreateWithdrawData(BaseModel): title: str = Query(...) - min_withdrawable: int = Query(..., ge=1) - max_withdrawable: int = Query(..., ge=1) + min_withdrawable: float = Query(..., ge=0.01) + max_withdrawable: float = Query(..., ge=0.01) uses: int = Query(..., ge=1) wait_time: int = Query(..., ge=1) is_unique: bool @@ -16,6 +16,7 @@ class CreateWithdrawData(BaseModel): webhook_body: str = Query(None) custom_url: str = Query(None) enabled: bool = Query(True) + currency: str = Query(None) class WithdrawLink(BaseModel): @@ -39,6 +40,7 @@ class WithdrawLink(BaseModel): custom_url: str = Query(None) created_at: datetime enabled: bool = Query(True) + currency: str = Query(None) lnurl: str | None = Field( default=None, no_database=True, diff --git a/static/js/index.js b/static/js/index.js index 0b42b40..b84ecfc 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -2,6 +2,10 @@ const mapWithdrawLink = function (obj) { obj._data = _.clone(obj) obj.uses_left = obj.uses - obj.used obj._data.use_custom = Boolean(obj.custom_url) + if (obj.currency) { + obj.min_withdrawable = obj.min_withdrawable / 100 + obj.max_withdrawable = obj.max_withdrawable / 100 + } return obj } @@ -14,6 +18,7 @@ window.app = Vue.createApp({ return { checker: null, withdrawLinks: [], + currencyOptions: [], lnurl: '', withdrawLinksTable: { columns: [ @@ -46,12 +51,24 @@ window.app = Vue.createApp({ label: 'Uses left', field: 'uses_left' }, + { + name: 'currency', + align: 'right', + label: 'Currency', + field: 'currency', + format: function (val) { + return val ? val.toUpperCase() : 'sat' + } + }, { name: 'max_withdrawable', align: 'right', - label: 'Max (sat)', + label: `Max`, field: 'max_withdrawable', - format: LNbits.utils.formatSat + format: (val, row) => + row.currency + ? LNbits.utils.formatCurrency(val, row.currency) + : val } ], pagination: { @@ -94,6 +111,24 @@ window.app = Vue.createApp({ return this.withdrawLinks.sort(function (a, b) { return b.uses_left - a.uses_left }) + }, + assertMinimumWithdrawable() { + const dialog = this.formDialog.show + ? this.formDialog + : this.simpleformDialog + return dialog.data.currency + ? dialog.data.min_withdrawable >= 0.01 + : dialog.data.min_withdrawable >= 1 + }, + assertMaximumWithdrawable() { + const dialog = this.formDialog.show + ? this.formDialog + : this.simpleformDialog + return dialog.data.currency + ? dialog.data.max_withdrawable >= 0.01 && + dialog.data.max_withdrawable >= dialog.data.min_withdrawable + : dialog.data.max_withdrawable >= 1 && + dialog.data.max_withdrawable >= dialog.data.min_withdrawable } }, methods: { @@ -164,6 +199,11 @@ window.app = Vue.createApp({ data.custom_url = CUSTOM_URL } + if (data.currency) { + data.min_withdrawable = data.min_withdrawable * 100 + data.max_withdrawable = data.max_withdrawable * 100 + } + data.wait_time = data.wait_time * { @@ -197,6 +237,11 @@ window.app = Vue.createApp({ data.custom_url = '/static/images/default_voucher.png' } + if (data.currency) { + data.min_withdrawable = data.min_withdrawable * 100 + data.max_withdrawable = data.max_withdrawable * 100 + } + if (data.id) { this.updateWithdrawLink(wallet, data) } else { @@ -231,6 +276,7 @@ window.app = Vue.createApp({ }) }, createWithdrawLink(wallet, data) { + console.log(data) LNbits.api .request('POST', '/withdraw/api/v1/links', wallet.adminkey, data) .then(response => { @@ -314,5 +360,6 @@ window.app = Vue.createApp({ this.getWithdrawLinks() this.checker = setInterval(this.getWithdrawLinks, 300000) } + this.currencyOptions = this.g.allowedCurrencies } }) diff --git a/templates/withdraw/index.html b/templates/withdraw/index.html index f208ece..4864740 100644 --- a/templates/withdraw/index.html +++ b/templates/withdraw/index.html @@ -172,21 +172,33 @@
type="text" label="Link title *" > + + :disable=" formDialog.data.wallet == null || formDialog.data.title == null || - (formDialog.data.min_withdrawable == null || formDialog.data.min_withdrawable < 1) || - ( - formDialog.data.max_withdrawable == null || - formDialog.data.max_withdrawable < 1 || - formDialog.data.max_withdrawable < formDialog.data.min_withdrawable - ) || + !assertMinimumWithdrawable || + !assertMaximumWithdrawable || formDialog.data.uses == null || formDialog.data.wait_time == null" type="submit" @@ -360,13 +368,24 @@
label="Wallet *" > + + color="primary" :disable=" simpleformDialog.data.wallet == null || - - simpleformDialog.data.max_withdrawable == null || - simpleformDialog.data.max_withdrawable < 1 || + simpleformDialog.data.max_withdrawable == null || + !assertMaximumWithdrawable || simpleformDialog.data.uses == null" type="submit" >Create vouchers 250: raise HTTPException(detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST) + if data.currency and data.currency.lower() != "sat": + if data.min_withdrawable < 0.01: + raise HTTPException( + detail="Min must be more than 0.01.", status_code=HTTPStatus.BAD_REQUEST + ) + if data.max_withdrawable < 0.01: + raise HTTPException( + detail="Max must be more than 0.01.", status_code=HTTPStatus.BAD_REQUEST + ) + # convert fiat float to int (cents) + data.min_withdrawable = int(data.min_withdrawable * 100) + data.max_withdrawable = int(data.max_withdrawable * 100) + if data.min_withdrawable < 1: raise HTTPException( detail="Min must be more than 1.", status_code=HTTPStatus.BAD_REQUEST diff --git a/views_lnurl.py b/views_lnurl.py index f893427..0c64e51 100644 --- a/views_lnurl.py +++ b/views_lnurl.py @@ -9,6 +9,7 @@ from lnbits.core.crud import update_payment from lnbits.core.models import Payment from lnbits.core.services import pay_invoice +from lnbits.utils.exchange_rates import get_fiat_rate_satoshis from lnurl import ( CallbackUrl, LnurlErrorResponse, @@ -26,6 +27,7 @@ increment_withdraw_link, remove_unique_withdraw_link, ) +from .helpers import min_max_withdrawable from .models import WithdrawLink withdraw_ext_lnurl = APIRouter(prefix="/api/v1/lnurl") @@ -57,12 +59,20 @@ async def api_lnurl_response( request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash) ) + min_withdrawable = link.min_withdrawable + max_withdrawable = link.max_withdrawable + + if link.currency: + rate = await get_fiat_rate_satoshis(link.currency) + min_withdrawable = round(min_withdrawable / 100 * rate) + max_withdrawable = round(max_withdrawable / 100 * rate) + callback_url = parse_obj_as(CallbackUrl, url) return LnurlWithdrawResponse( callback=callback_url, k1=link.k1, - minWithdrawable=MilliSatoshi(link.min_withdrawable * 1000), - maxWithdrawable=MilliSatoshi(link.max_withdrawable * 1000), + minWithdrawable=MilliSatoshi(min_withdrawable * 1000), + maxWithdrawable=MilliSatoshi(max_withdrawable * 1000), defaultDescription=link.title, ) @@ -136,11 +146,16 @@ async def api_lnurl_callback( except Exception: return LnurlErrorResponse(reason="LNURL already being processed.") + _, max_withdrawable = await min_max_withdrawable(link) + + # allow some fluctuation (as the fiat price may have changed between the calls) + max_withdrawable = round(max_withdrawable * 1.01) + try: payment = await pay_invoice( wallet_id=link.wallet, payment_request=pr, - max_sat=link.max_withdrawable, + max_sat=max_withdrawable, extra={"tag": "withdraw", "withdrawal_link_id": link.id}, ) await increment_withdraw_link(link) @@ -221,11 +236,13 @@ async def api_lnurl_multi_response( url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash) + min_withdrawable, max_withdrawable = await min_max_withdrawable(link) + callback_url = parse_obj_as(CallbackUrl, f"{url!s}?id_unique_hash={id_unique_hash}") return LnurlWithdrawResponse( callback=callback_url, k1=link.k1, - minWithdrawable=MilliSatoshi(link.min_withdrawable * 1000), - maxWithdrawable=MilliSatoshi(link.max_withdrawable * 1000), + minWithdrawable=MilliSatoshi(min_withdrawable * 1000), + maxWithdrawable=MilliSatoshi(max_withdrawable * 1000), defaultDescription=link.title, )