From 3df1e4a1d2e85ac722f8e6ed04c489ddc94b8ef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Fri, 17 Feb 2023 10:13:12 +0100 Subject: [PATCH] init commit --- .github/workflows/release.yml | 19 + .gitignore | 1 + README.md | 19 + __init__.py | 36 ++ config.json | 6 + crud.py | 210 +++++++++++ migrations.py | 55 +++ models.py | 104 ++++++ static/css/pay.css | 65 ++++ static/image/invoices.png | Bin 0 -> 8773 bytes tasks.py | 52 +++ templates/invoices/_api_docs.html | 153 ++++++++ templates/invoices/index.html | 571 ++++++++++++++++++++++++++++++ templates/invoices/pay.html | 433 ++++++++++++++++++++++ views.py | 57 +++ views_api.py | 133 +++++++ 16 files changed, 1914 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 config.json create mode 100644 crud.py create mode 100644 migrations.py create mode 100644 models.py create mode 100644 static/css/pay.css create mode 100644 static/image/invoices.png create mode 100644 tasks.py create mode 100644 templates/invoices/_api_docs.html create mode 100644 templates/invoices/index.html create mode 100644 templates/invoices/pay.html create mode 100644 views.py create mode 100644 views_api.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..92df069 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,19 @@ +name: release github version +on: + push: + tags: + - "[0-9]+.[0-9]+" +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Create GitHub Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: ${{ github.ref }} + draft: false + prerelease: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b5bd53 --- /dev/null +++ b/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/__init__.py b/__init__.py new file mode 100644 index 0000000..735e95d --- /dev/null +++ b/__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: F401,F403 +from .views_api import * # noqa: F401,F403 diff --git a/config.json b/config.json new file mode 100644 index 0000000..a604fec --- /dev/null +++ b/config.json @@ -0,0 +1,6 @@ +{ + "name": "Invoices", + "short_description": "Create invoices for your clients.", + "tile": "/invoices/static/image/invoices.png", + "contributors": ["leesalminen"] +} diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..3965280 --- /dev/null +++ b/crud.py @@ -0,0 +1,210 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import ( + CreateInvoiceData, + CreateInvoiceItemData, + 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( + "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) -> Optional[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( + "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) -> Optional[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: Union[UpdateInvoiceData, Invoice] +) -> 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 _ 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: + await create_invoice_items( + invoice_id=invoice_id, + data=[CreateInvoiceItemData(description=item.description)], + ) + + 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/migrations.py b/migrations.py new file mode 100644 index 0000000..74a0fdb --- /dev/null +++ b/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 {db.big_int} NOT NULL, + + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + + FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id) + ); + """ + ) diff --git a/models.py b/models.py new file mode 100644 index 0000000..6f0e63c --- /dev/null +++ b/models.py @@ -0,0 +1,104 @@ +from enum import Enum +from sqlite3 import Row +from typing import List, Optional + +from fastapi 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/static/css/pay.css b/static/css/pay.css new file mode 100644 index 0000000..ad7ce91 --- /dev/null +++ b/static/css/pay.css @@ -0,0 +1,65 @@ +#invoicePage>.row:first-child>.col-md-6 { + display: flex; +} + +#invoicePage>.row:first-child>.col-md-6>.q-card { + flex: 1; +} + +#invoicePage .clear { + margin-bottom: 25px; +} + +#printQrCode { + display: none; +} + +@media (min-width: 1024px) { + #invoicePage>.row:first-child>.col-md-6:first-child>div { + margin-right: 5px; + } + + #invoicePage>.row:first-child>.col-md-6:nth-child(2)>div { + margin-left: 5px; + } +} + + +@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/static/image/invoices.png b/static/image/invoices.png new file mode 100644 index 0000000000000000000000000000000000000000..823f9dee2b9f64a7ebe85a134827d75c9bc06181 GIT binary patch literal 8773 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 None: + if payment.extra.get("tag") != "invoices": + return + + invoice_id = payment.extra.get("invoice_id") + assert invoice_id + + amount = payment.extra.get("famount") + assert amount + + await create_invoice_payment(invoice_id=invoice_id, amount=amount) + + invoice = await get_invoice(invoice_id) + assert invoice + + 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 = InvoiceStatusEnum.paid + await update_invoice_internal(invoice.wallet, invoice) + + return diff --git a/templates/invoices/_api_docs.html b/templates/invoices/_api_docs.html new file mode 100644 index 0000000..6e2a635 --- /dev/null +++ b/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/templates/invoices/index.html b/templates/invoices/index.html new file mode 100644 index 0000000..4ef3b7f --- /dev/null +++ b/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/templates/invoices/pay.html b/templates/invoices/pay.html new file mode 100644 index 0000000..82f1765 --- /dev/null +++ b/templates/invoices/pay.html @@ -0,0 +1,433 @@ +{% 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/views.py b/views.py new file mode 100644 index 0000000..cc35b35 --- /dev/null +++ b/views.py @@ -0,0 +1,57 @@ +from datetime import datetime +from http import HTTPStatus + +from fastapi import Depends, HTTPException, Request +from fastapi.templating import Jinja2Templates +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 pay(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/views_api.py b/views_api.py new file mode 100644 index 0000000..1a7762a --- /dev/null +++ b/views_api.py @@ -0,0 +1,133 @@ +from http import HTTPStatus + +from fastapi import Depends, HTTPException, Query +from loguru import logger + +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 +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: + user = await get_user(wallet.wallet.user) + wallet_ids = user.wallet_ids if user else [] + + 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(invoice_id: str, famount: int = Query(..., ge=1)): + 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