remove satspay (#1520)
This commit is contained in:
parent
5a8db02c60
commit
6dade2edf3
|
@ -1,27 +0,0 @@
|
|||
# SatsPay Server
|
||||
|
||||
## Create onchain and LN charges. Includes webhooks!
|
||||
|
||||
Easilly create invoices that support Lightning Network and on-chain BTC payment.
|
||||
|
||||
1. Create a "NEW CHARGE"\
|
||||
![new charge](https://i.imgur.com/fUl6p74.png)
|
||||
2. Fill out the invoice fields
|
||||
- set a descprition for the payment
|
||||
- the amount in sats
|
||||
- the time, in minutes, the invoice is valid for, after this period the invoice can't be payed
|
||||
- set a webhook that will get the transaction details after a successful payment
|
||||
- set to where the user should redirect after payment
|
||||
- set the text for the button that will show after payment (not setting this, will display "NONE" in the button)
|
||||
- select if you want onchain payment, LN payment or both
|
||||
- depending on what you select you'll have to choose the respective wallets where to receive your payment\
|
||||
![charge form](https://i.imgur.com/F10yRiW.png)
|
||||
3. The charge will appear on the _Charges_ section\
|
||||
![charges](https://i.imgur.com/zqHpVxc.png)
|
||||
4. Your customer/payee will get the payment page
|
||||
- they can choose to pay on LN\
|
||||
![offchain payment](https://i.imgur.com/4191SMV.png)
|
||||
- or pay on chain\
|
||||
![onchain payment](https://i.imgur.com/wzLRR5N.png)
|
||||
5. You can check the state of your charges in LNbits\
|
||||
![invoice state](https://i.imgur.com/JnBd22p.png)
|
|
@ -1,35 +0,0 @@
|
|||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.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_satspay")
|
||||
|
||||
|
||||
satspay_ext: APIRouter = APIRouter(prefix="/satspay", tags=["satspay"])
|
||||
|
||||
satspay_static_files = [
|
||||
{
|
||||
"path": "/satspay/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/satspay/static"),
|
||||
"name": "satspay_static",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def satspay_renderer():
|
||||
return template_renderer(["lnbits/extensions/satspay/templates"])
|
||||
|
||||
|
||||
from .tasks import wait_for_paid_invoices
|
||||
from .views import * # noqa: F401,F403
|
||||
from .views_api import * # noqa: F401,F403
|
||||
|
||||
|
||||
def satspay_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "SatsPay Server",
|
||||
"short_description": "Create onchain and LN charges",
|
||||
"tile": "/satspay/static/image/satspay.png",
|
||||
"contributors": ["arcbtc"]
|
||||
}
|
|
@ -1,181 +0,0 @@
|
|||
import json
|
||||
from typing import List, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from ..watchonly.crud import get_config, get_fresh_address # type: ignore
|
||||
from . import db
|
||||
from .helpers import fetch_onchain_balance
|
||||
from .models import Charges, CreateCharge, SatsPayThemes
|
||||
|
||||
|
||||
async def create_charge(user: str, data: CreateCharge) -> Charges:
|
||||
data = CreateCharge(**data.dict())
|
||||
charge_id = urlsafe_short_hash()
|
||||
if data.onchainwallet:
|
||||
config = await get_config(user)
|
||||
assert config
|
||||
data.extra = json.dumps(
|
||||
{"mempool_endpoint": config.mempool_endpoint, "network": config.network}
|
||||
)
|
||||
onchain = await get_fresh_address(data.onchainwallet)
|
||||
if not onchain:
|
||||
raise Exception(f"Wallet '{data.onchainwallet}' can no longer be accessed.")
|
||||
onchainaddress = onchain.address
|
||||
else:
|
||||
onchainaddress = None
|
||||
if data.lnbitswallet:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=data.lnbitswallet,
|
||||
amount=data.amount,
|
||||
memo=charge_id,
|
||||
extra={"tag": "charge"},
|
||||
)
|
||||
else:
|
||||
payment_hash = None
|
||||
payment_request = None
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO satspay.charges (
|
||||
id,
|
||||
"user",
|
||||
description,
|
||||
onchainwallet,
|
||||
onchainaddress,
|
||||
lnbitswallet,
|
||||
payment_request,
|
||||
payment_hash,
|
||||
webhook,
|
||||
completelink,
|
||||
completelinktext,
|
||||
time,
|
||||
amount,
|
||||
balance,
|
||||
extra,
|
||||
custom_css
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
charge_id,
|
||||
user,
|
||||
data.description,
|
||||
data.onchainwallet,
|
||||
onchainaddress,
|
||||
data.lnbitswallet,
|
||||
payment_request,
|
||||
payment_hash,
|
||||
data.webhook,
|
||||
data.completelink,
|
||||
data.completelinktext,
|
||||
data.time,
|
||||
data.amount,
|
||||
0,
|
||||
data.extra,
|
||||
data.custom_css,
|
||||
),
|
||||
)
|
||||
charge = await get_charge(charge_id)
|
||||
assert charge, "Newly created charge does not exist"
|
||||
return charge
|
||||
|
||||
|
||||
async def update_charge(charge_id: str, **kwargs) -> Optional[Charges]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE satspay.charges SET {q} WHERE id = ?", (*kwargs.values(), charge_id)
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,))
|
||||
return Charges.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_charge(charge_id: str) -> Optional[Charges]:
|
||||
row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,))
|
||||
return Charges.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_charges(user: str) -> List[Charges]:
|
||||
rows = await db.fetchall(
|
||||
"""SELECT * FROM satspay.charges WHERE "user" = ? ORDER BY "timestamp" DESC """,
|
||||
(user,),
|
||||
)
|
||||
return [Charges.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def delete_charge(charge_id: str) -> None:
|
||||
await db.execute("DELETE FROM satspay.charges WHERE id = ?", (charge_id,))
|
||||
|
||||
|
||||
async def check_address_balance(charge_id: str) -> Optional[Charges]:
|
||||
charge = await get_charge(charge_id)
|
||||
assert charge
|
||||
|
||||
if not charge.paid:
|
||||
if charge.onchainaddress:
|
||||
try:
|
||||
respAmount = await fetch_onchain_balance(charge)
|
||||
if respAmount > charge.balance:
|
||||
await update_charge(charge_id=charge_id, balance=respAmount)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
if charge.lnbitswallet:
|
||||
invoice_status = await api_payment(charge.payment_hash)
|
||||
|
||||
if invoice_status["paid"]:
|
||||
return await update_charge(charge_id=charge_id, balance=charge.amount)
|
||||
return await get_charge(charge_id)
|
||||
|
||||
|
||||
################## SETTINGS ###################
|
||||
|
||||
|
||||
async def save_theme(data: SatsPayThemes, css_id: Optional[str]):
|
||||
# insert or update
|
||||
if css_id:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE satspay.themes SET custom_css = ?, title = ? WHERE css_id = ?
|
||||
""",
|
||||
(data.custom_css, data.title, css_id),
|
||||
)
|
||||
else:
|
||||
css_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO satspay.themes (
|
||||
css_id,
|
||||
title,
|
||||
"user",
|
||||
custom_css
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
css_id,
|
||||
data.title,
|
||||
data.user,
|
||||
data.custom_css,
|
||||
),
|
||||
)
|
||||
return await get_theme(css_id)
|
||||
|
||||
|
||||
async def get_theme(css_id: str) -> Optional[SatsPayThemes]:
|
||||
row = await db.fetchone("SELECT * FROM satspay.themes WHERE css_id = ?", (css_id,))
|
||||
return SatsPayThemes.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_themes(user_id: str) -> List[SatsPayThemes]:
|
||||
rows = await db.fetchall(
|
||||
"""SELECT * FROM satspay.themes WHERE "user" = ? ORDER BY "title" DESC """,
|
||||
(user_id,),
|
||||
)
|
||||
return [SatsPayThemes.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def delete_theme(theme_id: str) -> None:
|
||||
await db.execute("DELETE FROM satspay.themes WHERE css_id = ?", (theme_id,))
|
|
@ -1,61 +0,0 @@
|
|||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from .models import Charges
|
||||
|
||||
|
||||
def public_charge(charge: Charges):
|
||||
c = {
|
||||
"id": charge.id,
|
||||
"description": charge.description,
|
||||
"onchainaddress": charge.onchainaddress,
|
||||
"payment_request": charge.payment_request,
|
||||
"payment_hash": charge.payment_hash,
|
||||
"time": charge.time,
|
||||
"amount": charge.amount,
|
||||
"balance": charge.balance,
|
||||
"paid": charge.paid,
|
||||
"timestamp": charge.timestamp,
|
||||
"time_elapsed": charge.time_elapsed,
|
||||
"time_left": charge.time_left,
|
||||
"custom_css": charge.custom_css,
|
||||
}
|
||||
|
||||
if charge.paid:
|
||||
c["completelink"] = charge.completelink
|
||||
c["completelinktext"] = charge.completelinktext
|
||||
|
||||
return c
|
||||
|
||||
|
||||
async def call_webhook(charge: Charges):
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
assert charge.webhook
|
||||
r = await client.post(
|
||||
charge.webhook,
|
||||
json=public_charge(charge),
|
||||
timeout=40,
|
||||
)
|
||||
return {
|
||||
"webhook_success": r.is_success,
|
||||
"webhook_message": r.reason_phrase,
|
||||
"webhook_response": r.text,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to call webhook for charge {charge.id}")
|
||||
logger.warning(e)
|
||||
return {"webhook_success": False, "webhook_message": str(e)}
|
||||
|
||||
|
||||
async def fetch_onchain_balance(charge: Charges):
|
||||
endpoint = (
|
||||
f"{charge.config.mempool_endpoint}/testnet"
|
||||
if charge.config.network == "Testnet"
|
||||
else charge.config.mempool_endpoint
|
||||
)
|
||||
assert endpoint
|
||||
assert charge.onchainaddress
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(endpoint + "/api/address/" + charge.onchainaddress)
|
||||
return r.json()["chain_stats"]["funded_txo_sum"]
|
|
@ -1,64 +0,0 @@
|
|||
async def m001_initial(db):
|
||||
"""
|
||||
Initial wallet table.
|
||||
"""
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE satspay.charges (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
"user" TEXT,
|
||||
description TEXT,
|
||||
onchainwallet TEXT,
|
||||
onchainaddress TEXT,
|
||||
lnbitswallet TEXT,
|
||||
payment_request TEXT,
|
||||
payment_hash TEXT,
|
||||
webhook TEXT,
|
||||
completelink TEXT,
|
||||
completelinktext TEXT,
|
||||
time INTEGER,
|
||||
amount {db.big_int},
|
||||
balance {db.big_int} DEFAULT 0,
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m002_add_charge_extra_data(db):
|
||||
"""
|
||||
Add 'extra' column for storing various config about the charge (JSON format)
|
||||
"""
|
||||
await db.execute(
|
||||
"""ALTER TABLE satspay.charges
|
||||
ADD COLUMN extra TEXT DEFAULT '{"mempool_endpoint": "https://mempool.space", "network": "Mainnet"}';
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m003_add_themes_table(db):
|
||||
"""
|
||||
Themes table
|
||||
"""
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE satspay.themes (
|
||||
css_id TEXT NOT NULL PRIMARY KEY,
|
||||
"user" TEXT,
|
||||
title TEXT,
|
||||
custom_css TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m004_add_custom_css_to_charges(db):
|
||||
"""
|
||||
Add custom css option column to the 'charges' table
|
||||
"""
|
||||
|
||||
await db.execute("ALTER TABLE satspay.charges ADD COLUMN custom_css TEXT;")
|
|
@ -1,91 +0,0 @@
|
|||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from sqlite3 import Row
|
||||
from typing import Optional
|
||||
|
||||
from fastapi.param_functions import Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
DEFAULT_MEMPOOL_CONFIG = (
|
||||
'{"mempool_endpoint": "https://mempool.space", "network": "Mainnet"}'
|
||||
)
|
||||
|
||||
|
||||
class CreateCharge(BaseModel):
|
||||
onchainwallet: str = Query(None)
|
||||
lnbitswallet: str = Query(None)
|
||||
description: str = Query(...)
|
||||
webhook: str = Query(None)
|
||||
completelink: str = Query(None)
|
||||
completelinktext: str = Query(None)
|
||||
custom_css: Optional[str]
|
||||
time: int = Query(..., ge=1)
|
||||
amount: int = Query(..., ge=1)
|
||||
extra: str = DEFAULT_MEMPOOL_CONFIG
|
||||
|
||||
|
||||
class ChargeConfig(BaseModel):
|
||||
mempool_endpoint: Optional[str]
|
||||
network: Optional[str]
|
||||
webhook_success: Optional[bool] = False
|
||||
webhook_message: Optional[str]
|
||||
|
||||
|
||||
class Charges(BaseModel):
|
||||
id: str
|
||||
description: Optional[str]
|
||||
onchainwallet: Optional[str]
|
||||
onchainaddress: Optional[str]
|
||||
lnbitswallet: Optional[str]
|
||||
payment_request: Optional[str]
|
||||
payment_hash: Optional[str]
|
||||
webhook: Optional[str]
|
||||
completelink: Optional[str]
|
||||
completelinktext: Optional[str] = "Back to Merchant"
|
||||
custom_css: Optional[str]
|
||||
extra: str = DEFAULT_MEMPOOL_CONFIG
|
||||
time: int
|
||||
amount: int
|
||||
balance: int
|
||||
timestamp: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Charges":
|
||||
return cls(**dict(row))
|
||||
|
||||
@property
|
||||
def time_left(self):
|
||||
now = datetime.utcnow().timestamp()
|
||||
start = datetime.fromtimestamp(self.timestamp)
|
||||
expiration = (start + timedelta(minutes=self.time)).timestamp()
|
||||
return (expiration - now) / 60
|
||||
|
||||
@property
|
||||
def time_elapsed(self):
|
||||
return self.time_left < 0
|
||||
|
||||
@property
|
||||
def paid(self):
|
||||
if self.balance >= self.amount:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
@property
|
||||
def config(self) -> ChargeConfig:
|
||||
charge_config = json.loads(self.extra)
|
||||
return ChargeConfig(**charge_config)
|
||||
|
||||
def must_call_webhook(self):
|
||||
return self.webhook and self.paid and self.config.webhook_success is False
|
||||
|
||||
|
||||
class SatsPayThemes(BaseModel):
|
||||
css_id: str = Query(None)
|
||||
title: str = Query(None)
|
||||
custom_css: str = Query(None)
|
||||
user: Optional[str]
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "SatsPayThemes":
|
||||
return cls(**dict(row))
|
Binary file not shown.
Before Width: | Height: | Size: 24 KiB |
|
@ -1,36 +0,0 @@
|
|||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
const retryWithDelay = async function (fn, retryCount = 0) {
|
||||
try {
|
||||
await sleep(25)
|
||||
// Do not return the call directly, use result.
|
||||
// Otherwise the error will not be cought in this try-catch block.
|
||||
const result = await fn()
|
||||
return result
|
||||
} catch (err) {
|
||||
if (retryCount > 100) throw err
|
||||
await sleep((retryCount + 1) * 1000)
|
||||
return retryWithDelay(fn, retryCount + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const mapCharge = (obj, oldObj = {}) => {
|
||||
const charge = {...oldObj, ...obj}
|
||||
|
||||
charge.progress = obj.time_left < 0 ? 1 : 1 - obj.time_left / obj.time
|
||||
charge.time = minutesToTime(obj.time)
|
||||
charge.timeLeft = minutesToTime(obj.time_left)
|
||||
|
||||
charge.displayUrl = ['/satspay/', obj.id].join('')
|
||||
charge.expanded = oldObj.expanded || false
|
||||
charge.pendingBalance = oldObj.pendingBalance || 0
|
||||
charge.extra = charge.extra ? JSON.parse(charge.extra) : charge.extra
|
||||
return charge
|
||||
}
|
||||
|
||||
const mapCSS = (obj, oldObj = {}) => {
|
||||
const theme = _.clone(obj)
|
||||
return theme
|
||||
}
|
||||
|
||||
const minutesToTime = min =>
|
||||
min > 0 ? new Date(min * 1000).toISOString().substring(14, 19) : ''
|
|
@ -1,42 +0,0 @@
|
|||
import asyncio
|
||||
import json
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.helpers import get_current_extension_name
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import check_address_balance, get_charge, update_charge
|
||||
from .helpers import call_webhook
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
invoice_queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue, get_current_extension_name())
|
||||
|
||||
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") != "charge":
|
||||
# not a charge invoice
|
||||
return
|
||||
|
||||
assert payment.memo
|
||||
charge = await get_charge(payment.memo)
|
||||
if not charge:
|
||||
logger.error("this should never happen", payment)
|
||||
return
|
||||
|
||||
await payment.set_pending(False)
|
||||
charge = await check_address_balance(charge_id=charge.id)
|
||||
assert charge
|
||||
|
||||
if charge.must_call_webhook():
|
||||
resp = await call_webhook(charge)
|
||||
extra = {**charge.config.dict(), **resp}
|
||||
await update_charge(charge_id=charge.id, extra=json.dumps(extra))
|
|
@ -1,29 +0,0 @@
|
|||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
SatsPayServer, create Onchain/LN charges.<br />WARNING: If using with the
|
||||
WatchOnly extension, we highly reccomend using a fresh extended public Key
|
||||
specifically for SatsPayServer!<br />
|
||||
<small>
|
||||
Created by,
|
||||
<a class="text-secondary" href="https://github.com/benarc">Ben Arc</a>,
|
||||
<a
|
||||
class="text-secondary"
|
||||
target="_blank"
|
||||
style="color: unset"
|
||||
href="https://github.com/motorina0"
|
||||
>motorina0</a
|
||||
></small
|
||||
>
|
||||
</p>
|
||||
<br />
|
||||
<br />
|
||||
<a
|
||||
class="text-secondary"
|
||||
target="_blank"
|
||||
href="/docs#/satspay"
|
||||
class="text-white"
|
||||
>Swagger REST API Documentation</a
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
|
@ -1,479 +0,0 @@
|
|||
{% extends "public.html" %} {% block page %}
|
||||
<div class="row items-center q-mt-md">
|
||||
<div class="col-lg-4 col-md-3 col-sm-1"></div>
|
||||
<div class="col-lg-4 col-md-6 col-sm-10">
|
||||
<q-card>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col text-center q-mt-md">
|
||||
<span class="text-h4" v-text="charge.description"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<div
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-if="!charge.timeLeft"
|
||||
>
|
||||
Time elapsed
|
||||
</div>
|
||||
<div
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-else-if="charge.paid"
|
||||
>
|
||||
Charge paid
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-linear-progress
|
||||
size="30px"
|
||||
:value="charge.progress"
|
||||
color="secondary"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item style="padding: 3px">
|
||||
<q-spinner color="white" size="0.8em"></q-spinner
|
||||
><span style="font-size: 15px; color: white"
|
||||
><span class="q-pr-xl q-pl-md"> Awaiting payment...</span>
|
||||
<span class="q-pl-xl" style="color: white">
|
||||
{% raw %} {{ charge.timeLeft }} {% endraw %}</span
|
||||
></span
|
||||
>
|
||||
</q-item>
|
||||
</q-item-section>
|
||||
</q-linear-progress>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-ml-md q-mt-md q-mb-lg">
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col-4 q-pr-lg">Charge Id:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-btn flat dense outline @click="copyText(charge.id)"
|
||||
><span v-text="charge.id"></span
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center">
|
||||
<div class="col-4 q-pr-lg">Total to pay:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge color="blue">
|
||||
<span v-text="charge.amount" class="text-subtitle2"></span> sat
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-sm">
|
||||
<div class="col-4 q-pr-lg">Amount paid:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge color="orange">
|
||||
<span v-text="charge.balance" class="text-subtitle2"></span>
|
||||
sat</q-badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pendingFunds" class="row items-center q-mt-sm">
|
||||
<div class="col-4 q-pr-lg">Amount pending:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge color="gray">
|
||||
<span v-text="pendingFunds" class="text-subtitle2"></span> sat
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-sm">
|
||||
<div class="col-4 q-pr-lg">Amount due:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge v-if="charge.amount - charge.balance > 0" color="green">
|
||||
<span
|
||||
v-text="charge.amount - charge.balance"
|
||||
class="text-subtitle2"
|
||||
></span>
|
||||
sat
|
||||
</q-badge>
|
||||
<q-badge
|
||||
v-else="charge.amount - charge.balance <= 0"
|
||||
color="green"
|
||||
class="text-subtitle2"
|
||||
>
|
||||
none</q-badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="!charge.payment_request || charge.time_elapsed"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin lightning payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payInvoice"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
<q-tooltip> pay with lightning </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="!charge.onchainaddress || charge.time_elapsed"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin onchain payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payOnchain"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
<q-tooltip> pay onchain </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
<q-card class="q-pa-lg" v-if="lnbtc">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="row items-center q-mt-sm">
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<div v-if="!charge.timeLeft && !charge.paid">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge.paid">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<div class="row text-center q-mt-lg">
|
||||
<div class="col text-center">
|
||||
<q-btn
|
||||
outline
|
||||
v-if="charge.completelink"
|
||||
type="a"
|
||||
:href="charge.completelink"
|
||||
:label="charge.completelinktext"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="row text-center q-mb-sm">
|
||||
<div class="col text-center">
|
||||
<span class="text-subtitle2"
|
||||
>Pay this lightning-network invoice:</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="text-secondary"
|
||||
:href="'lightning:'+charge.payment_request"
|
||||
>
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="'lightning:' + charge.payment_request.toUpperCase()"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<div class="row text-center q-mt-lg">
|
||||
<div class="col text-center">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(charge.payment_request)"
|
||||
>Copy invoice</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card class="q-pa-lg" v-if="onbtc">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div v-if="charge.timeLeft && !charge.paid" class="row items-center">
|
||||
<div class="col text-center">
|
||||
<a
|
||||
class="text-secondary"
|
||||
style="color: unset"
|
||||
:href="'https://' + mempoolHostname + '/address/' + charge.onchainaddress"
|
||||
target="_blank"
|
||||
><span
|
||||
class="text-subtitle1"
|
||||
v-text="charge.onchainaddress"
|
||||
></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-md">
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
<div class="col-md-8 col-sm-12 text-center">
|
||||
<div v-if="!charge.timeLeft && !charge.paid">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge.paid">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<div class="row text-center q-mt-lg">
|
||||
<div class="col text-center">
|
||||
<q-btn
|
||||
outline
|
||||
v-if="charge.webhook"
|
||||
type="a"
|
||||
:href="charge.completelink"
|
||||
:label="charge.completelinktext"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="row items-center q-mb-sm">
|
||||
<div class="col text-center">
|
||||
<span class="text-subtitle2"
|
||||
>Send
|
||||
|
||||
<span v-text="charge.amount"></span>
|
||||
sats to this onchain address</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="text-secondary"
|
||||
:href="'bitcoin:'+charge.onchainaddress"
|
||||
>
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="charge.onchainaddress"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<div class="row items-center q-mt-lg">
|
||||
<div class="col text-center">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(charge.onchainaddress)"
|
||||
>Copy address</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-lg- 4 col-md-3 col-sm-1"></div>
|
||||
</div>
|
||||
{% endblock %} {% block styles %}
|
||||
<link
|
||||
href="/satspay/css/{{ charge_data.custom_css }}"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
<style>
|
||||
header button.q-btn-dropdown {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
{% endblock %} {% block scripts %}
|
||||
|
||||
<script src="https://mempool.space/mempool.js"></script>
|
||||
<script src="{{ url_for('satspay_static', path='js/utils.js') }}"></script>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
charge: JSON.parse('{{charge_data | tojson}}'),
|
||||
mempoolEndpoint: '{{mempool_endpoint}}',
|
||||
network: '{{network}}',
|
||||
pendingFunds: 0,
|
||||
ws: null,
|
||||
newProgress: 0.4,
|
||||
counter: 1,
|
||||
lnbtc: true,
|
||||
onbtc: false,
|
||||
wallet: {
|
||||
inkey: ''
|
||||
},
|
||||
cancelListener: () => {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
mempoolHostname: function () {
|
||||
let hostname = new URL(this.mempoolEndpoint).hostname
|
||||
if (this.network === 'Testnet') {
|
||||
hostname += '/testnet'
|
||||
}
|
||||
return hostname
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkBalances: async function () {
|
||||
if (!this.charge.payment_request && this.charge.hasOnchainStaleBalance)
|
||||
return
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/satspay/api/v1/charge/balance/${this.charge.id}`
|
||||
)
|
||||
this.charge = mapCharge(data, this.charge)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
checkPendingOnchain: async function () {
|
||||
if (!this.charge.onchainaddress) return
|
||||
|
||||
const {
|
||||
bitcoin: {addresses: addressesAPI}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.mempoolEndpoint).hostname
|
||||
})
|
||||
|
||||
try {
|
||||
const utxos = await addressesAPI.getAddressTxsUtxo({
|
||||
address: this.charge.onchainaddress
|
||||
})
|
||||
const newBalance = utxos.reduce((t, u) => t + u.value, 0)
|
||||
this.charge.hasOnchainStaleBalance =
|
||||
this.charge.balance === newBalance
|
||||
|
||||
this.pendingFunds = utxos
|
||||
.filter(u => !u.status.confirmed)
|
||||
.reduce((t, u) => t + u.value, 0)
|
||||
} catch (error) {
|
||||
console.error('cannot check pending funds')
|
||||
}
|
||||
},
|
||||
payInvoice: function () {
|
||||
this.lnbtc = true
|
||||
this.onbtc = false
|
||||
},
|
||||
payOnchain: function () {
|
||||
this.lnbtc = false
|
||||
this.onbtc = true
|
||||
},
|
||||
|
||||
loopRefresh: function () {
|
||||
// invoice only
|
||||
const refreshIntervalId = setInterval(async () => {
|
||||
if (this.charge.paid || !this.charge.timeLeft) {
|
||||
clearInterval(refreshIntervalId)
|
||||
}
|
||||
if (this.counter % 10 === 0) {
|
||||
await this.checkBalances()
|
||||
await this.checkPendingOnchain()
|
||||
}
|
||||
this.counter++
|
||||
}, 1000)
|
||||
},
|
||||
initWs: async function () {
|
||||
const {
|
||||
bitcoin: {websocket}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.mempoolEndpoint).hostname
|
||||
})
|
||||
|
||||
this.ws = new WebSocket(`wss://${this.mempoolHostname}/api/v1/ws`)
|
||||
this.ws.addEventListener('open', x => {
|
||||
if (this.charge.onchainaddress) {
|
||||
this.trackAddress(this.charge.onchainaddress)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.addEventListener('message', async ({data}) => {
|
||||
const res = JSON.parse(data.toString())
|
||||
if (res['address-transactions']) {
|
||||
await this.checkBalances()
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'New payment received!',
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
loopPingWs: function () {
|
||||
setInterval(() => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) this.initWs()
|
||||
this.ws.send(JSON.stringify({action: 'ping'}))
|
||||
}, 30 * 1000)
|
||||
},
|
||||
trackAddress: async function (address, retry = 0) {
|
||||
try {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) this.initWs()
|
||||
this.ws.send(JSON.stringify({'track-address': address}))
|
||||
} catch (error) {
|
||||
await sleep(1000)
|
||||
if (retry > 10) throw error
|
||||
this.trackAddress(address, retry + 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
// Remove a user defined theme
|
||||
if (this.charge.custom_css) {
|
||||
document.body.setAttribute('data-theme', '')
|
||||
}
|
||||
if (this.charge.payment_request) this.payInvoice()
|
||||
else this.payOnchain()
|
||||
|
||||
await this.checkBalances()
|
||||
|
||||
if (!this.charge.paid) {
|
||||
this.loopRefresh()
|
||||
}
|
||||
|
||||
if (this.charge.onchainaddress) {
|
||||
this.loopPingWs()
|
||||
this.checkPendingOnchain()
|
||||
this.trackAddress(this.charge.onchainaddress)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
File diff suppressed because it is too large
Load Diff
|
@ -1,49 +0,0 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Response
|
||||
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 satspay_ext, satspay_renderer
|
||||
from .crud import get_charge, get_theme
|
||||
from .helpers import public_charge
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@satspay_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
return satspay_renderer().TemplateResponse(
|
||||
"satspay/index.html",
|
||||
{"request": request, "user": user.dict(), "admin": user.admin},
|
||||
)
|
||||
|
||||
|
||||
@satspay_ext.get("/{charge_id}", response_class=HTMLResponse)
|
||||
async def display_charge(request: Request, charge_id: str):
|
||||
charge = await get_charge(charge_id)
|
||||
if not charge:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist."
|
||||
)
|
||||
|
||||
return satspay_renderer().TemplateResponse(
|
||||
"satspay/display.html",
|
||||
{
|
||||
"request": request,
|
||||
"charge_data": public_charge(charge),
|
||||
"mempool_endpoint": charge.config.mempool_endpoint,
|
||||
"network": charge.config.network,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@satspay_ext.get("/css/{css_id}")
|
||||
async def display_css(css_id: str):
|
||||
theme = await get_theme(css_id)
|
||||
if theme:
|
||||
return Response(content=theme.custom_css, media_type="text/css")
|
||||
return None
|
|
@ -1,180 +0,0 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_admin,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
|
||||
from . import satspay_ext
|
||||
from .crud import (
|
||||
check_address_balance,
|
||||
create_charge,
|
||||
delete_charge,
|
||||
delete_theme,
|
||||
get_charge,
|
||||
get_charges,
|
||||
get_theme,
|
||||
get_themes,
|
||||
save_theme,
|
||||
update_charge,
|
||||
)
|
||||
from .helpers import call_webhook, public_charge
|
||||
from .models import CreateCharge, SatsPayThemes
|
||||
|
||||
|
||||
@satspay_ext.post("/api/v1/charge")
|
||||
async def api_charge_create(
|
||||
data: CreateCharge, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||
):
|
||||
try:
|
||||
charge = await create_charge(user=wallet.wallet.user, data=data)
|
||||
assert charge
|
||||
return {
|
||||
**charge.dict(),
|
||||
**{"time_elapsed": charge.time_elapsed},
|
||||
**{"time_left": charge.time_left},
|
||||
**{"paid": charge.paid},
|
||||
}
|
||||
except Exception as ex:
|
||||
logger.debug(f"Satspay error: {str}")
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
|
||||
)
|
||||
|
||||
|
||||
@satspay_ext.put(
|
||||
"/api/v1/charge/{charge_id}", dependencies=[Depends(require_admin_key)]
|
||||
)
|
||||
async def api_charge_update(
|
||||
data: CreateCharge,
|
||||
charge_id: str,
|
||||
):
|
||||
charge = await update_charge(charge_id=charge_id, data=data)
|
||||
assert charge
|
||||
return charge.dict()
|
||||
|
||||
|
||||
@satspay_ext.get("/api/v1/charges")
|
||||
async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
try:
|
||||
return [
|
||||
{
|
||||
**charge.dict(),
|
||||
**{"time_elapsed": charge.time_elapsed},
|
||||
**{"time_left": charge.time_left},
|
||||
**{"paid": charge.paid},
|
||||
**{"webhook_message": charge.config.webhook_message},
|
||||
}
|
||||
for charge in await get_charges(wallet.wallet.user)
|
||||
]
|
||||
except:
|
||||
return ""
|
||||
|
||||
|
||||
@satspay_ext.get("/api/v1/charge/{charge_id}", dependencies=[Depends(get_key_type)])
|
||||
async def api_charge_retrieve(charge_id: str):
|
||||
charge = await get_charge(charge_id)
|
||||
|
||||
if not charge:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Charge does not exist."
|
||||
)
|
||||
|
||||
return {
|
||||
**charge.dict(),
|
||||
**{"time_elapsed": charge.time_elapsed},
|
||||
**{"time_left": charge.time_left},
|
||||
**{"paid": charge.paid},
|
||||
}
|
||||
|
||||
|
||||
@satspay_ext.delete("/api/v1/charge/{charge_id}", dependencies=[Depends(get_key_type)])
|
||||
async def api_charge_delete(charge_id: str):
|
||||
charge = await get_charge(charge_id)
|
||||
|
||||
if not charge:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Charge does not exist."
|
||||
)
|
||||
|
||||
await delete_charge(charge_id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
#############################BALANCE##########################
|
||||
|
||||
|
||||
@satspay_ext.get("/api/v1/charges/balance/{charge_ids}")
|
||||
async def api_charges_balance(charge_ids):
|
||||
charge_id_list = charge_ids.split(",")
|
||||
charges = []
|
||||
for charge_id in charge_id_list:
|
||||
charge = await api_charge_balance(charge_id)
|
||||
charges.append(charge)
|
||||
return charges
|
||||
|
||||
|
||||
@satspay_ext.get("/api/v1/charge/balance/{charge_id}")
|
||||
async def api_charge_balance(charge_id):
|
||||
charge = await check_address_balance(charge_id)
|
||||
|
||||
if not charge:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Charge does not exist."
|
||||
)
|
||||
|
||||
if charge.must_call_webhook():
|
||||
resp = await call_webhook(charge)
|
||||
extra = {**charge.config.dict(), **resp}
|
||||
await update_charge(charge_id=charge.id, extra=json.dumps(extra))
|
||||
|
||||
return {**public_charge(charge)}
|
||||
|
||||
|
||||
#############################THEMES##########################
|
||||
|
||||
|
||||
@satspay_ext.post("/api/v1/themes", dependencies=[Depends(check_admin)])
|
||||
@satspay_ext.post("/api/v1/themes/{css_id}", dependencies=[Depends(check_admin)])
|
||||
async def api_themes_save(
|
||||
data: SatsPayThemes,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
css_id: str = Query(...),
|
||||
):
|
||||
|
||||
if css_id:
|
||||
theme = await save_theme(css_id=css_id, data=data)
|
||||
else:
|
||||
data.user = wallet.wallet.user
|
||||
theme = await save_theme(data=data, css_id="no_id")
|
||||
return theme
|
||||
|
||||
|
||||
@satspay_ext.get("/api/v1/themes")
|
||||
async def api_themes_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
try:
|
||||
return await get_themes(wallet.wallet.user)
|
||||
except HTTPException:
|
||||
logger.error("Error loading satspay themes")
|
||||
logger.error(HTTPException)
|
||||
return ""
|
||||
|
||||
|
||||
@satspay_ext.delete("/api/v1/themes/{theme_id}", dependencies=[Depends(get_key_type)])
|
||||
async def api_theme_delete(theme_id):
|
||||
theme = await get_theme(theme_id)
|
||||
|
||||
if not theme:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Theme does not exist."
|
||||
)
|
||||
|
||||
await delete_theme(theme_id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
Loading…
Reference in New Issue
Block a user