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"\
+ 
+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\
+ 
\ 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>"
+
+
+ Invoice +
+ ++ Bill To +
+ ++ Items +
+ ++ Payments +
+ +Scan to View & Pay Online!
+{{ request.url }}
+