diff --git a/crud.py b/crud.py
index b914ae5..2332f3c 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,
@@ -141,7 +142,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 754a57f..b7734f3 100644
--- a/migrations.py
+++ b/migrations.py
@@ -139,3 +139,13 @@ async def m007_add_created_at_timestamp(db):
"ALTER TABLE withdraw.withdraw_link "
f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
)
+
+
+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 0b4b910..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
@@ -15,6 +15,8 @@ class CreateWithdrawData(BaseModel):
webhook_headers: str = Query(None)
webhook_body: str = Query(None)
custom_url: str = Query(None)
+ enabled: bool = Query(True)
+ currency: str = Query(None)
class WithdrawLink(BaseModel):
@@ -37,6 +39,8 @@ class WithdrawLink(BaseModel):
webhook_body: str = Query(None)
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 a5f4dbf..2f9a53b 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: {
@@ -68,7 +85,8 @@ window.app = Vue.createApp({
data: {
is_unique: false,
use_custom: false,
- has_webhook: false
+ has_webhook: false,
+ enabled: true
}
},
simpleformDialog: {
@@ -78,7 +96,8 @@ window.app = Vue.createApp({
use_custom: false,
title: 'Vouchers',
min_withdrawable: 0,
- wait_time: 1
+ wait_time: 1,
+ enabled: true
}
},
qrCodeDialog: {
@@ -92,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: {
@@ -125,13 +162,15 @@ window.app = Vue.createApp({
this.formDialog.data = {
is_unique: false,
use_custom: false,
- has_webhook: false
+ has_webhook: false,
+ enabled: true
}
},
simplecloseFormDialog() {
this.simpleformDialog.data = {
is_unique: false,
- use_custom: false
+ use_custom: false,
+ enabled: true
}
},
openQrCodeDialog(linkId) {
@@ -227,6 +266,7 @@ window.app = Vue.createApp({
})
},
createWithdrawLink(wallet, data) {
+ console.log(data)
LNbits.api
.request('POST', '/withdraw/api/v1/links', wallet.adminkey, data)
.then(response => {
@@ -310,5 +350,6 @@ window.app = Vue.createApp({
this.getWithdrawLinks()
this.checker = setInterval(this.getWithdrawLinks, 300000)
}
+ this.currencyOptions = this.g.allowedCurrencies
}
})
diff --git a/templates/withdraw/display.html b/templates/withdraw/display.html
index f9fbd53..812c95f 100644
--- a/templates/withdraw/display.html
+++ b/templates/withdraw/display.html
@@ -7,6 +7,12 @@
Withdraw is spent.
+ Withdraw is spent.
+ Withdraw is disabled.
spent: {{ 'true' if spent else 'false' }},
url: '{{ lnurl_url }}',
lnurl: '',
- nfcTagWriting: false
+ nfcTagWriting: false,
+ enabled: {{ 'true' if enabled else 'false' }}
}
}
})
diff --git a/templates/withdraw/index.html b/templates/withdraw/index.html
index 3b75e11..4864740 100644
--- a/templates/withdraw/index.html
+++ b/templates/withdraw/index.html
@@ -38,6 +38,7 @@ Withdraw links
>
+
Withdraw links
+
+
+
+
+
+
+
type="text"
label="Link title *"
>
+
+
hint="Custom data as JSON string, will get posted along with webhook 'body' field."
>
+
+
+
+
+
+ Enable / Disable
+ You can enable or disable these vouchers
+
+
: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"
@@ -332,13 +368,24 @@
label="Wallet *"
>
+
+
label="Number of vouchers"
>
+
+
+
+
+
+ Enable / Disable
+ You can enable or disable these vouchers
+
+
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 d1bba22..4be9d13 100644
--- a/views_lnurl.py
+++ b/views_lnurl.py
@@ -25,6 +25,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")
@@ -43,6 +44,9 @@ async def api_lnurl_response(
if not link:
return LnurlErrorResponse(reason="Withdraw link does not exist.")
+ if not link.enabled:
+ return LnurlErrorResponse(reason="Withdraw link is disabled.")
+
if link.is_spent:
return LnurlErrorResponse(reason="Withdraw is spent.")
@@ -53,12 +57,14 @@ async def api_lnurl_response(
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, 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,
)
@@ -86,11 +92,13 @@ async def api_lnurl_callback(
pr: str,
id_unique_hash: str | None = None,
) -> LnurlErrorResponse | LnurlSuccessResponse:
-
link = await get_withdraw_link_by_hash(unique_hash)
if not link:
return LnurlErrorResponse(reason="withdraw link not found.")
+ if not link.enabled:
+ return LnurlErrorResponse(reason="Withdraw link is disabled.")
+
if link.is_spent:
return LnurlErrorResponse(reason="withdraw is spent.")
@@ -120,11 +128,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)
@@ -194,6 +207,9 @@ async def api_lnurl_multi_response(
if not link:
return LnurlErrorResponse(reason="Withdraw link does not exist.")
+ if not link.enabled:
+ return LnurlErrorResponse(reason="Withdraw link is disabled.")
+
if link.is_spent:
return LnurlErrorResponse(reason="Withdraw is spent.")
@@ -202,11 +218,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,
)