├── tests ├── __init__.py └── test_init.py ├── .gitignore ├── static ├── image │ ├── 1.jpg │ ├── 1.png │ ├── 2.png │ ├── 3.png │ ├── 4.png │ ├── 5.png │ └── tpos.png ├── components │ ├── keypad.js │ ├── item-list.js │ └── receipt.js └── js │ └── index.js ├── manifest.json ├── .github └── workflows │ ├── lint.yml │ └── release.yml ├── .prettierrc ├── package.json ├── description.md ├── templates └── tpos │ ├── _tpos.html │ ├── _options_fab.html │ ├── _api_docs.html │ ├── tpos.html │ ├── _cart.html │ ├── dialogs.html │ └── index.html ├── Makefile ├── LICENSE ├── __init__.py ├── config.json ├── helpers.py ├── pyproject.toml ├── toc.md ├── crud.py ├── tasks.py ├── views.py ├── views_lnurl.py ├── services.py ├── models.py ├── views_atm.py ├── migrations.py ├── README.md └── views_api.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | node_modules 3 | .mypy_cache 4 | .venv 5 | -------------------------------------------------------------------------------- /static/image/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnbits/tpos/HEAD/static/image/1.jpg -------------------------------------------------------------------------------- /static/image/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnbits/tpos/HEAD/static/image/1.png -------------------------------------------------------------------------------- /static/image/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnbits/tpos/HEAD/static/image/2.png -------------------------------------------------------------------------------- /static/image/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnbits/tpos/HEAD/static/image/3.png -------------------------------------------------------------------------------- /static/image/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnbits/tpos/HEAD/static/image/4.png -------------------------------------------------------------------------------- /static/image/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnbits/tpos/HEAD/static/image/5.png -------------------------------------------------------------------------------- /static/image/tpos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnbits/tpos/HEAD/static/image/tpos.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "repos": [ 3 | { 4 | "id": "tpos", 5 | "organisation": "lnbits", 6 | "repository": "tpos" 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | lint: 10 | uses: lnbits/lnbits/.github/workflows/lint.yml@dev 11 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import APIRouter 3 | 4 | from .. import tpos_ext 5 | 6 | 7 | # just import router and add it to a test router 8 | @pytest.mark.asyncio 9 | async def test_router(): 10 | router = APIRouter() 11 | router.include_router(tpos_ext) 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "arrowParens": "avoid", 4 | "insertPragma": false, 5 | "printWidth": 80, 6 | "proseWrap": "preserve", 7 | "singleQuote": true, 8 | "trailingComma": "none", 9 | "useTabs": false, 10 | "bracketSameLine": false, 11 | "bracketSpacing": false 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tpos", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "prettier": "^3.2.5", 13 | "pyright": "^1.1.358" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /description.md: -------------------------------------------------------------------------------- 1 | A powerful air-gapped software Point of Sale can be shared via a QR code. 2 | 3 | Its functions include: 4 | 5 | - Generating invoices 6 | - Denomination in sats and ANY fiat currency 7 | - Boltcard support 8 | - Adding items for a checkout experience 9 | - An ATM feature that allows you to sell Bitcoin back to your customers for a profit! 10 | 11 | A favorite onboarding solution for merchants who want to accumulate Bitcoin, can ben achieved by also using the Boltz extension for automatic trustless swaps out to on-chain. 12 | -------------------------------------------------------------------------------- /templates/tpos/_tpos.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

5 | Point of Sale is a secure, mobile-ready, instant and shareable point of 6 | sale terminal (PoS) for merchants. The PoS is linked to your LNbits 7 | wallet but completely air-gapped so users can ONLY create invoices. To 8 | share the TPoS hit the hash on the terminal. 9 |

10 | Created by 12 | Tiago Vasconcelos. 19 |
20 |
21 |
22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: format check 2 | 3 | format: prettier black ruff 4 | 5 | check: mypy pyright checkblack checkruff checkprettier 6 | 7 | prettier: 8 | uv run ./node_modules/.bin/prettier --write . 9 | pyright: 10 | uv run ./node_modules/.bin/pyright 11 | 12 | mypy: 13 | uv run mypy . 14 | 15 | black: 16 | uv run black . 17 | 18 | ruff: 19 | uv run ruff check . --fix 20 | 21 | checkruff: 22 | uv run ruff check . 23 | 24 | checkprettier: 25 | uv run ./node_modules/.bin/prettier --check . 26 | 27 | checkblack: 28 | uv run black --check . 29 | 30 | checkeditorconfig: 31 | editorconfig-checker 32 | 33 | test: 34 | PYTHONUNBUFFERED=1 \ 35 | DEBUG=true \ 36 | uv run pytest 37 | install-pre-commit-hook: 38 | @echo "Installing pre-commit hook to git" 39 | @echo "Uninstall the hook with uv run pre-commit uninstall" 40 | uv run pre-commit install 41 | 42 | pre-commit: 43 | uv run pre-commit run --all-files 44 | 45 | 46 | checkbundle: 47 | @echo "skipping checkbundle" 48 | -------------------------------------------------------------------------------- /static/components/keypad.js: -------------------------------------------------------------------------------- 1 | window.app.component('keypad-item', { 2 | name: 'keypad-item', 3 | props: ['value'], 4 | template: ` 5 | 14 | ` 15 | }) 16 | 17 | window.app.component('keypad', { 18 | name: 'keypad', 19 | data() { 20 | return {} 21 | }, 22 | computed: {}, 23 | methods: { 24 | taxString(item) { 25 | return `tax ${this.inclusive ? 'incl.' : 'excl.'} ${item.tax ? item.tax + '%' : ''}` 26 | }, 27 | formatPrice(item) { 28 | return `Price w/ tax: ${this.format(item.price * (1 + item.tax * 0.01), this.currency)}` 29 | }, 30 | addToCart(item) { 31 | this.$emit('add-product', item) 32 | } 33 | }, 34 | template: ` 35 |
36 | 37 |
38 | ` 39 | }) 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 LNbits 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from fastapi import APIRouter 4 | from loguru import logger 5 | 6 | from .crud import db 7 | from .tasks import wait_for_paid_invoices 8 | from .views import tpos_generic_router 9 | from .views_api import tpos_api_router 10 | from .views_atm import tpos_atm_router 11 | from .views_lnurl import tpos_lnurl_router 12 | 13 | tpos_ext = APIRouter(prefix="/tpos", tags=["TPoS"]) 14 | tpos_ext.include_router(tpos_generic_router) 15 | tpos_ext.include_router(tpos_lnurl_router) 16 | tpos_ext.include_router(tpos_api_router) 17 | tpos_ext.include_router(tpos_atm_router) 18 | 19 | tpos_static_files = [ 20 | { 21 | "path": "/tpos/static", 22 | "name": "tpos_static", 23 | } 24 | ] 25 | 26 | scheduled_tasks: list[asyncio.Task] = [] 27 | 28 | 29 | def tpos_stop(): 30 | for task in scheduled_tasks: 31 | try: 32 | task.cancel() 33 | except Exception as ex: 34 | logger.warning(ex) 35 | 36 | 37 | def tpos_start(): 38 | from lnbits.tasks import create_permanent_unique_task 39 | 40 | task = create_permanent_unique_task("ext_tpos", wait_for_paid_invoices) 41 | scheduled_tasks.append(task) 42 | 43 | 44 | __all__ = [ 45 | "db", 46 | "tpos_ext", 47 | "tpos_start", 48 | "tpos_static_files", 49 | "tpos_stop", 50 | ] 51 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "tpos", 3 | "version": "1.1.2", 4 | "name": "TPoS", 5 | "repo": "https://github.com/lnbits/tpos", 6 | "short_description": "A shareable PoS terminal!", 7 | "tile": "/tpos/static/image/tpos.png", 8 | "min_lnbits_version": "1.4.0", 9 | "contributors": [ 10 | { 11 | "name": "Ben Arc", 12 | "uri": "mailto:ben@lnbits.com", 13 | "role": "Developer" 14 | }, 15 | { 16 | "name": "talvasconcelos", 17 | "uri": "https://github.com/talvasconcelos", 18 | "role": "Developer" 19 | }, 20 | { 21 | "name": "Yvette", 22 | "uri": "https://github.com/arbadacarbaYK", 23 | "role": "Contributor" 24 | }, 25 | { 26 | "name": "leesalminen", 27 | "uri": "https://github.com/leesalminen", 28 | "role": "Developer" 29 | } 30 | ], 31 | "images": [ 32 | { 33 | "uri": "https://raw.githubusercontent.com/lnbits/tpos/main/static/image/1.jpg", 34 | "link": "https://www.youtube.com/embed/-dg1BAzwSQw?si=U2QT9ncbIIuP5Jz1" 35 | }, 36 | { 37 | "uri": "https://raw.githubusercontent.com/lnbits/tpos/main/static/image/1.png" 38 | }, 39 | { 40 | "uri": "https://raw.githubusercontent.com/lnbits/tpos/main/static/image/2.png" 41 | }, 42 | { 43 | "uri": "https://raw.githubusercontent.com/lnbits/tpos/main/static/image/3.png" 44 | }, 45 | { 46 | "uri": "https://raw.githubusercontent.com/lnbits/tpos/main/static/image/4.png" 47 | } 48 | ], 49 | "description_md": "https://raw.githubusercontent.com/lnbits/tpos/main/description.md", 50 | "terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/tpos/main/toc.md", 51 | "license": "MIT", 52 | "paid_features": "Free to use all the PoS features, apart from 0.5% charge for the ATM whithdraws to help maintain development." 53 | } 54 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v[0-9]+.[0-9]+.[0-9]+' 5 | 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Create github release 12 | env: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | tag: ${{ github.ref_name }} 15 | run: | 16 | gh release create "$tag" --generate-notes 17 | 18 | pullrequest: 19 | needs: [release] 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | token: ${{ secrets.EXT_GITHUB }} 25 | repository: lnbits/lnbits-extensions 26 | path: './lnbits-extensions' 27 | 28 | - name: setup git user 29 | run: | 30 | git config --global user.name "alan" 31 | git config --global user.email "alan@lnbits.com" 32 | 33 | - name: Create pull request in extensions repo 34 | env: 35 | GH_TOKEN: ${{ secrets.EXT_GITHUB }} 36 | repo_name: '${{ github.event.repository.name }}' 37 | tag: '${{ github.ref_name }}' 38 | branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}' 39 | title: '[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}' 40 | body: 'https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}' 41 | archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip' 42 | run: | 43 | cd lnbits-extensions 44 | git checkout -b $branch 45 | 46 | # if there is another open PR 47 | git pull origin $branch || echo "branch does not exist" 48 | 49 | sh util.sh update_extension $repo_name $tag 50 | 51 | git add -A 52 | git commit -am "$title" 53 | git push origin $branch 54 | 55 | # check if pr exists before creating it 56 | gh config set pager cat 57 | check=$(gh pr list -H $branch | wc -l) 58 | test $check -ne 0 || gh pr create --title "$title" --body "$body" --repo lnbits/lnbits-extensions 59 | -------------------------------------------------------------------------------- /helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from loguru import logger 4 | 5 | 6 | def _from_csv(value: str | None, separator: str = ",") -> list[str]: 7 | if not value: 8 | return [] 9 | parts = [part.strip() for part in value.split(separator)] 10 | return [part for part in parts if part] 11 | 12 | 13 | def _serialize_inventory_tags(tags: list[str] | str | None) -> str | None: 14 | if isinstance(tags, list): 15 | return ",".join([tag for tag in tags if tag]) 16 | return tags 17 | 18 | 19 | def _inventory_tags_to_list(raw_tags: str | list[str] | None) -> list[str]: 20 | if raw_tags is None: 21 | return [] 22 | if isinstance(raw_tags, list): 23 | return [tag.strip() for tag in raw_tags if tag and tag.strip()] 24 | return [tag.strip() for tag in raw_tags.split(",") if tag and tag.strip()] 25 | 26 | 27 | def _inventory_tags_to_string(raw_tags: str | list[str] | None) -> str | None: 28 | if raw_tags is None: 29 | return None 30 | if isinstance(raw_tags, str): 31 | return raw_tags 32 | return ",".join([tag for tag in raw_tags if tag]) 33 | 34 | 35 | def _first_image(images: str | list[str] | None) -> str | None: 36 | if not images: 37 | return None 38 | if isinstance(images, list): 39 | return _normalize_image(images[0]) if images else None 40 | raw = str(images).strip() 41 | if not raw: 42 | return None 43 | try: 44 | parsed = json.loads(raw) 45 | if isinstance(parsed, list) and parsed: 46 | return _normalize_image(parsed[0]) 47 | except Exception as exc: 48 | logger.exception(f"Exception occurred while parsing image JSON: {exc}") 49 | 50 | if "|||" in raw: 51 | return _normalize_image(raw.split("|||")[0]) 52 | 53 | if "," in raw: 54 | return _normalize_image(raw.split(",")[0]) 55 | return _normalize_image(raw) 56 | 57 | 58 | def _normalize_image(val: str | None) -> str | None: 59 | if not val: 60 | return None 61 | val = str(val).strip() 62 | if not val: 63 | return None 64 | if val.startswith("http") or val.startswith("/api/") or val.startswith("data:"): 65 | return val 66 | return f"/api/v1/assets/{val}/binary" 67 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "lnbits-tpos" 3 | version = "0.0.0" 4 | requires-python = ">=3.10,<3.13" 5 | description = "LNbits, free and open-source Lightning wallet and accounts system." 6 | authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] 7 | urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/tpos" } 8 | dependencies = [ "lnbits>1" ] 9 | 10 | [tool.uv] 11 | dev-dependencies = [ 12 | "black", 13 | "pytest-asyncio", 14 | "pytest", 15 | "mypy", 16 | "pre-commit", 17 | "ruff", 18 | ] 19 | 20 | [tool.poetry] 21 | package-mode = false 22 | 23 | [tool.mypy] 24 | exclude = "(nostr/*)" 25 | plugins = ["pydantic.mypy"] 26 | 27 | [tool.pydantic-mypy] 28 | init_forbid_extra = true 29 | init_typed = true 30 | warn_required_dynamic_aliases = true 31 | warn_untyped_fields = true 32 | 33 | 34 | [tool.pytest.ini_options] 35 | log_cli = false 36 | testpaths = [ 37 | "tests" 38 | ] 39 | 40 | [tool.black] 41 | line-length = 88 42 | 43 | [tool.ruff] 44 | # Same as Black. + 10% rule of black 45 | line-length = 88 46 | exclude = [ 47 | "nostr", 48 | ] 49 | 50 | [tool.ruff.lint] 51 | # Enable: 52 | # F - pyflakes 53 | # E - pycodestyle errors 54 | # W - pycodestyle warnings 55 | # I - isort 56 | # A - flake8-builtins 57 | # C - mccabe 58 | # N - naming 59 | # UP - pyupgrade 60 | # RUF - ruff 61 | # B - bugbear 62 | select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"] 63 | ignore = ["C901"] 64 | 65 | # Allow autofix for all enabled rules (when `--fix`) is provided. 66 | fixable = ["ALL"] 67 | unfixable = [] 68 | 69 | # Allow unused variables when underscore-prefixed. 70 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 71 | 72 | # needed for pydantic 73 | [tool.ruff.lint.pep8-naming] 74 | classmethod-decorators = [ 75 | "validator", 76 | "root_validator", 77 | ] 78 | 79 | # Ignore unused imports in __init__.py files. 80 | # [tool.ruff.lint.extend-per-file-ignores] 81 | # "__init__.py" = ["F401", "F403"] 82 | 83 | # [tool.ruff.lint.mccabe] 84 | # max-complexity = 10 85 | 86 | [tool.ruff.lint.flake8-bugbear] 87 | # Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`. 88 | extend-immutable-calls = [ 89 | "fastapi.Depends", 90 | "fastapi.Query", 91 | ] 92 | -------------------------------------------------------------------------------- /templates/tpos/_options_fab.html: -------------------------------------------------------------------------------- 1 | 9 | 17 | 25 | 33 | 42 | 51 | 60 | 69 | 77 | 78 | -------------------------------------------------------------------------------- /toc.md: -------------------------------------------------------------------------------- 1 | # Terms and Conditions for LNbits Extension 2 | 3 | ## 1. Acceptance of Terms 4 | 5 | By installing and using the LNbits extension ("Extension"), you agree to be bound by these terms and conditions ("Terms"). If you do not agree to these Terms, do not use the Extension. 6 | 7 | ## 2. License 8 | 9 | The Extension is free and open-source software, released under [specify the FOSS license here, e.g., GPL-3.0, MIT, etc.]. You are permitted to use, copy, modify, and distribute the Extension under the terms of that license. 10 | 11 | ## 3. No Warranty 12 | 13 | The Extension is provided "as is" and with all faults, and the developer expressly disclaims all warranties of any kind, whether express, implied, statutory, or otherwise, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, and any warranties arising out of course of dealing or usage of trade. No advice or information, whether oral or written, obtained from the developer or elsewhere will create any warranty not expressly stated in this Terms. 14 | 15 | ## 4. Limitation of Liability 16 | 17 | In no event will the developer be liable to you or any third party for any direct, indirect, incidental, special, consequential, or punitive damages, including lost profit, lost revenue, loss of data, or other damages arising out of or in connection with your use of the Extension, even if the developer has been advised of the possibility of such damages. The foregoing limitation of liability shall apply to the fullest extent permitted by law in the applicable jurisdiction. 18 | 19 | ## 5. Modification of Terms 20 | 21 | The developer reserves the right to modify these Terms at any time. You are advised to review these Terms periodically for any changes. Changes to these Terms are effective when they are posted on the appropriate location within or associated with the Extension. 22 | 23 | ## 6. General Provisions 24 | 25 | If any provision of these Terms is held to be invalid or unenforceable, that provision will be enforced to the maximum extent permissible, and the other provisions of these Terms will remain in full force and effect. These Terms constitute the entire agreement between you and the developer regarding the use of the Extension. 26 | 27 | ## 7. Contact Information 28 | 29 | If you have any questions about these Terms, please contact the developer at [developer's contact information]. 30 | -------------------------------------------------------------------------------- /crud.py: -------------------------------------------------------------------------------- 1 | from lnbits.db import Database 2 | from lnbits.helpers import urlsafe_short_hash 3 | 4 | from .helpers import _serialize_inventory_tags 5 | from .models import CreateTposData, LnurlCharge, Tpos, TposClean 6 | 7 | db = Database("ext_tpos") 8 | 9 | 10 | async def create_tpos(data: CreateTposData) -> Tpos: 11 | tpos_id = urlsafe_short_hash() 12 | data_dict = data.dict() 13 | data_dict["inventory_tags"] = _serialize_inventory_tags(data.inventory_tags) 14 | data_dict["inventory_omit_tags"] = _serialize_inventory_tags( 15 | data.inventory_omit_tags 16 | ) 17 | tpos = Tpos(id=tpos_id, **data_dict) 18 | await db.insert("tpos.pos", tpos) 19 | return tpos 20 | 21 | 22 | async def get_tpos(tpos_id: str) -> Tpos | None: 23 | return await db.fetchone( 24 | "SELECT * FROM tpos.pos WHERE id = :id", {"id": tpos_id}, Tpos 25 | ) 26 | 27 | 28 | async def create_lnurlcharge(tpos_id: str) -> LnurlCharge: 29 | charge_id = urlsafe_short_hash() 30 | lnurlcharge = LnurlCharge(id=charge_id, tpos_id=tpos_id) 31 | await db.insert("tpos.withdraws", lnurlcharge) 32 | return lnurlcharge 33 | 34 | 35 | async def get_lnurlcharge(lnurlcharge_id: str) -> LnurlCharge | None: 36 | return await db.fetchone( 37 | "SELECT * FROM tpos.withdraws WHERE id = :id", 38 | {"id": lnurlcharge_id}, 39 | LnurlCharge, 40 | ) 41 | 42 | 43 | async def update_lnurlcharge(charge: LnurlCharge) -> LnurlCharge: 44 | await db.update("tpos.withdraws", charge) 45 | return charge 46 | 47 | 48 | async def get_clean_tpos(tpos_id: str) -> TposClean | None: 49 | return await db.fetchone( 50 | "SELECT * FROM tpos.pos WHERE id = :id", {"id": tpos_id}, TposClean 51 | ) 52 | 53 | 54 | async def update_tpos(tpos: Tpos) -> Tpos: 55 | tpos.inventory_tags = _serialize_inventory_tags(tpos.inventory_tags) 56 | tpos.inventory_omit_tags = _serialize_inventory_tags(tpos.inventory_omit_tags) 57 | await db.update("tpos.pos", tpos) 58 | return tpos 59 | 60 | 61 | async def get_tposs(wallet_ids: str | list[str]) -> list[Tpos]: 62 | if isinstance(wallet_ids, str): 63 | wallet_ids = [wallet_ids] 64 | q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) 65 | tposs = await db.fetchall( 66 | f"SELECT * FROM tpos.pos WHERE wallet IN ({q})", model=Tpos 67 | ) 68 | return tposs 69 | 70 | 71 | async def delete_tpos(tpos_id: str) -> None: 72 | await db.execute("DELETE FROM tpos.pos WHERE id = :id", {"id": tpos_id}) 73 | -------------------------------------------------------------------------------- /static/components/item-list.js: -------------------------------------------------------------------------------- 1 | window.app.component('item-list', { 2 | name: 'item-list', 3 | props: ['items', 'inclusive', 'format', 'currency', 'add-product'], 4 | data: function () { 5 | return {} 6 | }, 7 | computed: {}, 8 | methods: { 9 | taxString(item) { 10 | return `tax ${this.inclusive ? 'incl.' : 'excl.'} ${item.tax ? item.tax + '%' : ''}` 11 | }, 12 | formatPrice(item) { 13 | return `Price w/ tax: ${this.format(item.price * (1 + item.tax * 0.01), this.currency)}` 14 | }, 15 | addToCart(item) { 16 | this.$emit('add-product', item) 17 | } 18 | }, 19 | template: ` 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 38 | 39 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 56 | 57 | 58 | 61 | 62 | 63 | 64 | 65 |
66 | 67 |
68 |
69 |
70 |
71 | 72 |
73 | ` 74 | }) 75 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from lnbits.core.models import Payment 4 | from lnbits.core.services import ( 5 | create_invoice, 6 | get_pr_from_lnurl, 7 | pay_invoice, 8 | websocket_updater, 9 | ) 10 | from lnbits.tasks import register_invoice_listener 11 | from loguru import logger 12 | 13 | from .crud import get_tpos 14 | from .services import _deduct_inventory_stock 15 | 16 | 17 | async def wait_for_paid_invoices(): 18 | invoice_queue = asyncio.Queue() 19 | register_invoice_listener(invoice_queue, "ext_tpos") 20 | 21 | while True: 22 | payment = await invoice_queue.get() 23 | await on_invoice_paid(payment) 24 | 25 | 26 | async def on_invoice_paid(payment: Payment) -> None: 27 | if ( 28 | not payment.extra 29 | or payment.extra.get("tag") != "tpos" 30 | or payment.extra.get("tipSplitted") 31 | ): 32 | return 33 | tip_amount = payment.extra.get("tip_amount") 34 | 35 | stripped_payment = { 36 | "amount": payment.amount, 37 | "fee": payment.fee, 38 | "checking_id": payment.checking_id, 39 | "payment_hash": payment.payment_hash, 40 | "bolt11": payment.bolt11, 41 | } 42 | 43 | tpos_id = payment.extra.get("tpos_id") 44 | assert tpos_id 45 | 46 | tpos = await get_tpos(tpos_id) 47 | assert tpos 48 | if payment.extra.get("lnaddress") and payment.extra["lnaddress"] != "": 49 | calc_amount = payment.amount - ((payment.amount / 100) * tpos.lnaddress_cut) 50 | address = payment.extra.get("lnaddress") 51 | if address: 52 | try: 53 | pr = await get_pr_from_lnurl(address, int(calc_amount)) 54 | except Exception as e: 55 | logger.error(f"tpos: Error getting payment request from lnurl: {e}") 56 | return 57 | 58 | payment.extra["lnaddress"] = "" 59 | paid_payment = await pay_invoice( 60 | payment_request=pr, 61 | wallet_id=payment.wallet_id, 62 | extra={**payment.extra}, 63 | ) 64 | logger.debug(f"tpos: LNaddress paid cut: {paid_payment.checking_id}") 65 | 66 | await websocket_updater(tpos_id, str(stripped_payment)) 67 | 68 | inventory_payload = payment.extra.get("inventory") 69 | if inventory_payload: 70 | await _deduct_inventory_stock(payment.wallet_id, inventory_payload) 71 | 72 | if not tip_amount: 73 | # no tip amount 74 | return 75 | 76 | wallet_id = tpos.tip_wallet 77 | assert wallet_id 78 | 79 | tip_payment = await create_invoice( 80 | wallet_id=wallet_id, 81 | amount=int(tip_amount), 82 | internal=True, 83 | memo="tpos tip", 84 | ) 85 | logger.debug(f"tpos: tip invoice created: {payment.payment_hash}") 86 | 87 | paid_payment = await pay_invoice( 88 | payment_request=tip_payment.bolt11, 89 | wallet_id=payment.wallet_id, 90 | extra={**payment.extra, "tipSplitted": True}, 91 | ) 92 | logger.debug(f"tpos: tip invoice paid: {paid_payment.checking_id}") 93 | -------------------------------------------------------------------------------- /views.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from fastapi import APIRouter, Depends, HTTPException, Request 4 | from lnbits.core.models import User 5 | from lnbits.decorators import check_user_exists 6 | from lnbits.helpers import template_renderer 7 | from lnbits.settings import settings 8 | from starlette.responses import HTMLResponse 9 | 10 | from .crud import get_clean_tpos, get_tpos 11 | from .models import TposClean 12 | 13 | tpos_generic_router = APIRouter() 14 | 15 | 16 | def tpos_renderer(): 17 | return template_renderer(["tpos/templates"]) 18 | 19 | 20 | @tpos_generic_router.get("/", response_class=HTMLResponse) 21 | async def index(request: Request, user: User = Depends(check_user_exists)): 22 | return tpos_renderer().TemplateResponse( 23 | "tpos/index.html", {"request": request, "user": user.json()} 24 | ) 25 | 26 | 27 | @tpos_generic_router.get("/{tpos_id}") 28 | async def tpos(request: Request, tpos_id, lnaddress: str | None = ""): 29 | tpos = await get_tpos(tpos_id) 30 | if not tpos: 31 | raise HTTPException( 32 | status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." 33 | ) 34 | 35 | tpos_clean = TposClean(**tpos.dict()) 36 | response = tpos_renderer().TemplateResponse( 37 | "tpos/tpos.html", 38 | { 39 | "request": request, 40 | "tpos": tpos_clean.json(), 41 | "lnaddressparam": lnaddress, 42 | "withdraw_maximum": tpos.withdraw_maximum, 43 | "web_manifest": f"/tpos/manifest/{tpos_id}.webmanifest", 44 | }, 45 | ) 46 | # This is just for hiding the user-account UI elements. 47 | # It is not a security measure. 48 | response.set_cookie("is_lnbits_user_authorized", "false", path=request.url.path) 49 | return response 50 | 51 | 52 | @tpos_generic_router.get("/manifest/{tpos_id}.webmanifest") 53 | async def manifest(tpos_id: str): 54 | tpos = await get_clean_tpos(tpos_id) 55 | if not tpos: 56 | raise HTTPException( 57 | status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." 58 | ) 59 | 60 | return { 61 | "short_name": settings.lnbits_site_title, 62 | "name": tpos.name + " - " + settings.lnbits_site_title, 63 | "icons": [ 64 | { 65 | "src": ( 66 | settings.lnbits_custom_logo 67 | if settings.lnbits_custom_logo 68 | else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png" 69 | ), 70 | "type": "image/png", 71 | "sizes": "900x900", 72 | } 73 | ], 74 | "start_url": "/tpos/" + tpos_id, 75 | "background_color": "#1F2234", 76 | "description": "Bitcoin Lightning tPOS", 77 | "display": "standalone", 78 | "scope": "/tpos/" + tpos_id, 79 | "theme_color": "#1F2234", 80 | "shortcuts": [ 81 | { 82 | "name": tpos.name + " - " + settings.lnbits_site_title, 83 | "short_name": tpos.name, 84 | "description": tpos.name + " - " + settings.lnbits_site_title, 85 | "url": "/tpos/" + tpos_id, 86 | } 87 | ], 88 | } 89 | -------------------------------------------------------------------------------- /templates/tpos/_api_docs.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | GET /tpos/api/v1/tposs 12 |
Headers
13 | {"X-Api-Key": <invoice_key>}
14 |
Body (application/json)
15 |
16 | Returns 200 OK (application/json) 17 |
18 | [<tpos_object>, ...] 19 |
Curl example
20 | curl -X GET {{ request.base_url }}tpos/api/v1/tposs -H "X-Api-Key: 22 | <invoice_key>" 23 | 24 |
25 |
26 |
27 | 28 | 29 | 30 | POST /tpos/api/v1/tposs 31 |
Headers
32 | {"X-Api-Key": <invoice_key>}
33 |
Body (application/json)
34 | {"name": <string>, "currency": <string*ie USD*>} 37 |
38 | Returns 201 CREATED (application/json) 39 |
40 | {"currency": <string>, "id": <string>, "name": 42 | <string>, "wallet": <string>} 44 |
Curl example
45 | curl -X POST {{ request.base_url }}tpos/api/v1/tposs -d '{"name": 47 | <string>, "currency": <string>}' -H "Content-type: 48 | application/json" -H "X-Api-Key: <admin_key>" 49 | 50 |
51 |
52 |
53 | 54 | 61 | 62 | 63 | DELETE 65 | /tpos/api/v1/tposs/<tpos_id> 67 |
Headers
68 | {"X-Api-Key": <admin_key>}
69 |
Returns 204 NO CONTENT
70 | 71 |
Curl example
72 | curl -X DELETE {{ request.base_url 74 | }}tpos/api/v1/tposs/<tpos_id> -H "X-Api-Key: <admin_key>" 75 | 76 |
77 |
78 |
79 |
80 | -------------------------------------------------------------------------------- /static/components/receipt.js: -------------------------------------------------------------------------------- 1 | window.app.component('receipt', { 2 | name: 'receipt', 3 | props: ['data'], 4 | data() { 5 | return { 6 | currency: null, 7 | exchangeRate: null 8 | } 9 | }, 10 | computed: { 11 | cartSubtotal() { 12 | let subtotal = 0 13 | if (!this.data.extra?.details?.items) { 14 | subtotal = this.fiatAmount(this.data.extra.amount) 15 | } else { 16 | this.data.extra.details.items.forEach(item => { 17 | subtotal += item.price * item.quantity 18 | }) 19 | } 20 | return subtotal 21 | }, 22 | cartTotal() { 23 | let total = 0 24 | if (!this.data.extra?.details?.items) { 25 | return this.fiatAmount(this.data.extra.amount) 26 | } 27 | this.data.extra.details.items.forEach(item => { 28 | total += item.price * item.quantity 29 | }) 30 | if (this.data.extra.details.taxIncluded) { 31 | return total 32 | } 33 | return total + this.data.extra.details.taxValue 34 | }, 35 | exchangeRateInfo() { 36 | if (!this.exchangeRate) { 37 | return 'Exchange rate not available' 38 | } 39 | return `Rate (sat/${this.currency}): ${this.exchangeRate.toFixed(2)}` 40 | }, 41 | formattedDate() { 42 | return LNbits.utils.formatDateString(this.data.created_at) 43 | }, 44 | businessAddress() { 45 | return this.data.business_address.split('\n') 46 | }, 47 | currencyText() { 48 | return `(${this.currency})` 49 | } 50 | }, 51 | methods: { 52 | fiatAmount(amount) { 53 | if (!this.exchangeRate) { 54 | return amount 55 | } 56 | return amount / this.exchangeRate 57 | } 58 | }, 59 | created() { 60 | this.currency = this.data.extra.details.currency || LNBITS_DENOMINATION 61 | this.exchangeRate = this.data.extra.details.exchangeRate || 1 62 | console.log('Receipt component created', this.data) 63 | }, 64 | template: ` 65 |
66 |
67 |

Receipt

68 |

69 |

70 |
71 |
72 | 73 |
74 |
75 | 76 |
77 |
78 | VAT: 79 | 80 |
81 | 93 | 94 |
95 |
96 |
97 | Subtotal 98 | 99 |
100 |
101 | 102 |
103 |
104 |
105 |
106 | Tax 107 | 108 |
109 |
110 | 111 |
112 |
113 |
114 |
115 | Total 116 | 117 |
118 |
119 | 120 |
121 |
122 |
123 |
Total (sats)
124 |
125 | 126 |
127 |
128 |
129 |
130 |

131 | Thank you for your purchase! 132 |

133 |
134 |
135 | ` 136 | }) 137 | -------------------------------------------------------------------------------- /views_lnurl.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | 3 | from fastapi import APIRouter, Request 4 | from lnbits.core.services import get_pr_from_lnurl, pay_invoice, websocket_updater 5 | from lnurl import ( 6 | CallbackUrl, 7 | LnurlErrorResponse, 8 | LnurlSuccessResponse, 9 | LnurlWithdrawResponse, 10 | MilliSatoshi, 11 | ) 12 | from loguru import logger 13 | from pydantic import parse_obj_as 14 | 15 | from .crud import get_lnurlcharge, get_tpos, update_lnurlcharge, update_tpos 16 | 17 | tpos_lnurl_router = APIRouter(prefix="/api/v1/lnurl", tags=["LNURL"]) 18 | 19 | 20 | async def pay_tribute( 21 | withdraw_amount: int, wallet_id: str, percent: float = 0.5 22 | ) -> None: 23 | try: 24 | tribute = int(percent * (withdraw_amount / 100)) 25 | tribute = max(1, tribute) 26 | try: 27 | pr = await get_pr_from_lnurl( 28 | "lnbits@nostr.com", 29 | tribute * 1000, 30 | comment="LNbits TPoS tribute", 31 | ) 32 | except Exception: 33 | logger.warning("Error fetching tribute invoice") 34 | return 35 | await pay_invoice( 36 | wallet_id=wallet_id, 37 | payment_request=pr, 38 | max_sat=tribute, 39 | description="Tribute to help support LNbits", 40 | ) 41 | except Exception: 42 | logger.warning("Error paying tribute") 43 | return 44 | 45 | 46 | @tpos_lnurl_router.get("/{lnurlcharge_id}/{amount}", name="tpos.tposlnurlcharge") 47 | async def lnurl_params( 48 | request: Request, 49 | lnurlcharge_id: str, 50 | amount: int, 51 | ) -> LnurlWithdrawResponse | LnurlErrorResponse: 52 | lnurlcharge = await get_lnurlcharge(lnurlcharge_id) 53 | if not lnurlcharge: 54 | return LnurlErrorResponse( 55 | reason=f"lnurlcharge {lnurlcharge_id} not found on this server" 56 | ) 57 | 58 | tpos = await get_tpos(lnurlcharge.tpos_id) 59 | if not tpos: 60 | return LnurlErrorResponse( 61 | reason=f"TPoS {lnurlcharge.tpos_id} not found on this server" 62 | ) 63 | 64 | if amount > tpos.withdraw_maximum: 65 | return LnurlErrorResponse( 66 | reason=( 67 | f"Amount requested {amount} is too high, " 68 | f"maximum withdrawable is {tpos.withdraw_maximum}" 69 | ) 70 | ) 71 | 72 | logger.debug(f"Amount to withdraw: {amount}") 73 | callback = parse_obj_as( 74 | CallbackUrl, str(request.url_for("tpos.tposlnurlcharge.callback")) 75 | ) 76 | return LnurlWithdrawResponse( 77 | callback=callback, 78 | k1=lnurlcharge_id, 79 | minWithdrawable=MilliSatoshi(amount * 1000), 80 | maxWithdrawable=MilliSatoshi(amount * 1000), 81 | defaultDescription="TPoS withdraw", 82 | ) 83 | 84 | 85 | @tpos_lnurl_router.get("/cb", name="tpos.tposlnurlcharge.callback") 86 | async def lnurl_callback( 87 | pr: str | None = None, 88 | k1: str | None = None, 89 | ) -> LnurlErrorResponse | LnurlSuccessResponse: 90 | if not pr: 91 | return LnurlErrorResponse(reason="Payment request (pr) is required") 92 | if not k1: 93 | return LnurlErrorResponse(reason="k1 is required") 94 | 95 | lnurlcharge = await get_lnurlcharge(k1) 96 | if not lnurlcharge: 97 | return LnurlErrorResponse(reason=f"lnurlcharge {k1} not found on this server") 98 | 99 | if not lnurlcharge.amount: 100 | return LnurlErrorResponse(reason=f"LnurlCharge {k1} has no amount specified") 101 | 102 | if lnurlcharge.claimed: 103 | return LnurlErrorResponse(reason=f"LnurlCharge {k1} has already been claimed") 104 | 105 | tpos = await get_tpos(lnurlcharge.tpos_id) 106 | if not tpos: 107 | return LnurlErrorResponse( 108 | reason=f"TPoS {lnurlcharge.tpos_id} not found on this server" 109 | ) 110 | 111 | if lnurlcharge.amount > tpos.withdraw_maximum: 112 | return LnurlErrorResponse( 113 | reason=( 114 | f"Amount requested {lnurlcharge.amount} is too high, " 115 | f"maximum withdrawable is {tpos.withdraw_maximum}" 116 | ) 117 | ) 118 | 119 | lnurlcharge.claimed = True 120 | await update_lnurlcharge(lnurlcharge) 121 | 122 | tpos.withdrawn_amount = int(tpos.withdrawn_amount or 0) + int(lnurlcharge.amount) 123 | tpos.withdraw_time = int(time()) 124 | await update_tpos(tpos) 125 | 126 | try: 127 | await pay_invoice( 128 | wallet_id=tpos.wallet, 129 | payment_request=pr, 130 | max_sat=int(lnurlcharge.amount), 131 | extra={"tag": "TPoSWithdraw", "tpos_id": lnurlcharge.tpos_id}, 132 | ) 133 | await websocket_updater(k1, "paid") 134 | except Exception as exc: 135 | return LnurlErrorResponse(reason=f"withdraw not working. {exc!s}") 136 | 137 | # pay tribute to help support LNbits 138 | await pay_tribute(withdraw_amount=int(lnurlcharge.amount), wallet_id=tpos.wallet) 139 | 140 | return LnurlSuccessResponse() 141 | -------------------------------------------------------------------------------- /services.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import httpx 4 | from lnbits.core.crud import get_wallet 5 | from lnbits.core.models import User 6 | from lnbits.helpers import create_access_token 7 | from lnbits.settings import settings 8 | 9 | from .helpers import _from_csv, _inventory_tags_to_list 10 | 11 | 12 | async def _deduct_inventory_stock(wallet_id: str, inventory_payload: dict) -> None: 13 | wallet = await get_wallet(wallet_id) 14 | if not wallet: 15 | return 16 | inventory_id = inventory_payload.get("inventory_id") 17 | items = inventory_payload.get("items") or [] 18 | if not inventory_id or not items: 19 | return 20 | items_to_update = [] 21 | for item in items: 22 | item_id = item.get("id") 23 | qty = item.get("quantity") or 0 24 | if not item_id or qty <= 0: 25 | continue 26 | items_to_update.append({"id": item_id, "quantity": int(qty)}) 27 | if not items_to_update: 28 | return 29 | 30 | ids = [item["id"] for item in items_to_update] 31 | quantities = [item["quantity"] for item in items_to_update] 32 | 33 | # Needed to accomodate admin users, as using user ID is not possible 34 | access = create_access_token( 35 | {"sub": "", "usr": wallet.user}, token_expire_minutes=1 36 | ) 37 | async with httpx.AsyncClient() as client: 38 | await client.patch( 39 | url=f"http://{settings.host}:{settings.port}/inventory/api/v1/items/{inventory_id}/quantities", 40 | headers={"Authorization": f"Bearer {access}"}, 41 | params={"source": "tpos", "ids": ids, "quantities": quantities}, 42 | ) 43 | return 44 | 45 | 46 | async def _get_default_inventory(user_id: str) -> dict[str, Any] | None: 47 | access = create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1) 48 | async with httpx.AsyncClient() as client: 49 | resp = await client.get( 50 | url=f"http://{settings.host}:{settings.port}/inventory/api/v1", 51 | headers={"Authorization": f"Bearer {access}"}, 52 | ) 53 | inventory = resp.json() 54 | if not inventory: 55 | return None 56 | if isinstance(inventory, list): 57 | inventory = inventory[0] if inventory else None 58 | if not isinstance(inventory, dict): 59 | return None 60 | inventory["tags"] = _inventory_tags_to_list(inventory.get("tags")) 61 | inventory["omit_tags"] = _inventory_tags_to_list(inventory.get("omit_tags")) 62 | return inventory 63 | 64 | 65 | async def _get_inventory_items_for_tpos( 66 | user_id: str, 67 | inventory_id: str, 68 | tags: str | list[str] | None, 69 | omit_tags: str | list[str] | None, 70 | ) -> list[Any]: 71 | tag_list = _inventory_tags_to_list(tags) 72 | omit_list = [tag.lower() for tag in _inventory_tags_to_list(omit_tags)] 73 | allowed_tags = [tag.lower() for tag in tag_list] 74 | access = create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1) 75 | async with httpx.AsyncClient() as client: 76 | resp = await client.get( 77 | url=f"http://{settings.host}:{settings.port}/inventory/api/v1/items/{inventory_id}/paginated", 78 | headers={"Authorization": f"Bearer {access}"}, 79 | params={"limit": 500, "offset": 0, "is_active": True}, 80 | ) 81 | payload = resp.json() 82 | items = payload.get("data", []) if isinstance(payload, dict) else payload 83 | 84 | # item images are a comma separated string; make a list 85 | for item in items: 86 | images = item.get("images") 87 | item["images"] = _from_csv(images) 88 | 89 | def has_allowed_tag(item_tags: str | list[str] | None) -> bool: 90 | # When no tags are configured for this TPoS, show no items 91 | if not tag_list: 92 | return False 93 | item_tag_list = [tag.lower() for tag in _inventory_tags_to_list(item_tags)] 94 | return any(tag in item_tag_list for tag in allowed_tags) 95 | 96 | def has_omit_tag(item_omit_tags: str | list[str] | None) -> bool: 97 | if not omit_list: 98 | return False 99 | item_tag_list = [tag.lower() for tag in _inventory_tags_to_list(item_omit_tags)] 100 | return any(tag in item_tag_list for tag in omit_list) 101 | 102 | filtered = [ 103 | item 104 | for item in items 105 | if has_allowed_tag(item.get("tags")) and not has_omit_tag(item.get("omit_tags")) 106 | ] 107 | # If no items matched the provided tags, fall back to all items minus omitted ones. 108 | if tag_list and not filtered: 109 | filtered = [item for item in items if not has_omit_tag(item.get("omit_tags"))] 110 | 111 | # hide items with no stock when stock tracking is enabled 112 | return [ 113 | item 114 | for item in filtered 115 | if item.get("quantity_in_stock") is None or item.get("quantity_in_stock") > 0 116 | ] 117 | 118 | 119 | def _inventory_available_for_user(user: User | None) -> bool: 120 | return bool(user and "inventory" in (user.extensions or [])) 121 | -------------------------------------------------------------------------------- /models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from time import time 4 | 5 | from fastapi import Query 6 | from pydantic import BaseModel, Field, validator 7 | 8 | 9 | class PayLnurlWData(BaseModel): 10 | lnurl: str 11 | 12 | 13 | class CreateWithdrawPay(BaseModel): 14 | pay_link: str 15 | 16 | 17 | class CreateTposInvoice(BaseModel): 18 | amount: int = Query(..., ge=1) 19 | memo: str | None = Query(None) 20 | exchange_rate: float | None = Query(None, ge=0.0) 21 | details: dict | None = Query(None) 22 | inventory: InventorySale | None = Query(None) 23 | tip_amount: int | None = Query(None, ge=1) 24 | user_lnaddress: str | None = Query(None) 25 | internal_memo: str | None = Query(None, max_length=512) 26 | pay_in_fiat: bool = Query(False) 27 | fiat_method: str | None = Query(None) 28 | amount_fiat: float | None = Query(None, ge=0.0) 29 | tip_amount_fiat: float | None = Query(None, ge=0.0) 30 | 31 | 32 | class InventorySaleItem(BaseModel): 33 | id: str 34 | quantity: int = Field(1, ge=1) 35 | 36 | 37 | class InventorySale(BaseModel): 38 | inventory_id: str 39 | tags: list[str] = Field(default_factory=list) 40 | items: list[InventorySaleItem] = Field(default_factory=list) 41 | 42 | 43 | class CreateTposData(BaseModel): 44 | wallet: str | None 45 | name: str | None 46 | currency: str | None 47 | use_inventory: bool = Field(False) 48 | inventory_id: str | None = None 49 | inventory_tags: list[str] | None = None 50 | inventory_omit_tags: list[str] | None = None 51 | tax_inclusive: bool = Field(True) 52 | tax_default: float | None = Field(0.0) 53 | tip_options: str = Field("[]") 54 | tip_wallet: str = Field("") 55 | withdraw_time: int = Field(0) 56 | withdraw_between: int = Field(10, ge=1) 57 | withdraw_limit: int | None = Field(None, ge=1) 58 | withdraw_time_option: str | None = Field(None) 59 | withdraw_premium: float | None = Field(None) 60 | lnaddress: bool = Field(False) 61 | lnaddress_cut: int | None = Field(0) 62 | enable_receipt_print: bool = Query(False) 63 | business_name: str | None 64 | business_address: str | None 65 | business_vat_id: str | None 66 | fiat_provider: str | None = Field(None) 67 | stripe_card_payments: bool = False 68 | stripe_reader_id: str | None = None 69 | 70 | @validator("tax_default", pre=True, always=True) 71 | def default_tax_when_none(cls, v): 72 | return 0.0 if v is None else v 73 | 74 | 75 | class TposClean(BaseModel): 76 | id: str 77 | name: str 78 | currency: str 79 | tax_inclusive: bool 80 | tax_default: float | None = None 81 | withdraw_time: int 82 | withdraw_between: int 83 | withdraw_limit: int | None = None 84 | withdraw_time_option: str | None = None 85 | withdraw_premium: float | None = None 86 | withdrawn_amount: int = 0 87 | lnaddress: bool | None = None 88 | lnaddress_cut: int = 0 89 | items: str | None = None 90 | use_inventory: bool = False 91 | inventory_id: str | None = None 92 | inventory_tags: str | None = None 93 | inventory_omit_tags: str | None = None 94 | tip_options: str | None = None 95 | enable_receipt_print: bool 96 | business_name: str | None = None 97 | business_address: str | None = None 98 | business_vat_id: str | None = None 99 | fiat_provider: str | None = None 100 | stripe_card_payments: bool = False 101 | stripe_reader_id: str | None = None 102 | 103 | @property 104 | def withdraw_maximum(self) -> int: 105 | if not self.withdraw_limit: 106 | return 0 107 | return self.withdraw_limit - self.withdrawn_amount 108 | 109 | @property 110 | def can_withdraw(self) -> bool: 111 | now = int(time()) 112 | seconds = ( 113 | self.withdraw_between * 60 114 | if self.withdraw_time_option != "secs" 115 | else self.withdraw_between 116 | ) 117 | last_withdraw_time = self.withdraw_time - now 118 | return last_withdraw_time < seconds 119 | 120 | 121 | class Tpos(TposClean, BaseModel): 122 | wallet: str 123 | tip_wallet: str | None = None 124 | 125 | 126 | class LnurlCharge(BaseModel): 127 | id: str 128 | tpos_id: str 129 | amount: int = 0 130 | claimed: bool = False 131 | 132 | 133 | class Item(BaseModel): 134 | image: str | None 135 | price: float 136 | title: str 137 | description: str | None 138 | tax: float | None = Field(0, ge=0.0) 139 | disabled: bool = False 140 | categories: list[str] | None = [] 141 | 142 | @validator("tax", pre=True, always=True) 143 | def set_default_tax(cls, v): 144 | return v or 0 145 | 146 | 147 | class CreateUpdateItemData(BaseModel): 148 | items: list[Item] 149 | 150 | 151 | class TapToPay(BaseModel): 152 | type: str = "tap_to_pay" 153 | payment_intent_id: str | None = None 154 | client_secret: str | None = None 155 | amount: int = 0 156 | currency: str | None = None 157 | tpos_id: str | None = None 158 | payment_hash: str | None = None 159 | paid: bool = False 160 | 161 | 162 | CreateTposInvoice.update_forward_refs(InventorySale=InventorySale) 163 | -------------------------------------------------------------------------------- /views_atm.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from fastapi import APIRouter, Depends, HTTPException, Request 4 | from lnbits.core.crud import ( 5 | get_wallet, 6 | ) 7 | from lnbits.core.models import User 8 | from lnbits.core.models.misc import SimpleStatus 9 | from lnbits.decorators import check_user_exists 10 | from lnurl import ( 11 | CallbackUrl, 12 | LnurlErrorResponse, 13 | LnurlPayResponse, 14 | LnurlWithdrawResponse, 15 | MilliSatoshi, 16 | execute_pay_request, 17 | execute_withdraw, 18 | ) 19 | from lnurl import handle as lnurl_handle 20 | from loguru import logger 21 | from pydantic import parse_obj_as 22 | 23 | from .crud import ( 24 | create_lnurlcharge, 25 | get_lnurlcharge, 26 | get_tpos, 27 | update_lnurlcharge, 28 | ) 29 | from .models import ( 30 | CreateWithdrawPay, 31 | LnurlCharge, 32 | ) 33 | 34 | tpos_atm_router = APIRouter(prefix="/api/v1/atm", tags=["TPoS ATM"]) 35 | 36 | 37 | @tpos_atm_router.post("/{tpos_id}/create") 38 | async def api_tpos_atm_pin_check( 39 | tpos_id: str, user: User = Depends(check_user_exists) 40 | ) -> LnurlCharge: 41 | tpos = await get_tpos(tpos_id) 42 | if not tpos: 43 | raise HTTPException(HTTPStatus.NOT_FOUND, "TPoS does not exist.") 44 | 45 | # check if the user has access to the TPoS wallet 46 | if any(wallet.id == tpos.wallet for wallet in user.wallets) is False: 47 | raise HTTPException( 48 | HTTPStatus.FORBIDDEN, 49 | "You do not have access to this TPoS wallet.", 50 | ) 51 | 52 | if not tpos.can_withdraw: 53 | raise HTTPException( 54 | status_code=HTTPStatus.BAD_REQUEST, 55 | detail="Withdrawals are not allowed at this time. Try again later.", 56 | ) 57 | 58 | charge = await create_lnurlcharge(tpos.id) 59 | return charge 60 | 61 | 62 | @tpos_atm_router.get("/withdraw/{charge_id}/{amount}") 63 | async def api_tpos_create_withdraw(charge_id: str, amount: str) -> LnurlCharge: 64 | lnurlcharge = await get_lnurlcharge(charge_id) 65 | if not lnurlcharge: 66 | raise HTTPException( 67 | status_code=HTTPStatus.NOT_FOUND, 68 | detail=f"lnurlcharge {charge_id} not found on this server.", 69 | ) 70 | if lnurlcharge.claimed is True: 71 | raise HTTPException( 72 | status_code=HTTPStatus.BAD_REQUEST, 73 | detail=f"lnurlcharge {charge_id} has already been claimed.", 74 | ) 75 | tpos = await get_tpos(lnurlcharge.tpos_id) 76 | if not tpos: 77 | raise HTTPException( 78 | status_code=HTTPStatus.NOT_FOUND, 79 | detail=f"TPoS {lnurlcharge.tpos_id} does not exist.", 80 | ) 81 | 82 | wallet = await get_wallet(tpos.wallet) 83 | if not wallet: 84 | raise HTTPException( 85 | status_code=HTTPStatus.NOT_FOUND, 86 | detail=f"Wallet {tpos.wallet} does not exist.", 87 | ) 88 | 89 | balance = int(wallet.balance_msat / 1000) 90 | if balance < int(amount): 91 | raise HTTPException( 92 | status_code=HTTPStatus.BAD_REQUEST, 93 | detail=f"Insufficient balance. Your balance is {balance} sats", 94 | ) 95 | lnurlcharge.amount = int(amount) 96 | await update_lnurlcharge(lnurlcharge) 97 | return lnurlcharge 98 | 99 | 100 | @tpos_atm_router.post("/withdraw/{charge_id}/{amount}/pay", status_code=HTTPStatus.OK) 101 | async def api_tpos_atm_pay( 102 | request: Request, charge_id: str, amount: int, data: CreateWithdrawPay 103 | ) -> SimpleStatus: 104 | try: 105 | res = await lnurl_handle(data.pay_link, user_agent="lnbits/tpos") 106 | if not isinstance(res, LnurlPayResponse): 107 | raise HTTPException( 108 | status_code=HTTPStatus.BAD_REQUEST, 109 | detail="Excepted LNURL pay reponse.", 110 | ) 111 | if isinstance(res, LnurlErrorResponse): 112 | raise HTTPException( 113 | status_code=HTTPStatus.BAD_REQUEST, 114 | detail=f"Error processing lnurl pay link: {res.reason}", 115 | ) 116 | res2 = await execute_pay_request( 117 | res, msat=amount * 1000, user_agent="lnbits/tpos" 118 | ) 119 | if isinstance(res2, LnurlErrorResponse): 120 | raise HTTPException( 121 | status_code=HTTPStatus.BAD_REQUEST, 122 | detail=f"Error processing pay request: {res2.reason}", 123 | ) 124 | callback_url = str(request.url_for("tpos.tposlnurlcharge.callback")) 125 | withdraw_res = LnurlWithdrawResponse( 126 | k1=charge_id, 127 | callback=parse_obj_as(CallbackUrl, callback_url), 128 | maxWithdrawable=MilliSatoshi(amount * 1000), 129 | minWithdrawable=MilliSatoshi(amount * 1000), 130 | ) 131 | try: 132 | res3 = await execute_withdraw( 133 | withdraw_res, res2.pr, user_agent="lnbits/tpos" 134 | ) 135 | if isinstance(res3, LnurlErrorResponse): 136 | raise HTTPException( 137 | status_code=HTTPStatus.BAD_REQUEST, 138 | detail=f"Error processing withdraw: {res3.reason}", 139 | ) 140 | except Exception as exc: 141 | logger.error(f"Error processing withdraw: {exc}") 142 | 143 | return SimpleStatus(success=True, message="Withdraw processed successfully.") 144 | 145 | except Exception as exc: 146 | raise HTTPException( 147 | status_code=HTTPStatus.INTERNAL_SERVER_ERROR, 148 | detail="Cannot process atm withdraw", 149 | ) from exc 150 | -------------------------------------------------------------------------------- /migrations.py: -------------------------------------------------------------------------------- 1 | from lnbits.db import Database 2 | 3 | 4 | async def m001_initial(db: Database): 5 | """ 6 | Initial tposs table. 7 | """ 8 | await db.execute( 9 | """ 10 | CREATE TABLE tpos.tposs ( 11 | id TEXT PRIMARY KEY, 12 | wallet TEXT NOT NULL, 13 | name TEXT NOT NULL, 14 | currency TEXT NOT NULL 15 | ); 16 | """ 17 | ) 18 | 19 | 20 | async def m002_addtip_wallet(db: Database): 21 | """ 22 | Add tips to tposs table 23 | """ 24 | await db.execute( 25 | """ 26 | ALTER TABLE tpos.tposs ADD tip_wallet TEXT NULL; 27 | """ 28 | ) 29 | 30 | 31 | async def m003_addtip_options(db: Database): 32 | """ 33 | Add tips to tposs table 34 | """ 35 | await db.execute( 36 | """ 37 | ALTER TABLE tpos.tposs ADD tip_options TEXT NULL; 38 | """ 39 | ) 40 | 41 | 42 | async def m004_addwithdrawlimit(db: Database): 43 | await db.execute( 44 | """ 45 | CREATE TABLE tpos.pos ( 46 | id TEXT PRIMARY KEY, 47 | wallet TEXT NOT NULL, 48 | name TEXT NOT NULL, 49 | currency TEXT NOT NULL, 50 | tip_wallet TEXT NULL, 51 | tip_options TEXT NULL, 52 | withdrawlimit INTEGER DEFAULT 0, 53 | withdrawpin INTEGER DEFAULT 878787, 54 | withdrawamt INTEGER DEFAULT 0, 55 | withdrawtime INTEGER NOT NULL DEFAULT 0, 56 | withdrawbtwn INTEGER NOT NULL DEFAULT 10 57 | ); 58 | """ 59 | ) 60 | result = await db.execute("SELECT * FROM tpos.tposs") 61 | rows = result.mappings().all() 62 | for row in rows: 63 | await db.execute( 64 | """ 65 | INSERT INTO tpos.pos ( 66 | id, 67 | wallet, 68 | name, 69 | currency, 70 | tip_wallet, 71 | tip_options 72 | ) 73 | VALUES (:id, :wallet, :name, :currency, :tip_wallet, :tip_options) 74 | """, 75 | { 76 | "id": row["id"], 77 | "wallet": row["wallet"], 78 | "name": row["name"], 79 | "currency": row["currency"], 80 | "tip_wallet": row["tip_wallet"], 81 | "tip_options": row["tip_options"], 82 | }, 83 | ) 84 | await db.execute("DROP TABLE tpos.tposs") 85 | 86 | 87 | async def m005_initial(db: Database): 88 | """ 89 | Initial withdraws table. 90 | """ 91 | await db.execute( 92 | """ 93 | CREATE TABLE tpos.withdraws ( 94 | id TEXT PRIMARY KEY, 95 | tpos_id TEXT NOT NULL, 96 | amount int, 97 | claimed BOOLEAN DEFAULT false 98 | ); 99 | """ 100 | ) 101 | 102 | 103 | async def m006_items(db: Database): 104 | """ 105 | Add items to tpos table for storing various items (JSON format) 106 | See `Item` class in models. 107 | """ 108 | await db.execute( 109 | """ 110 | ALTER TABLE tpos.pos ADD items TEXT DEFAULT '[]'; 111 | """ 112 | ) 113 | 114 | 115 | async def m007_atm_premium(db: Database): 116 | """ 117 | Add a premium % to ATM withdraws 118 | """ 119 | await db.execute("ALTER TABLE tpos.pos ADD COLUMN withdrawpremium FLOAT;") 120 | 121 | 122 | async def m008_atm_time_option_and_pin_toggle(db: Database): 123 | """ 124 | Add a time mins/sec and pin toggle 125 | """ 126 | await db.execute( 127 | "ALTER TABLE tpos.pos ADD COLUMN withdrawtimeopt TEXT DEFAULT 'mins'" 128 | ) 129 | await db.execute( 130 | "ALTER TABLE tpos.pos " 131 | "ADD COLUMN withdrawpindisabled BOOL NOT NULL DEFAULT false" 132 | ) 133 | 134 | 135 | async def m009_tax_inclusive(db: Database): 136 | """ 137 | Add tax_inclusive column 138 | """ 139 | await db.execute( 140 | "ALTER TABLE tpos.pos ADD COLUMN tax_inclusive BOOL NOT NULL DEFAULT true;" 141 | ) 142 | await db.execute("ALTER TABLE tpos.pos ADD COLUMN tax_default FLOAT DEFAULT 0;") 143 | 144 | 145 | async def m010_rename_tpos_withdraw_columns(db: Database): 146 | """ 147 | Add rename tpos withdraw columns 148 | """ 149 | await db.execute( 150 | """ 151 | CREATE TABLE tpos.pos_backup AS 152 | SELECT 153 | id, name, currency, items, wallet, tax_inclusive, 154 | tax_default, tip_wallet, tip_options, 155 | withdrawamt AS withdrawn_amount, 156 | withdrawtime AS withdraw_time, 157 | withdrawbtwn AS withdraw_between, 158 | withdrawlimit AS withdraw_limit, 159 | withdrawtimeopt AS withdraw_time_option, 160 | withdrawpremium AS withdraw_premium, 161 | withdrawpindisabled AS withdraw_pin_disabled, 162 | withdrawpin AS withdraw_pin 163 | FROM tpos.pos 164 | """ 165 | ) 166 | await db.execute("DROP TABLE tpos.pos") 167 | await db.execute("ALTER TABLE tpos.pos_backup RENAME TO pos") 168 | 169 | 170 | async def m011_lnaddress(db: Database): 171 | """ 172 | Add lnaddress to tpos table 173 | """ 174 | await db.execute( 175 | """ 176 | ALTER TABLE tpos.pos ADD lnaddress BOOLEAN DEFAULT false; 177 | """ 178 | ) 179 | 180 | 181 | async def m012_addlnaddress(db: Database): 182 | """ 183 | Add lnaddress_cut to tpos table 184 | """ 185 | await db.execute( 186 | """ 187 | ALTER TABLE tpos.pos ADD lnaddress_cut INTEGER NULL; 188 | """ 189 | ) 190 | 191 | 192 | async def m013_add_receipt_print(db: Database): 193 | """ 194 | Add enable_receipt_print to tpos table 195 | """ 196 | await db.execute( 197 | """ 198 | ALTER TABLE tpos.pos ADD enable_receipt_print BOOLEAN DEFAULT false; 199 | """ 200 | ) 201 | 202 | await db.execute("ALTER TABLE tpos.pos ADD business_name TEXT;") 203 | await db.execute("ALTER TABLE tpos.pos ADD business_address TEXT;") 204 | await db.execute("ALTER TABLE tpos.pos ADD business_vat_id TEXT;") 205 | 206 | 207 | async def m014_addfiat(db: Database): 208 | """ 209 | Add fiat invoicing to tpos table 210 | """ 211 | await db.execute( 212 | """ 213 | ALTER TABLE tpos.pos ADD fiat_provider TEXT NULL; 214 | """ 215 | ) 216 | 217 | 218 | async def m015_addfiat(db: Database): 219 | """ 220 | Add fiat stripe_card_payments to tpos table 221 | """ 222 | await db.execute( 223 | """ 224 | ALTER TABLE tpos.pos ADD stripe_card_payments BOOLEAN DEFAULT false; 225 | """ 226 | ) 227 | 228 | 229 | async def m016_add_inventory_settings(db: Database): 230 | """ 231 | Add inventory integration columns 232 | """ 233 | await db.execute( 234 | """ 235 | ALTER TABLE tpos.pos ADD use_inventory BOOLEAN DEFAULT false; 236 | """ 237 | ) 238 | await db.execute( 239 | """ 240 | ALTER TABLE tpos.pos ADD inventory_id TEXT NULL; 241 | """ 242 | ) 243 | await db.execute( 244 | """ 245 | ALTER TABLE tpos.pos ADD inventory_tags TEXT NULL; 246 | """ 247 | ) 248 | 249 | 250 | async def m017_add_inventory_omit_tags(db: Database): 251 | """ 252 | Add inventory omit tags column 253 | """ 254 | await db.execute( 255 | """ 256 | ALTER TABLE tpos.pos ADD inventory_omit_tags TEXT NULL; 257 | """ 258 | ) 259 | 260 | 261 | async def m018_add_stripe_reader_id(db: Database): 262 | """ 263 | Add Stripe reader id column 264 | """ 265 | await db.execute( 266 | """ 267 | ALTER TABLE tpos.pos ADD stripe_reader_id TEXT NULL; 268 | """ 269 | ) 270 | -------------------------------------------------------------------------------- /templates/tpos/tpos.html: -------------------------------------------------------------------------------- 1 | {% extends "public.html" %} {% block toolbar_title %} {{ tpos.name }} 2 | 12 | Add a comment 21 | {% endblock %} {% block footer %}{% endblock %} {% block page_container %} 22 | 23 | 24 | 25 |
26 |
27 |

