├── 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 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
75 |
76 |
82 |
87 |
93 |
94 |
95 |
96 |
97 |
98 | {% include "tpos/_options_fab.html" %}
99 |
100 |
101 | {% include "tpos/_cart.html" %}
102 | {% include "tpos/dialogs.html" %}
103 |
104 |
105 |
106 |
107 |
108 |
109 |
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 |
41 |
42 |
43 |
44 |
45 |
54 |
55 |
56 |
57 |
65 |
66 |
67 |
68 |
69 |
74 |
75 |
76 |
77 |
84 |
85 |
86 |
87 |
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 |
171 |
177 |
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 |
5 |
6 |
7 |
8 | [](https://opensats.org)
9 | [](./LICENSE)
10 | [](https://github.com/lnbits/lnbits) [](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
11 | [](https://extensions.lnbits.com/tpos/)
12 | [](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 |
58 |
59 | 3. **Open** TPoS in the browser.
60 |
61 |
62 |
63 | 4. **Present** the invoice QR to the customer.
64 |
65 |
66 |
67 | ## Receiving Tips
68 |
69 | 1. Create or edit a TPoS and activate **Enable tips**.
70 |
71 |
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 |
82 |
83 | 4. A tip dialog appears.
84 |
85 |
86 |
87 | 5. Select a percentage or **Round to**.
88 |
89 |
90 |
91 | 6. Present the updated invoice to the customer.
92 |
93 |
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 |
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 |
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 |
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 |
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 |
169 |
170 | Click **Add** to add to a cart / total:
171 |
172 |
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 |
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 |
187 |
188 | 2. Open the TPoS, then tap the **ATM** button.
189 |
190 |
191 |
192 | > [!WARNING]
193 | > The red badge centered at the top indicates you are in ATM mode.
194 |
195 |
196 |
197 | 3. Set the amount to sell and present the **LNURLw** QR to the buyer.
198 |
199 |
200 |
201 | 4. After a successful withdrawal, a confirmation appears and TPoS exits ATM mode.
202 |
203 |
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 |
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 |
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 | [](https://shop.lnbits.com/)
231 | [](https://my.lnbits.com/login)
232 | [](https://news.lnbits.com/)
233 | [](https://extensions.lnbits.com/) [](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
234 |
--------------------------------------------------------------------------------
/templates/tpos/dialogs.html:
--------------------------------------------------------------------------------
1 |
2 |
8 |
12 |
16 |
Waiting for card…
17 |
18 |
19 |
20 |
Close
21 |
22 |
23 |
30 |
31 |
${ amountWithTipFormatted }
32 |
33 | ${ amountFormatted }
34 | (+ ${ tipAmountFormatted } tip)
37 |
38 |
39 |
40 | NFC supported
41 |
42 | NFC not supported
43 |
44 |
45 | Copy invoice
51 | Close
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | Would you like to leave a tip?
61 |
62 |
63 |
${ tip }%
75 |
85 |
86 |
95 |
96 | Ok
103 |
104 |
105 |
106 | No, thanks
109 | Close
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 | {{ tpos.name }} {{ request.url }}
120 |
121 |
122 |
123 | Copy URL
129 | Close
130 |
131 |
132 |
133 |
134 |
135 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 | No paid invoices
153 |
154 |
155 | 0 ? showDetails(payment.checking_id) : null"
157 | @before-hide="paymentDetails = null"
158 | group="paymentList"
159 | v-for="(payment, idx) in lastPaymentsDialog.data"
160 | :key="idx"
161 | dense
162 | >
163 |
164 |
165 |
166 |
167 |
171 |
172 |
173 | Amount at payment time vs current amount
174 |
175 |
176 |
180 |
181 |
182 |
188 |
189 |
190 |
195 |
196 |
197 |
198 |
199 |
200 |
204 |
205 |
208 |
212 |
213 |
214 |
215 |
216 | Total
217 |
218 |
219 |
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |
234 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 |
248 |
249 |
250 |
251 |
252 |
253 |
254 |
259 |
260 |
261 |
262 |
263 |
264 |
265 |
273 |
274 |
275 |
276 |
277 |
278 |
279 |
280 |
281 |
282 | Withdraw PIN
283 |
284 |
285 |
286 |
293 |
299 |
300 |
301 |
302 |
303 |
304 |
305 |
306 |
307 |
308 |
309 |
310 | LNaddress
311 |
312 |
313 |
314 |
315 |
316 |
317 |
318 |
319 |
320 |
321 |
322 |
323 |
324 |
325 |
326 | Payment Method
327 |
328 |
336 |
337 |
338 |
339 |
347 |
355 |
356 |
357 |
358 |
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 |
32 |
33 |
34 |
35 |
36 |
37 |
43 | Withdraw Limit
44 | Withdraw Premium
45 |
46 |
47 |
48 |
49 |
50 |
51 |
59 |
60 |
61 | PoS QR
71 | Open PoS
82 |
83 |
84 |
92 |
93 |
94 |
102 |
103 |
104 |
110 |
111 |
112 |
113 |
114 | N/A
115 |
116 |
117 |
118 | 0
119 |
120 |
121 |
122 |
123 |
124 |
129 |
133 | inventory extension must be enabled.
134 |
135 |
136 |
137 |
138 |
154 |
170 |
171 |
172 |
173 |
174 | Using items from the Inventory extension. Tags control which
175 | products appear in PoS.
176 |
177 |
178 |
179 |
180 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 | Add Item
200 | Delete All
209 |
216 |
217 |
222 |
228 |
229 | Import
230 | Import a JSON file
233 |
234 |
235 |
236 |
241 |
242 | Export
243 | Export a JSON file
246 |
247 |
248 |
249 |
250 |
251 |
252 |
253 |
254 |
255 |
263 |
264 |
265 |
271 |
272 |
273 |
274 |
275 |
276 |
284 |
285 |
286 |
294 |
295 |
296 |
299 |
300 |
301 |
302 |
303 |
304 |
307 |
308 |
309 |
310 |
311 |
312 |
313 |
314 |
315 |
316 |
317 |
318 |
319 |
320 |
321 |
322 |
323 |
324 |
325 |
326 |
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 |
403 |
408 |
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 |
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 |
499 |
500 | Receipt printing is an experimental feature. Not all devices work
501 | correctly, or work at all.
502 |
503 |
510 |
517 |
524 |
525 |
526 |
534 | Hit enter to add values
549 |
550 | You can leave this blank. A default rounding option is available
551 | (round amount to a value)
552 |
553 |
554 |
555 |
556 |
563 |
564 |
565 |
566 |
574 |
575 |
576 |
582 |
583 |
584 |
585 |
594 |
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 |
--------------------------------------------------------------------------------