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

Filter by extension

Filter by extension


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

.codex
12 changes: 12 additions & 0 deletions migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,15 @@ async def m022_add_onchain_settings_and_payments(db: Database):
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
""")


async def m023_add_tabs_integration_settings(db: Database):
"""
Add tabs integration settings.
"""
await db.execute("""
ALTER TABLE tpos.pos ADD tabs_enabled BOOLEAN DEFAULT false;
""")
await db.execute("""
ALTER TABLE tpos.pos ADD tabs_allow_create BOOLEAN DEFAULT false;
""")
46 changes: 46 additions & 0 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ class CreateWithdrawPay(BaseModel):
pay_link: str


class CreateTposInvoiceTabSettlement(BaseModel):
tab_id: str = Field(..., min_length=1)
amount: float = Field(..., gt=0)
reference: str | None = Field(None, max_length=120)
description: str | None = Field(None, max_length=512)
idempotency_key: str = Field(..., min_length=8, max_length=128)


class CreateTposInvoice(BaseModel):
amount: int = Query(..., ge=1)
memo: str | None = Query(None)
Expand All @@ -31,6 +39,7 @@ class CreateTposInvoice(BaseModel):
payment_method: str | None = Query(None)
amount_fiat: float | None = Query(None, ge=0.0)
tip_amount_fiat: float | None = Query(None, ge=0.0)
tab_settlement: CreateTposInvoiceTabSettlement | None = Query(None)


class InventorySaleItem(BaseModel):
Expand Down Expand Up @@ -76,6 +85,8 @@ class CreateTposData(BaseModel):
onchain_enabled: bool = Field(False)
onchain_wallet_id: str | None = None
onchain_zero_conf: bool = Field(True)
tabs_enabled: bool = Field(False)
tabs_allow_create: bool = Field(False)

@validator("tax_default", pre=True, always=True)
def default_tax_when_none(cls, v):
Expand Down Expand Up @@ -115,6 +126,8 @@ class TposClean(BaseModel):
onchain_enabled: bool = False
onchain_wallet_id: str | None = None
onchain_zero_conf: bool = True
tabs_enabled: bool = False
tabs_allow_create: bool = False

@property
def withdraw_maximum(self) -> int:
Expand Down Expand Up @@ -168,6 +181,39 @@ class TposInvoiceResponse(BaseModel):
extra: dict[str, Any] = Field(default_factory=dict)


class TposTab(BaseModel):
id: str
name: str
customer_name: str | None = None
reference: str | None = None
currency: str = "sats"
status: str = "open"
balance: float = 0
is_archived: bool = False


class TposTabList(BaseModel):
data: list[TposTab] = Field(default_factory=list)


class CreateTposTabData(BaseModel):
name: str = Field(..., min_length=1, max_length=120)
customer_name: str | None = None
reference: str | None = None
currency: str | None = None
limit_type: str = "none"
limit_amount: float | None = None


class CreateTposTabCharge(BaseModel):
amount: float = Field(..., gt=0)
description: str | None = Field(None, max_length=512)
items: list[dict[str, Any]] = Field(default_factory=list, max_items=200)
notes: dict[str, Any] | None = None
internal_memo: str | None = Field(None, max_length=512)
idempotency_key: str = Field(..., min_length=8, max_length=128)


class LnurlCharge(BaseModel):
id: str
tpos_id: str
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/tpos" }
dependencies = [ "lnbits>1" ]

[tool.uv]
dev-dependencies = [
[dependency-groups]
dev = [
"black",
"pytest-asyncio",
"pytest",
Expand Down
196 changes: 193 additions & 3 deletions services.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from http import HTTPStatus
from typing import Any

import httpx
from fastapi import HTTPException
from lnbits.core.crud import (
get_installed_extension,
get_user_active_extensions_ids,
Expand All @@ -12,6 +14,19 @@
from loguru import logger

from .helpers import from_csv, inventory_tags_to_list
from .models import Tpos

_TAB_STATUSES = {"open", "suspended", "closed"}


async def get_tpos_owner_user_id(tpos: Tpos) -> str:
wallet = await get_wallet(tpos.wallet)
if not wallet:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="TPoS is not ready for tabs integration.",
)
return wallet.user


async def deduct_inventory_stock(wallet_id: str, inventory_payload: dict) -> None:
Expand Down Expand Up @@ -49,7 +64,7 @@ async def deduct_inventory_stock(wallet_id: str, inventory_payload: dict) -> Non


async def get_default_inventory(user_id: str) -> dict[str, Any] | None:
access = create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1)
access = _create_internal_user_access_token(user_id)
async with httpx.AsyncClient() as client:
resp = await client.get(
url=f"http://{settings.host}:{settings.port}/inventory/api/v1",
Expand All @@ -76,7 +91,7 @@ async def get_inventory_items_for_tpos(
tag_list = inventory_tags_to_list(tags)
omit_list = [tag.lower() for tag in inventory_tags_to_list(omit_tags)]
allowed_tags = [tag.lower() for tag in tag_list]
access = create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1)
access = _create_internal_user_access_token(user_id)
async with httpx.AsyncClient() as client:
resp = await client.get(
url=f"http://{settings.host}:{settings.port}/inventory/api/v1/items/{inventory_id}/paginated",
Expand Down Expand Up @@ -212,6 +227,162 @@ def sum_transactions(address: str, txs: list[dict[str, Any]]) -> int:
return sum(sum_outputs(address, tx.get("vout", [])) for tx in txs)


async def tabs_available_for_user(user_id: str) -> bool:
installed = await get_installed_extension("tabs")
if not installed or not installed.active:
return False
active_extensions = await get_user_active_extensions_ids(user_id)
return "tabs" in active_extensions


async def ensure_tpos_tabs_access(tpos: Tpos) -> str:
if not tpos.tabs_enabled:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Tabs integration is not enabled for this TPoS.",
)
user_id = await get_tpos_owner_user_id(tpos)
if not await tabs_available_for_user(user_id):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Tabs integration is unavailable for this TPoS.",
)
return user_id


async def fetch_tabs_for_tpos(
user_id: str,
wallet_id: str,
status: str | None = "open",
query: str | None = None,
) -> list[dict[str, Any]]:
if status and status not in _TAB_STATUSES:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid tab status filter.",
)
access = _create_internal_user_access_token(user_id)
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
url=f"http://{settings.host}:{settings.port}/tabs/api/v1/tabs",
headers={"Authorization": f"Bearer {access}"},
)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
raise _raise_tabs_bridge_error(exc) from exc
except httpx.RequestError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_GATEWAY,
detail="Tabs service is temporarily unavailable.",
) from exc
payload = resp.json()
if not isinstance(payload, list):
return []
tabs = [tab for tab in payload if tab.get("wallet") == wallet_id]
if status:
tabs = [tab for tab in tabs if tab.get("status") == status]
if query:
needle = query.lower()
tabs = [
tab
for tab in tabs
if needle in (tab.get("name") or "").lower()
or needle in (tab.get("customer_name") or "").lower()
or needle in (tab.get("reference") or "").lower()
or needle in (tab.get("id") or "").lower()
]
tabs.sort(key=lambda tab: tab.get("updated_at") or "", reverse=True)
return tabs[:50]


async def create_tab_for_tpos(user_id: str, payload: dict[str, Any]) -> dict[str, Any]:
access = _create_internal_user_access_token(user_id)
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
url=f"http://{settings.host}:{settings.port}/tabs/api/v1/tabs",
headers={"Authorization": f"Bearer {access}"},
json=payload,
)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
raise _raise_tabs_bridge_error(exc) from exc
except httpx.RequestError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_GATEWAY,
detail="Tabs service is temporarily unavailable.",
) from exc
return resp.json()


async def create_tab_charge_for_tpos(
user_id: str,
tab_id: str,
payload: dict[str, Any],
) -> dict[str, Any]:
access = _create_internal_user_access_token(user_id)
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
url=f"http://{settings.host}:{settings.port}/tabs/api/v1/tabs/{tab_id}/entries",
headers={"Authorization": f"Bearer {access}"},
json=payload,
)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
raise _raise_tabs_bridge_error(exc) from exc
except httpx.RequestError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_GATEWAY,
detail="Tabs service is temporarily unavailable.",
) from exc
return resp.json()


async def fetch_single_tab_for_tpos(user_id: str, tab_id: str) -> dict[str, Any]:
access = _create_internal_user_access_token(user_id)
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
url=f"http://{settings.host}:{settings.port}/tabs/api/v1/tabs/{tab_id}",
headers={"Authorization": f"Bearer {access}"},
)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
raise _raise_tabs_bridge_error(exc) from exc
except httpx.RequestError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_GATEWAY,
detail="Tabs service is temporarily unavailable.",
) from exc
return resp.json()


async def create_tab_settlement_for_tpos(
user_id: str,
tab_id: str,
payload: dict[str, Any],
) -> dict[str, Any]:
access = _create_internal_user_access_token(user_id)
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
url=f"http://{settings.host}:{settings.port}/tabs/api/v1/tabs/{tab_id}/settlements",
headers={"Authorization": f"Bearer {access}"},
json=payload,
)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
raise _raise_tabs_bridge_error(exc) from exc
except httpx.RequestError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_GATEWAY,
detail="Tabs service is temporarily unavailable.",
) from exc
return resp.json()


async def push_order_to_orders(
user_id: str,
payment,
Expand Down Expand Up @@ -241,7 +412,7 @@ async def push_order_to_orders(
"shipped": True,
}

access = create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1)
access = _create_internal_user_access_token(user_id)
params = {}
if base_url:
params["base_url"] = base_url
Expand All @@ -255,3 +426,22 @@ async def push_order_to_orders(
)
except Exception as exc:
logger.warning(f"tpos: failed to push order to orders: {exc}")


def _create_internal_user_access_token(user_id: str) -> str:
return create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1)


def _raise_tabs_bridge_error(exc: httpx.HTTPStatusError) -> HTTPException:
status_code = exc.response.status_code if exc.response else HTTPStatus.BAD_GATEWAY
if status_code == HTTPStatus.NOT_FOUND:
detail = "Tab not found."
elif status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
detail = "Tabs action not allowed for this TPoS."
elif status_code == HTTPStatus.BAD_REQUEST:
detail = "Invalid tabs request."
else:
detail = "Tabs service is temporarily unavailable."
if status_code >= HTTPStatus.INTERNAL_SERVER_ERROR:
status_code = HTTPStatus.BAD_GATEWAY
return HTTPException(status_code=status_code, detail=detail)
13 changes: 11 additions & 2 deletions static/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ const mapTpos = obj => {
obj.onchain_enabled = Boolean(obj.onchain_enabled)
obj.onchain_wallet_id = obj.onchain_wallet_id || null
obj.onchain_zero_conf = obj.onchain_zero_conf ?? true
obj.tabs_enabled = Boolean(obj.tabs_enabled)
obj.tabs_allow_create = Boolean(obj.tabs_allow_create)
obj.useWrapper = false
obj.posLocation = ''
obj.auth = ''
Expand Down Expand Up @@ -154,7 +156,9 @@ window.app = Vue.createApp({
allow_cash_settlement: false,
onchain_enabled: false,
onchain_wallet_id: null,
onchain_zero_conf: true
onchain_zero_conf: true,
tabs_enabled: false,
tabs_allow_create: false
},
advanced: {
tips: false,
Expand Down Expand Up @@ -309,7 +313,9 @@ window.app = Vue.createApp({
allow_cash_settlement: false,
onchain_enabled: false,
onchain_wallet_id: null,
onchain_zero_conf: true
onchain_zero_conf: true,
tabs_enabled: false,
tabs_allow_create: false
}
this.formDialog.advanced = {tips: false, otc: false}
},
Expand Down Expand Up @@ -402,6 +408,9 @@ window.app = Vue.createApp({
data.onchain_wallet_id = null
data.onchain_zero_conf = true
}
if (!data.tabs_enabled) {
data.tabs_allow_create = false
}
const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
Expand Down
Loading
Loading