Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@ __pycache__
node_modules
.mypy_cache
.venv

# test data
data/
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ LNbits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes t
- select wallet
- set the amount each voucher will allow someone to withdraw
- set the amount of vouchers you want to create - _have in mind you need to have a balance on the wallet that supports the amount \* number of vouchers_
- optionally set an expiration time in days, weeks, or months. The expiration starts from the date the vouchers are created. Months are counted as 30 days
2. You can now print, share, display your LNURLw links or QR codes\
![lnurlw created](https://i.imgur.com/X00twiX.jpg)
- on details you can print the vouchers\
Expand All @@ -39,6 +40,7 @@ LNbits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes t
- set a title for the LNURLw (it will show up in users wallet)
- define the minimum and maximum a user can withdraw, if you want a fixed amount set them both to an equal value
- set how many times can the LNURLw be scanned, if it's a one time use or it can be scanned 100 times
- optionally set an expiration time in days, weeks, or months. The expiration starts from the date the LNURLw is created. Months are counted as 30 days
- LNbits has the "_Time between withdraws_" setting, you can define how long the LNURLw will be unavailable between scans
- you can set the time in _seconds, minutes or hours_
- the "_Use unique withdraw QR..._" reduces the chance of your LNURL withdraw being exploited and depleted by one person, by generating a new QR code every time it's scanned
Expand All @@ -47,4 +49,6 @@ LNbits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes t

**LNbits bonus:** If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with their smartphone camera, or a QR scanner app, they can follow the link provided to claim their satoshis and get an instant LNbits wallet!

If no expiration time is set, the LNURLw never expires. Existing LNURLw links and vouchers keep working without an expiration unless one is added later.

![](https://i.imgur.com/2zZ7mi8.jpg)
2 changes: 2 additions & 0 deletions crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ async def create_withdraw_link(
webhook_headers=data.webhook_headers,
webhook_body=data.webhook_body,
custom_url=data.custom_url,
enabled=data.enabled,
validity_seconds=data.validity_seconds,
number=0,
)
await db.insert("withdraw.withdraw_link", withdraw_link)
Expand Down
7 changes: 7 additions & 0 deletions migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,10 @@ async def m008_add_enabled_column(db):

async def m009_add_currency(db):
await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN currency TEXT;")


async def m010_add_validity_seconds(db):
await db.execute(
"ALTER TABLE withdraw.withdraw_link "
"ADD COLUMN validity_seconds INTEGER NOT NULL DEFAULT 0;"
)
15 changes: 14 additions & 1 deletion models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timedelta

from fastapi import Query
from pydantic import BaseModel, Field
Expand All @@ -17,6 +17,7 @@ class CreateWithdrawData(BaseModel):
custom_url: str = Query(None)
enabled: bool = Query(True)
currency: str = Query(None)
validity_seconds: int = Query(0, ge=0)


class WithdrawLink(BaseModel):
Expand All @@ -41,6 +42,7 @@ class WithdrawLink(BaseModel):
created_at: datetime
enabled: bool = Query(True)
currency: str = Query(None)
validity_seconds: int = Query(0)
lnurl: str | None = Field(
default=None,
no_database=True,
Expand All @@ -61,6 +63,17 @@ class WithdrawLink(BaseModel):
def is_spent(self) -> bool:
return self.used >= self.uses

@property
def expires_at(self) -> datetime | None:
if self.validity_seconds <= 0:
return None
return self.created_at + timedelta(seconds=self.validity_seconds)

@property
def is_expired(self) -> bool:
expires_at = self.expires_at
return bool(expires_at and datetime.now(expires_at.tzinfo) > expires_at)


class HashCheck(BaseModel):
hash: bool
Expand Down
65 changes: 63 additions & 2 deletions static/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,27 @@ const mapWithdrawLink = function (obj) {
obj._data = _.clone(obj)
obj.uses_left = obj.uses - obj.used
obj._data.use_custom = Boolean(obj.custom_url)
obj.expires_at = obj.validity_seconds
? new Date(
new Date(obj.created_at).getTime() + obj.validity_seconds * 1000
).toISOString()
: null
obj.is_expired = obj.expires_at
? new Date(obj.expires_at) < new Date()
: false
obj._data.validity_multiplier = 'days'
obj._data.validity_amount = null
if (obj.validity_seconds) {
if (obj.validity_seconds % 2592000 === 0) {
obj._data.validity_multiplier = 'months'
obj._data.validity_amount = obj.validity_seconds / 2592000
} else if (obj.validity_seconds % 604800 === 0) {
obj._data.validity_multiplier = 'weeks'
obj._data.validity_amount = obj.validity_seconds / 604800
} else {
obj._data.validity_amount = obj.validity_seconds / 86400
}
}
if (obj.currency) {
obj.min_withdrawable = obj.min_withdrawable / 100
obj.max_withdrawable = obj.max_withdrawable / 100
Expand Down Expand Up @@ -41,6 +62,15 @@ window.app = Vue.createApp({
label: 'Wait',
field: 'wait_time'
},
{
name: 'expires_at',
align: 'left',
label: 'Expires',
field: 'expires_at',
format: function (val) {
return val ? new Date(val).toLocaleString() : 'Never'
}
},
{
name: 'uses',
align: 'right',
Expand Down Expand Up @@ -84,6 +114,8 @@ window.app = Vue.createApp({
show: false,
secondMultiplier: 'seconds',
secondMultiplierOptions: ['seconds', 'minutes', 'hours'],
validityMultiplier: 'days',
validityMultiplierOptions: ['days', 'weeks', 'months'],
data: {
is_unique: false,
use_custom: false,
Expand All @@ -93,6 +125,8 @@ window.app = Vue.createApp({
},
simpleformDialog: {
show: false,
validityMultiplier: 'days',
validityMultiplierOptions: ['days', 'weeks', 'months'],
data: {
is_unique: true,
use_custom: false,
Expand Down Expand Up @@ -161,18 +195,22 @@ window.app = Vue.createApp({
})
},
closeFormDialog() {
this.formDialog.validityMultiplier = 'days'
this.formDialog.data = {
is_unique: false,
use_custom: false,
has_webhook: false,
enabled: true
enabled: true,
validity_seconds: 0
}
},
simplecloseFormDialog() {
this.simpleformDialog.validityMultiplier = 'days'
this.simpleformDialog.data = {
is_unique: false,
use_custom: false,
enabled: true
enabled: true,
validity_seconds: 0
}
},
openQrCodeDialog(linkId) {
Expand All @@ -184,6 +222,7 @@ window.app = Vue.createApp({
openUpdateDialog(linkId) {
let link = _.findWhere(this.withdrawLinks, {id: linkId})
link._data.has_webhook = link._data.webhook_url ? true : false
this.formDialog.validityMultiplier = link._data.validity_multiplier
this.formDialog.data = _.clone(link._data)
this.formDialog.show = true
},
Expand All @@ -208,6 +247,12 @@ window.app = Vue.createApp({
minutes: 60,
hours: 3600
}[this.formDialog.secondMultiplier]
data.validity_seconds = this.validitySeconds(
data.validity_amount,
this.formDialog.validityMultiplier
)
delete data.validity_amount
delete data.validity_multiplier

if (data.id) {
this.updateWithdrawLink(wallet, data)
Expand All @@ -225,6 +270,12 @@ window.app = Vue.createApp({
data.min_withdrawable = data.max_withdrawable
data.title = 'vouchers'
data.is_unique = true
data.validity_seconds = this.validitySeconds(
data.validity_amount,
this.simpleformDialog.validityMultiplier
)
delete data.validity_amount
delete data.validity_multiplier

if (!data.use_custom) {
data.custom_url = null
Expand Down Expand Up @@ -267,6 +318,16 @@ window.app = Vue.createApp({
LNbits.utils.notifyApiError(error)
})
},
validitySeconds(amount, unit) {
return (
(Number(amount) || 0) *
{
days: 86400,
weeks: 604800,
months: 2592000
}[unit]
)
},
createWithdrawLink(wallet, data) {
console.log(data)
LNbits.api
Expand Down
5 changes: 3 additions & 2 deletions templates/withdraw/display.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
<q-badge v-if="spent" color="red" class="q-mb-md"
>Withdraw is spent.</q-badge
>
<q-badge v-if="spent" color="red" class="q-mb-md"
>Withdraw is spent.</q-badge
<q-badge v-else-if="expired" color="red" class="q-mb-md"
>Withdraw has expired.</q-badge
>
<q-badge v-else-if="!enabled" color="grey" class="q-mb-md"
>Withdraw is disabled.</q-badge
Expand Down Expand Up @@ -61,6 +61,7 @@ <h6 class="text-subtitle1 q-mb-sm q-mt-none">
data() {
return {
spent: {{ 'true' if spent else 'false' }},
expired: {{ 'true' if expired else 'false' }},
url: '{{ lnurl_url }}',
lnurl: '',
nfcTagWriting: false,
Expand Down
49 changes: 49 additions & 0 deletions templates/withdraw/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,28 @@ <h6 class="text-subtitle1 q-my-none">
</q-select>
</div>
</div>
<div class="row q-col-gutter-none">
<div class="col-8">
<q-input
filled
dense
v-model.number="formDialog.data.validity_amount"
type="number"
min="0"
label="Expiration time (optional)"
hint="The expiration time counts from the created at date."
></q-input>
</div>
<div class="col-4 q-pl-xs">
<q-select
filled
dense
v-model="formDialog.validityMultiplier"
:options="formDialog.validityMultiplierOptions"
>
</q-select>
</div>
</div>
<q-toggle
label="Webhook"
color="secondary"
Expand Down Expand Up @@ -396,6 +418,28 @@ <h6 class="text-subtitle1 q-my-none">
:default="1"
label="Number of vouchers"
></q-input>
<div class="row q-col-gutter-none">
<div class="col-8">
<q-input
filled
dense
v-model.number="simpleformDialog.data.validity_amount"
type="number"
min="0"
label="Expiration time (optional)"
hint="The expiration time counts from the created at date."
></q-input>
</div>
<div class="col-4 q-pl-xs">
<q-select
filled
dense
v-model="simpleformDialog.validityMultiplier"
:options="simpleformDialog.validityMultiplierOptions"
>
</q-select>
</div>
</div>
<q-list>
<q-item tag="label" class="rounded-borders">
<q-item-section avatar>
Expand Down Expand Up @@ -475,6 +519,11 @@ <h6 class="text-subtitle1 q-my-none">
<span v-text="qrCodeDialog.data.max_withdrawable"></span> sat<br />
<strong>Wait time:</strong>
<span v-text="qrCodeDialog.data.wait_time"></span> seconds<br />
<strong>Expires:</strong>
<span
v-text="qrCodeDialog.data.expires_at ? new Date(qrCodeDialog.data.expires_at).toLocaleString() : 'Never'"
></span
><br />
<strong>Withdraws:</strong>
<span v-text="qrCodeDialog.data.used"></span>/
<span v-text="qrCodeDialog.data.uses"></span><br />
Expand Down
Loading
Loading