diff --git a/lnbits/extensions/tipjar/README.md b/lnbits/extensions/tipjar/README.md
new file mode 100644
index 00000000..4965ec93
--- /dev/null
+++ b/lnbits/extensions/tipjar/README.md
@@ -0,0 +1,15 @@
+
Tip Jars
+Accept tips in Bitcoin, with small messages attached!
+The TipJar extension allows you to integrate Bitcoin Lightning (and on-chain) tips into your website or social media!
+
+![image](https://user-images.githubusercontent.com/28876473/134997129-c2f3f13c-a65d-42ed-a9c4-8a1da569d74f.png)
+
+How to set it up
+
+1. Simply create a new Tip Jar with the desired details (onchain optional):
+![image](https://user-images.githubusercontent.com/28876473/134996842-ec2f2783-2eef-4671-8eaf-023713865512.png)
+1. Share the URL you get from this little button:
+![image](https://user-images.githubusercontent.com/28876473/134996973-f8ed4632-ea2f-4b62-83f1-1e4c6b6c91fa.png)
+
+
+And that's it already! Let the sats flow!
diff --git a/lnbits/extensions/tipjar/__init__.py b/lnbits/extensions/tipjar/__init__.py
new file mode 100644
index 00000000..7877cab3
--- /dev/null
+++ b/lnbits/extensions/tipjar/__init__.py
@@ -0,0 +1,19 @@
+import asyncio
+from fastapi import APIRouter, FastAPI
+from fastapi.staticfiles import StaticFiles
+from starlette.routing import Mount
+from lnbits.db import Database
+from lnbits.helpers import template_renderer
+from lnbits.tasks import catch_everything_and_restart
+
+db = Database("ext_tipjar")
+
+tipjar_ext: APIRouter = APIRouter(prefix="/tipjar", tags=["tipjar"])
+
+
+def tipjar_renderer():
+ return template_renderer(["lnbits/extensions/tipjar/templates"])
+
+
+from .views_api import * # noqa
+from .views import * # noqa
diff --git a/lnbits/extensions/tipjar/config.json b/lnbits/extensions/tipjar/config.json
new file mode 100644
index 00000000..e48eb4ea
--- /dev/null
+++ b/lnbits/extensions/tipjar/config.json
@@ -0,0 +1,6 @@
+{
+ "name": "Tip Jar",
+ "short_description": "Accept Bitcoin donations, with messages attached!",
+ "icon": "favorite",
+ "contributors": ["Fittiboy"]
+}
diff --git a/lnbits/extensions/tipjar/crud.py b/lnbits/extensions/tipjar/crud.py
new file mode 100644
index 00000000..1d30fe23
--- /dev/null
+++ b/lnbits/extensions/tipjar/crud.py
@@ -0,0 +1,123 @@
+from . import db
+from .models import Tip, TipJar, createTip, createTipJar
+
+from ..satspay.crud import delete_charge # type: ignore
+
+from typing import Optional
+
+from lnbits.db import SQLITE
+
+
+async def create_tip(data: createTip) -> Tip:
+ """Create a new Tip"""
+ await db.execute(
+ """
+ INSERT INTO tipjar.Tips (
+ id,
+ wallet,
+ name,
+ message,
+ sats,
+ tipjar
+ )
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ (data.id, data.wallet, data.name, data.message, data.sats, data.tipjar),
+ )
+
+ tip = await get_tip(data.id)
+ assert tip, "Newly created tip couldn't be retrieved"
+ return tip
+
+
+async def create_tipjar(data: createTipJar) -> TipJar:
+ """Create a new TipJar"""
+
+ returning = "" if db.type == SQLITE else "RETURNING ID"
+ method = db.execute if db.type == SQLITE else db.fetchone
+
+ result = await (method)(
+ f"""
+ INSERT INTO tipjar.TipJars (
+ name,
+ wallet,
+ webhook,
+ onchain
+ )
+ VALUES (?, ?, ?, ?)
+ {returning}
+ """,
+ (data.name, data.wallet, data.webhook, data.onchain),
+ )
+ if db.type == SQLITE:
+ tipjar_id = result._result_proxy.lastrowid
+ else:
+ tipjar_id = result[0]
+
+ tipjar = await get_tipjar(tipjar_id)
+ assert tipjar
+ return tipjar
+
+
+async def get_tipjar(tipjar_id: int) -> Optional[TipJar]:
+ """Return a tipjar by ID"""
+ row = await db.fetchone("SELECT * FROM tipjar.TipJars WHERE id = ?", (tipjar_id,))
+ return TipJar.from_row(row) if row else None
+
+
+async def get_tipjars(wallet_id: str) -> Optional[list]:
+ """Return all TipJars belonging assigned to the wallet_id"""
+ rows = await db.fetchall(
+ "SELECT * FROM tipjar.TipJars WHERE wallet = ?", (wallet_id,)
+ )
+ return [TipJar.from_row(row) for row in rows] if rows else None
+
+
+async def delete_tipjar(tipjar_id: int) -> None:
+ """Delete a TipJar and all corresponding Tips"""
+ await db.execute("DELETE FROM tipjar.TipJars WHERE id = ?", (tipjar_id,))
+ rows = await db.fetchall("SELECT * FROM tipjar.Tips WHERE tipjar = ?", (tipjar_id,))
+ for row in rows:
+ await delete_tip(row["id"])
+
+
+async def get_tip(tip_id: str) -> Optional[Tip]:
+ """Return a Tip"""
+ row = await db.fetchone("SELECT * FROM tipjar.Tips WHERE id = ?", (tip_id,))
+ return Tip.from_row(row) if row else None
+
+
+async def get_tips(wallet_id: str) -> Optional[list]:
+ """Return all Tips assigned to wallet_id"""
+ rows = await db.fetchall("SELECT * FROM tipjar.Tips WHERE wallet = ?", (wallet_id,))
+ return [Tip.from_row(row) for row in rows] if rows else None
+
+
+async def delete_tip(tip_id: str) -> None:
+ """Delete a Tip and its corresponding statspay charge"""
+ await db.execute("DELETE FROM tipjar.Tips WHERE id = ?", (tip_id,))
+ await delete_charge(tip_id)
+
+
+async def update_tip(tip_id: str, **kwargs) -> Tip:
+ """Update a Tip"""
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE tipjar.Tips SET {q} WHERE id = ?",
+ (*kwargs.values(), tip_id),
+ )
+ row = await db.fetchone("SELECT * FROM tipjar.Tips WHERE id = ?", (tip_id,))
+ assert row, "Newly updated tip couldn't be retrieved"
+ return Tip(**row)
+
+
+async def update_tipjar(tipjar_id: str, **kwargs) -> TipJar:
+ """Update a tipjar"""
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE tipjar.TipJars SET {q} WHERE id = ?",
+ (*kwargs.values(), tipjar_id),
+ )
+ row = await db.fetchone("SELECT * FROM tipjar.TipJars WHERE id = ?", (tipjar_id,))
+ assert row, "Newly updated tipjar couldn't be retrieved"
+ return TipJar(**row)
diff --git a/lnbits/extensions/tipjar/helpers.py b/lnbits/extensions/tipjar/helpers.py
new file mode 100644
index 00000000..7e89b7a9
--- /dev/null
+++ b/lnbits/extensions/tipjar/helpers.py
@@ -0,0 +1,19 @@
+from lnbits.core.crud import get_wallet
+from .crud import get_tipjar
+
+
+async def get_charge_details(tipjar_id):
+ """Return the default details for a satspay charge"""
+ tipjar = await get_tipjar(tipjar_id)
+ wallet_id = tipjar.wallet
+ wallet = await get_wallet(wallet_id)
+ user = wallet.user
+ details = {
+ "time": 1440,
+ "user": user,
+ "lnbitswallet": wallet_id,
+ "onchainwallet": tipjar.onchain,
+ "completelink": "/tipjar/" + str(tipjar_id),
+ "completelinktext": "Thanks for the tip!"
+ }
+ return details
diff --git a/lnbits/extensions/tipjar/migrations.py b/lnbits/extensions/tipjar/migrations.py
new file mode 100644
index 00000000..6b58fbca
--- /dev/null
+++ b/lnbits/extensions/tipjar/migrations.py
@@ -0,0 +1,27 @@
+async def m001_initial(db):
+
+ await db.execute(
+ f"""
+ CREATE TABLE IF NOT EXISTS tipjar.TipJars (
+ id {db.serial_primary_key},
+ name TEXT NOT NULL,
+ wallet TEXT NOT NULL,
+ onchain TEXT,
+ webhook TEXT
+ );
+ """
+ )
+
+ await db.execute(
+ f"""
+ CREATE TABLE IF NOT EXISTS tipjar.Tips (
+ id TEXT PRIMARY KEY,
+ wallet TEXT NOT NULL,
+ name TEXT NOT NULL,
+ message TEXT NOT NULL,
+ sats INT NOT NULL,
+ tipjar INT NOT NULL,
+ FOREIGN KEY(tipjar) REFERENCES {db.references_schema}TipJars(id)
+ );
+ """
+ )
diff --git a/lnbits/extensions/tipjar/models.py b/lnbits/extensions/tipjar/models.py
new file mode 100644
index 00000000..3e68f846
--- /dev/null
+++ b/lnbits/extensions/tipjar/models.py
@@ -0,0 +1,64 @@
+import json
+from lnurl import Lnurl, LnurlWithdrawResponse, encode as lnurl_encode # type: ignore
+from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult
+from lnurl.types import LnurlPayMetadata # type: ignore
+from sqlite3 import Row
+from typing import NamedTuple, Optional, Dict
+import shortuuid # type: ignore
+from fastapi.param_functions import Query
+from pydantic.main import BaseModel
+from pydantic import BaseModel
+from typing import Optional, NamedTuple
+from fastapi import FastAPI, Request
+
+
+class createTip(BaseModel):
+ id: str
+ wallet: str
+ sats: int
+ tipjar: int
+ name: str = "Anonymous"
+ message: str = ""
+
+
+class Tip(NamedTuple):
+ """A Tip represents a single donation"""
+
+ id: str # This ID always corresponds to a satspay charge ID
+ wallet: str
+ name: str # Name of the donor
+ message: str # Donation message
+ sats: int
+ tipjar: int # The ID of the corresponding tip jar
+
+ @classmethod
+ def from_row(cls, row: Row) -> "Tip":
+ return cls(**dict(row))
+
+
+class createTipJar(BaseModel):
+ name: str
+ wallet: str
+ webhook: str = None
+ onchain: str = None
+
+
+class createTips(BaseModel):
+ name: str
+ sats: str
+ tipjar: str
+ message: str
+
+
+class TipJar(NamedTuple):
+ """A TipJar represents a user's tip jar"""
+
+ id: int
+ name: str # The name of the donatee
+ wallet: str # Lightning wallet
+ onchain: Optional[str] # Watchonly wallet
+ webhook: Optional[str] # URL to POST tips to
+
+ @classmethod
+ def from_row(cls, row: Row) -> "TipJar":
+ return cls(**dict(row))
diff --git a/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html b/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html
new file mode 100644
index 00000000..42788bad
--- /dev/null
+++ b/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html
@@ -0,0 +1,16 @@
+
+
+
+ Tip Jar: Receive tips with messages!
+
+
+ Your personal Bitcoin tip page, which supports
+ lightning and on-chain payments.
+ Notifications, including a donation message,
+ can be sent via webhook.
+
+ Created by, Fitti
+
+
+
diff --git a/lnbits/extensions/tipjar/templates/tipjar/display.html b/lnbits/extensions/tipjar/templates/tipjar/display.html
new file mode 100644
index 00000000..87bce0f4
--- /dev/null
+++ b/lnbits/extensions/tipjar/templates/tipjar/display.html
@@ -0,0 +1,92 @@
+{% extends "public.html" %} {% block page %}
+
+
+
+
+ Tip {{ donatee }} some sats!
+
+
+
+
+
+
+ Submit
+
+
+
+
+
+
+
+{% endblock %} {% block scripts %}
+
+{% endblock %}
diff --git a/lnbits/extensions/tipjar/templates/tipjar/index.html b/lnbits/extensions/tipjar/templates/tipjar/index.html
new file mode 100644
index 00000000..7c58a74f
--- /dev/null
+++ b/lnbits/extensions/tipjar/templates/tipjar/index.html
@@ -0,0 +1,447 @@
+{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
+%} {% block page %}
+
+
+
+
+ New TipJar
+
+
+
+
+
+
+
+
TipJars
+
+
+ Export to CSV
+
+
+
+ {% raw %}
+
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+
+
+
+
+ {{ col.value }}
+
+
+
+
+
+
+ {% endraw %}
+
+
+
+
+
+
+
+
+
Tips
+
+
+ Export to CSV
+
+
+
+ {% raw %}
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+ {{ col.value }}
+
+
+
+
+
+
+ {% endraw %}
+
+
+
+
+
+
+
+
+ {{SITE_TITLE}} TipJar extension
+
+
+
+
+ {% include "tipjar/_api_docs.html" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Watch-Only extension MUST be activated and have a wallet
+
+
+
+
+
+
+
+
+
+
+
+ Update TipJar
+
+ Create TipJar
+ Cancel
+
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }}
+
+{% endblock %}
diff --git a/lnbits/extensions/tipjar/views.py b/lnbits/extensions/tipjar/views.py
new file mode 100644
index 00000000..03e93993
--- /dev/null
+++ b/lnbits/extensions/tipjar/views.py
@@ -0,0 +1,47 @@
+from .crud import get_tipjar
+
+from http import HTTPStatus
+import httpx
+from collections import defaultdict
+from lnbits.decorators import check_user_exists
+
+from functools import wraps
+import hashlib
+from lnbits.core.services import check_invoice_status
+from lnbits.core.crud import update_payment_status, get_standalone_payment
+from fastapi import FastAPI, Request
+from fastapi.templating import Jinja2Templates
+from starlette.exceptions import HTTPException
+from starlette.responses import HTMLResponse
+from fastapi.params import Depends
+from fastapi.param_functions import Query
+import random
+
+from datetime import datetime
+from http import HTTPStatus
+from . import tipjar_ext, tipjar_renderer
+from lnbits.core.models import User, Payment
+
+templates = Jinja2Templates(directory="templates")
+
+
+@tipjar_ext.get("/")
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return tipjar_renderer().TemplateResponse(
+ "tipjar/index.html", {"request": request, "user": user.dict()}
+ )
+
+
+@tipjar_ext.route("/{id}")
+async def tip(request: Request, id: str = Query(None)):
+ """Return the donation form for the Tipjar corresponding to id"""
+ tipjar = await get_tipjar(id)
+ if not tipjar:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="TipJar does not exist."
+ )
+
+ return tipjar_renderer().TemplateResponse(
+ "tipjar/display.html",
+ {"request": request, "donatee": tipjar.name, "tipjar": tipjar.id},
+ )
diff --git a/lnbits/extensions/tipjar/views_api.py b/lnbits/extensions/tipjar/views_api.py
new file mode 100644
index 00000000..1c409db0
--- /dev/null
+++ b/lnbits/extensions/tipjar/views_api.py
@@ -0,0 +1,193 @@
+from http import HTTPStatus
+
+from fastapi import Request
+from fastapi.param_functions import Query
+from fastapi.params import Depends
+from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
+from starlette.exceptions import HTTPException
+
+from lnbits.decorators import (
+ WalletTypeInfo,
+ get_key_type,
+)
+from lnbits.core.crud import get_user
+
+from . import tipjar_ext
+from .helpers import get_charge_details
+from .crud import (
+ create_tipjar,
+ get_tipjar,
+ create_tip,
+ get_tipjars,
+ get_tip,
+ get_tips,
+ update_tip,
+ update_tipjar,
+ delete_tip,
+ delete_tipjar,
+)
+from ..satspay.crud import create_charge
+from .models import createTipJar, createTips, createTip
+
+
+@tipjar_ext.post("/api/v1/tipjars")
+async def api_create_tipjar(data: createTipJar):
+ """Create a tipjar, which holds data about how/where to post tips"""
+ try:
+ tipjar = await create_tipjar(**data)
+ except Exception as e:
+ raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
+
+ return tipjar.dict()
+
+
+@tipjar_ext.post("/api/v1/tips")
+async def api_create_tip(data: createTips, dataCreateTip: createTip):
+ """Take data from tip form and return satspay charge"""
+ sats = data.sats
+ message = data.get("message", "")[:144]
+ if not message:
+ message = "No message"
+ tipjar_id = data.tipjar
+ tipjar = await get_tipjar(tipjar_id)
+ webhook = tipjar.webhook
+ charge_details = await get_charge_details(tipjar.id)
+ name = data.get("name", "")[:25]
+ # Ensure that description string can be split reliably
+ name = name.replace('"', "''")
+ if not name:
+ name = "Anonymous"
+ description = f'"{name}": {message}'
+
+ charge = await create_charge(
+ amount=sats,
+ webhook=webhook,
+ description=description,
+ **charge_details,
+ )
+ dataCreateTip.id = charge.id
+ dataCreateTip.wallet = tipjar.wallet
+ dataCreateTip.message = message
+ dataCreateTip.name = name
+ dataCreateTip.sats = data.sats
+ dataCreateTip.tipjar = data.tipjar
+ await create_tip(dataCreateTip)
+
+ return {"redirect_url": f"/satspay/{charge.id}"}
+
+
+@tipjar_ext.get("/api/v1/tipjars")
+async def api_get_tipjars(wallet: WalletTypeInfo = Depends(get_key_type)):
+ """Return list of all tipjars assigned to wallet with given invoice key"""
+ wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
+ tipjars = []
+ for wallet_id in wallet_ids:
+ new_tipjars = await get_tipjars(wallet_id)
+ tipjars += new_tipjars if new_tipjars else []
+ return [tipjar._asdict() for tipjar in tipjars] if tipjars else []
+
+
+@tipjar_ext.get("/api/v1/tips")
+async def api_get_tips(wallet: WalletTypeInfo = Depends(get_key_type)):
+ """Return list of all tips assigned to wallet with given invoice key"""
+ wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
+ tips = []
+ for wallet_id in wallet_ids:
+ new_tips = await get_tips(wallet_id)
+ tips += new_tips if new_tips else []
+ return [tip._asdict() for tip in tips] if tips else []
+
+
+@tipjar_ext.put("/api/v1/tips/{tip_id}")
+async def api_update_tip(
+ wallet: WalletTypeInfo = Depends(get_key_type), tip_id: str = Query(None)
+):
+ """Update a tip with the data given in the request"""
+ if tip_id:
+ tip = await get_tip(tip_id)
+
+ if not tip:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Tip does not exist."
+ )
+
+ if tip.wallet != wallet.wallet.id:
+
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Not your tip."
+ )
+
+ tip = await update_tip(tip_id, **g.data)
+ else:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST, detail="No tip ID specified"
+ )
+ return tip.dict()
+
+
+@tipjar_ext.put("/api/v1/tipjars/{tipjar_id}")
+async def api_update_tipjar(
+ wallet: WalletTypeInfo = Depends(get_key_type), tipjar_id: str = Query(None)
+):
+ """Update a tipjar with the data given in the request"""
+ if tipjar_id:
+ tipjar = await get_tipjar(tipjar_id)
+
+ if not tipjar:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="TipJar does not exist."
+ )
+
+ if tipjar.wallet != wallet.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Not your tipjar."
+ )
+
+ tipjar = await update_tipjar(tipjar_id, **data)
+ else:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST, detail="No tipjar ID specified"
+ )
+ return tipjar.dict()
+
+
+@tipjar_ext.delete("/api/v1/tips/{tip_id}")
+async def api_delete_tip(
+ wallet: WalletTypeInfo = Depends(get_key_type), tip_id: str = Query(None)
+):
+ """Delete the tip with the given tip_id"""
+ tip = await get_tip(tip_id)
+ if not tip:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="No tip with this ID!"
+ )
+ if tip.wallet != g.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Not authorized to delete this tip!",
+ )
+ await delete_tip(tip_id)
+
+ return "", HTTPStatus.NO_CONTENT
+
+
+@tipjar_ext.delete("/api/v1/tipjars/{tipjar_id}")
+async def api_delete_tipjar(
+ wallet: WalletTypeInfo = Depends(get_key_type), tipjar_id: str = Query(None)
+):
+ """Delete the tipjar with the given tipjar_id"""
+ tipjar = await get_tipjar(tipjar_id)
+ if not tipjar:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="No tipjar with this ID!",
+ )
+ if tipjar.wallet != g.wallet.id:
+
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Not authorized to delete this tipjar!",
+ )
+ await delete_tipjar(tipjar_id)
+
+ return "", HTTPStatus.NO_CONTENT