Skip to content
Merged
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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ test: teststatic
teststatic:
node tests/check_static_load.js
node tests/test_tax.js
node tests/test_payment_status.js

test-e2e:
"$(abspath ../../../node_modules/.bin/playwright)" test --config "$(CURDIR)/tests/e2e/playwright.config.ts"
Expand Down
2 changes: 1 addition & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"short_description": "A shareable PoS terminal!",
"description": "",
"tile": "/tpos/static/image/tpos.png",
"min_lnbits_version": "1.5.0",
"min_lnbits_version": "1.6.2",
"contributors": [
{
"name": "Ben Arc",
Expand Down
3 changes: 1 addition & 2 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,7 @@ def can_withdraw(self) -> bool:
if self.withdraw_time_option != "secs"
else self.withdraw_between
)
last_withdraw_time = self.withdraw_time - now
return last_withdraw_time < seconds
return now - self.withdraw_time >= seconds


class Tpos(TposClean, BaseModel):
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/tpos" }
dependencies = [ "lnbits>1" ]
dependencies = [ "lnbits>=1.6.2" ]

[dependency-groups]
dev = [
Expand All @@ -20,6 +20,9 @@ dev = [
[tool.poetry]
package-mode = false

# Keep UV configuration independent of a parent LNbits checkout.
[tool.uv]

[tool.mypy]
exclude = "(nostr/*)"
plugins = ["pydantic.mypy"]
Expand Down
33 changes: 32 additions & 1 deletion services.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from http import HTTPStatus
from math import ceil, isfinite

from fastapi import HTTPException
from lnbits.core.crud import get_wallet
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis

from .models import Tpos
from .models import CreateTposInvoice, Tpos
from .services_tabs import (
tabs_available_for_user,
)
Expand Down Expand Up @@ -32,3 +34,32 @@ async def ensure_tpos_tabs_access(tpos: Tpos) -> str:
detail="Tabs integration is unavailable for this TPoS.",
)
return user_id


async def validate_tpos_sale_amount(
tpos: Tpos, data: CreateTposInvoice, amount: float, *, minimum: bool = False
) -> None:
if not isfinite(amount) or amount <= 0:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid sale amount.")
if tpos.currency.lower() == "sats":
expected = ceil(amount)
if data.amount < expected if minimum else data.amount != expected:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "Invoice amount does not match the sale."
)
return

supplied = data.amount_fiat if data.amount_fiat is not None else amount
if not minimum and abs(supplied - amount) > 0.01:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "Invoice amount does not match the sale."
)
if minimum:
amount = max(supplied, amount)
data.amount = await fiat_amount_as_satoshis(amount, tpos.currency)
if data.amount <= 0:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Sale amount is below one satoshi.")
data.amount_fiat = amount
data.exchange_rate = data.amount / amount
if data.details:
data.details["exchangeRate"] = data.exchange_rate
97 changes: 97 additions & 0 deletions services_inventory.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,108 @@
from decimal import ROUND_HALF_UP, Decimal
from http import HTTPStatus
from math import ceil
from typing import Any

import httpx
from fastapi import HTTPException
from lnbits.core.crud import get_wallet
from lnbits.core.models import User
from lnbits.settings import settings
from pydantic import BaseModel, Field, ValidationError

from .helpers import create_internal_user_access_token, from_csv, inventory_tags_to_list
from .models import CreateTposInvoice, Tpos
from .services import get_tpos_owner_user_id, validate_tpos_sale_amount


class InventoryCartItem(BaseModel):
id: str
quantity: int = Field(..., ge=1)
price: float = Field(..., ge=0)

class Config:
allow_inf_nan = False