28 | 29 |

30 |
34 | 35 |  sat 36 |
37 |
38 |
39 | 40 |
41 | 42 |
43 |
44 |
45 |
46 |
47 | EXIT ATM 54 |
55 |
56 | 101 | 102 | {% include "tpos/dialogs.html" %} 103 |
104 |
105 | 110 | {% endblock %} {% block styles %} 111 | 198 | {% endblock %} {% block scripts %} 199 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 238 | {% endblock %} 239 | -------------------------------------------------------------------------------- /templates/tpos/_cart.html: -------------------------------------------------------------------------------- 1 |
2 | 10 |
11 |
12 | Hide Cart 15 |
16 |
17 |

18 |
19 | sat 20 |
21 |
22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 43 | 67 | 76 | 85 | 86 | 87 |
41 | 42 | 44 |
45 | 54 |
55 | 56 |
57 | 65 |
66 |
68 |
69 | 74 |
75 |
77 | 84 |
88 |
89 |
90 |
91 |
92 | 93 | 94 | Subtotal 95 | 96 | 97 | 98 | 101 | 102 | 103 | 104 | Manual Input 105 | 109 | 110 | 111 | 112 | Total 113 | 114 | 115 | 116 |
117 |
118 | 126 |
127 |
128 | 135 |
136 |
137 |
138 |
139 | 140 | 148 | 149 | 150 | 156 | 162 | 163 |
{% include "tpos/_options_fab.html" %}
164 | 165 | 166 |
170 | 178 |
179 | 180 | 181 |
{% include "tpos/_options_fab.html" %}
182 |
183 | 184 |
185 | 186 |
187 |
192 | 193 |
194 | 195 | 196 | 197 | 198 | 199 | 203 |
204 |
205 |
206 |
207 | 208 | 209 | 210 | 211 | 212 | 219 | 220 | 226 | 227 | 228 | 229 | 230 | 231 | 235 | 236 | 237 | 238 | 242 | 243 | 244 |
245 | 254 |
255 | 256 |
257 | 264 |
265 |
266 |
267 |
268 |
269 | 270 | 280 | 288 | 289 |
290 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | LNbits 5 | 6 | 7 | 8 | [![OpenSats Supported](https://img.shields.io/badge/OpenSats-Supported-orange?logo=bitcoin&logoColor=white)](https://opensats.org) 9 | [![License: MIT](https://img.shields.io/badge/License-MIT-success?logo=open-source-initiative&logoColor=white)](./LICENSE) 10 | [![Built for LNbits](https://img.shields.io/badge/Built%20for-LNbits-4D4DFF?logo=lightning&logoColor=white)](https://github.com/lnbits/lnbits) [![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) 11 | [![Explore LNbits TPoS](https://img.shields.io/badge/Explore-LNbits%20TPoS-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/tpos/) 12 | [![Stripe Tap-to-Pay Wrapper](https://img.shields.io/badge/Stripe%20Tap--to--Pay-Wrapper-635BFF?logo=stripe&logoColor=white&labelColor=312E81)](https://github.com/lnbits/TPoS-Stripe-Tap-to-Pay-Wrapper) 13 | 14 | # TPoS — _[LNbits](https://lnbits.com) extension_ 15 | 16 | **A shareable Bitcoin Lightning Point of Sale that runs directly in your browser.** 17 | No installation required — simple, fast, and safe for any employee to use. 18 | 19 | **One Checkout · Two Payment Rails** with the optional 20 | [Stripe Tap-to-Pay Wrapper](https://github.com/lnbits/TPoS-Stripe-Tap-to-Pay-Wrapper) for Android. 21 | Take card (fiat) via Stripe and Lightning payments side by side from a single TPoS flow. 22 | 23 | _For video content about the TPoS extension, watch the [official demo](https://www.youtube.com/watch?v=8w4-VQ3WFrk)._ 24 | 25 | --- 26 | 27 | ### Quick Links 28 | 29 | - [Overview](#overview) 30 | - [Usage](#usage) 31 | - [Receiving Tips](#receiving-tips) 32 | - [LN Address Funding](#ln-address-funding) 33 | - [Adding Items to PoS](#adding-items-to-pos) 34 | - [OTC ATM Functionality](#otc-atm-functionality) 35 | - [Tax Settings](#tax-settings) 36 | - [Powered by LNbits](#powered-by-lnbits) 37 | 38 | ## Features 39 | 40 | - **Create invoices** — instant Lightning QR invoices 41 | - **Tipping** — percentages or rounding, split to a tip wallet 42 | - **Item management** — products, cart, JSON import/export 43 | - **OTC ATM** — LNURL withdraw limits and cooldown 44 | - **Stripe fiat payment integration** — accept tap-to-pay via Stripe 45 | - **Tax settings** — global/per-item, inclusive or exclusive 46 | 47 | ## Overview 48 | 49 | TPoS lets you take Lightning payments right from the browser. Every TPoS runs isolated from your main wallet for safer staff use and multi-branch setups, and you can create as many terminals as you need. 50 | 51 | ## Usage 52 | 53 | 1. **Enable** the extension. 54 | 55 | 2. **Create** a TPoS. 56 | 57 | Create a TPoS 58 | 59 | 3. **Open** TPoS in the browser. 60 | 61 | Open TPoS 62 | 63 | 4. **Present** the invoice QR to the customer. 64 | 65 | Invoice QR 66 | 67 | ## Receiving Tips 68 | 69 | 1. Create or edit a TPoS and activate **Enable tips**. 70 | 71 | Enable tips 72 | 73 | 2. Fill in: 74 | 75 | - Wallet to receive tips 76 | - Tip percentages (press Enter after each) 77 | - If no values are set, a default **Rounding** option is available 78 | 79 | 3. In TPoS, set an amount and click **OK**. 80 | 81 | Enter amount 82 | 83 | 4. A tip dialog appears. 84 | 85 | Tip selection dialog 86 | 87 | 5. Select a percentage or **Round to**. 88 | 89 | Select tip or round 90 | 91 | 6. Present the updated invoice to the customer. 92 | 93 | Invoice with tip 94 | 95 | 7. After payment the tip is sent to the defined wallet (e.g., employee wallet) and the rest to the main wallet. 96 | 97 | Tip distribution 98 | 99 | ## LN Address Funding 100 | 101 | Some deployments require sharing revenue from every payment made through a TPoS. 102 | This feature allows you to automatically forward a defined percentage of each received payment 103 | to a specific Lightning Address. 104 | 105 | This is especially useful when: 106 | 107 | - **You host an LNbits server** and give a TPoS to a vendor, and you want to receive a **host fee / revenue share**, or 108 | - **Two participants share one TPoS**, and a portion of each incoming payment should automatically go to a partner, co-owner, or collaborator. 109 | 110 | In these cases, the function helps identify the **initial TPoS initiator** and forward their share without manual reconciliation. 111 | 112 | ### How it works 113 | 114 | 1. Open or edit a TPoS. 115 | 2. Enable **LN Address Funding**. 116 | 3. Enter: 117 | - **Lightning Address** of the recipient (e.g., `alice@yourdomain.com`) 118 | - **Percentage share** (e.g., `10` for 10%) 119 | Bildschirmfoto 2025-11-25 um 04 02 20 120 | 121 | When a customer pays: 122 | 123 | - TPoS receives the full Lightning payment. 124 | - The extension splits the amount automatically. 125 | - The defined percentage is forwarded to the configured Lightning Address. 126 | 127 | This happens instantly, with no extra setup and no additional wallets required. 128 | 129 | ## Adding Items to PoS 130 | 131 | You can add items to a TPoS and use an item list for sales. 132 | 133 | 1. After creating or opening a TPoS, click the **expand** button. 134 | 135 | Expand items 136 | 137 | Then you can: 138 | 139 | - Add items 140 | - Delete all items 141 | - Import or export items via JSON 142 | 143 | 2. Click **Add Item** and fill in details (title and price are mandatory). 144 | 145 | Add item dialog 146 | 147 | 3. Or import a JSON with your products using this format: 148 | 149 | ```json 150 | [ 151 | { 152 | "image": "https://image.url", 153 | "price": 1.99, 154 | "title": "Item 1", 155 | "tax": 3.5, 156 | "disabled": false 157 | }, 158 | { 159 | "price": 0.99, 160 | "title": "Item 2", 161 | "description": "My cool Item #2" 162 | } 163 | ] 164 | ``` 165 | 166 | After adding products, the TPoS defaults to the **Items View** (PoS view): 167 | 168 | Items view 169 | 170 | Click **Add** to add to a cart / total: 171 | 172 | Add to cart 173 | 174 | Click **Pay** to show the invoice for the customer. To use the regular keypad TPoS, switch via the bottom button. 175 | 176 | **Regular TPoS also supports adding to total:** enter a value and click `+`, repeat as needed, then click **OK**. 177 | 178 | Add custom value 179 | 180 | ## OTC ATM Functionality 181 | 182 | 1. Create or edit a TPoS and activate **Enable selling bitcoin**. Configure: 183 | - Maximum withdrawable per day 184 | - Cooldown between withdrawals (min. 1 minute) 185 | 186 | ATM settings 187 | 188 | 2. Open the TPoS, then tap the **ATM** button. 189 | 190 | ATM button 191 | 192 | > [!WARNING] 193 | > The red badge centered at the top indicates you are in ATM mode. 194 | 195 | ATM mode badge 196 | 197 | 3. Set the amount to sell and present the **LNURLw** QR to the buyer. 198 | 199 | Withdraw QR 200 | 201 | 4. After a successful withdrawal, a confirmation appears and TPoS exits ATM mode. 202 | 203 | Withdrawal success 204 | 205 | > [!NOTE] 206 | > OTC ATM requires a signed-in account. When sharing a TPoS, be signed in or have the login details ready. 207 | 208 | - **Today:** If you are not signed in, you will see a **Not logged in** error. 209 | - **Coming soon:** A feedback dialog will appear and prompt you to sign in. 210 | 211 | Not logged in feedback 212 | 213 | ## Tax Settings 214 | 215 | By default, tax is included in price. Set a default tax rate (%) (e.g., 13). Items can override this with their own tax value. 216 | 217 | - **Tax Exclusive** — tax is applied on top of the unit price. 218 | - **Tax Inclusive** — unit price already includes tax. 219 | 220 | In the keypad PoS, the default tax is used and is always included in the value. 221 | 222 | Tax settings 223 | 224 | --- 225 | 226 | ## Powered by LNbits 227 | 228 | LNbits empowers developers and merchants with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable. 229 | 230 | [![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/) 231 | [![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login) 232 | [![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/) 233 | [![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/) [![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=7c3aed&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) 234 | -------------------------------------------------------------------------------- /templates/tpos/dialogs.html: -------------------------------------------------------------------------------- 1 | 359 | -------------------------------------------------------------------------------- /views_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | from http import HTTPStatus 3 | from typing import Any 4 | 5 | import httpx 6 | from fastapi import APIRouter, Depends, HTTPException, Query 7 | from lnbits.core.crud import ( 8 | get_latest_payments_by_extension, 9 | get_standalone_payment, 10 | get_user, 11 | get_wallet, 12 | ) 13 | from lnbits.core.models import CreateInvoice, Payment, WalletTypeInfo 14 | from lnbits.core.services import create_payment_request, websocket_updater 15 | from lnbits.decorators import ( 16 | require_admin_key, 17 | require_invoice_key, 18 | ) 19 | from lnurl import LnurlPayResponse 20 | from lnurl import decode as decode_lnurl 21 | from lnurl import handle as lnurl_handle 22 | 23 | from .crud import ( 24 | create_tpos, 25 | delete_tpos, 26 | get_tpos, 27 | get_tposs, 28 | update_tpos, 29 | ) 30 | from .helpers import ( 31 | _first_image, 32 | _inventory_tags_to_list, 33 | _inventory_tags_to_string, 34 | ) 35 | from .models import ( 36 | CreateTposData, 37 | CreateTposInvoice, 38 | CreateUpdateItemData, 39 | InventorySale, 40 | PayLnurlWData, 41 | TapToPay, 42 | Tpos, 43 | ) 44 | from .services import ( 45 | _get_default_inventory, 46 | _get_inventory_items_for_tpos, 47 | _inventory_available_for_user, 48 | ) 49 | 50 | tpos_api_router = APIRouter() 51 | 52 | 53 | @tpos_api_router.get("/api/v1/tposs", status_code=HTTPStatus.OK) 54 | async def api_tposs( 55 | all_wallets: bool = Query(False), 56 | key_info: WalletTypeInfo = Depends(require_invoice_key), 57 | ) -> list[Tpos]: 58 | wallet_ids = [key_info.wallet.id] 59 | if all_wallets: 60 | user = await get_user(key_info.wallet.user) 61 | wallet_ids = user.wallet_ids if user else [] 62 | return await get_tposs(wallet_ids) 63 | 64 | 65 | @tpos_api_router.get("/api/v1/inventory/status", status_code=HTTPStatus.OK) 66 | async def api_inventory_status( 67 | wallet: WalletTypeInfo = Depends(require_admin_key), 68 | ) -> dict: 69 | user = await get_user(wallet.wallet.user) 70 | if not _inventory_available_for_user(user): 71 | return {"enabled": False, "inventory_id": None, "tags": [], "omit_tags": []} 72 | inventory = await _get_default_inventory(wallet.wallet.user) 73 | tags = _inventory_tags_to_list(inventory.get("tags")) if inventory else [] 74 | omit_tags = _inventory_tags_to_list(inventory.get("omit_tags")) if inventory else [] 75 | return { 76 | "enabled": True, 77 | "inventory_id": inventory.get("id") if inventory else None, 78 | "tags": tags, 79 | "omit_tags": omit_tags, 80 | } 81 | 82 | 83 | @tpos_api_router.post("/api/v1/tposs", status_code=HTTPStatus.CREATED) 84 | async def api_tpos_create( 85 | data: CreateTposData, wallet: WalletTypeInfo = Depends(require_admin_key) 86 | ): 87 | data.wallet = wallet.wallet.id 88 | user = await get_user(wallet.wallet.user) 89 | if data.use_inventory and not _inventory_available_for_user(user): 90 | data.use_inventory = False 91 | if data.use_inventory and not data.inventory_id: 92 | inventory = await _get_default_inventory(wallet.wallet.user) 93 | if not inventory: 94 | data.use_inventory = False 95 | else: 96 | data.inventory_id = inventory.get("id") 97 | data.inventory_tags = inventory.get("tags") 98 | data.inventory_omit_tags = inventory.get("omit_tags") 99 | tpos = await create_tpos(data) 100 | return tpos 101 | 102 | 103 | @tpos_api_router.put("/api/v1/tposs/{tpos_id}") 104 | async def api_tpos_update( 105 | data: CreateTposData, 106 | tpos_id: str, 107 | wallet: WalletTypeInfo = Depends(require_admin_key), 108 | ): 109 | tpos = await get_tpos(tpos_id) 110 | if not tpos: 111 | raise HTTPException( 112 | status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." 113 | ) 114 | if wallet.wallet.id != tpos.wallet: 115 | raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.") 116 | user = await get_user(wallet.wallet.user) 117 | update_payload = data.dict(exclude_unset=True) 118 | if update_payload.get("use_inventory") and not update_payload.get("inventory_id"): 119 | inventory = await _get_default_inventory(wallet.wallet.user) 120 | if inventory: 121 | update_payload["inventory_id"] = inventory.get("id") 122 | else: 123 | raise HTTPException( 124 | status_code=HTTPStatus.BAD_REQUEST, 125 | detail="No inventory found for this user.", 126 | ) 127 | if update_payload.get("use_inventory") and not _inventory_available_for_user(user): 128 | raise HTTPException( 129 | status_code=HTTPStatus.BAD_REQUEST, 130 | detail="Inventory extension must be enabled to use it.", 131 | ) 132 | if "inventory_tags" in update_payload: 133 | update_payload["inventory_tags"] = _inventory_tags_to_string( 134 | _inventory_tags_to_list(update_payload["inventory_tags"]) 135 | ) 136 | if "inventory_omit_tags" in update_payload: 137 | update_payload["inventory_omit_tags"] = _inventory_tags_to_string( 138 | _inventory_tags_to_list(update_payload["inventory_omit_tags"]) 139 | ) 140 | for field, value in update_payload.items(): 141 | setattr(tpos, field, value) 142 | tpos = await update_tpos(tpos) 143 | return tpos 144 | 145 | 146 | @tpos_api_router.delete("/api/v1/tposs/{tpos_id}") 147 | async def api_tpos_delete( 148 | tpos_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) 149 | ): 150 | tpos = await get_tpos(tpos_id) 151 | 152 | if not tpos: 153 | raise HTTPException( 154 | status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." 155 | ) 156 | 157 | if tpos.wallet != wallet.wallet.id: 158 | raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.") 159 | 160 | await delete_tpos(tpos_id) 161 | return "", HTTPStatus.NO_CONTENT 162 | 163 | 164 | @tpos_api_router.post( 165 | "/api/v1/tposs/{tpos_id}/invoices", status_code=HTTPStatus.CREATED 166 | ) 167 | async def api_tpos_create_invoice(tpos_id: str, data: CreateTposInvoice) -> Payment: 168 | tpos = await get_tpos(tpos_id) 169 | 170 | if not tpos: 171 | raise HTTPException( 172 | status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." 173 | ) 174 | 175 | inventory_payload: InventorySale | None = data.inventory 176 | if inventory_payload: 177 | if not tpos.use_inventory or not tpos.inventory_id: 178 | raise HTTPException( 179 | status_code=HTTPStatus.BAD_REQUEST, 180 | detail="Inventory is not enabled for this TPoS.", 181 | ) 182 | inventory_payload.tags = _inventory_tags_to_list(inventory_payload.tags) 183 | if tpos.inventory_id and inventory_payload.inventory_id != tpos.inventory_id: 184 | raise HTTPException( 185 | status_code=HTTPStatus.BAD_REQUEST, 186 | detail="Mismatched inventory selection.", 187 | ) 188 | allowed_tags = set(_inventory_tags_to_list(tpos.inventory_tags)) 189 | if allowed_tags and any( 190 | tag not in allowed_tags for tag in inventory_payload.tags 191 | ): 192 | raise HTTPException( 193 | status_code=HTTPStatus.BAD_REQUEST, 194 | detail="Provided tags are not allowed for this TPoS.", 195 | ) 196 | 197 | if not data.details: 198 | tax_value = 0.0 199 | if tpos.tax_default: 200 | tax_value = ( 201 | (data.amount / data.exchange_rate) * (tpos.tax_default * 0.01) 202 | if data.exchange_rate 203 | else 0.0 204 | ) 205 | data.details = { 206 | "currency": tpos.currency, 207 | "exchangeRate": data.exchange_rate, 208 | "items": None, 209 | "taxIncluded": True, 210 | "taxValue": tax_value, 211 | } 212 | 213 | currency = tpos.currency if data.pay_in_fiat else "sat" 214 | amount = data.amount + (data.tip_amount or 0.0) 215 | if data.pay_in_fiat: 216 | amount = (data.amount_fiat or 0.0) + (data.tip_amount_fiat or 0.0) 217 | 218 | try: 219 | extra = { 220 | "tag": "tpos", 221 | "tip_amount": data.tip_amount, 222 | "tpos_id": tpos_id, 223 | "amount": data.amount, 224 | "exchangeRate": data.exchange_rate if data.exchange_rate else None, 225 | "details": data.details if data.details else None, 226 | "lnaddress": data.user_lnaddress if data.user_lnaddress else None, 227 | "internal_memo": data.internal_memo if data.internal_memo else None, 228 | } 229 | if inventory_payload: 230 | extra["inventory"] = inventory_payload.dict() 231 | if data.pay_in_fiat and tpos.fiat_provider: 232 | extra["fiat_method"] = data.fiat_method if data.fiat_method else "checkout" 233 | if tpos.stripe_reader_id: 234 | extra["terminal"] = {"reader_id": tpos.stripe_reader_id} 235 | invoice_data = CreateInvoice( 236 | unit=currency, 237 | out=False, 238 | amount=amount, 239 | memo=f"{data.memo} to {tpos.name}" if data.memo else f"{tpos.name}", 240 | extra=extra, 241 | fiat_provider=tpos.fiat_provider if data.pay_in_fiat else None, 242 | ) 243 | payment = await create_payment_request(tpos.wallet, invoice_data) 244 | if (invoice_data.extra or {}).get("fiat_method") == "terminal": 245 | pi_id = payment.extra.get("fiat_checking_id") 246 | client_secret = payment.extra.get("fiat_payment_request") 247 | if pi_id and client_secret: 248 | amount_minor = round(amount * 100) 249 | payload = TapToPay( 250 | payment_intent_id=pi_id, 251 | client_secret=client_secret, 252 | currency=invoice_data.unit.lower(), 253 | amount=amount_minor, 254 | tpos_id=tpos_id, 255 | payment_hash=payment.payment_hash, 256 | ) 257 | await websocket_updater(tpos_id, str(payload)) 258 | return payment 259 | 260 | except Exception as exc: 261 | raise HTTPException( 262 | status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc) 263 | ) from exc 264 | 265 | 266 | @tpos_api_router.get("/api/v1/tposs/{tpos_id}/invoices") 267 | async def api_tpos_get_latest_invoices(tpos_id: str): 268 | payments = await get_latest_payments_by_extension(ext_name="tpos", ext_id=tpos_id) 269 | result = [] 270 | for payment in payments: 271 | details = payment.extra.get("details", {}) 272 | currency = details.get("currency", None) 273 | exchange_rate = details.get("exchangeRate") or payment.extra.get("exchangeRate") 274 | result.append( 275 | { 276 | "checking_id": payment.checking_id, 277 | "amount": payment.amount, 278 | "time": payment.time, 279 | "pending": payment.pending, 280 | "currency": currency, 281 | "exchange_rate": exchange_rate, 282 | } 283 | ) 284 | return result 285 | 286 | 287 | @tpos_api_router.post( 288 | "/api/v1/tposs/{tpos_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK 289 | ) 290 | async def api_tpos_pay_invoice( 291 | lnurl_data: PayLnurlWData, payment_request: str, tpos_id: str 292 | ): 293 | tpos = await get_tpos(tpos_id) 294 | 295 | if not tpos: 296 | raise HTTPException( 297 | status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." 298 | ) 299 | 300 | lnurl = ( 301 | lnurl_data.lnurl.replace("lnurlw://", "") 302 | .replace("lightning://", "") 303 | .replace("LIGHTNING://", "") 304 | .replace("lightning:", "") 305 | .replace("LIGHTNING:", "") 306 | ) 307 | 308 | if lnurl.lower().startswith("lnurl"): 309 | lnurl = decode_lnurl(lnurl) 310 | else: 311 | lnurl = "https://" + lnurl 312 | 313 | async with httpx.AsyncClient() as client: 314 | try: 315 | headers = {"user-agent": "lnbits/tpos"} 316 | r = await client.get(lnurl, follow_redirects=True, headers=headers) 317 | if r.is_error: 318 | lnurl_response = {"success": False, "detail": "Error loading"} 319 | else: 320 | resp = r.json() 321 | if resp.get("status") == "ERROR": 322 | lnurl_response = { 323 | "success": False, 324 | "detail": resp.get("reason", ""), 325 | } 326 | return lnurl_response 327 | 328 | if resp.get("tag") != "withdrawRequest": 329 | lnurl_response = {"success": False, "detail": "Wrong tag type"} 330 | else: 331 | r2 = await client.get( 332 | resp.get("callback", ""), 333 | follow_redirects=True, 334 | headers=headers, 335 | params={ 336 | "k1": resp.get("k1", ""), 337 | "pr": payment_request, 338 | }, 339 | ) 340 | resp2 = r2.json() 341 | if r2.is_error: 342 | lnurl_response = { 343 | "success": False, 344 | "detail": "Error loading callback", 345 | } 346 | elif resp2.get("status") == "ERROR": 347 | lnurl_response = {"success": False, "detail": resp2["reason"]} 348 | else: 349 | lnurl_response = {"success": True, "detail": resp2} 350 | except (httpx.ConnectError, httpx.RequestError): 351 | lnurl_response = {"success": False, "detail": "Unexpected error occurred"} 352 | 353 | return lnurl_response 354 | 355 | 356 | @tpos_api_router.get( 357 | "/api/v1/tposs/{tpos_id}/invoices/{payment_hash}", status_code=HTTPStatus.OK 358 | ) 359 | async def api_tpos_check_invoice( 360 | tpos_id: str, payment_hash: str, extra: bool = Query(False) 361 | ): 362 | tpos = await get_tpos(tpos_id) 363 | if not tpos: 364 | raise HTTPException( 365 | status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." 366 | ) 367 | payment = await get_standalone_payment(payment_hash, incoming=True) 368 | if not payment: 369 | raise HTTPException( 370 | status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." 371 | ) 372 | if payment.extra.get("tag") != "tpos": 373 | raise HTTPException( 374 | status_code=HTTPStatus.NOT_FOUND, detail="TPoS payment does not exist." 375 | ) 376 | 377 | if extra: 378 | return { 379 | "paid": payment.success, 380 | "extra": payment.extra, 381 | "created_at": payment.created_at, 382 | "business_name": tpos.business_name, 383 | "business_address": tpos.business_address, 384 | "business_vat_id": tpos.business_vat_id, 385 | } 386 | return {"paid": payment.success} 387 | 388 | 389 | @tpos_api_router.put("/api/v1/tposs/{tpos_id}/items", status_code=HTTPStatus.CREATED) 390 | async def api_tpos_create_items( 391 | data: CreateUpdateItemData, 392 | tpos_id: str, 393 | wallet: WalletTypeInfo = Depends(require_admin_key), 394 | ) -> Tpos: 395 | tpos = await get_tpos(tpos_id) 396 | if not tpos: 397 | raise HTTPException( 398 | status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." 399 | ) 400 | if wallet.wallet.id != tpos.wallet: 401 | raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.") 402 | 403 | tpos.items = json.dumps(data.dict()["items"]) 404 | tpos = await update_tpos(tpos) 405 | return tpos 406 | 407 | 408 | @tpos_api_router.get("/api/v1/tposs/lnaddresscheck", status_code=HTTPStatus.OK) 409 | async def api_tpos_check_lnaddress(lnaddress: str): 410 | try: 411 | res = await lnurl_handle(lnaddress) 412 | except Exception as exc: 413 | raise HTTPException( 414 | status_code=HTTPStatus.BAD_REQUEST, 415 | detail=f"Error checking lnaddress: {exc!s}", 416 | ) from exc 417 | 418 | if not isinstance(res, LnurlPayResponse): 419 | raise HTTPException( 420 | status_code=HTTPStatus.BAD_REQUEST, 421 | detail="The provided lnaddress returned an unexpected response type.", 422 | ) 423 | 424 | return True 425 | 426 | 427 | @tpos_api_router.get( 428 | "/api/v1/tposs/{tpos_id}/inventory-items", status_code=HTTPStatus.OK 429 | ) 430 | async def api_tpos_inventory_items(tpos_id: str): 431 | tpos = await get_tpos(tpos_id) 432 | if not tpos or not tpos.use_inventory: 433 | raise HTTPException( 434 | status_code=HTTPStatus.NOT_FOUND, 435 | detail="Inventory not enabled for this TPoS.", 436 | ) 437 | 438 | wallet = await get_wallet(tpos.wallet) 439 | if not wallet: 440 | raise HTTPException( 441 | status_code=HTTPStatus.NOT_FOUND, 442 | detail="Wallet not found for this TPoS.", 443 | ) 444 | 445 | inventory_id = tpos.inventory_id 446 | inventory_data: dict[str, Any] | None = None 447 | if not inventory_id: 448 | inventory_data = await _get_default_inventory(wallet.user) 449 | inventory_id = inventory_data.get("id") if inventory_data else None 450 | else: 451 | inventory_data = await _get_default_inventory(wallet.user) 452 | if not inventory_id: 453 | raise HTTPException( 454 | status_code=HTTPStatus.NOT_FOUND, 455 | detail="No inventory found for this TPoS.", 456 | ) 457 | 458 | items = await _get_inventory_items_for_tpos( 459 | wallet.user, 460 | inventory_id, 461 | tpos.inventory_tags, 462 | tpos.inventory_omit_tags, 463 | ) 464 | return [ 465 | { 466 | "id": item.get("id"), 467 | "title": item.get("name"), 468 | "description": item.get("description"), 469 | "price": item.get("price"), 470 | "tax": item.get("tax_rate"), 471 | "image": _first_image(item.get("images")), 472 | "categories": _inventory_tags_to_list(item.get("tags")), 473 | "quantity_in_stock": item.get("quantity_in_stock"), 474 | "disabled": (not item.get("is_active")) 475 | or ( 476 | item.get("quantity_in_stock") is not None 477 | and item.get("quantity_in_stock") <= 0 478 | ), 479 | } 480 | for item in items 481 | ] 482 | -------------------------------------------------------------------------------- /static/js/index.js: -------------------------------------------------------------------------------- 1 | const mapTpos = obj => { 2 | obj.date = Quasar.date.formatDate( 3 | new Date(obj.time * 1000), 4 | 'YYYY-MM-DD HH:mm' 5 | ) 6 | obj.tpos = ['/tpos/', obj.id].join('') 7 | obj.shareUrl = [ 8 | window.location.protocol, 9 | '//', 10 | window.location.host, 11 | obj.tpos 12 | ].join('') 13 | obj.items = obj.items ? JSON.parse(obj.items) : [] 14 | obj.use_inventory = Boolean(obj.use_inventory) 15 | obj.inventory_id = obj.inventory_id || null 16 | const tagString = 17 | obj.inventory_tags === 'null' ? '' : obj.inventory_tags || '' 18 | obj.inventory_tags = tagString ? tagString.split(',').filter(Boolean) : [] 19 | const omitTagString = 20 | obj.inventory_omit_tags === 'null' ? '' : obj.inventory_omit_tags || '' 21 | obj.inventory_omit_tags = omitTagString 22 | ? omitTagString.split(',').filter(Boolean) 23 | : [] 24 | obj.itemsMap = new Map() 25 | obj.items.forEach((item, idx) => { 26 | let id = `${obj.id}:${idx + 1}` 27 | obj.itemsMap.set(id, {...item, id}) 28 | }) 29 | return obj 30 | } 31 | 32 | window.app = Vue.createApp({ 33 | el: '#vue', 34 | mixins: [window.windowMixin], 35 | data() { 36 | return { 37 | tposs: [], 38 | currencyOptions: [], 39 | hasFiatProvider: false, 40 | fiatProviders: null, 41 | inventoryStatus: { 42 | enabled: false, 43 | inventory_id: null, 44 | tags: [], 45 | omit_tags: [] 46 | }, 47 | tpossTable: { 48 | columns: [ 49 | {name: 'name', align: 'left', label: 'Name', field: 'name'}, 50 | { 51 | name: 'currency', 52 | align: 'left', 53 | label: 'Currency', 54 | field: 'currency' 55 | }, 56 | { 57 | name: 'fiat_provider', 58 | align: 'left', 59 | label: 'Fiat Provider', 60 | field: 'fiat_provider', 61 | format: val => val && val.charAt(0).toUpperCase() + val.slice(1) 62 | }, 63 | { 64 | name: 'withdraw_time_option', 65 | align: 'left', 66 | label: 'mins/sec', 67 | field: 'withdraw_time_option' 68 | }, 69 | { 70 | name: 'lnaddress', 71 | align: 'left', 72 | label: 'LNaddress', 73 | field: 'lnaddress' 74 | }, 75 | { 76 | name: 'lnaddress_cut', 77 | align: 'left', 78 | label: 'LNaddress Cut', 79 | field: 'lnaddress_cut' 80 | } 81 | ], 82 | pagination: { 83 | rowsPerPage: 10 84 | } 85 | }, 86 | withdraw_options: [ 87 | { 88 | label: 'Mins', 89 | value: 'mins' 90 | }, 91 | { 92 | label: 'Secs', 93 | value: 'secs' 94 | } 95 | ], 96 | formDialog: { 97 | show: false, 98 | data: { 99 | use_inventory: false, 100 | inventory_id: null, 101 | inventory_tags: [], 102 | tip_options: [], 103 | withdraw_between: 10, 104 | withdraw_time_option: '', 105 | tax_inclusive: true, 106 | lnaddress: false, 107 | lnaddress_cut: 2, 108 | enable_receipt_print: false, 109 | fiat: false, 110 | stripe_card_payments: false, 111 | stripe_reader_id: '' 112 | }, 113 | advanced: { 114 | tips: false, 115 | otc: false 116 | } 117 | }, 118 | itemDialog: { 119 | show: false, 120 | data: { 121 | title: '', 122 | image: '', 123 | price: '', 124 | categories: [], 125 | disabled: false 126 | } 127 | }, 128 | tab: 'items', 129 | itemsTable: { 130 | columns: [ 131 | { 132 | name: 'delete', 133 | align: 'left', 134 | label: '', 135 | field: '' 136 | }, 137 | { 138 | name: 'edit', 139 | align: 'left', 140 | label: '', 141 | field: '' 142 | }, 143 | { 144 | name: 'title', 145 | align: 'left', 146 | label: 'Title', 147 | field: 'title' 148 | }, 149 | { 150 | name: 'price', 151 | align: 'left', 152 | label: 'Price', 153 | field: 'price' 154 | }, 155 | { 156 | name: 'tax', 157 | align: 'left', 158 | label: 'Tax', 159 | field: 'tax' 160 | }, 161 | { 162 | name: 'disabled', 163 | align: 'left', 164 | label: 'Disabled', 165 | field: 'disabled' 166 | } 167 | ], 168 | pagination: { 169 | rowsPerPage: 10 170 | } 171 | }, 172 | fileDataDialog: { 173 | show: false, 174 | data: {}, 175 | import: () => {} 176 | }, 177 | urlDialog: { 178 | show: false, 179 | data: {} 180 | } 181 | } 182 | }, 183 | computed: { 184 | anyRecordsWithFiat() { 185 | return this.tposs.some(t => !!t.stripe_card_payments) 186 | }, 187 | categoryList() { 188 | return Array.from( 189 | new Set( 190 | this.tposs.flatMap(tpos => 191 | tpos.items.flatMap(item => item.categories || []) 192 | ) 193 | ) 194 | ) 195 | }, 196 | createOrUpdateDisabled() { 197 | if (!this.formDialog.show) return true 198 | const data = this.formDialog.data 199 | return ( 200 | !data.name || 201 | !data.currency || 202 | !data.wallet || 203 | (this.formDialog.advanced.otc && !data.withdraw_limit) 204 | ) 205 | }, 206 | inventoryModeOptions() { 207 | return [ 208 | { 209 | label: 'Use inventory extension', 210 | value: true, 211 | disable: !this.inventoryStatus.enabled 212 | }, 213 | {label: 'Use TPoS items', value: false} 214 | ] 215 | }, 216 | inventoryTagOptions() { 217 | return (this.inventoryStatus.tags || []).map(tag => ({ 218 | label: tag, 219 | value: tag 220 | })) 221 | }, 222 | inventoryOmitTagOptions() { 223 | return (this.inventoryStatus.omit_tags || []).map(tag => ({ 224 | label: tag, 225 | value: tag 226 | })) 227 | } 228 | }, 229 | methods: { 230 | closeFormDialog() { 231 | this.formDialog.show = false 232 | this.formDialog.data = { 233 | use_inventory: false, 234 | inventory_id: this.inventoryStatus.inventory_id, 235 | inventory_tags: [...(this.inventoryStatus.tags || [])], 236 | inventory_omit_tags: [...(this.inventoryStatus.omit_tags || [])], 237 | tip_options: [], 238 | withdraw_between: 10, 239 | withdraw_time_option: '', 240 | tax_inclusive: true, 241 | lnaddress: false, 242 | lnaddress_cut: 2, 243 | enable_receipt_print: false, 244 | fiat: false, 245 | stripe_card_payments: false, 246 | stripe_reader_id: '' 247 | } 248 | this.formDialog.advanced = {tips: false, otc: false} 249 | }, 250 | getTposs() { 251 | LNbits.api 252 | .request( 253 | 'GET', 254 | '/tpos/api/v1/tposs?all_wallets=true', 255 | this.g.user.wallets[0].inkey 256 | ) 257 | .then(response => { 258 | this.tposs = response.data.map(obj => { 259 | return mapTpos(obj) 260 | }) 261 | }) 262 | }, 263 | async loadInventoryStatus() { 264 | if (!this.g.user.wallets.length) return 265 | try { 266 | const {data} = await LNbits.api.request( 267 | 'GET', 268 | '/tpos/api/v1/inventory/status', 269 | this.g.user.wallets[0].adminkey 270 | ) 271 | this.inventoryStatus = data 272 | // Default remains "Use TPoS items"; keep inventory info available without auto-enabling. 273 | if (!this.formDialog.data.inventory_id) { 274 | this.formDialog.data.inventory_id = data.inventory_id 275 | this.formDialog.data.inventory_tags = [...data.tags] 276 | this.formDialog.data.inventory_omit_tags = [...data.omit_tags] 277 | } 278 | } catch (error) { 279 | console.error(error) 280 | } 281 | }, 282 | sendTposData() { 283 | const data = { 284 | ...this.formDialog.data, 285 | tip_options: 286 | this.formDialog.advanced.tips && this.formDialog.data.tip_options 287 | ? JSON.stringify( 288 | this.formDialog.data.tip_options.map(str => parseInt(str)) 289 | ) 290 | : JSON.stringify([]), 291 | tip_wallet: 292 | (this.formDialog.advanced.tips && this.formDialog.data.tip_wallet) || 293 | '', 294 | items: JSON.stringify(this.formDialog.data.items || []) 295 | } 296 | data.inventory_tags = data.inventory_tags || [] 297 | if (!this.inventoryStatus.enabled) { 298 | data.use_inventory = false 299 | } else if (!data.inventory_id) { 300 | data.inventory_id = this.inventoryStatus.inventory_id 301 | } 302 | if (data.use_inventory && !data.inventory_id) { 303 | data.use_inventory = false 304 | } 305 | // delete withdraw_between if value is empty string, defaults to 10 minutes 306 | if (this.formDialog.data.withdraw_between == '') { 307 | delete data.withdraw_between 308 | } 309 | if (!this.formDialog.advanced.otc) { 310 | data.withdraw_limit = null 311 | data.withdraw_premium = null 312 | } 313 | const wallet = _.findWhere(this.g.user.wallets, { 314 | id: this.formDialog.data.wallet 315 | }) 316 | if (data.id) { 317 | this.updateTpos(wallet, data) 318 | } else { 319 | this.createTpos(wallet, data) 320 | } 321 | }, 322 | updateTposForm(tposId) { 323 | const tpos = _.findWhere(this.tposs, {id: tposId}) 324 | this.formDialog.data = { 325 | ...tpos, 326 | tip_options: JSON.parse(tpos.tip_options) 327 | } 328 | if (this.formDialog.data.tip_wallet != '') { 329 | this.formDialog.advanced.tips = true 330 | } 331 | if (this.formDialog.data.withdraw_limit >= 1) { 332 | this.formDialog.advanced.otc = true 333 | } else { 334 | this.formDialog.advanced.otc = false 335 | } 336 | this.formDialog.show = true 337 | }, 338 | createTpos(wallet, data) { 339 | LNbits.api 340 | .request('POST', '/tpos/api/v1/tposs', wallet.adminkey, data) 341 | .then(response => { 342 | this.tposs.push(mapTpos(response.data)) 343 | this.closeFormDialog() 344 | }) 345 | .catch(error => { 346 | LNbits.utils.notifyApiError(error) 347 | }) 348 | }, 349 | updateTpos(wallet, data) { 350 | LNbits.api 351 | .request('PUT', `/tpos/api/v1/tposs/${data.id}`, wallet.adminkey, data) 352 | .then(response => { 353 | this.tposs = _.reject(this.tposs, obj => { 354 | return obj.id == data.id 355 | }) 356 | this.tposs.push(mapTpos(response.data)) 357 | this.closeFormDialog() 358 | }) 359 | .catch(error => { 360 | LNbits.utils.notifyApiError(error) 361 | }) 362 | }, 363 | saveInventorySettings(tpos) { 364 | const wallet = _.findWhere(this.g.user.wallets, {id: tpos.wallet}) 365 | if (!wallet) return 366 | const resolvedInventoryId = 367 | this.inventoryStatus.inventory_id || tpos.inventory_id 368 | const payload = { 369 | use_inventory: this.inventoryStatus.enabled && tpos.use_inventory, 370 | inventory_id: 371 | this.inventoryStatus.enabled && tpos.use_inventory 372 | ? resolvedInventoryId 373 | : null, 374 | inventory_tags: tpos.inventory_tags || [], 375 | inventory_omit_tags: tpos.inventory_omit_tags || [] 376 | } 377 | if (payload.use_inventory && !payload.inventory_id) { 378 | Quasar.Notify.create({ 379 | type: 'warning', 380 | message: 'No inventory found for this user.' 381 | }) 382 | return 383 | } 384 | LNbits.api 385 | .request( 386 | 'PUT', 387 | `/tpos/api/v1/tposs/${tpos.id}`, 388 | wallet.adminkey, 389 | payload 390 | ) 391 | .then(response => { 392 | this.tposs = _.reject(this.tposs, obj => obj.id == tpos.id) 393 | this.tposs.push(mapTpos(response.data)) 394 | }) 395 | .catch(LNbits.utils.notifyApiError) 396 | }, 397 | onInventoryModeChange(tpos, value) { 398 | tpos.use_inventory = value 399 | if (value && this.inventoryStatus.enabled) { 400 | tpos.inventory_id = this.inventoryStatus.inventory_id 401 | if (!tpos.inventory_tags.length && this.inventoryStatus.tags.length) { 402 | tpos.inventory_tags = [...this.inventoryStatus.tags] 403 | } 404 | if ( 405 | !tpos.inventory_omit_tags.length && 406 | this.inventoryStatus.omit_tags 407 | ) { 408 | tpos.inventory_omit_tags = [...this.inventoryStatus.omit_tags] 409 | } 410 | } 411 | this.saveInventorySettings(tpos) 412 | }, 413 | onInventoryTagsChange(tpos, tags) { 414 | tpos.inventory_tags = tags || [] 415 | this.saveInventorySettings(tpos) 416 | }, 417 | onInventoryOmitTagsChange(tpos, tags) { 418 | tpos.inventory_omit_tags = tags || [] 419 | this.saveInventorySettings(tpos) 420 | }, 421 | deleteTpos(tposId) { 422 | const tpos = _.findWhere(this.tposs, {id: tposId}) 423 | 424 | LNbits.utils 425 | .confirmDialog('Are you sure you want to delete this Tpos?') 426 | .onOk(() => { 427 | LNbits.api 428 | .request( 429 | 'DELETE', 430 | '/tpos/api/v1/tposs/' + tposId, 431 | _.findWhere(this.g.user.wallets, {id: tpos.wallet}).adminkey 432 | ) 433 | .then(() => { 434 | this.tposs = _.reject(this.tposs, obj => { 435 | return obj.id == tposId 436 | }) 437 | }) 438 | .catch(LNbits.utils.notifyApiError) 439 | }) 440 | }, 441 | exportCSV() { 442 | LNbits.utils.exportCSV(this.tpossTable.columns, this.tposs) 443 | }, 444 | itemsArray(tposId) { 445 | const tpos = _.findWhere(this.tposs, {id: tposId}) 446 | return [...tpos.itemsMap.values()] 447 | }, 448 | itemFormatPrice(price, id) { 449 | const tpos = id.split(':')[0] 450 | const currency = _.findWhere(this.tposs, {id: tpos}).currency 451 | if (currency == 'sats') { 452 | return LNbits.utils.formatSat(price) + ' sat' 453 | } else { 454 | return LNbits.utils.formatCurrency(Number(price).toFixed(2), currency) 455 | } 456 | }, 457 | openItemDialog(id) { 458 | const [tposId, itemId] = id.split(':') 459 | const tpos = _.findWhere(this.tposs, {id: tposId}) 460 | 461 | if (itemId) { 462 | const item = tpos.itemsMap.get(id) 463 | this.itemDialog.data = { 464 | ...item, 465 | tpos: tposId 466 | } 467 | } else { 468 | this.itemDialog.data.tpos = tposId 469 | } 470 | this.itemDialog.taxInclusive = tpos.tax_inclusive 471 | this.itemDialog.data.currency = tpos.currency 472 | this.itemDialog.show = true 473 | }, 474 | closeItemDialog() { 475 | this.itemDialog.show = false 476 | this.itemDialog.data = { 477 | title: '', 478 | image: '', 479 | price: '', 480 | categories: [], 481 | disabled: false 482 | } 483 | }, 484 | deleteItem(id) { 485 | const [tposId, itemId] = id.split(':') 486 | const tpos = _.findWhere(this.tposs, {id: tposId}) 487 | const wallet = _.findWhere(this.g.user.wallets, { 488 | id: tpos.wallet 489 | }) 490 | LNbits.utils 491 | .confirmDialog('Are you sure you want to delete this item?') 492 | .onOk(() => { 493 | tpos.itemsMap.delete(id) 494 | const data = { 495 | items: [...tpos.itemsMap.values()] 496 | } 497 | this.updateTposItems(tpos.id, wallet, data) 498 | }) 499 | }, 500 | addItems() { 501 | const tpos = _.findWhere(this.tposs, {id: this.itemDialog.data.tpos}) 502 | const wallet = _.findWhere(this.g.user.wallets, { 503 | id: tpos.wallet 504 | }) 505 | if (this.itemDialog.data.id) { 506 | tpos.itemsMap.set(this.itemDialog.data.id, this.itemDialog.data) 507 | } 508 | const data = { 509 | items: this.itemDialog.data.id 510 | ? [...tpos.itemsMap.values()] 511 | : [...tpos.items, this.itemDialog.data] 512 | } 513 | this.updateTposItems(tpos.id, wallet, data) 514 | }, 515 | deleteAllItems(tposId) { 516 | const tpos = _.findWhere(this.tposs, {id: tposId}) 517 | const wallet = _.findWhere(this.g.user.wallets, { 518 | id: tpos.wallet 519 | }) 520 | LNbits.utils 521 | .confirmDialog('Are you sure you want to delete ALL items?') 522 | .onOk(() => { 523 | tpos.itemsMap.clear() 524 | const data = { 525 | items: [] 526 | } 527 | this.updateTposItems(tpos.id, wallet, data) 528 | }) 529 | }, 530 | updateTposItems(tposId, wallet, data) { 531 | const tpos = _.findWhere(this.tposs, {id: tposId}) 532 | if (tpos.tax_inclusive != this.taxInclusive) { 533 | data.tax_inclusive = this.taxInclusive 534 | } 535 | LNbits.api 536 | .request( 537 | 'PUT', 538 | `/tpos/api/v1/tposs/${tposId}/items`, 539 | wallet.adminkey, 540 | data 541 | ) 542 | .then(response => { 543 | this.tposs = _.reject(this.tposs, obj => { 544 | return obj.id == tposId 545 | }) 546 | this.tposs.push(mapTpos(response.data)) 547 | this.closeItemDialog() 548 | }) 549 | .catch(error => { 550 | LNbits.utils.notifyApiError(error) 551 | }) 552 | }, 553 | exportJSON(tposId) { 554 | const tpos = _.findWhere(this.tposs, {id: tposId}) 555 | const data = [...tpos.items] 556 | const filename = `items_${tpos.id}.json` 557 | const json = JSON.stringify(data, null, 2) 558 | let status = Quasar.exportFile(filename, json) 559 | if (status !== true) { 560 | Quasar.Notify.create({ 561 | message: 'Browser denied file download...', 562 | color: 'negative' 563 | }) 564 | } 565 | }, 566 | importJSON(tposId) { 567 | try { 568 | let input = document.getElementById('import') 569 | input.click() 570 | input.onchange = e => { 571 | let file = e.target.files[0] 572 | let reader = new FileReader() 573 | reader.readAsText(file, 'UTF-8') 574 | reader.onload = async readerEvent => { 575 | try { 576 | let content = readerEvent.target.result 577 | let data = JSON.parse(content).filter( 578 | obj => obj.title && obj.price 579 | ) 580 | if (!data.length) { 581 | throw new Error('Invalid JSON or missing data.') 582 | } 583 | this.openFileDataDialog(tposId, data) 584 | } catch (error) { 585 | Quasar.Notify.create({ 586 | message: `Error importing file. ${error.message}`, 587 | color: 'negative' 588 | }) 589 | return 590 | } 591 | } 592 | } 593 | } catch (error) { 594 | Quasar.Notify.create({ 595 | message: 'Error importing file', 596 | color: 'negative' 597 | }) 598 | } 599 | }, 600 | openFileDataDialog(tposId, data) { 601 | const tpos = _.findWhere(this.tposs, {id: tposId}) 602 | const wallet = _.findWhere(this.g.user.wallets, { 603 | id: tpos.wallet 604 | }) 605 | data.forEach(item => { 606 | item.formattedPrice = this.formatAmount(item.price, tpos.currency) 607 | }) 608 | this.fileDataDialog.data = data 609 | this.fileDataDialog.count = data.length 610 | this.fileDataDialog.show = true 611 | this.fileDataDialog.import = () => { 612 | let updatedData = { 613 | items: [...tpos.items, ...data] 614 | } 615 | this.updateTposItems(tpos.id, wallet, updatedData) 616 | this.fileDataDialog.data = {} 617 | this.fileDataDialog.show = false 618 | } 619 | }, 620 | openUrlDialog(id) { 621 | if (this.tposs.stripe_card_payments) { 622 | this.urlDialog.data = _.findWhere(this.tposs, { 623 | id, 624 | stripe_card_payments: true 625 | }) 626 | } else { 627 | this.urlDialog.data = _.findWhere(this.tposs, {id}) 628 | } 629 | this.urlDialog.show = true 630 | }, 631 | formatAmount(amount, currency) { 632 | if (currency == 'sats') { 633 | return LNbits.utils.formatSat(amount) + ' sat' 634 | } else { 635 | return LNbits.utils.formatCurrency(Number(amount).toFixed(2), currency) 636 | } 637 | } 638 | }, 639 | created() { 640 | if (this.g.user.wallets.length) { 641 | this.getTposs() 642 | this.loadInventoryStatus() 643 | } 644 | LNbits.api 645 | .request('GET', '/api/v1/currencies') 646 | .then(response => { 647 | this.currencyOptions = ['sats', ...response.data] 648 | if (LNBITS_DENOMINATION != 'sats') { 649 | this.formDialog.data.currency = DENOMINATION 650 | } 651 | }) 652 | .catch(LNbits.utils.notifyApiError) 653 | if (this.g.user.fiat_providers && this.g.user.fiat_providers.length > 0) { 654 | this.hasFiatProvider = true 655 | this.fiatProviders = [...this.g.user.fiat_providers] 656 | } 657 | } 658 | }) 659 | -------------------------------------------------------------------------------- /templates/tpos/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} {% from "macros.jinja" import window_vars with context 2 | %} {% block page %} 3 |
4 |
5 | 6 | 7 | New TPoS 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |
TPoS
18 |
19 |
20 | Export to CSV 21 |
22 |
23 | 31 | 47 | 48 | 327 | 328 |
329 |
330 |
331 | 332 |
333 | 334 | 335 |
{{SITE_TITLE}} TPoS extension
336 |
337 | 338 | 339 | 340 | {% include "tpos/_api_docs.html" %} 341 | 342 | {% include "tpos/_tpos.html" %} 343 | 344 | 345 | 346 | TO USE FIAT TAP-TO-PAY
347 | * Your LNbits install must have a Stripe key/webhook in the "fiat 348 | provider" area in settings, the key must have permissions for all 349 | "checkout" and "payment_intents".
350 | * Install latest release for 351 | TPoS-Wrapper 354 | apk (Android only).
355 | * Create a location for a terminal in Stripe.
356 | * Create an ACL token in LNbits with permissions for "fiat" 357 | Access Control List.
358 | * Click the QR for the PoS record and add a Stripe terminal location ID 359 | and ACL token.
360 | * Scan the QR in the TPoS Wrapper app.
361 |
362 |
363 |
364 | 365 | 366 | 367 | 368 | 375 | 383 | 392 | 402 | 409 | 410 |
414 |
415 | 419 | 420 | 424 | 425 |
426 |
427 | 428 | After saving use the QR code button to create the pairing code. 429 | 430 |
431 |
432 |
439 |
440 | 447 |
448 |
449 |
450 |
451 | 455 | 456 | 460 | 461 |
462 |
463 | 471 | 475 | 476 |
477 |
478 |
479 |
480 | 484 |
485 |
486 | 490 |
491 |
492 | 496 |
497 |
498 | 525 | 555 | 595 | 596 | 602 | 603 | 604 | Tax Inclusive means the unit price includes tax. (default) 605 |
606 | Tax Exclusive means tax is applied on top of the unit price. 607 |
608 | 609 |
610 |
611 | 617 | 621 |
622 |
623 | 634 |
635 |
636 |
637 |
638 |
639 |
640 |
641 | Update TPoS 649 | Create TPoS 657 | Cancel 660 |
661 |
662 |
663 |
664 | 665 | 666 | 667 | 673 | 679 | 685 | 691 | 705 | 712 | 716 |
717 | 724 | Cancel 732 |
733 |
734 |
735 |
736 | 737 | 738 | 741 |
742 |

743 |
744 | 745 |

746 |
747 |
748 |
749 |
750 | 754 | 755 |
756 |
757 | 758 | Create a new location and copy the ID here 759 | https://dashboard.stripe.com/terminal 760 | 761 |
762 |
763 |
764 |
765 | 766 |
767 |
768 | 769 | Go to /account and set a new auth token with /fiat read and write 770 | permissions. 771 | 772 |
773 |
774 |
775 |
776 | Copy URL 782 | Close 783 |
784 |
785 |
786 | 787 | 788 | 789 |
790 | 791 |
792 |
793 | 794 | 795 | 796 | 797 | 798 | 799 | 800 | 801 | 802 | 803 | 808 | 809 | 810 | 811 | 812 | 813 | 814 | 815 |
816 | Import 819 | Close 820 |
821 |
822 |
823 |
824 | {% endblock %} {% block scripts %} {{ window_vars(user) }} 825 | 828 | 829 | {% endblock %} 830 | --------------------------------------------------------------------------------