diff --git a/lnbits/extensions/invoices/README.md b/lnbits/extensions/invoices/README.md new file mode 100644 index 000000000..2b5bd538d --- /dev/null +++ b/lnbits/extensions/invoices/README.md @@ -0,0 +1,19 @@ +# Invoices + +## Create invoices that you can send to your client to pay online over Lightning. + +This extension allows users to create "traditional" invoices (not in the lightning sense) that contain one or more line items. Line items are denominated in a user-configurable fiat currency. Each invoice contains one or more payments up to the total of the invoice. Each invoice creates a public link that can be shared with a customer that they can use to (partially or in full) pay the invoice. + +## Usage + +1. Create an invoice by clicking "NEW INVOICE"\ + ![create new invoice](https://imgur.com/a/Dce3wrr.png) +2. Fill the options for your INVOICE + - select the wallet + - select the fiat currency the invoice will be denominated in + - select a status for the invoice (default is draft) + - enter a company name, first name, last name, email, phone & address (optional) + - add one or more line items + - enter a name & price for each line item +3. You can then use share your invoice link with your customer to receive payment\ + ![invoice link](https://imgur.com/a/L0JOj4T.png) \ No newline at end of file diff --git a/lnbits/extensions/invoices/__init__.py b/lnbits/extensions/invoices/__init__.py new file mode 100644 index 000000000..0b60837bb --- /dev/null +++ b/lnbits/extensions/invoices/__init__.py @@ -0,0 +1,36 @@ +import asyncio + +from fastapi import APIRouter +from starlette.staticfiles import StaticFiles + +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_invoices") + +invoices_static_files = [ + { + "path": "/invoices/static", + "app": StaticFiles(directory="lnbits/extensions/invoices/static"), + "name": "invoices_static", + } +] + +invoices_ext: APIRouter = APIRouter(prefix="/invoices", tags=["invoices"]) + + +def invoices_renderer(): + return template_renderer(["lnbits/extensions/invoices/templates"]) + + +from .tasks import wait_for_paid_invoices + + +def invoices_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) + + +from .views import * # noqa +from .views_api import * # noqa diff --git a/lnbits/extensions/invoices/config.json b/lnbits/extensions/invoices/config.json new file mode 100644 index 000000000..0811e0efd --- /dev/null +++ b/lnbits/extensions/invoices/config.json @@ -0,0 +1,6 @@ +{ + "name": "Invoices", + "short_description": "Create invoices for your clients.", + "icon": "request_quote", + "contributors": ["leesalminen"] +} diff --git a/lnbits/extensions/invoices/crud.py b/lnbits/extensions/invoices/crud.py new file mode 100644 index 000000000..4fd055e9c --- /dev/null +++ b/lnbits/extensions/invoices/crud.py @@ -0,0 +1,206 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import ( + CreateInvoiceData, + CreateInvoiceItemData, + CreatePaymentData, + Invoice, + InvoiceItem, + Payment, + UpdateInvoiceData, + UpdateInvoiceItemData, +) + + +async def get_invoice(invoice_id: str) -> Optional[Invoice]: + row = await db.fetchone( + "SELECT * FROM invoices.invoices WHERE id = ?", (invoice_id,) + ) + return Invoice.from_row(row) if row else None + + +async def get_invoice_items(invoice_id: str) -> List[InvoiceItem]: + rows = await db.fetchall( + f"SELECT * FROM invoices.invoice_items WHERE invoice_id = ?", (invoice_id,) + ) + + return [InvoiceItem.from_row(row) for row in rows] + + +async def get_invoice_item(item_id: str) -> InvoiceItem: + row = await db.fetchone( + "SELECT * FROM invoices.invoice_items WHERE id = ?", (item_id,) + ) + return InvoiceItem.from_row(row) if row else None + + +async def get_invoice_total(items: List[InvoiceItem]) -> int: + return sum(item.amount for item in items) + + +async def get_invoices(wallet_ids: Union[str, List[str]]) -> List[Invoice]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM invoices.invoices WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Invoice.from_row(row) for row in rows] + + +async def get_invoice_payments(invoice_id: str) -> List[Payment]: + rows = await db.fetchall( + f"SELECT * FROM invoices.payments WHERE invoice_id = ?", (invoice_id,) + ) + + return [Payment.from_row(row) for row in rows] + + +async def get_invoice_payment(payment_id: str) -> Payment: + row = await db.fetchone( + "SELECT * FROM invoices.payments WHERE id = ?", (payment_id,) + ) + return Payment.from_row(row) if row else None + + +async def get_payments_total(payments: List[Payment]) -> int: + return sum(item.amount for item in payments) + + +async def create_invoice_internal(wallet_id: str, data: CreateInvoiceData) -> Invoice: + invoice_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO invoices.invoices (id, wallet, status, currency, company_name, first_name, last_name, email, phone, address) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + invoice_id, + wallet_id, + data.status, + data.currency, + data.company_name, + data.first_name, + data.last_name, + data.email, + data.phone, + data.address, + ), + ) + + invoice = await get_invoice(invoice_id) + assert invoice, "Newly created invoice couldn't be retrieved" + return invoice + + +async def create_invoice_items( + invoice_id: str, data: List[CreateInvoiceItemData] +) -> List[InvoiceItem]: + for item in data: + item_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO invoices.invoice_items (id, invoice_id, description, amount) + VALUES (?, ?, ?, ?) + """, + ( + item_id, + invoice_id, + item.description, + int(item.amount * 100), + ), + ) + + invoice_items = await get_invoice_items(invoice_id) + return invoice_items + + +async def update_invoice_internal(wallet_id: str, data: UpdateInvoiceData) -> Invoice: + await db.execute( + """ + UPDATE invoices.invoices + SET wallet = ?, currency = ?, status = ?, company_name = ?, first_name = ?, last_name = ?, email = ?, phone = ?, address = ? + WHERE id = ? + """, + ( + wallet_id, + data.currency, + data.status, + data.company_name, + data.first_name, + data.last_name, + data.email, + data.phone, + data.address, + data.id, + ), + ) + + invoice = await get_invoice(data.id) + assert invoice, "Newly updated invoice couldn't be retrieved" + return invoice + + +async def update_invoice_items( + invoice_id: str, data: List[UpdateInvoiceItemData] +) -> List[InvoiceItem]: + updated_items = [] + for item in data: + if item.id: + updated_items.append(item.id) + await db.execute( + """ + UPDATE invoices.invoice_items + SET description = ?, amount = ? + WHERE id = ? + """, + (item.description, int(item.amount * 100), item.id), + ) + + placeholders = ",".join("?" for i in range(len(updated_items))) + if not placeholders: + placeholders = "?" + updated_items = ("skip",) + + await db.execute( + f""" + DELETE FROM invoices.invoice_items + WHERE invoice_id = ? + AND id NOT IN ({placeholders}) + """, + ( + invoice_id, + *tuple(updated_items), + ), + ) + + for item in data: + if not item.id: + await create_invoice_items(invoice_id=invoice_id, data=[item]) + + invoice_items = await get_invoice_items(invoice_id) + return invoice_items + + +async def create_invoice_payment(invoice_id: str, amount: int) -> Payment: + payment_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO invoices.payments (id, invoice_id, amount) + VALUES (?, ?, ?) + """, + ( + payment_id, + invoice_id, + amount, + ), + ) + + payment = await get_invoice_payment(payment_id) + assert payment, "Newly created payment couldn't be retrieved" + return payment diff --git a/lnbits/extensions/invoices/migrations.py b/lnbits/extensions/invoices/migrations.py new file mode 100644 index 000000000..c47a954ae --- /dev/null +++ b/lnbits/extensions/invoices/migrations.py @@ -0,0 +1,55 @@ +async def m001_initial_invoices(db): + + # STATUS COLUMN OPTIONS: 'draft', 'open', 'paid', 'canceled' + + await db.execute( + f""" + CREATE TABLE invoices.invoices ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + + status TEXT NOT NULL DEFAULT 'draft', + + currency TEXT NOT NULL, + + company_name TEXT DEFAULT NULL, + first_name TEXT DEFAULT NULL, + last_name TEXT DEFAULT NULL, + email TEXT DEFAULT NULL, + phone TEXT DEFAULT NULL, + address TEXT DEFAULT NULL, + + + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE invoices.invoice_items ( + id TEXT PRIMARY KEY, + invoice_id TEXT NOT NULL, + + description TEXT NOT NULL, + amount INTEGER NOT NULL, + + FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id) + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE invoices.payments ( + id TEXT PRIMARY KEY, + invoice_id TEXT NOT NULL, + + amount INT NOT NULL, + + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + + FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id) + ); + """ + ) diff --git a/lnbits/extensions/invoices/models.py b/lnbits/extensions/invoices/models.py new file mode 100644 index 000000000..adf03e462 --- /dev/null +++ b/lnbits/extensions/invoices/models.py @@ -0,0 +1,104 @@ +from enum import Enum +from sqlite3 import Row +from typing import List, Optional + +from fastapi.param_functions import Query +from pydantic import BaseModel + + +class InvoiceStatusEnum(str, Enum): + draft = "draft" + open = "open" + paid = "paid" + canceled = "canceled" + + +class CreateInvoiceItemData(BaseModel): + description: str + amount: float = Query(..., ge=0.01) + + +class CreateInvoiceData(BaseModel): + status: InvoiceStatusEnum = InvoiceStatusEnum.draft + currency: str + company_name: Optional[str] + first_name: Optional[str] + last_name: Optional[str] + email: Optional[str] + phone: Optional[str] + address: Optional[str] + items: List[CreateInvoiceItemData] + + class Config: + use_enum_values = True + + +class UpdateInvoiceItemData(BaseModel): + id: Optional[str] + description: str + amount: float = Query(..., ge=0.01) + + +class UpdateInvoiceData(BaseModel): + id: str + wallet: str + status: InvoiceStatusEnum = InvoiceStatusEnum.draft + currency: str + company_name: Optional[str] + first_name: Optional[str] + last_name: Optional[str] + email: Optional[str] + phone: Optional[str] + address: Optional[str] + items: List[UpdateInvoiceItemData] + + +class Invoice(BaseModel): + id: str + wallet: str + status: InvoiceStatusEnum = InvoiceStatusEnum.draft + currency: str + company_name: Optional[str] + first_name: Optional[str] + last_name: Optional[str] + email: Optional[str] + phone: Optional[str] + address: Optional[str] + time: int + + class Config: + use_enum_values = True + + @classmethod + def from_row(cls, row: Row) -> "Invoice": + return cls(**dict(row)) + + +class InvoiceItem(BaseModel): + id: str + invoice_id: str + description: str + amount: int + + class Config: + orm_mode = True + + @classmethod + def from_row(cls, row: Row) -> "InvoiceItem": + return cls(**dict(row)) + + +class Payment(BaseModel): + id: str + invoice_id: str + amount: int + time: int + + @classmethod + def from_row(cls, row: Row) -> "Payment": + return cls(**dict(row)) + + +class CreatePaymentData(BaseModel): + invoice_id: str + amount: int diff --git a/lnbits/extensions/invoices/static/css/pay.css b/lnbits/extensions/invoices/static/css/pay.css new file mode 100644 index 000000000..626f5a43c --- /dev/null +++ b/lnbits/extensions/invoices/static/css/pay.css @@ -0,0 +1,55 @@ +#invoicePage>.row:first-child>.col-xs { + display: flex; +} + +#invoicePage>.row:first-child>.col-xs>.q-card { + flex: 1; +} + + +#invoicePage .clear { + margin-bottom: 25px; +} + +#printQrCode { + display: none; +} + +@media print { + * { + color: black !important; + } + + header, button, #payButtonContainer { + display: none !important; + } + + main, .q-page-container { + padding-top: 0px !important; + } + + .q-card { + box-shadow: none !important; + border: 1px solid black; + } + + .q-item { + padding: 5px; + } + + .q-card__section { + padding: 5px; + } + + #printQrCode { + display: block; + } + + p { + margin-bottom: 0px !important; + } + + #invoicePage .clear { + margin-bottom: 10px !important; + } +} \ No newline at end of file diff --git a/lnbits/extensions/invoices/tasks.py b/lnbits/extensions/invoices/tasks.py new file mode 100644 index 000000000..61bcb7b4b --- /dev/null +++ b/lnbits/extensions/invoices/tasks.py @@ -0,0 +1,51 @@ +import asyncio +import json + +from lnbits.core.models import Payment +from lnbits.helpers import urlsafe_short_hash +from lnbits.tasks import internal_invoice_queue, register_invoice_listener + +from .crud import ( + create_invoice_payment, + get_invoice, + get_invoice_items, + get_invoice_payments, + get_invoice_total, + get_payments_total, + update_invoice_internal, +) + + +async def wait_for_paid_invoices(): + invoice_queue = asyncio.Queue() + register_invoice_listener(invoice_queue) + + while True: + payment = await invoice_queue.get() + await on_invoice_paid(payment) + + +async def on_invoice_paid(payment: Payment) -> None: + if payment.extra.get("tag") != "invoices": + # not relevant + return + + invoice_id = payment.extra.get("invoice_id") + + payment = await create_invoice_payment( + invoice_id=invoice_id, amount=payment.extra.get("famount") + ) + + invoice = await get_invoice(invoice_id) + + invoice_items = await get_invoice_items(invoice_id) + invoice_total = await get_invoice_total(invoice_items) + + invoice_payments = await get_invoice_payments(invoice_id) + payments_total = await get_payments_total(invoice_payments) + + if payments_total >= invoice_total: + invoice.status = "paid" + await update_invoice_internal(invoice.wallet, invoice) + + return diff --git a/lnbits/extensions/invoices/templates/invoices/_api_docs.html b/lnbits/extensions/invoices/templates/invoices/_api_docs.html new file mode 100644 index 000000000..6e2a63554 --- /dev/null +++ b/lnbits/extensions/invoices/templates/invoices/_api_docs.html @@ -0,0 +1,153 @@ + + + + + GET /invoices/api/v1/invoices +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<invoice_object>, ...] +
Curl example
+ curl -X GET {{ request.base_url }}invoices/api/v1/invoices -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + GET + /invoices/api/v1/invoice/{invoice_id} +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {invoice_object} +
Curl example
+ curl -X GET {{ request.base_url + }}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key: + <invoice_key>" + +
+
+
+ + + + + POST /invoices/api/v1/invoice +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {invoice_object} +
Curl example
+ curl -X POST {{ request.base_url }}invoices/api/v1/invoice -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + POST + /invoices/api/v1/invoice/{invoice_id} +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {invoice_object} +
Curl example
+ curl -X POST {{ request.base_url + }}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key: + <invoice_key>" + +
+
+
+ + + + + POST + /invoices/api/v1/invoice/{invoice_id}/payments +
Headers
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {payment_object} +
Curl example
+ curl -X POST {{ request.base_url + }}invoices/api/v1/invoice/{invoice_id}/payments -H "X-Api-Key: + <invoice_key>" + +
+
+
+ + + + + GET + /invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash} +
Headers
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+
Curl example
+ curl -X GET {{ request.base_url + }}invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash} -H + "X-Api-Key: <invoice_key>" + +
+
+
+
diff --git a/lnbits/extensions/invoices/templates/invoices/index.html b/lnbits/extensions/invoices/templates/invoices/index.html new file mode 100644 index 000000000..e3093e3cb --- /dev/null +++ b/lnbits/extensions/invoices/templates/invoices/index.html @@ -0,0 +1,571 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New Invoice + + + + + +
+
+
Invoices
+
+
+ Export to CSV +
+
+ + {% raw %} + + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} Invoices extension +
+
+ + + {% include "invoices/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Add Line Item + + + + +
+ Create Invoice + Save Invoice + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/invoices/templates/invoices/pay.html b/lnbits/extensions/invoices/templates/invoices/pay.html new file mode 100644 index 000000000..14e3fcb7b --- /dev/null +++ b/lnbits/extensions/invoices/templates/invoices/pay.html @@ -0,0 +1,430 @@ +{% extends "public.html" %} {% block toolbar_title %} Invoice + + +{% endblock %} {% from "macros.jinja" import window_vars with context %} {% +block page %} + +
+
+
+ + +

+ Invoice +

+ + + + ID + {{ invoice_id }} + + + + Created At + {{ datetime.utcfromtimestamp(invoice.time).strftime('%Y-%m-%d + %H:%M') }} + + + + Status + + + {{ invoice.status }} + + + + + + Total + + {{ "{:0,.2f}".format(invoice_total / 100) }} {{ invoice.currency + }} + + + + + Paid + +
+
+ {{ "{:0,.2f}".format(payments_total / 100) }} {{ + invoice.currency }} +
+
+ {% if payments_total < invoice_total %} + + Pay Invoice + + {% endif %} +
+
+
+
+
+
+
+
+ +
+ + +

+ Bill To +

+ + + + Company Name + {{ invoice.company_name }} + + + + Name + {{ invoice.first_name }} {{ invoice.last_name + }} + + + + Address + {{ invoice.address }} + + + + Email + {{ invoice.email }} + + + + Phone + {{ invoice.phone }} + + +
+
+
+
+ +
+ +
+
+ + +

+ Items +

+ + + {% if invoice_items %} + + Item + Amount + + {% endif %} {% for item in invoice_items %} + + {{item.description}} + + {{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency + }} + + + {% endfor %} {% if not invoice_items %} No Invoice Items {% endif %} + +
+
+
+
+ +
+ +
+
+ + +

+ Payments +

+ + + {% if invoice_payments %} + + Date + Amount + + {% endif %} {% for item in invoice_payments %} + + {{ datetime.utcfromtimestamp(item.time).strftime('%Y-%m-%d + %H:%M') }} + + {{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency + }} + + + {% endfor %} {% if not invoice_payments %} No Invoice Payments {% + endif %} + +
+
+
+
+ +
+ +
+
+
+

Scan to View & Pay Online!

+ +
+
+
+ + + + + + + + +
+ Create Payment + Cancel +
+
+
+
+ + + + + + + + +
+ Copy Invoice +
+
+ + + + + + +
+

{{ request.url }}

+
+
+ Copy URL + Close +
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/invoices/views.py b/lnbits/extensions/invoices/views.py new file mode 100644 index 000000000..08223df87 --- /dev/null +++ b/lnbits/extensions/invoices/views.py @@ -0,0 +1,59 @@ +from datetime import datetime +from http import HTTPStatus + +from fastapi import FastAPI, Request +from fastapi.params import Depends +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import invoices_ext, invoices_renderer +from .crud import ( + get_invoice, + get_invoice_items, + get_invoice_payments, + get_invoice_total, + get_payments_total, +) + +templates = Jinja2Templates(directory="templates") + + +@invoices_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return invoices_renderer().TemplateResponse( + "invoices/index.html", {"request": request, "user": user.dict()} + ) + + +@invoices_ext.get("/pay/{invoice_id}", response_class=HTMLResponse) +async def index(request: Request, invoice_id: str): + invoice = await get_invoice(invoice_id) + + if not invoice: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist." + ) + + invoice_items = await get_invoice_items(invoice_id) + invoice_total = await get_invoice_total(invoice_items) + + invoice_payments = await get_invoice_payments(invoice_id) + payments_total = await get_payments_total(invoice_payments) + + return invoices_renderer().TemplateResponse( + "invoices/pay.html", + { + "request": request, + "invoice_id": invoice_id, + "invoice": invoice.dict(), + "invoice_items": invoice_items, + "invoice_total": invoice_total, + "invoice_payments": invoice_payments, + "payments_total": payments_total, + "datetime": datetime, + }, + ) diff --git a/lnbits/extensions/invoices/views_api.py b/lnbits/extensions/invoices/views_api.py new file mode 100644 index 000000000..23a262e39 --- /dev/null +++ b/lnbits/extensions/invoices/views_api.py @@ -0,0 +1,136 @@ +from http import HTTPStatus + +from fastapi import Query +from fastapi.params import Depends +from loguru import logger +from starlette.exceptions import HTTPException + +from lnbits.core.crud import get_user +from lnbits.core.services import create_invoice +from lnbits.core.views.api import api_payment +from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key +from lnbits.utils.exchange_rates import fiat_amount_as_satoshis + +from . import invoices_ext +from .crud import ( + create_invoice_internal, + create_invoice_items, + get_invoice, + get_invoice_items, + get_invoice_payments, + get_invoice_total, + get_invoices, + get_payments_total, + update_invoice_internal, + update_invoice_items, +) +from .models import CreateInvoiceData, UpdateInvoiceData + + +@invoices_ext.get("/api/v1/invoices", status_code=HTTPStatus.OK) +async def api_invoices( + all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type) +): + wallet_ids = [wallet.wallet.id] + if all_wallets: + wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + + return [invoice.dict() for invoice in await get_invoices(wallet_ids)] + + +@invoices_ext.get("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK) +async def api_invoice(invoice_id: str): + invoice = await get_invoice(invoice_id) + if not invoice: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist." + ) + invoice_items = await get_invoice_items(invoice_id) + + invoice_payments = await get_invoice_payments(invoice_id) + payments_total = await get_payments_total(invoice_payments) + + invoice_dict = invoice.dict() + invoice_dict["items"] = invoice_items + invoice_dict["payments"] = payments_total + return invoice_dict + + +@invoices_ext.post("/api/v1/invoice", status_code=HTTPStatus.CREATED) +async def api_invoice_create( + data: CreateInvoiceData, wallet: WalletTypeInfo = Depends(get_key_type) +): + invoice = await create_invoice_internal(wallet_id=wallet.wallet.id, data=data) + items = await create_invoice_items(invoice_id=invoice.id, data=data.items) + invoice_dict = invoice.dict() + invoice_dict["items"] = items + return invoice_dict + + +@invoices_ext.post("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK) +async def api_invoice_update( + data: UpdateInvoiceData, + invoice_id: str, + wallet: WalletTypeInfo = Depends(get_key_type), +): + invoice = await update_invoice_internal(wallet_id=wallet.wallet.id, data=data) + items = await update_invoice_items(invoice_id=invoice.id, data=data.items) + invoice_dict = invoice.dict() + invoice_dict["items"] = items + return invoice_dict + + +@invoices_ext.post( + "/api/v1/invoice/{invoice_id}/payments", status_code=HTTPStatus.CREATED +) +async def api_invoices_create_payment( + famount: int = Query(..., ge=1), invoice_id: str = None +): + invoice = await get_invoice(invoice_id) + invoice_items = await get_invoice_items(invoice_id) + invoice_total = await get_invoice_total(invoice_items) + + invoice_payments = await get_invoice_payments(invoice_id) + payments_total = await get_payments_total(invoice_payments) + + if payments_total + famount > invoice_total: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Amount exceeds invoice due." + ) + + if not invoice: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist." + ) + + price_in_sats = await fiat_amount_as_satoshis(famount / 100, invoice.currency) + + try: + payment_hash, payment_request = await create_invoice( + wallet_id=invoice.wallet, + amount=price_in_sats, + memo=f"Payment for invoice {invoice_id}", + extra={"tag": "invoices", "invoice_id": invoice_id, "famount": famount}, + ) + except Exception as e: + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + + return {"payment_hash": payment_hash, "payment_request": payment_request} + + +@invoices_ext.get( + "/api/v1/invoice/{invoice_id}/payments/{payment_hash}", status_code=HTTPStatus.OK +) +async def api_invoices_check_payment(invoice_id: str, payment_hash: str): + invoice = await get_invoice(invoice_id) + if not invoice: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist." + ) + try: + status = await api_payment(payment_hash) + + except Exception as exc: + logger.error(exc) + return {"paid": False} + return status diff --git a/tests/data/mock_data.zip b/tests/data/mock_data.zip index 6f7165b36..3a7938917 100644 Binary files a/tests/data/mock_data.zip and b/tests/data/mock_data.zip differ diff --git a/tests/extensions/invoices/__init__.py b/tests/extensions/invoices/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/extensions/invoices/conftest.py b/tests/extensions/invoices/conftest.py new file mode 100644 index 000000000..09ac42ecb --- /dev/null +++ b/tests/extensions/invoices/conftest.py @@ -0,0 +1,37 @@ +import pytest +import pytest_asyncio + +from lnbits.core.crud import create_account, create_wallet +from lnbits.extensions.invoices.crud import ( + create_invoice_internal, + create_invoice_items, +) +from lnbits.extensions.invoices.models import CreateInvoiceData + + +@pytest_asyncio.fixture +async def invoices_wallet(): + user = await create_account() + wallet = await create_wallet(user_id=user.id, wallet_name="invoices_test") + + return wallet + + +@pytest_asyncio.fixture +async def accounting_invoice(invoices_wallet): + invoice_data = CreateInvoiceData( + status="open", + currency="USD", + company_name="LNBits, Inc", + first_name="Ben", + last_name="Arc", + items=[{"amount": 10.20, "description": "Item costs 10.20"}], + ) + invoice = await create_invoice_internal( + wallet_id=invoices_wallet.id, data=invoice_data + ) + items = await create_invoice_items(invoice_id=invoice.id, data=invoice_data.items) + + invoice_dict = invoice.dict() + invoice_dict["items"] = items + return invoice_dict diff --git a/tests/extensions/invoices/test_invoices_api.py b/tests/extensions/invoices/test_invoices_api.py new file mode 100644 index 000000000..eaadd07b3 --- /dev/null +++ b/tests/extensions/invoices/test_invoices_api.py @@ -0,0 +1,135 @@ +import pytest +import pytest_asyncio +from loguru import logger + +from lnbits.core.crud import get_wallet +from tests.conftest import adminkey_headers_from, client, invoice +from tests.extensions.invoices.conftest import accounting_invoice, invoices_wallet +from tests.helpers import credit_wallet +from tests.mocks import WALLET + + +@pytest.mark.asyncio +async def test_invoices_unknown_invoice(client): + response = await client.get("/invoices/pay/u") + assert response.json() == {"detail": "Invoice does not exist."} + + +@pytest.mark.asyncio +async def test_invoices_api_create_invoice_valid(client, invoices_wallet): + query = { + "status": "open", + "currency": "EUR", + "company_name": "LNBits, Inc.", + "first_name": "Ben", + "last_name": "Arc", + "email": "ben@legend.arc", + "items": [ + {"amount": 2.34, "description": "Item 1"}, + {"amount": 0.98, "description": "Item 2"}, + ], + } + + status = query["status"] + currency = query["currency"] + fname = query["first_name"] + total = sum(d["amount"] for d in query["items"]) + + response = await client.post( + "/invoices/api/v1/invoice", + json=query, + headers={"X-Api-Key": invoices_wallet.inkey}, + ) + + assert response.status_code == 201 + data = response.json() + + assert data["status"] == status + assert data["wallet"] == invoices_wallet.id + assert data["currency"] == currency + assert data["first_name"] == fname + assert sum(d["amount"] / 100 for d in data["items"]) == total + + +@pytest.mark.asyncio +async def test_invoices_api_partial_pay_invoice( + client, accounting_invoice, adminkey_headers_from +): + invoice_id = accounting_invoice["id"] + amount_to_pay = int(5.05 * 100) # mock invoice total amount is 10 USD + + # ask for an invoice + response = await client.post( + f"/invoices/api/v1/invoice/{invoice_id}/payments?famount={amount_to_pay}" + ) + assert response.status_code < 300 + data = response.json() + payment_hash = data["payment_hash"] + + # pay the invoice + data = {"out": True, "bolt11": data["payment_request"]} + response = await client.post( + "/api/v1/payments", json=data, headers=adminkey_headers_from + ) + assert response.status_code < 300 + assert len(response.json()["payment_hash"]) == 64 + assert len(response.json()["checking_id"]) > 0 + + # check invoice is paid + response = await client.get( + f"/invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash}" + ) + assert response.status_code == 200 + assert response.json()["paid"] == True + + # check invoice status + response = await client.get(f"/invoices/api/v1/invoice/{invoice_id}") + assert response.status_code == 200 + data = response.json() + + assert data["status"] == "open" + + +#### +# +# TEST FAILS FOR NOW, AS LISTENERS ARE NOT WORKING ON TESTING +# +### + +# @pytest.mark.asyncio +# async def test_invoices_api_full_pay_invoice(client, accounting_invoice, adminkey_headers_to): +# invoice_id = accounting_invoice["id"] +# print(accounting_invoice["id"]) +# amount_to_pay = int(10.20 * 100) + +# # ask for an invoice +# response = await client.post( +# f"/invoices/api/v1/invoice/{invoice_id}/payments?famount={amount_to_pay}" +# ) +# assert response.status_code == 201 +# data = response.json() +# payment_hash = data["payment_hash"] + +# # pay the invoice +# data = {"out": True, "bolt11": data["payment_request"]} +# response = await client.post( +# "/api/v1/payments", json=data, headers=adminkey_headers_to +# ) +# assert response.status_code < 300 +# assert len(response.json()["payment_hash"]) == 64 +# assert len(response.json()["checking_id"]) > 0 + +# # check invoice is paid +# response = await client.get( +# f"/invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash}" +# ) +# assert response.status_code == 200 +# assert response.json()["paid"] == True + +# # check invoice status +# response = await client.get(f"/invoices/api/v1/invoice/{invoice_id}") +# assert response.status_code == 200 +# data = response.json() + +# print(data) +# assert data["status"] == "paid"