async def validate_inventory_sale(tpos: Tpos, data: CreateTposInvoice) -> None:
inventory = data.inventory
assert inventory
user_id = await get_tpos_owner_user_id(tpos)
catalog = {
item["id"]: item
for item in await get_inventory_items_for_tpos(
user_id,
inventory.inventory_id,
tpos.inventory_tags,
tpos.inventory_omit_tags,
)
}
try:
cart_data = (data.details or {}).get("items")
if not cart_data:
cart_data = [
{
"id": item.id,
"quantity": item.quantity,
"price": catalog[item.id].get("price") or 0,
}
for item in inventory.items
if item.id in catalog
]
cart = [InventoryCartItem(**item) for item in cart_data]
except (ValidationError, TypeError):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid inventory cart.") from None
quantities = {item.id: item.quantity for item in inventory.items}
if (
not cart
or len(quantities) != len(inventory.items)
or len({item.id for item in cart}) != len(cart)
or quantities != {item.id: item.quantity for item in cart}
):
raise HTTPException(
HTTPStatus.BAD_REQUEST, "Inventory quantities do not match the cart."
)
total = 0.0
for line in cart:
item = catalog.get(line.id)
if not item:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "Item is not available on this TPoS."
)
stock = item.get("quantity_in_stock")
if stock is not None and line.quantity > stock:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Not enough stock available.")
if not tpos.allow_price_adjustment and line.price != float(
item.get("price") or 0
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Item price cannot be changed.")
tax = item.get("tax_rate") or tpos.tax_default or 0
total += (
line.price * line.quantity * (1 if tpos.tax_inclusive else 1 + tax / 100)
)
currency = tpos.currency.upper()
if currency == "SATS":
total = ceil(total)
else:
# Match the currency precision used by Intl.NumberFormat in the POS.
digits = 2
if (
currency
in (
"AFN ALL BIF BYR CLP COP DJF GNF HUF IDR IQD ISK JPY KMF KRW LAK LBP "
"MGA MMK MRO PKR PYG RWF SLL SOS STD UGX VND VUV XAF XOF XPF YER"
).split()
):
digits = 0
elif currency in {"BHD", "JOD", "KWD", "LYD", "OMR", "TND"}:
digits = 3
elif currency == "CLF":
digits = 4
total = float(
Decimal(str(total)).quantize(Decimal(10) ** -digits, rounding=ROUND_HALF_UP)
)
# The browser can round up after each cart addition; retain that overpayment.
await validate_tpos_sale_amount(tpos, data, total, minimum=True)


async def deduct_inventory_stock(wallet_id: str, inventory_payload: dict) -> None:
Expand Down
2 changes: 1 addition & 1 deletion static/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ window.app = Vue.createApp({
.request(
'GET',
'/tpos/api/v1/tposs?all_wallets=true',
this.g.user.wallets[0].inkey
this.g.user.wallets[0].adminkey
)
.then(response => {
this.tposs = response.data.map(obj => {
Expand Down
88 changes: 49 additions & 39 deletions static/js/tpos.js
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,9 @@ window.app = Vue.createApp({
null,
params
)
if (data.extra?.amount_fiat !== undefined) {
this.paymentAmount = data.extra.amount_fiat
}
this.openInvoiceDialog(data)
this.subscribeToPaymentWS(data.payment_hash)
} catch (error) {
Expand All @@ -1414,6 +1417,18 @@ window.app = Vue.createApp({
}
},
finalizeSuccessfulPayment(paymentHash) {
if (
!this.invoiceDialog.show ||
this.invoiceDialog.data?.payment_hash !== paymentHash
) {
return
}
if (this.invoiceDialog.paymentChecker) {
clearInterval(this.invoiceDialog.paymentChecker)
this.invoiceDialog.paymentChecker = null
}
const ws = this.paymentWsByHash[paymentHash]
if (ws) ws.close()
Quasar.Notify.create({
type: 'positive',
message: 'Invoice Paid!'
Expand Down Expand Up @@ -1448,25 +1463,28 @@ window.app = Vue.createApp({
if (this.invoiceDialog.paymentChecker) {
clearInterval(this.invoiceDialog.paymentChecker)
}
this.invoiceDialog.paymentChecker = setInterval(async () => {
try {
const {data} = await LNbits.api.request(
'GET',
`/tpos/api/v1/tposs/${this.tposId}/invoices/${paymentHash}`
)
if (data.paid) {
clearInterval(this.invoiceDialog.paymentChecker)
this.invoiceDialog.paymentChecker = null
this.finalizeSuccessfulPayment(paymentHash)
} else if (
['expired', 'underpaid', 'abandoned'].includes(data.status)
) {
this.stopTerminalPayment(paymentHash, data.status)
}
} catch (error) {
console.warn('TPoS payment status check failed:', error)
this.invoiceDialog.paymentChecker = setInterval(
() => this.checkPaymentStatus(paymentHash),
3000
)
},
async checkPaymentStatus(paymentHash) {
try {
const {data} = await LNbits.api.request(
'GET',
`/tpos/api/v1/tposs/${this.tposId}/invoices/${paymentHash}`
)
if (this.invoiceDialog.data?.payment_hash !== paymentHash) return
if (data.paid === true) {
this.finalizeSuccessfulPayment(paymentHash)
} else if (
['expired', 'underpaid', 'abandoned'].includes(data.status)
) {
this.stopTerminalPayment(paymentHash, data.status)
}
}, 3000)
} catch (error) {
console.warn('TPoS payment status check failed:', error)
}
},
subscribeToPaymentWS(paymentHash) {
if (this.paymentWsByHash[paymentHash]) return
Expand All @@ -1478,21 +1496,7 @@ window.app = Vue.createApp({
url.protocol = wsProtocol
const ws = new WebSocket(url)
this.paymentWsByHash[paymentHash] = ws
ws.onmessage = async ({data}) => {
const payment = JSON.parse(data)
if (payment.pending === false) {
if (this.invoiceDialog.paymentChecker) {
clearInterval(this.invoiceDialog.paymentChecker)
this.invoiceDialog.paymentChecker = null
}
this.finalizeSuccessfulPayment(paymentHash)
ws.close()
} else if (
['expired', 'underpaid', 'abandoned'].includes(payment.status)
) {
this.stopTerminalPayment(paymentHash, payment.status)
}
}
ws.onmessage = () => this.checkPaymentStatus(paymentHash)
ws.onerror = err => {
console.warn('TPoS payment websocket error:', err)
}
Expand Down Expand Up @@ -1601,12 +1605,18 @@ window.app = Vue.createApp({
this._withdrawing = true
LNbits.api
.request(
'POST',
`/tpos/api/v1/atm/withdraw/${this.atmToken}/${this.sat}/pay`,
null,
{
pay_link: payLink
}
'GET',
`/tpos/api/v1/atm/withdraw/${this.atmToken}/${this.sat}`
)
.then(() =>
LNbits.api.request(
'POST',
`/tpos/api/v1/atm/withdraw/${this.atmToken}/${this.sat}/pay`,
null,
{
pay_link: payLink
}
)
)
.then(res => {
if (!res.data.success) {
Expand Down
Loading
Loading