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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

from datetime import datetime
from time import time
from typing import Any, Literal

from fastapi import Query
from pydantic import BaseModel, Field, validator
Expand Down Expand Up @@ -166,4 +168,151 @@ class TapToPay(BaseModel):
paid: bool = False


class PrintReceiptRequest(BaseModel):
receipt_type: Literal["receipt", "order_receipt"] = "receipt"


class ReceiptItemData(BaseModel):
title: str = ""
note: str | None = None
quantity: int = 0
price: float = 0.0


class ReceiptDetailsData(BaseModel):
currency: str = "sats"
exchange_rate: float = 1.0
tax_value: float = 0.0
tax_included: bool = False
items: list[ReceiptItemData] = Field(default_factory=list)


class ReceiptExtraData(BaseModel):
amount: int = 0
paid_in_fiat: bool = False
fiat_method: str | None = None
fiat_payment_request: str | None = None
details: ReceiptDetailsData = Field(default_factory=ReceiptDetailsData)


class ReceiptData(BaseModel):
paid: bool = False
extra: ReceiptExtraData = Field(default_factory=ReceiptExtraData)
created_at: Any = None
business_name: str | None = None
business_address: str | None = None
business_vat_id: str | None = None
only_show_sats_on_bitcoin: bool = True

def paid_in_fiat(self) -> bool:
return bool(
self.extra.paid_in_fiat
or self.extra.fiat_method
or self.extra.fiat_payment_request
)

def show_bitcoin_details(self) -> bool:
return (not self.only_show_sats_on_bitcoin) or (not self.paid_in_fiat())

def subtotal(self) -> float:
if self.extra.details.items:
return sum(item.price * item.quantity for item in self.extra.details.items)
rate = self.extra.details.exchange_rate or 1.0
return self.extra.amount / rate

def total(self) -> float:
if not self.extra.details.items:
rate = self.extra.details.exchange_rate or 1.0
return self.extra.amount / rate
if self.extra.details.tax_included:
return self.subtotal()
return self.subtotal() + self.extra.details.tax_value

def format_money(self, amount: float) -> str:
return f"{amount:.2f} {self.extra.details.currency.upper()}"

def formatted_created_at(self) -> str | None:
if not self.created_at:
return None
if isinstance(self.created_at, datetime):
return self.created_at.strftime("%Y-%m-%d %H:%M")
value = str(self.created_at).strip()
if not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
return parsed.strftime("%Y-%m-%d %H:%M")
except ValueError:
return value

def render_text(
self, receipt_type: Literal["receipt", "order_receipt"] = "receipt"
) -> str:
lines: list[str] = []
lines.append("ORDER" if receipt_type == "order_receipt" else "RECEIPT")
formatted_created_at = self.formatted_created_at()
if formatted_created_at:
lines.append(formatted_created_at)
if self.show_bitcoin_details() and receipt_type != "order_receipt":
lines.append(
f"Rate (sat/{self.extra.details.currency}): "
f"{self.extra.details.exchange_rate:.2f}"
)
lines.append("")

for item in self.extra.details.items:
if item.title.strip():
lines.append(item.title.strip())
if receipt_type == "order_receipt":
lines.append(f"Qty: {item.quantity}")
else:
lines.append(f"{item.quantity} x {self.format_money(item.price)}")
if item.note and item.note.strip():
lines.append(item.note.strip())
lines.append("")

if receipt_type != "order_receipt":
lines.append(f"Subtotal: {self.format_money(self.subtotal())}")
lines.append(f"Tax: {self.format_money(self.extra.details.tax_value)}")
lines.append(f"Total: {self.format_money(self.total())}")
if self.show_bitcoin_details():
lines.append(f"Total (sats): {self.extra.amount}")
lines.append("")
lines.append("Thank you for your purchase!")

if receipt_type != "order_receipt":
if self.business_name:
lines.append(self.business_name)
if self.business_address:
lines.extend(
line for line in self.business_address.splitlines() if line.strip()
)
if self.business_vat_id:
lines.append(f"VAT: {self.business_vat_id}")

while lines and not lines[-1].strip():
lines.pop()
return "\n".join(lines)

def to_api_dict(self) -> dict[str, Any]:
data = self.dict()
details = data.get("extra", {}).get("details", {})
details["exchangeRate"] = details.pop("exchange_rate", 1.0)
details["taxValue"] = details.pop("tax_value", 0.0)
details["taxIncluded"] = details.pop("tax_included", False)
data["created_at"] = self.formatted_created_at()
data["print_text"] = self.render_text("receipt")
data["order_print_text"] = self.render_text("order_receipt")
return data


class ReceiptPrint(BaseModel):
type: str = "receipt_print"
tpos_id: str | None = None
payment_hash: str | None = None
receipt_type: Literal["receipt", "order_receipt"] = "receipt"
print_text: str = ""
receipt: dict[str, Any] = Field(default_factory=dict)


CreateTposInvoice.update_forward_refs(InventorySale=InventorySale)
73 changes: 73 additions & 0 deletions static/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ const mapTpos = obj => {
: []
obj.only_show_sats_on_bitcoin = obj.only_show_sats_on_bitcoin ?? true
obj.allow_cash_settlement = Boolean(obj.allow_cash_settlement)
obj.useWrapper = false
obj.posLocation = ''
obj.auth = ''
obj.loadingWrapperToken = false
obj.itemsMap = new Map()
obj.items.forEach((item, idx) => {
let id = `${obj.id}:${idx + 1}`
Expand Down Expand Up @@ -661,6 +665,68 @@ window.app = Vue.createApp({
this.fileDataDialog.show = false
}
},
buildTposShareUrl(tpos) {
if (!tpos) return ''

if (!tpos.useWrapper) {
return tpos.shareUrl || ''
}

if (tpos.loadingWrapperToken && !tpos.auth) {
return ''
}

const url = new URL(
`/tpos/${encodeURIComponent(tpos.id)}`,
window.location.origin
)
url.searchParams.set('wrapper', 'true')

if (tpos.posLocation) {
url.searchParams.set('pos', tpos.posLocation)
}
if (tpos.auth) {
url.searchParams.set('auth', tpos.auth)
}

return url.toString()
},
async generateWrapperToken(tpos) {
if (!tpos?.useWrapper || tpos.loadingWrapperToken) {
return
}

const wallet = _.findWhere(this.g.user.wallets, {
id: tpos.wallet
})
if (!wallet) {
tpos.useWrapper = false
Quasar.Notify.create({
type: 'warning',
message: 'Unable to find the wallet for this TPoS.'
})
return
}

tpos.loadingWrapperToken = true
try {
const {data} = await LNbits.api.request(
'POST',
`/tpos/api/v1/tposs/${tpos.id}/wrapper-token`,
wallet.adminkey,
{}
)
tpos.auth = data.auth
Quasar.Notify.create({
type: 'positive',
message: 'ACL token generated.'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
tpos.loadingWrapperToken = false
}
},
openUrlDialog(id) {
if (this.tposs.stripe_card_payments) {
this.urlDialog.data = _.findWhere(this.tposs, {
Expand All @@ -670,6 +736,13 @@ window.app = Vue.createApp({
} else {
this.urlDialog.data = _.findWhere(this.tposs, {id})
}
this.urlDialog.data = {
...this.urlDialog.data,
useWrapper: false,
posLocation: '',
auth: '',
loadingWrapperToken: false
}
this.urlDialog.show = true
},
formatAmount(amount, currency) {
Expand Down
Loading
Loading