Fork xmrcheckout for Wownero (WOW)

Minimal changes from upstream xmrcheckout to support Wownero:
- Rate provider: Nonlogs WOW-BTC via Kraken BTC/USD
- Subaddress derivation: Wownero network byte overrides (53/63/54)
- Payment URI scheme: wownero:
- Dockerfile: Wownero binaries from Codeberg
- Display text: Monero→Wownero, XMR→WOW throughout UI
- Default daemon: 192.168.88.235:34568
- Added wownero-logo.svg

File and folder structure kept identical to upstream for easy sync.
This commit is contained in:
jwinterm
2026-03-09 11:08:55 -04:00
commit f8f9b4424c
111 changed files with 23058 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# Database
POSTGRES_USER=wowcheckout
POSTGRES_PASSWORD=wowcheckout
POSTGRES_DB=wowcheckout
DATABASE_URL=postgresql://wowcheckout:wowcheckout@db:5432/wowcheckout
# Optional: Postgres backups (used by the `db-backup` Compose profile)
BACKUP_RETENTION_DAYS=7
# API authentication
API_KEYS=dev-key-1
API_KEY_ENCRYPTION_KEY=replace-with-fernet-key
RATE_PROVIDER=nonlogs
INVOICE_RECONCILE_INTERVAL_SECONDS=30
# Founder donation wallet (view-only)
FOUNDER_PAYMENT_ADDRESS=
FOUNDER_VIEW_KEY=
DONATIONS_ENABLED=false
# Wownero wallet RPC (view-only wallet generation + subaddresses)
MONERO_WALLET_RPC_URLS=http://wallet-rpc-reconciler-1:18083,http://wallet-rpc-reconciler-2:18083,http://wallet-rpc-reconciler-3:18083
MONERO_WALLET_RPC_USER=
MONERO_WALLET_RPC_PASSWORD=
MONERO_WALLET_RPC_WALLET_PASSWORD=
MONERO_WALLET_RPC_WALLET_DIR=/wallets
# Wownero daemon
# - Default: uses a local daemon
# - Local: run the bundled `wownerod` service and set MONERO_DAEMON_URL=http://wownerod:34568
MONERO_DAEMON_URL=http://192.168.88.235:34568
# UI
API_BASE_URL=http://api:8000
SITE_URL=https://wowcheckout.such.software
+12
View File
@@ -0,0 +1,12 @@
.env
ui/.env
ui/.next/
ui/node_modules/
**/__pycache__/
*.py[cod]
nginx/certs/
backups/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 xmrcheckout contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+320
View File
@@ -0,0 +1,320 @@
<p align="center">
<img src="ui/public/logo.png" width="120" alt="XMR Checkout logo" />
</p>
# wowcheckout
Non-custodial Wownero checkout software for merchants. Payments go directly from the customer to the merchant wallet.
wowcheckout is open source and self-hostable. It creates invoices, shows payment instructions, and watches the chain for incoming payments using view-only wallet access (address + secret view key). It can also notify your systems via API and webhooks.
Hard rules:
- It never requests or stores private spend keys.
- It never signs transactions.
- It never moves funds on behalf of users.
- View-only access (wallet address + private view key) is the maximum trust boundary.
## Contents
- [Who it's for](#who-its-for)
- [What it does](#what-it-does)
- [Trust model](#trust-model)
- [Repository layout](#repository-layout)
- [Screenshots](#screenshots)
- [Quick start](#quick-start)
- [Self-hosted deployment](#self-hosted-deployment-docker-compose)
- [Self-hosted deployment (no Docker)](#self-hosted-deployment-no-docker)
- [Development (API)](#development-api-python)
## Who it's for
If you want Wownero payments to go straight to your own wallet, this is for you.
Common fits:
- A merchant who wants a clean hosted checkout UI (self-hosted by you).
- A team that wants API + webhooks to plug into an existing order flow.
- Anyone who prefers a conservative trust model (view-only access for detection).
## What it does
Typical flow:
1. Your integration creates an invoice (defined in WOW).
2. The UI shows payment instructions (address + amount).
3. The system observes the chain using view-only wallet access to detect payments and update invoice status.
4. Optional integrations (for example webhooks) can be used to trigger your internal order flow.
Not included (by design):
- It does not provide custody, refunds, or any fund-moving automation.
- It does not act as a financial intermediary.
- It does not touch fiat rails in the core system.
## Trust model
- Funds always move from the customer to the merchant wallet; wowcheckout only observes the chain and reports status.
- You keep spend authority. The maximum permission level wowcheckout uses is view-only wallet access (wallet address + private view key).
- If any configuration or integration implies spend authority, treat it as a misconfiguration and stop.
## Repository layout
- `ui/`: web UI
- `api/`: API service (Python)
- `docker-compose.yml`: local stack and self-hosted deployment
- `nginx/`: optional reverse proxy / TLS termination (used by Docker Compose)
## Screenshots
Post-login UI:
### Dashboard
![Dashboard overview](screenshots/ui_dashboard.png)
### Invoices
![Invoice list and status](screenshots/ui_invoices.png)
### Create invoice
![Create invoice dialog (WOW amount)](screenshots/create_invoice_xmr.png)
![Create invoice dialog (fiat-denominated input, informational estimate)](screenshots/create_invoice_fiat.png)
![Create invoice dialog (summary)](screenshots/create_invoice_summary.png)
### Public invoice page
![Public invoice page awaiting on-chain payment](screenshots/public_invoice_pending_payment.png)
## Quick start
### Homepage only (Docker)
```
docker build -t wowcheckout-home .
docker run --rm -p 8080:80 wowcheckout-home
```
Open `http://localhost:8080`.
### Full stack (Docker Compose)
```
docker compose up --build
```
Open `http://localhost:8180` for the UI.
In the default Compose configuration, only `nginx` is published to the host. The API, Postgres, and wallet-rpc services are reachable only inside the Docker network.
If you prefer not to run `nginx`, you can publish `ui` and `api` ports directly instead (you will also need to serve `qr/` at `/qr/` on your site URL).
## Self-hosted deployment (Docker Compose)
1. Copy the environment template and fill in required values:
```
cp .env.example .env
```
2. Set required values in `.env`:
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`
- `API_KEYS`, `API_KEY_ENCRYPTION_KEY`
- `SITE_URL` (public URL for the UI)
- Wownero view-only wallet settings (`MONERO_WALLET_RPC_*`)
- `MONERO_DAEMON_URL` (choose one of the options below)
3. Choose a Wownero daemon source:
- **Use an existing daemon (default):**
- Leave `MONERO_DAEMON_URL` as-is (the default points at a local Wownero daemon).
- **Run your own daemon via Docker Compose:**
- Set `MONERO_DAEMON_URL=http://wownerod:34568`
- Start the stack with the `local-daemon` profile (see step 5)
- Note: initial sync can take a long time and uses significant disk; payment detection won’t be reliable until the daemon is synced.
4. Choose a wallet-rpc target and provision view-only wallets:
- Use the bundled wallet-rpc containers:
- Set `MONERO_WALLET_RPC_URLS=http://wallet-rpc-reconciler-1:18083,http://wallet-rpc-reconciler-2:18083,http://wallet-rpc-reconciler-3:18083`
- Or point to an external wallet-rpc service:
- Set `MONERO_WALLET_RPC_URLS`, `MONERO_WALLET_RPC_USER`, `MONERO_WALLET_RPC_PASSWORD`, and `MONERO_WALLET_RPC_WALLET_PASSWORD`
5. Start the stack:
```
docker compose up --build -d
```
If you’re running the bundled `wownerod` service:
```
docker compose --profile local-daemon up --build -d
```
### Optional: Postgres backups (disabled by default)
This repository includes an optional `db-backup` service that runs `pg_dump` hourly and writes backups to `./backups/postgres` on the host.
Enable it by starting Compose with the `db-backup` profile:
```
docker compose --profile db-backup up --build -d
```
Retention defaults to 7 days. To override:
- Set `BACKUP_RETENTION_DAYS` in `.env`
### Optional: donations (disabled by default)
Donation endpoints and UI are off by default for self-hosted deployments.
To enable donations:
- Set `DONATIONS_ENABLED=true`
- Set `FOUNDER_PAYMENT_ADDRESS` and `FOUNDER_VIEW_KEY`
The UI uses the same flag (via Compose), so `/donate` stays unavailable unless donations are explicitly enabled.
## Self-hosted deployment (no Docker)
This section describes running the services directly on a host (or VMs) without Docker.
You will run:
- Postgres (external service)
- API (`gunicorn` / `uvicorn`)
- Reconciler worker (a separate process)
- UI (Next.js)
- A reverse proxy is optional, but strongly recommended for TLS, serving `/qr/`, and providing a single origin for UI + API.
### 1. Install prerequisites
- Postgres 16+
- Python 3.12+
- Node.js 20+ (for the UI)
- A Wownero daemon endpoint (`MONERO_DAEMON_URL`) and at least one `wownero-wallet-rpc` instance reachable by the API
### 2. Configure environment variables
Start from `.env.example` and adjust for your host. For non-Docker deployments you will typically set:
- `DATABASE_URL=postgresql://...@127.0.0.1:5432/...`
- `QR_STORAGE_DIR` to a persistent directory on disk (for example `/var/lib/wowcheckout/qr`)
- `SITE_URL` to your public URL (for example `https://example.com`)
- `API_BASE_URL` for the UI:
- If using a reverse proxy that routes `/api/` to the API on the same origin, set `API_BASE_URL=https://example.com`
- If not using a reverse proxy, set `API_BASE_URL=http://127.0.0.1:8000` (see the notes in step 6)
If you need a new `API_KEY_ENCRYPTION_KEY` value:
```
python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'
```
### 3. Create the Postgres database
Create a database and user matching your `DATABASE_URL`. On first startup, the API will create tables automatically.
### 4. Run the API
```
python -m venv api/.venv
api/.venv/bin/pip install -r api/requirements.txt
# Export env vars (or use your process manager to load them)
set -a
source .env
set +a
api/.venv/bin/gunicorn app.main:app -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 -w "${GUNICORN_WORKERS:-2}" --chdir api
```
### 5. Run the reconciler worker
Run this as a separate long-running process (same environment variables as the API):
```
set -a
source .env
set +a
api/.venv/bin/python -m app.reconciler
```
### 6. Build and run the UI
```
cd ui
npm ci
# Optional but recommended to keep these explicit in non-Docker deployments
export API_BASE_URL="${API_BASE_URL:-http://127.0.0.1:8000}"
export NEXT_PUBLIC_SITE_URL="${SITE_URL:-http://127.0.0.1:3000}"
export NEXT_PUBLIC_DONATIONS_ENABLED="${DONATIONS_ENABLED:-false}"
npm run build
npm run start -- -p 3000 -H 127.0.0.1
```
Notes:
- The UI makes some in-browser requests to `/api/...` on the same origin. In production, prefer a reverse proxy that serves the UI and forwards `/api/` to the API.
- The QR PNGs are written to `QR_STORAGE_DIR` and must be reachable at `https://<your-site>/qr/<invoice_id>.png`.
### Optional: reverse proxy (Nginx)
Nginx is optional. Any reverse proxy that can:
- route `/api/` to the API,
- route `/` to the UI,
- and serve the QR directory at `/qr/`
is fine.
Example (adjust `server_name`, TLS, and paths to match your host):
```
server {
listen 443 ssl;
server_name example.com;
# ssl_certificate ...;
# ssl_certificate_key ...;
location /api/ {
proxy_pass http://127.0.0.1:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /qr/ {
alias /var/lib/wowcheckout/qr/;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
proxy_pass http://127.0.0.1:3000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## Development (API, Python)
1. Set environment variables (see `api/.env.example`):
```
export DATABASE_URL=postgresql://wowcheckout:wowcheckout@localhost:5432/wowcheckout
export API_KEYS=change-me-1
```
2. Install dependencies:
```
python -m venv .venv
source .venv/bin/activate
pip install -r api/requirements.txt
```
3. Start the API:
```
cd api
uvicorn app.main:app --reload
```
The API listens on `http://127.0.0.1:8000`.
+7
View File
@@ -0,0 +1,7 @@
DATABASE_URL=postgresql://wowcheckout:wowcheckout@localhost:5432/wowcheckout
API_KEYS=change-me-1,change-me-2
RATE_PROVIDER=nonlogs
INVOICE_RECONCILE_INTERVAL_SECONDS=30
FOUNDER_PAYMENT_ADDRESS=
FOUNDER_VIEW_KEY=
DONATIONS_ENABLED=false
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY app /app/app
EXPOSE 8000
CMD ["/bin/sh", "-c", "gunicorn app.main:app -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 -w ${GUNICORN_WORKERS:-2}"]
+1
View File
@@ -0,0 +1 @@
"""API package."""
+704
View File
@@ -0,0 +1,704 @@
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from decimal import Decimal, ROUND_DOWN
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from .btcpay_schemas import (
BtcpayInvoiceCreate,
BtcpayInvoiceStatusUpdate,
BtcpayWebhookCreate,
BtcpayWebhookUpdate,
)
from .btcpay_webhooks import dispatch_btcpay_webhooks
from .config import INVOICE_DEFAULT_EXPIRY_HOURS
from .config import QR_STORAGE_DIR
from .db import get_db
from .formatting import format_xmr_amount
from .models import BtcpayWebhook, Invoice, InvoiceTransfer, User
from .rates import get_wow_rate
from .security import (
decrypt_api_key,
encrypt_secret,
generate_webhook_secret,
hash_api_key,
require_api_key,
)
from .subaddress_allocator import create_subaddress_for_user
from .webhooks import dispatch_webhooks
from .qr_codes import ensure_invoice_qr_png, resolve_qr_settings
router = APIRouter()
BTCPAY_PAYMENT_METHOD = "WOW-CHAIN"
BTCPAY_PAYMENT_METHOD_ALIASES = {BTCPAY_PAYMENT_METHOD, "WOW", "WOW_CHAIN"}
BTCPAY_WEBHOOK_EVENTS = {
"InvoiceCreated",
"InvoiceReceivedPayment",
"InvoicePaidInFull",
"InvoicePaymentSettled",
"InvoiceProcessing",
"InvoiceExpired",
"InvoiceSettled",
"InvoiceInvalid",
}
def _require_btcpay_user(
api_key: str = Depends(require_api_key),
db: Session = Depends(get_db),
) -> User:
user = db.query(User).filter(User.api_key_hash == hash_api_key(api_key)).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User API key required",
)
return user
def _require_store(store_id: str, user: User) -> str:
if store_id != str(user.id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Store not found",
)
return store_id
def _store_payload(user: User) -> dict[str, Any]:
expiry_minutes = max(1, INVOICE_DEFAULT_EXPIRY_HOURS * 60)
return {
"id": str(user.id),
"name": "WOW Checkout",
"website": "",
"defaultCurrency": "WOW",
"invoiceExpiration": expiry_minutes,
"displayExpirationTimer": True,
"monitoringExpiration": expiry_minutes,
"speedPolicy": "MediumSpeed",
"paymentTolerance": 0,
"anyoneCanCreateInvoice": False,
"requiresRefundEmail": False,
"lightningAmountInSatoshi": False,
"lightningPrivateRouteHints": False,
"onChainWithLnInvoiceFallback": False,
"redirectAutomatically": False,
"showRecommendedFee": False,
"recommendedFeeBlockTarget": 0,
"defaultLang": "en",
"customLogo": "",
"customCSS": "",
"htmlTitle": "WOW Checkout",
"networkFeeMode": "Never",
"payJoinEnabled": False,
"lazyPaymentMethods": False,
"defaultPaymentMethod": BTCPAY_PAYMENT_METHOD,
}
def _invoice_checkout_link(invoice: Invoice, request: Request) -> str:
proto = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("x-forwarded-host", request.headers.get("host", ""))
if not host:
return f"/i/{invoice.id}"
return f"{proto}://{host}/i/{invoice.id}"
def _normalize_btcpay_payment_method(value: str) -> str:
normalized = value.strip().upper()
if normalized in BTCPAY_PAYMENT_METHOD_ALIASES:
return BTCPAY_PAYMENT_METHOD
return normalized
def _epoch_seconds(value: datetime | None) -> int:
if value is None:
return 0
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return int(value.timestamp())
def _resolve_btcpay_amount(
amount: Decimal,
currency: str,
) -> tuple[Decimal, dict[str, str] | None]:
if currency.upper() == "WOW":
return amount, None
try:
quote = get_wow_rate(currency)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Fiat quote service unavailable",
) from exc
amount_xmr = (Decimal(amount) / quote.rate).quantize(
Decimal("0.000000000001"), rounding=ROUND_DOWN
)
quote_payload = {
"fiat_amount": str(amount),
"fiat_currency": quote.currency,
"rate": str(quote.rate),
"source": quote.source,
"quoted_at": quote.quoted_at.isoformat(),
}
return amount_xmr, quote_payload
def _btcpay_status(invoice: Invoice) -> tuple[str, str]:
additional_status = _btcpay_additional_status(invoice)
if invoice.status == "payment_detected":
return "Processing", additional_status
if invoice.status == "confirmed":
return "Settled", additional_status
if invoice.status == "expired":
return "Expired", additional_status
if invoice.status == "invalid":
return "Invalid", additional_status
return "New", additional_status
def _btcpay_additional_status(invoice: Invoice) -> str:
if invoice.status == "invalid":
return "Marked"
paid_atomic = invoice.total_paid_atomic or 0
required_atomic = _xmr_to_atomic(invoice.amount_xmr)
if paid_atomic > required_atomic:
return "PaidOver"
if 0 < paid_atomic < required_atomic:
return "PaidPartial"
if _after_expiration(invoice):
return "PaidLate"
return "None"
def _after_expiration(invoice: Invoice) -> bool:
expires_at = invoice.expires_at
detected_at = invoice.detected_at
if expires_at is None or detected_at is None:
return False
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if detected_at.tzinfo is None:
detected_at = detected_at.replace(tzinfo=timezone.utc)
return detected_at > expires_at
def _xmr_to_atomic(amount: Decimal) -> int:
quantized = (Decimal(amount) * Decimal("1000000000000")).to_integral_value(
rounding=ROUND_DOWN
)
return int(quantized)
def _btcpay_amount_currency(invoice: Invoice) -> tuple[str, str, dict[str, Any]]:
metadata = invoice.metadata_json or {}
btcpay_data = metadata.get("btcpay") if isinstance(metadata, dict) else None
if isinstance(btcpay_data, dict):
amount = btcpay_data.get("amount")
currency = btcpay_data.get("currency")
if isinstance(amount, str) and isinstance(currency, str):
return amount, currency, btcpay_data
return format_xmr_amount(invoice.amount_xmr), "WOW", {}
def _format_xmr_fixed(value: Decimal) -> str:
return f"{value.quantize(Decimal('0.000000000001')):.12f}"
def _atomic_to_xmr(value: int) -> Decimal:
return (Decimal(value) / Decimal("1000000000000")).quantize(
Decimal("0.000000000001"), rounding=ROUND_DOWN
)
@router.get("/api/v1/stores")
def list_stores(user: User = Depends(_require_btcpay_user)):
return [_store_payload(user)]
@router.get("/api/v1/stores/{store_id}")
def get_store(store_id: str, user: User = Depends(_require_btcpay_user)):
_require_store(store_id, user)
return _store_payload(user)
@router.get("/api/v1/stores/{store_id}/payment-methods")
def list_payment_methods(store_id: str, user: User = Depends(_require_btcpay_user)):
_require_store(store_id, user)
return [
{
"paymentMethodId": BTCPAY_PAYMENT_METHOD,
"paymentMethod": BTCPAY_PAYMENT_METHOD,
"enabled": True,
"cryptoCode": "WOW",
}
]
@router.post("/api/v1/stores/{store_id}/invoices", status_code=200)
def create_invoice(
store_id: str,
payload: BtcpayInvoiceCreate,
request: Request,
user: User = Depends(_require_btcpay_user),
db: Session = Depends(get_db),
):
_require_store(store_id, user)
checkout = payload.checkout
payment_methods = (
[_normalize_btcpay_payment_method(method) for method in checkout.paymentMethods]
if checkout and checkout.paymentMethods
else None
)
if payment_methods and BTCPAY_PAYMENT_METHOD not in payment_methods:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Unsupported payment method",
)
amount_xmr, quote_payload = _resolve_btcpay_amount(payload.amount, payload.currency)
expires_at = None
now = datetime.now(timezone.utc)
expiration_minutes = None
monitoring_minutes = None
if checkout and checkout.expirationMinutes:
expiration_minutes = int(checkout.expirationMinutes)
if expiration_minutes:
expires_at = now + timedelta(minutes=expiration_minutes)
else:
expires_at = now + timedelta(hours=INVOICE_DEFAULT_EXPIRY_HOURS)
if checkout and checkout.monitoringMinutes is not None:
monitoring_minutes = int(checkout.monitoringMinutes)
metadata: dict[str, Any] = dict(payload.metadata or {})
btcpay_data = metadata.get("btcpay") if isinstance(metadata.get("btcpay"), dict) else {}
btcpay_data = dict(btcpay_data)
btcpay_data.update(
{
"amount": str(payload.amount),
"currency": payload.currency,
"checkout": checkout.model_dump(mode="json") if checkout else None,
"expiration_minutes": expiration_minutes,
"monitoring_minutes": monitoring_minutes,
}
)
metadata["btcpay"] = btcpay_data
if quote_payload and "quote" not in metadata:
metadata["quote"] = quote_payload
qr = metadata.get("qr")
if not isinstance(qr, dict):
qr = {"logo": user.default_qr_logo}
if user.default_qr_logo == "custom" and user.default_qr_logo_data_url:
qr["logo_data_url"] = user.default_qr_logo_data_url
metadata["qr"] = qr
address, subaddress_index = create_subaddress_for_user(db, user=user)
invoice = Invoice(
user_id=user.id,
wallet_address=user.payment_address,
address=address,
subaddress_index=subaddress_index,
amount_xmr=amount_xmr,
status="pending",
confirmation_target=user.default_confirmation_target,
total_paid_atomic=0,
metadata_json=metadata,
expires_at=expires_at,
)
db.add(invoice)
db.commit()
db.refresh(invoice)
try:
ensure_invoice_qr_png(
invoice=invoice,
storage_dir=QR_STORAGE_DIR,
settings=resolve_qr_settings(invoice),
)
except HTTPException:
pass
dispatch_webhooks(db, str(user.id), "invoice.created", invoice)
dispatch_btcpay_webhooks(db, str(user.id), "InvoiceCreated", invoice)
status_name, additional_status = _btcpay_status(invoice)
amount, currency, _ = _btcpay_amount_currency(invoice)
expiration_time = _epoch_seconds(invoice.expires_at)
monitoring_time = expiration_time
if monitoring_minutes is None:
monitoring_minutes = 60
monitoring_time = expiration_time + int(monitoring_minutes * 60)
return {
"id": str(invoice.id),
"amount": amount,
"currency": currency,
"type": "Standard",
"checkoutLink": _invoice_checkout_link(invoice, request),
"createdTime": _epoch_seconds(invoice.created_at),
"expirationTime": expiration_time,
"monitoringTime": monitoring_time,
"archived": False,
"status": status_name,
"additionalStatus": additional_status,
"availableStatusesForManualMarking": ["Invalid"],
}
@router.get("/api/v1/stores/{store_id}/invoices/{invoice_id}")
def get_invoice(
store_id: str,
invoice_id: str,
request: Request,
user: User = Depends(_require_btcpay_user),
db: Session = Depends(get_db),
):
_require_store(store_id, user)
try:
invoice_uuid = uuid.UUID(invoice_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invoice not found",
)
invoice = (
db.query(Invoice)
.filter(Invoice.id == invoice_uuid, Invoice.user_id == user.id)
.first()
)
if invoice is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invoice not found",
)
status_name, additional_status = _btcpay_status(invoice)
amount, currency, btcpay_data = _btcpay_amount_currency(invoice)
expiration_time = _epoch_seconds(invoice.expires_at)
monitoring_minutes = (
btcpay_data.get("monitoring_minutes")
if isinstance(btcpay_data, dict)
else None
)
if monitoring_minutes is None:
monitoring_minutes = 60
monitoring_time = expiration_time + int(int(monitoring_minutes) * 60)
return {
"id": str(invoice.id),
"amount": amount,
"currency": currency,
"type": "Standard",
"checkoutLink": _invoice_checkout_link(invoice, request),
"createdTime": _epoch_seconds(invoice.created_at),
"expirationTime": expiration_time,
"monitoringTime": monitoring_time,
"archived": invoice.archived_at is not None,
"status": status_name,
"additionalStatus": additional_status,
"availableStatusesForManualMarking": ["Invalid"],
}
@router.get("/api/v1/stores/{store_id}/invoices/{invoice_id}/payment-methods")
def get_invoice_payment_methods(
store_id: str,
invoice_id: str,
user: User = Depends(_require_btcpay_user),
db: Session = Depends(get_db),
):
_require_store(store_id, user)
try:
invoice_uuid = uuid.UUID(invoice_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invoice not found",
)
invoice = (
db.query(Invoice)
.filter(Invoice.id == invoice_uuid, Invoice.user_id == user.id)
.first()
)
if invoice is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invoice not found",
)
transfers = (
db.query(InvoiceTransfer)
.filter(InvoiceTransfer.invoice_id == invoice.id)
.order_by(InvoiceTransfer.created_at.asc())
.all()
)
total_atomic = sum(
transfer.amount_atomic for transfer in transfers if transfer.amount_atomic > 0
)
total_paid = _atomic_to_xmr(total_atomic)
due = max(Decimal("0"), invoice.amount_xmr - total_paid)
metadata = invoice.metadata_json or {}
quote = metadata.get("quote") if isinstance(metadata, dict) else None
rate_value = "0"
if isinstance(quote, dict) and "rate" in quote:
rate_value = str(quote.get("rate", "0"))
payments = []
created_fallback = _epoch_seconds(invoice.created_at)
for transfer in transfers:
if transfer.amount_atomic <= 0:
continue
confirmations = transfer.confirmations
status_label = (
"confirmed"
if confirmations >= max(0, invoice.confirmation_target)
else "pending"
)
payments.append(
{
"id": f"{transfer.txid}-0",
"value": _format_xmr_fixed(_atomic_to_xmr(transfer.amount_atomic)),
"fee": "0",
"destination": transfer.address or invoice.address,
"status": status_label,
"receivedDate": transfer.timestamp or created_fallback,
}
)
return [
{
"paymentMethodId": BTCPAY_PAYMENT_METHOD,
"paymentMethod": BTCPAY_PAYMENT_METHOD,
"cryptoCode": "WOW",
"destination": invoice.address,
"rate": rate_value,
"amount": _format_xmr_fixed(invoice.amount_xmr),
"due": _format_xmr_fixed(due),
"totalPaid": _format_xmr_fixed(total_paid),
"paymentMethodPaid": _format_xmr_fixed(total_paid),
"networkFee": "0",
"payments": payments,
}
]
@router.post("/api/v1/stores/{store_id}/invoices/{invoice_id}/status", status_code=200)
def mark_invoice_status(
store_id: str,
invoice_id: str,
payload: BtcpayInvoiceStatusUpdate,
user: User = Depends(_require_btcpay_user),
db: Session = Depends(get_db),
):
_require_store(store_id, user)
if payload.status.strip().lower() != "invalid":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Unsupported status",
)
try:
invoice_uuid = uuid.UUID(invoice_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invoice not found",
)
invoice = (
db.query(Invoice)
.filter(Invoice.id == invoice_uuid, Invoice.user_id == user.id)
.first()
)
if invoice is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invoice not found",
)
if invoice.status == "confirmed":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Confirmed invoices cannot be marked invalid",
)
invoice.status = "invalid"
db.add(invoice)
db.commit()
db.refresh(invoice)
dispatch_btcpay_webhooks(
db,
str(user.id),
"InvoiceInvalid",
invoice,
manually_marked=True,
)
return {"status": "Invalid"}
@router.post("/api/v1/stores/{store_id}/webhooks", status_code=200)
def create_webhook(
store_id: str,
payload: BtcpayWebhookCreate,
user: User = Depends(_require_btcpay_user),
db: Session = Depends(get_db),
):
_require_store(store_id, user)
_validate_webhook_events(payload.authorizedEvents.specificEvents)
secret = generate_webhook_secret()
webhook = BtcpayWebhook(
user_id=user.id,
url=str(payload.url),
enabled=payload.enabled,
automatic_redelivery=payload.automaticRedelivery,
authorized_events=payload.authorizedEvents.model_dump(),
secret_encrypted=encrypt_secret(secret),
)
db.add(webhook)
db.commit()
db.refresh(webhook)
return _webhook_response(webhook, include_secret=True, secret=secret)
@router.get("/api/v1/stores/{store_id}/webhooks")
def list_webhooks(
store_id: str,
user: User = Depends(_require_btcpay_user),
db: Session = Depends(get_db),
):
_require_store(store_id, user)
hooks = (
db.query(BtcpayWebhook)
.filter(BtcpayWebhook.user_id == user.id)
.order_by(BtcpayWebhook.created_at.desc())
.all()
)
return [_webhook_response(hook) for hook in hooks]
@router.get("/api/v1/stores/{store_id}/webhooks/{webhook_id}")
def get_webhook(
store_id: str,
webhook_id: str,
user: User = Depends(_require_btcpay_user),
db: Session = Depends(get_db),
):
_require_store(store_id, user)
hook = _get_webhook(db, webhook_id, user)
return _webhook_response(hook)
@router.put("/api/v1/stores/{store_id}/webhooks/{webhook_id}")
def update_webhook(
store_id: str,
webhook_id: str,
payload: BtcpayWebhookUpdate,
user: User = Depends(_require_btcpay_user),
db: Session = Depends(get_db),
):
_require_store(store_id, user)
hook = _get_webhook(db, webhook_id, user)
if payload.authorizedEvents and payload.authorizedEvents.specificEvents:
_validate_webhook_events(payload.authorizedEvents.specificEvents)
if payload.enabled is not None:
hook.enabled = payload.enabled
if payload.automaticRedelivery is not None:
hook.automatic_redelivery = payload.automaticRedelivery
if payload.url is not None:
hook.url = str(payload.url)
if payload.authorizedEvents is not None:
hook.authorized_events = payload.authorizedEvents.model_dump()
db.add(hook)
db.commit()
db.refresh(hook)
return _webhook_response(hook)
@router.delete(
"/api/v1/stores/{store_id}/webhooks/{webhook_id}",
status_code=204,
)
def delete_webhook(
store_id: str,
webhook_id: str,
user: User = Depends(_require_btcpay_user),
db: Session = Depends(get_db),
):
_require_store(store_id, user)
hook = _get_webhook(db, webhook_id, user)
db.delete(hook)
db.commit()
return None
@router.get("/api/v1/server/info")
def server_info():
return {
"version": "1.7.5",
"onion": None,
"fullySynched": True,
"supportedPaymentMethods": [BTCPAY_PAYMENT_METHOD],
}
@router.get("/api/v1/api-keys/current")
def api_key_current(
user: User = Depends(_require_btcpay_user),
):
store_id = str(user.id)
return {
"apiKey": decrypt_api_key(user.api_key_encrypted),
"label": "WOW Checkout",
"permissions": [
f"btcpay.store.canviewinvoices:{store_id}",
f"btcpay.store.cancreateinvoice:{store_id}",
f"btcpay.store.canviewstoresettings:{store_id}",
f"btcpay.store.canmodifyinvoices:{store_id}",
f"btcpay.store.webhooks.canmodifywebhooks:{store_id}",
],
}
def _get_webhook(db: Session, webhook_id: str, user: User) -> BtcpayWebhook:
try:
webhook_uuid = uuid.UUID(webhook_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Webhook not found",
)
hook = (
db.query(BtcpayWebhook)
.filter(BtcpayWebhook.id == webhook_uuid, BtcpayWebhook.user_id == user.id)
.first()
)
if hook is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Webhook not found",
)
return hook
def _validate_webhook_events(events: list[str] | None) -> None:
if not events:
return
for event in events:
if event not in BTCPAY_WEBHOOK_EVENTS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Webhook event is not supported",
)
def _webhook_response(
hook: BtcpayWebhook,
*,
include_secret: bool = False,
secret: str | None = None,
) -> dict[str, Any]:
payload = {
"id": str(hook.id),
"enabled": hook.enabled,
"automaticRedelivery": hook.automatic_redelivery,
"url": hook.url,
"authorizedEvents": hook.authorized_events,
}
if include_secret and secret:
payload["secret"] = secret
return payload
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from decimal import Decimal
from typing import Any
from pydantic import (
AnyUrl,
BaseModel,
Field,
conint,
field_validator,
model_validator,
)
class BtcpayCheckout(BaseModel):
redirectURL: AnyUrl | None = None
speedPolicy: str | None = None
paymentMethods: list[str] | None = None
expirationMinutes: conint(ge=1, le=10080) | None = None
monitoringMinutes: conint(ge=0, le=10080) | None = None
paymentTolerance: Decimal | None = None
redirectAutomatically: bool | None = None
defaultLanguage: str | None = None
class BtcpayInvoiceCreate(BaseModel):
amount: Decimal = Field(gt=0)
currency: str = Field(min_length=1)
metadata: dict[str, Any] | None = None
checkout: BtcpayCheckout | None = None
@field_validator("currency")
@classmethod
def _normalize_currency(cls, value: str) -> str:
return value.strip().upper()
class BtcpayWebhookEvents(BaseModel):
everything: bool = False
specificEvents: list[str] | None = None
@model_validator(mode="after")
def _validate_selection(self) -> "BtcpayWebhookEvents":
if not self.everything and not self.specificEvents:
raise ValueError("Select at least one event")
return self
class BtcpayWebhookCreate(BaseModel):
enabled: bool = True
automaticRedelivery: bool = True
url: AnyUrl
authorizedEvents: BtcpayWebhookEvents
class BtcpayWebhookUpdate(BaseModel):
enabled: bool | None = None
automaticRedelivery: bool | None = None
url: AnyUrl | None = None
authorizedEvents: BtcpayWebhookEvents | None = None
class BtcpayInvoiceStatusUpdate(BaseModel):
status: str = Field(min_length=1)
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import time
from datetime import datetime, timezone
from urllib.parse import urljoin
import requests
from requests import RequestException
from sqlalchemy.orm import Session
from .models import BtcpayWebhook, Invoice
from .security import decrypt_secret
logger = logging.getLogger(__name__)
_REDIRECT_STATUSES = {301, 302, 303, 307, 308}
def dispatch_btcpay_webhooks(
db: Session,
user_id: str,
event_type: str,
invoice: Invoice,
*,
manually_marked: bool = False,
) -> None:
hooks = (
db.query(BtcpayWebhook)
.filter(
BtcpayWebhook.user_id == user_id,
BtcpayWebhook.enabled.is_(True),
)
.all()
)
if not hooks:
return
payload = _build_payload(
event_type=event_type,
user_id=user_id,
invoice=invoice,
manually_marked=manually_marked,
)
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
for hook in hooks:
if not _event_allowed(hook.authorized_events, event_type):
continue
try:
secret = decrypt_secret(hook.secret_encrypted)
signature = _sign_payload(body, secret)
headers = {
"BTCPay-Sig": f"sha256={signature}",
"Content-Type": "application/json",
"User-Agent": "wowcheckout-btcpay-webhook/1.0",
}
response = _post_with_redirects(
hook.url,
data=body,
headers=headers,
timeout=5,
)
if response is None:
continue
if response.status_code >= 400:
logger.warning(
"BTCPay webhook delivered non-success status",
extra={
"webhook_id": str(hook.id),
"event": event_type,
"http_status": response.status_code,
},
)
except RequestException as exc:
logger.warning(
"BTCPay webhook delivery failed",
extra={"webhook_id": str(hook.id), "event": event_type},
)
logger.debug("BTCPay webhook delivery error: %s", exc)
except Exception as exc:
logger.warning(
"BTCPay webhook dispatch failed",
extra={"webhook_id": str(hook.id), "event": event_type, "error": str(exc)},
)
def _event_allowed(authorized_events: object, event_type: str) -> bool:
if not isinstance(authorized_events, dict):
return False
if authorized_events.get("everything") is True:
return True
specific = authorized_events.get("specificEvents") or []
return event_type in specific
def _build_payload(
*,
event_type: str,
user_id: str,
invoice: Invoice,
manually_marked: bool,
) -> dict[str, object]:
return {
"type": event_type,
"timestamp": int(time.time()),
"storeId": user_id,
"invoiceId": str(invoice.id),
"manuallyMarked": manually_marked,
"overPaid": False,
"partiallyPaid": False,
"afterExpiration": _after_expiration(invoice),
"metadata": invoice.metadata_json or {},
}
def _after_expiration(invoice: Invoice) -> bool:
expires_at = invoice.expires_at
detected_at = invoice.detected_at
if expires_at is None or detected_at is None:
return False
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if detected_at.tzinfo is None:
detected_at = detected_at.replace(tzinfo=timezone.utc)
return detected_at > expires_at
def _sign_payload(body: bytes, secret: str) -> str:
return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
def _post_with_redirects(
url: str,
*,
data: bytes,
headers: dict[str, str],
timeout: int,
max_redirects: int = 3,
) -> requests.Response | None:
current_url = url
for _ in range(max_redirects + 1):
response = requests.post(
current_url,
data=data,
headers=headers,
timeout=timeout,
allow_redirects=False,
)
if response.status_code not in _REDIRECT_STATUSES:
return response
location = response.headers.get("Location")
if not location:
return response
current_url = urljoin(current_url, location)
return None
+47
View File
@@ -0,0 +1,47 @@
import os
def _get_bool_env(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None or value == "":
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _get_env(name: str, default: str | None = None) -> str:
value = os.getenv(name, default)
if value is None or value == "":
raise RuntimeError(f"Missing required environment variable: {name}")
return value
DATABASE_URL = _get_env("DATABASE_URL")
API_KEYS = [
key.strip()
for key in os.getenv("API_KEYS", "").split(",")
if key.strip()
]
API_KEY_ENCRYPTION_KEY = _get_env("API_KEY_ENCRYPTION_KEY")
INVOICE_RECONCILE_INTERVAL_SECONDS = int(os.getenv("INVOICE_RECONCILE_INTERVAL_SECONDS", "30"))
INVOICE_DEFAULT_EXPIRY_HOURS = int(os.getenv("INVOICE_DEFAULT_EXPIRY_HOURS", "1"))
LATE_PAYMENT_LOOKBACK_HOURS = int(os.getenv("LATE_PAYMENT_LOOKBACK_HOURS", "48"))
DONATION_EXPIRY_MINUTES = int(os.getenv("DONATION_EXPIRY_MINUTES", "30"))
DONATION_ACTIVE_INVOICE_LIMIT = int(os.getenv("DONATION_ACTIVE_INVOICE_LIMIT", "25"))
RATE_PROVIDER = os.getenv("RATE_PROVIDER", "kraken")
DONATIONS_ENABLED = _get_bool_env("DONATIONS_ENABLED", False)
MONERO_WALLET_RPC_URLS = [
url.strip()
for url in os.getenv("MONERO_WALLET_RPC_URLS", "").split(",")
if url.strip()
]
MONERO_WALLET_RPC_USER = os.getenv("MONERO_WALLET_RPC_USER", "")
MONERO_WALLET_RPC_PASSWORD = os.getenv("MONERO_WALLET_RPC_PASSWORD", "")
MONERO_WALLET_RPC_WALLET_PASSWORD = os.getenv("MONERO_WALLET_RPC_WALLET_PASSWORD", "")
MONERO_DAEMON_URL = os.getenv("MONERO_DAEMON_URL")
MONERO_WALLET_RPC_WALLET_DIR = os.getenv("MONERO_WALLET_RPC_WALLET_DIR", "")
FOUNDER_PAYMENT_ADDRESS = os.getenv("FOUNDER_PAYMENT_ADDRESS", "")
FOUNDER_VIEW_KEY = os.getenv("FOUNDER_VIEW_KEY", "")
QR_STORAGE_DIR = os.getenv("QR_STORAGE_DIR", "/qr")
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from .config import DATABASE_URL
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+9
View File
@@ -0,0 +1,9 @@
from decimal import Decimal
def format_xmr_amount(value: Decimal) -> str:
normalized = value.normalize()
formatted = format(normalized, "f")
if "." in formatted:
formatted = formatted.rstrip("0").rstrip(".")
return formatted or "0"
+265
View File
@@ -0,0 +1,265 @@
import logging
import os
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from sqlalchemy import text
from .db import Base, engine
from .config import DONATIONS_ENABLED
from .btcpay_routes import router as btcpay_router
from .routes import router
app = FastAPI(title="wowcheckout.com API", version="0.1.0")
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper())
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=app.title,
version=app.version,
description=app.description,
routes=app.routes,
)
openapi_schema.get("paths", {}).pop("/api/core/donations", None)
if not DONATIONS_ENABLED:
openapi_schema.get("paths", {}).pop("/api/core/public/donation/{invoice_id}", None)
openapi_schema.get("paths", {}).pop("/api/core/auth/validate", None)
openapi_schema.get("paths", {}).pop("/api/core/profile", None)
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi
@app.on_event("startup")
def startup():
lock_acquired = False
lock_id = 894221741
with engine.begin() as connection:
try:
connection.execute(text("SELECT pg_advisory_lock(:lock_id)"), {"lock_id": lock_id})
lock_acquired = True
except Exception:
lock_acquired = False
try:
Base.metadata.create_all(bind=connection)
connection.execute(
text("ALTER TABLE invoices ADD COLUMN IF NOT EXISTS user_id UUID")
)
connection.execute(
text("ALTER TABLE invoices ADD COLUMN IF NOT EXISTS wallet_address VARCHAR")
)
connection.execute(
text("ALTER TABLE invoices ADD COLUMN IF NOT EXISTS subaddress_index INTEGER")
)
connection.execute(
text(
"ALTER TABLE invoices "
"ADD COLUMN IF NOT EXISTS confirmations INTEGER DEFAULT 0"
)
)
connection.execute(
text(
"ALTER TABLE invoices "
"ADD COLUMN IF NOT EXISTS total_paid_atomic BIGINT"
)
)
connection.execute(
text(
"ALTER TABLE invoices "
"ADD COLUMN IF NOT EXISTS paid_after_expiry BOOLEAN DEFAULT false"
)
)
connection.execute(
text(
"ALTER TABLE invoices "
"ADD COLUMN IF NOT EXISTS paid_after_expiry_at TIMESTAMP WITH TIME ZONE"
)
)
connection.execute(
text(
"ALTER TABLE invoices "
"ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP WITH TIME ZONE"
)
)
connection.execute(
text("UPDATE invoices SET confirmations = 0 WHERE confirmations IS NULL")
)
connection.execute(
text("CREATE INDEX IF NOT EXISTS ix_invoices_user_id ON invoices (user_id)")
)
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_invoices_wallet_address ON invoices (wallet_address)"
)
)
connection.execute(text("ALTER TABLE webhooks ADD COLUMN IF NOT EXISTS user_id UUID"))
connection.execute(
text("ALTER TABLE webhooks ADD COLUMN IF NOT EXISTS event_urls JSON")
)
connection.execute(
text("ALTER TABLE webhooks ALTER COLUMN url DROP NOT NULL")
)
connection.execute(
text("CREATE INDEX IF NOT EXISTS ix_webhooks_user_id ON webhooks (user_id)")
)
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_webhook_deliveries_user_id "
"ON webhook_deliveries (user_id)"
)
)
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_webhook_deliveries_webhook_id "
"ON webhook_deliveries (webhook_id)"
)
)
connection.execute(
text(
"ALTER TABLE webhook_deliveries "
"ADD COLUMN IF NOT EXISTS invoice_id UUID"
)
)
connection.execute(
text(
"ALTER TABLE webhook_deliveries "
"ADD COLUMN IF NOT EXISTS invoice_address VARCHAR"
)
)
connection.execute(
text(
"ALTER TABLE webhook_deliveries "
"ADD COLUMN IF NOT EXISTS invoice_subaddress_index INTEGER"
)
)
connection.execute(
text(
"ALTER TABLE webhook_deliveries "
"ADD COLUMN IF NOT EXISTS invoice_amount_xmr NUMERIC(18, 12)"
)
)
connection.execute(
text(
"ALTER TABLE webhook_deliveries "
"ADD COLUMN IF NOT EXISTS invoice_status VARCHAR"
)
)
connection.execute(
text(
"ALTER TABLE webhook_deliveries "
"ADD COLUMN IF NOT EXISTS payload JSON"
)
)
connection.execute(
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS payment_address VARCHAR")
)
connection.execute(
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS view_key_encrypted VARCHAR")
)
connection.execute(
text(
"ALTER TABLE users "
"ADD COLUMN IF NOT EXISTS webhook_secret_encrypted VARCHAR"
)
)
connection.execute(
text(
"ALTER TABLE users "
"ADD COLUMN IF NOT EXISTS next_subaddress_index INTEGER DEFAULT 1"
)
)
connection.execute(
text(
"ALTER TABLE users "
"ADD COLUMN IF NOT EXISTS subaddress_start_index INTEGER DEFAULT 0"
)
)
connection.execute(
text(
"ALTER TABLE users "
"ADD COLUMN IF NOT EXISTS default_confirmation_target INTEGER DEFAULT 1"
)
)
connection.execute(
text(
"ALTER TABLE users "
"ALTER COLUMN default_confirmation_target SET DEFAULT 1"
)
)
connection.execute(
text(
"ALTER TABLE users "
"ADD COLUMN IF NOT EXISTS default_qr_logo VARCHAR DEFAULT 'wownero'"
)
)
connection.execute(
text(
"ALTER TABLE users "
"ADD COLUMN IF NOT EXISTS default_qr_logo_data_url VARCHAR"
)
)
connection.execute(
text(
"ALTER TABLE users "
"ADD COLUMN IF NOT EXISTS btcpay_checkout_style VARCHAR DEFAULT 'btcpay_classic'"
)
)
connection.execute(
text(
"ALTER TABLE users "
"ALTER COLUMN btcpay_checkout_style SET DEFAULT 'btcpay_classic'"
)
)
connection.execute(
text(
"ALTER TABLE users "
"ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()"
)
)
connection.execute(
text(
"UPDATE users SET btcpay_checkout_style = 'btcpay_classic' "
"WHERE btcpay_checkout_style IS NULL"
)
)
connection.execute(
text(
"UPDATE users "
"SET btcpay_checkout_style = 'btcpay_classic' "
"WHERE btcpay_checkout_style = 'standard' "
"AND NOT EXISTS ("
" SELECT 1 FROM profile_history ph "
" WHERE ph.user_id = users.id AND ph.field_name = 'btcpay_checkout_style'"
")"
)
)
connection.execute(
text(
"UPDATE users SET default_confirmation_target = 1 "
"WHERE default_confirmation_target IS NULL"
)
)
connection.execute(
text(
"UPDATE users SET default_qr_logo = 'wownero' "
"WHERE default_qr_logo IS NULL"
)
)
connection.execute(
text("CREATE INDEX IF NOT EXISTS ix_users_payment_address ON users (payment_address)")
)
finally:
if lock_acquired:
connection.execute(
text("SELECT pg_advisory_unlock(:lock_id)"), {"lock_id": lock_id}
)
app.include_router(router)
app.include_router(btcpay_router)
+145
View File
@@ -0,0 +1,145 @@
import uuid
from sqlalchemy import (
BigInteger,
Boolean,
Column,
DateTime,
Integer,
Numeric,
String,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import ARRAY, UUID
from sqlalchemy.sql import func
from sqlalchemy.types import JSON
from .db import Base
class Invoice(Base):
__tablename__ = "invoices"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=True, index=True)
wallet_address = Column(String, nullable=True, index=True)
address = Column(String, nullable=False)
subaddress_index = Column(Integer, nullable=True)
amount_xmr = Column(Numeric(18, 12), nullable=False)
status = Column(String, nullable=False, index=True)
confirmation_target = Column(Integer, nullable=False)
confirmations = Column(Integer, nullable=False, server_default="0")
total_paid_atomic = Column(BigInteger, nullable=True)
paid_after_expiry = Column(Boolean, nullable=False, server_default="false")
paid_after_expiry_at = Column(DateTime(timezone=True), nullable=True)
metadata_json = Column("metadata", JSON, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
archived_at = Column(DateTime(timezone=True), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=True)
detected_at = Column(DateTime(timezone=True), nullable=True)
confirmed_at = Column(DateTime(timezone=True), nullable=True)
class InvoiceTransfer(Base):
__tablename__ = "invoice_transfers"
__table_args__ = (
UniqueConstraint(
"invoice_id",
"txid",
name="uq_invoice_transfers_invoice_txid",
),
)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
invoice_id = Column(UUID(as_uuid=True), nullable=False, index=True)
txid = Column(String, nullable=False)
amount_atomic = Column(BigInteger, nullable=False)
confirmations = Column(Integer, nullable=False, server_default="0")
timestamp = Column(BigInteger, nullable=True)
address = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)
class Webhook(Base):
__tablename__ = "webhooks"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=True, index=True)
url = Column(String, nullable=True)
events = Column(ARRAY(String), nullable=False)
event_urls = Column(JSON, nullable=True)
active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class BtcpayWebhook(Base):
__tablename__ = "btcpay_webhooks"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
url = Column(String, nullable=False)
enabled = Column(Boolean, nullable=False, default=True)
automatic_redelivery = Column(Boolean, nullable=False, default=True)
authorized_events = Column(JSON, nullable=False)
secret_encrypted = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)
class WebhookDelivery(Base):
__tablename__ = "webhook_deliveries"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
webhook_id = Column(UUID(as_uuid=True), nullable=True, index=True)
event = Column(String, nullable=False)
url = Column(String, nullable=False)
invoice_id = Column(UUID(as_uuid=True), nullable=True)
invoice_address = Column(String, nullable=True)
invoice_subaddress_index = Column(Integer, nullable=True)
invoice_amount_xmr = Column(Numeric(18, 12), nullable=True)
invoice_status = Column(String, nullable=True)
payload_json = Column("payload", JSON, nullable=True)
http_status = Column(Integer, nullable=True)
error_message = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class User(Base):
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
payment_address = Column(String, nullable=False, unique=True, index=True)
view_key_encrypted = Column(String, nullable=False)
api_key_hash = Column(String, nullable=False, unique=True, index=True)
api_key_encrypted = Column(String, nullable=False)
webhook_secret_encrypted = Column(String, nullable=True)
next_subaddress_index = Column(Integer, nullable=False, server_default="1")
subaddress_start_index = Column(Integer, nullable=False, server_default="0")
default_confirmation_target = Column(Integer, nullable=False, server_default="1")
default_qr_logo = Column(String, nullable=False, server_default="wownero")
default_qr_logo_data_url = Column(String, nullable=True)
btcpay_checkout_style = Column(String, nullable=False, server_default="btcpay_classic")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class ProfileHistory(Base):
__tablename__ = "profile_history"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
field_name = Column(String, nullable=False)
old_value = Column(String, nullable=True)
new_value = Column(String, nullable=True)
value_encrypted = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
+453
View File
@@ -0,0 +1,453 @@
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import logging
import time
from urllib.parse import urlparse
from fastapi import HTTPException, status
from monero.backends.jsonrpc import JSONRPCWallet
from monero.backends.jsonrpc.exceptions import RPCError
import requests
from requests import RequestException
from requests.auth import HTTPDigestAuth
from .config import (
MONERO_DAEMON_URL,
MONERO_WALLET_RPC_PASSWORD,
MONERO_WALLET_RPC_URLS,
MONERO_WALLET_RPC_USER,
MONERO_WALLET_RPC_WALLET_PASSWORD,
MONERO_WALLET_RPC_WALLET_DIR,
)
from .models import User
from .security import decrypt_secret
logger = logging.getLogger(__name__)
def _normalize_daemon_address(value: str | None) -> str | None:
if not value:
return None
parsed = urlparse(value)
if parsed.scheme:
address = parsed.netloc
else:
address = value.split("/", 1)[0]
return address.rstrip("/") if address else None
def _normalize_daemon_url(value: str | None) -> str | None:
if not value:
return None
parsed = urlparse(value)
if parsed.scheme:
return value.rstrip("/")
return f"http://{value.rstrip('/')}"
@dataclass(frozen=True)
class SubaddressResult:
address: str
address_index: int | None
@dataclass(frozen=True)
class TransferDetail:
txid: str
amount_atomic: int
confirmations: int
timestamp: int | None
address: str | None
@dataclass
class WalletBackend:
client: JSONRPCWallet
url: str
current_wallet: str | None = None
class MoneroWalletService:
def __init__(self) -> None:
urls = MONERO_WALLET_RPC_URLS
if not urls:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Monero wallet RPC is not configured",
)
self._backends: list[WalletBackend] = []
for url in urls:
parsed = urlparse(url)
host = parsed.hostname or "127.0.0.1"
port = parsed.port or 18082
client = JSONRPCWallet(
host=host,
port=port,
user=MONERO_WALLET_RPC_USER or None,
password=MONERO_WALLET_RPC_PASSWORD or None,
)
if MONERO_WALLET_RPC_USER and MONERO_WALLET_RPC_PASSWORD:
# monero-wallet-rpc uses digest auth when rpc-login is set.
client.session.auth = HTTPDigestAuth(
MONERO_WALLET_RPC_USER, MONERO_WALLET_RPC_PASSWORD
)
self._backends.append(WalletBackend(client=client, url=url))
self._daemon_url = _normalize_daemon_url(MONERO_DAEMON_URL)
self._daemon_address = _normalize_daemon_address(MONERO_DAEMON_URL)
self._wallet_dir = MONERO_WALLET_RPC_WALLET_DIR.strip()
def get_status(self) -> dict[str, str]:
for backend in self._backends:
try:
backend.client.raw_request("get_version")
except Exception:
return {"wallet_rpc": "unreachable"}
if self._daemon_address:
try:
self._backends[0].client.raw_request(
"set_daemon",
{"address": self._daemon_address},
)
except RPCError as exc:
message = str(exc)
if "no connection to daemon" in message or "no_connection_to_daemon" in message:
return {"wallet_rpc": "ok", "daemon": "unreachable"}
return {"wallet_rpc": "ok", "daemon": "ok" if self._daemon_address else "unknown"}
def create_subaddress(self, user: User, label: str) -> SubaddressResult:
start_total = time.monotonic()
view_key = decrypt_secret(user.view_key_encrypted)
wallet_name = self._wallet_name(user, user.payment_address, view_key)
backend = self._backend_for_wallet_name(wallet_name)
start_wallet = time.monotonic()
self._ensure_wallet_open(
backend=backend,
wallet_name=wallet_name,
payment_address=user.payment_address,
view_key=view_key,
)
wallet_elapsed = time.monotonic() - start_wallet
start_daemon = time.monotonic()
self._ensure_daemon(backend)
daemon_elapsed = time.monotonic() - start_daemon
start_create = time.monotonic()
try:
response = backend.client.raw_request(
"create_address",
{"account_index": 0, "label": label},
)
except (RPCError, RequestException) as exc:
self._raise_wallet_rpc_error(exc)
create_elapsed = time.monotonic() - start_create
address = response.get("address")
if not address:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Monero wallet RPC did not return a subaddress",
)
start_store = time.monotonic()
try:
backend.client.raw_request("store")
except RPCError:
# Storing can fail if the wallet RPC is in an odd state; the
# subaddress is still valid, but we prefer to surface issues later.
pass
store_elapsed = time.monotonic() - start_store
total_elapsed = time.monotonic() - start_total
logger.info(
"create_subaddress timing wallet=%.3fs daemon=%.3fs create=%.3fs store=%.3fs total=%.3fs",
wallet_elapsed,
daemon_elapsed,
create_elapsed,
store_elapsed,
total_elapsed,
)
return SubaddressResult(
address=address,
address_index=response.get("address_index"),
)
def get_received_atomic(
self,
user: User,
address: str,
) -> tuple[int, int]:
view_key = decrypt_secret(user.view_key_encrypted)
wallet_name = self._wallet_name(user, user.payment_address, view_key)
backend = self._backend_for_wallet_name(wallet_name)
self._ensure_wallet_open(
backend=backend,
wallet_name=wallet_name,
payment_address=user.payment_address,
view_key=view_key,
)
self._ensure_daemon(backend)
try:
index_response = backend.client.raw_request(
"get_address_index",
{"address": address},
)
except (RPCError, RequestException) as exc:
self._raise_wallet_rpc_error(exc)
index = index_response.get("index") if isinstance(index_response, dict) else None
major = index.get("major") if isinstance(index, dict) else None
minor = index.get("minor") if isinstance(index, dict) else None
if major is None or minor is None:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Monero wallet RPC did not return an address index",
)
try:
transfers = backend.client.raw_request(
"get_transfers",
{
"in": True,
"pool": True,
"account_index": major,
"subaddr_indices": [minor],
},
)
except (RPCError, RequestException) as exc:
self._raise_wallet_rpc_error(exc)
incoming = transfers.get("in", []) if isinstance(transfers, dict) else []
pool = transfers.get("pool", []) if isinstance(transfers, dict) else []
total_atomic = 0
max_confirmations = 0
seen_txids: set[str] = set()
for item in [*incoming, *pool]:
if not isinstance(item, dict):
continue
txid = item.get("txid")
if isinstance(txid, str):
if txid in seen_txids:
continue
seen_txids.add(txid)
total_atomic += int(item.get("amount", 0) or 0)
confirmations = int(item.get("confirmations", 0) or 0)
if confirmations > max_confirmations:
max_confirmations = confirmations
return total_atomic, max_confirmations
def get_transfers_for_address(
self,
user: User,
address: str,
) -> list[TransferDetail]:
view_key = decrypt_secret(user.view_key_encrypted)
wallet_name = self._wallet_name(user, user.payment_address, view_key)
backend = self._backend_for_wallet_name(wallet_name)
self._ensure_wallet_open(
backend=backend,
wallet_name=wallet_name,
payment_address=user.payment_address,
view_key=view_key,
)
self._ensure_daemon(backend)
try:
index_response = backend.client.raw_request(
"get_address_index",
{"address": address},
)
except (RPCError, RequestException) as exc:
self._raise_wallet_rpc_error(exc)
index = index_response.get("index") if isinstance(index_response, dict) else None
major = index.get("major") if isinstance(index, dict) else None
minor = index.get("minor") if isinstance(index, dict) else None
if major is None or minor is None:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Monero wallet RPC did not return an address index",
)
try:
transfers = backend.client.raw_request(
"get_transfers",
{
"in": True,
"pool": True,
"account_index": major,
"subaddr_indices": [minor],
},
)
except (RPCError, RequestException) as exc:
self._raise_wallet_rpc_error(exc)
incoming = transfers.get("in", []) if isinstance(transfers, dict) else []
pool = transfers.get("pool", []) if isinstance(transfers, dict) else []
details: list[TransferDetail] = []
seen_txids: set[str] = set()
for item in [*incoming, *pool]:
if not isinstance(item, dict):
continue
txid = item.get("txid")
if not isinstance(txid, str) or not txid:
continue
if txid in seen_txids:
continue
seen_txids.add(txid)
amount_atomic = int(item.get("amount", 0) or 0)
confirmations = int(item.get("confirmations", 0) or 0)
timestamp = item.get("timestamp")
timestamp_value = int(timestamp) if isinstance(timestamp, (int, float)) else None
details.append(
TransferDetail(
txid=txid,
amount_atomic=amount_atomic,
confirmations=confirmations,
timestamp=timestamp_value,
address=item.get("address") if isinstance(item.get("address"), str) else None,
)
)
return details
@staticmethod
def _rpc_error_message(exc: RPCError) -> str:
message = str(exc)
error = getattr(exc, "error", None)
if isinstance(error, dict):
detail = error.get("message")
if detail:
message = f"{message} {detail}"
return message
def _daemon_height(self) -> int | None:
if not self._daemon_url:
return None
url = self._daemon_url.rstrip("/")
payload = {"jsonrpc": "2.0", "id": "0", "method": "get_height"}
try:
response = requests.post(f"{url}/json_rpc", json=payload, timeout=5)
response.raise_for_status()
data = response.json()
result = data.get("result") if isinstance(data, dict) else None
height = result.get("height") if isinstance(result, dict) else None
if isinstance(height, int):
return height
except (requests.RequestException, ValueError):
pass
try:
response = requests.get(f"{url}/get_height", timeout=5)
response.raise_for_status()
data = response.json()
except (requests.RequestException, ValueError):
return None
height = data.get("height") if isinstance(data, dict) else None
return height if isinstance(height, int) else None
@staticmethod
def _wallet_name(user: User, payment_address: str, view_key: str) -> str:
fingerprint = hashlib.sha256(
f"{payment_address}:{view_key}".encode("utf-8")
).hexdigest()[:12]
return f"user-{user.id}-{fingerprint}"
def _backend_for_wallet_name(self, wallet_name: str) -> WalletBackend:
digest = hashlib.sha256(wallet_name.encode("utf-8")).hexdigest()
index = int(digest, 16) % len(self._backends)
return self._backends[index]
def _ensure_wallet_open(
self,
*,
backend: WalletBackend,
wallet_name: str,
payment_address: str,
view_key: str,
) -> None:
if backend.current_wallet == wallet_name:
return
close_start = time.monotonic()
try:
backend.client.raw_request("close_wallet")
except (RPCError, RequestException):
pass
close_elapsed = time.monotonic() - close_start
open_start = time.monotonic()
try:
backend.client.raw_request(
"open_wallet",
{"filename": wallet_name, "password": MONERO_WALLET_RPC_WALLET_PASSWORD},
)
backend.current_wallet = wallet_name
open_elapsed = time.monotonic() - open_start
logger.info(
"wallet open timing close=%.3fs open=%.3fs",
close_elapsed,
open_elapsed,
)
except (RPCError, RequestException):
try:
restore_height = self._daemon_height() or 0
gen_start = time.monotonic()
# generate_from_keys opens the wallet when successful.
backend.client.raw_request(
"generate_from_keys",
{
"restore_height": restore_height,
"filename": wallet_name,
"address": payment_address,
"viewkey": view_key,
"password": MONERO_WALLET_RPC_WALLET_PASSWORD,
},
)
backend.current_wallet = wallet_name
gen_elapsed = time.monotonic() - gen_start
open_elapsed = time.monotonic() - open_start
logger.info(
"wallet generate timing close=%.3fs open_fail=%.3fs generate=%.3fs",
close_elapsed,
open_elapsed,
gen_elapsed,
)
except (RPCError, RequestException) as gen_exc:
if isinstance(gen_exc, RequestException):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Monero wallet RPC is unreachable",
) from gen_exc
gen_message = self._rpc_error_message(gen_exc).lower()
if "wallet_files_doesnt_correspond" in gen_message or "file_exists" in gen_message:
expected_path = (
f"{self._wallet_dir.rstrip('/')}/{wallet_name}"
if self._wallet_dir
else wallet_name
)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"Wallet file exists but could not be opened. Verify the wallet "
"password, wallet directory, and that the cache file matches the "
".keys file. If needed, remove or rename the wallet cache file "
f"to rebuild it. Expected filename: {expected_path}"
),
) from gen_exc
raise
def _ensure_daemon(self, backend: WalletBackend) -> None:
if not self._daemon_address:
return
try:
backend.client.raw_request(
"set_daemon",
{"address": self._daemon_address},
)
except (RPCError, RequestException) as exc:
self._raise_wallet_rpc_error(exc)
@staticmethod
def _raise_wallet_rpc_error(exc: Exception) -> None:
if isinstance(exc, RequestException):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Monero wallet RPC is unreachable",
) from exc
message = str(exc)
if "no connection to daemon" in message or "no_connection_to_daemon" in message:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Wallet RPC cannot reach the Monero daemon",
) from exc
raise
+188
View File
@@ -0,0 +1,188 @@
from __future__ import annotations
import base64
import os
import tempfile
from dataclasses import dataclass
from typing import Any
from fastapi import HTTPException, status
import qrcode
from qrcode.constants import ERROR_CORRECT_H
from PIL import Image
from .formatting import format_xmr_amount
from .models import Invoice
@dataclass(frozen=True)
class QrSettings:
logo: str # "monero" | "none" | "custom"
logo_data_url: str | None = None
def invoice_qr_url(invoice_id: str) -> str:
return f"/qr/{invoice_id}.png"
def ensure_invoice_qr_png(
*,
invoice: Invoice,
storage_dir: str,
settings: QrSettings,
) -> str:
if not storage_dir:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="QR storage is not configured",
)
os.makedirs(storage_dir, exist_ok=True)
try:
os.chmod(storage_dir, 0o755)
except Exception:
pass
filename = f"{invoice.id}.png"
path = os.path.join(storage_dir, filename)
if os.path.exists(path):
try:
os.chmod(path, 0o644)
except Exception:
pass
return path
png_bytes = build_invoice_qr_png_bytes(invoice=invoice, settings=settings)
_atomic_write(path, png_bytes)
try:
os.chmod(path, 0o644)
except Exception:
pass
return path
def build_invoice_qr_png_bytes(*, invoice: Invoice, settings: QrSettings) -> bytes:
uri = build_monero_uri(invoice)
qr = qrcode.QRCode(
error_correction=ERROR_CORRECT_H,
border=2,
box_size=10,
)
qr.add_data(uri)
qr.make(fit=True)
image = qr.make_image(fill_color="black", back_color="white").convert("RGBA")
if settings.logo == "custom" and settings.logo_data_url:
logo = _load_logo_from_data_url(settings.logo_data_url)
image = _overlay_logo(image, logo)
out = tempfile.SpooledTemporaryFile(max_size=1024 * 1024)
image.save(out, format="PNG", optimize=True)
out.seek(0)
return out.read()
def build_monero_uri(invoice: Invoice) -> str:
amount = format_xmr_amount(invoice.amount_xmr)
params: dict[str, str] = {"tx_amount": amount}
metadata = invoice.metadata_json or {}
if isinstance(metadata, dict):
recipient_name = metadata.get("recipient_name")
description = metadata.get("description")
if isinstance(recipient_name, str) and recipient_name.strip():
params["recipient_name"] = recipient_name.strip()
if isinstance(description, str) and description.strip():
params["tx_description"] = description.strip()
query = "&".join(f"{_url_escape(k)}={_url_escape(v)}" for k, v in params.items())
return f"wownero:{invoice.address}?{query}"
def resolve_qr_settings(invoice: Invoice) -> QrSettings:
metadata: Any = invoice.metadata_json or {}
if not isinstance(metadata, dict):
return QrSettings(logo="wownero", logo_data_url=None)
qr_meta = metadata.get("qr")
if not isinstance(qr_meta, dict):
return QrSettings(logo="wownero", logo_data_url=None)
logo = qr_meta.get("logo")
logo_data_url = qr_meta.get("logo_data_url")
if not isinstance(logo, str) or logo not in ("wownero", "none", "custom"):
return QrSettings(logo="wownero", logo_data_url=None)
if logo == "custom" and isinstance(logo_data_url, str) and logo_data_url.strip():
return QrSettings(logo="custom", logo_data_url=logo_data_url.strip())
return QrSettings(logo=logo, logo_data_url=None)
def _atomic_write(path: str, data: bytes) -> None:
directory = os.path.dirname(path) or "."
os.makedirs(directory, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".tmp-", dir=directory)
try:
with os.fdopen(fd, "wb") as tmp_file:
tmp_file.write(data)
tmp_file.flush()
os.fsync(tmp_file.fileno())
os.replace(tmp_path, path)
finally:
try:
if os.path.exists(tmp_path):
os.remove(tmp_path)
except Exception:
pass
def _load_logo_from_data_url(data_url: str) -> Image.Image:
# Expected: data:image/png;base64,<...>
try:
header, encoded = data_url.split(",", 1)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="QR logo must be a data URL",
) from exc
if ";base64" not in header:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="QR logo must be base64 encoded",
)
try:
raw = base64.b64decode(encoded, validate=True)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="QR logo could not be decoded",
) from exc
try:
from io import BytesIO
logo = Image.open(BytesIO(raw)).convert("RGBA")
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="QR logo image format is not supported",
) from exc
return logo
def _overlay_logo(qr_image: Image.Image, logo: Image.Image) -> Image.Image:
# Center a small logo with a white backing.
size = min(qr_image.size)
target_logo_size = int(size * 0.22)
if target_logo_size < 24:
return qr_image
logo = logo.copy()
logo.thumbnail((target_logo_size, target_logo_size), Image.LANCZOS)
overlay = Image.new("RGBA", qr_image.size, (255, 255, 255, 0))
box_size = int(size * 0.28)
box_offset = int((size - box_size) / 2)
box = Image.new("RGBA", (box_size, box_size), (255, 255, 255, 255))
overlay.paste(box, (box_offset, box_offset))
logo_offset = (int((size - logo.size[0]) / 2), int((size - logo.size[1]) / 2))
overlay.paste(logo, logo_offset, mask=logo)
return Image.alpha_composite(qr_image, overlay)
def _url_escape(value: str) -> str:
from urllib.parse import quote
return quote(value, safe="")
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal
import threading
import time
from typing import Any
import requests
NONLOGS_MARKETS_URL = "https://api.nonlogs.io/api/markets"
KRAKEN_TICKER_URL = "https://api.kraken.com/0/public/Ticker"
RATE_SOURCE = "nonlogs"
RATE_TTL_SECONDS = 60
_cache_lock = threading.Lock()
_cached_quote: QuoteResult | None = None
_cached_at: float | None = None
_btc_cache_lock = threading.Lock()
_cached_btc_usd: Decimal | None = None
_cached_btc_at: float | None = None
BTC_CACHE_TTL = 30
@dataclass(frozen=True)
class QuoteResult:
rate: Decimal
currency: str
source: str
quoted_at: datetime
def _get_btc_usd() -> Decimal:
global _cached_btc_usd, _cached_btc_at
with _btc_cache_lock:
if _cached_btc_usd and _cached_btc_at:
if time.monotonic() - _cached_btc_at < BTC_CACHE_TTL:
return _cached_btc_usd
resp = requests.get(KRAKEN_TICKER_URL, params={"pair": "XXBTZUSD"}, timeout=5)
resp.raise_for_status()
data = resp.json()
if data.get("error"):
raise RuntimeError(f"Kraken API error: {data['error']}")
price = data["result"]["XXBTZUSD"]["c"][0]
result = Decimal(str(price))
with _btc_cache_lock:
_cached_btc_usd = result
_cached_btc_at = time.monotonic()
return result
def get_wow_rate(currency: str) -> QuoteResult:
global _cached_quote, _cached_at
normalized_currency = currency.strip().lower()
if normalized_currency != "usd":
raise ValueError(f"Unsupported fiat currency: {currency} (supported: usd)")
with _cache_lock:
if _cached_quote and _cached_at:
if time.monotonic() - _cached_at < RATE_TTL_SECONDS:
return _cached_quote
headers = {
"Accept": "application/json",
"User-Agent": "wowcheckout/1.0",
}
resp = requests.get(NONLOGS_MARKETS_URL, headers=headers, timeout=10)
resp.raise_for_status()
data: dict[str, Any] = resp.json()
markets = data.get("markets", {})
# Use WOW-BTC price converted via Kraken BTC/USD
wow_btc = markets.get("WOW-BTC", {})
if not wow_btc.get("last_price"):
raise ValueError("Could not get WOW-BTC rate from Nonlogs")
btc_usd = _get_btc_usd()
rate = Decimal(str(wow_btc["last_price"])) * btc_usd
quote = QuoteResult(
rate=rate,
currency="USD",
source=RATE_SOURCE,
quoted_at=datetime.now(timezone.utc),
)
with _cache_lock:
_cached_quote = quote
_cached_at = time.monotonic()
return quote
+265
View File
@@ -0,0 +1,265 @@
from __future__ import annotations
import logging
import time
from datetime import datetime, timedelta, timezone
from decimal import Decimal, ROUND_DOWN
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from .btcpay_webhooks import dispatch_btcpay_webhooks
from .config import INVOICE_RECONCILE_INTERVAL_SECONDS, LATE_PAYMENT_LOOKBACK_HOURS
from .db import SessionLocal
from .models import Invoice, InvoiceTransfer, User
from .monero_service import MoneroWalletService, TransferDetail
from .webhooks import dispatch_webhooks
logger = logging.getLogger(__name__)
def main() -> None:
level_name = "INFO"
try:
level_name = __import__("os").getenv("LOG_LEVEL", "INFO")
except Exception:
level_name = "INFO"
level = getattr(logging, level_name.upper(), logging.INFO)
logging.basicConfig(level=level)
service = MoneroWalletService()
while True:
try:
_reconcile_invoices(service)
except Exception as exc:
logger.exception("Invoice reconcile failed: %s", exc)
time.sleep(INVOICE_RECONCILE_INTERVAL_SECONDS)
def _reconcile_invoices(service: MoneroWalletService) -> None:
db: Session = SessionLocal()
try:
now = datetime.now(timezone.utc)
late_cutoff = now - timedelta(hours=max(0, LATE_PAYMENT_LOOKBACK_HOURS))
invoices = (
db.query(Invoice)
.filter(
or_(
Invoice.status.in_(["pending", "payment_detected"]),
and_(
Invoice.status == "expired",
Invoice.expires_at.is_not(None),
Invoice.expires_at >= late_cutoff,
),
)
)
.order_by(Invoice.created_at.asc())
.all()
)
user_groups: dict[object, list[Invoice]] = {}
for invoice in invoices:
if invoice.user_id is None:
logger.debug(
"Skipping invoice without user",
extra={"invoice_id": str(invoice.id)},
)
continue
user_groups.setdefault(invoice.user_id, []).append(invoice)
logger.debug(
"Reconciling %d invoices across %d users",
len(invoices),
len(user_groups),
)
for user_id, user_invoices in user_groups.items():
user = db.query(User).filter(User.id == user_id).first()
if user is None:
logger.debug(
"Skipping invoices with missing user",
extra={"user_id": str(user_id)},
)
continue
if not user.payment_address or not user.view_key_encrypted:
logger.debug(
"Skipping invoices without payment address",
extra={"user_id": str(user.id)},
)
continue
for invoice in user_invoices:
try:
transfers = service.get_transfers_for_address(
user=user,
address=invoice.address,
)
except Exception as exc:
logger.warning(
"Skipping invoice reconcile due to wallet RPC error",
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
)
logger.debug("Wallet RPC error: %s", exc)
continue
total_atomic = 0
max_confirmations = 0
for transfer in transfers:
if transfer.amount_atomic <= 0:
continue
total_atomic += transfer.amount_atomic
if transfer.confirmations > max_confirmations:
max_confirmations = transfer.confirmations
logger.debug(
"Invoice totals",
extra={
"invoice_id": str(invoice.id),
"received_atomic": total_atomic,
"confirmations": max_confirmations,
},
)
now = datetime.now(timezone.utc)
previous_confirmations = invoice.confirmations or 0
total_changed = invoice.total_paid_atomic != total_atomic
confirmations_changed = previous_confirmations != max_confirmations
transfers_changed = _sync_invoice_transfers(
db,
invoice=invoice,
transfers=transfers,
)
if total_changed or confirmations_changed or transfers_changed:
if confirmations_changed:
invoice.confirmations = max_confirmations
if total_changed:
invoice.total_paid_atomic = total_atomic
db.add(invoice)
db.commit()
required_atomic = _xmr_to_atomic(invoice.amount_xmr)
is_paid = total_atomic >= required_atomic
expires_at = invoice.expires_at
if expires_at is not None and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
is_after_expiry = bool(expires_at and now >= expires_at)
if total_atomic < required_atomic:
logger.debug(
"Payment not yet detected",
extra={
"invoice_id": str(invoice.id),
"required_atomic": required_atomic,
"received_atomic": total_atomic,
},
)
if invoice.status == "pending" and is_after_expiry:
logger.info(
"Invoice expired",
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
)
invoice.status = "expired"
db.add(invoice)
db.commit()
dispatch_webhooks(db, str(user.id), "invoice.expired", invoice)
dispatch_btcpay_webhooks(
db, str(user.id), "InvoiceExpired", invoice
)
continue
if is_paid and invoice.status in ("pending", "expired"):
logger.info(
"Invoice marked payment detected",
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
)
previous_status = invoice.status
invoice.status = "payment_detected"
if invoice.detected_at is None:
invoice.detected_at = now
if previous_status == "expired" or (previous_status == "pending" and is_after_expiry):
invoice.paid_after_expiry = True
if invoice.paid_after_expiry_at is None:
invoice.paid_after_expiry_at = now
db.add(invoice)
db.commit()
dispatch_webhooks(db, str(user.id), "invoice.payment_detected", invoice)
dispatch_btcpay_webhooks(
db, str(user.id), "InvoiceReceivedPayment", invoice
)
dispatch_btcpay_webhooks(db, str(user.id), "InvoicePaidInFull", invoice)
dispatch_btcpay_webhooks(
db, str(user.id), "InvoiceProcessing", invoice
)
if max_confirmations >= invoice.confirmation_target and invoice.status != "confirmed":
logger.info(
"Invoice confirmed",
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
)
invoice.status = "confirmed"
if invoice.confirmed_at is None:
invoice.confirmed_at = now
db.add(invoice)
db.commit()
dispatch_webhooks(db, str(user.id), "invoice.confirmed", invoice)
dispatch_btcpay_webhooks(db, str(user.id), "InvoiceSettled", invoice)
dispatch_btcpay_webhooks(
db, str(user.id), "InvoicePaymentSettled", invoice
)
finally:
db.close()
def _sync_invoice_transfers(
db: Session,
*,
invoice: Invoice,
transfers: list[TransferDetail],
) -> bool:
existing = (
db.query(InvoiceTransfer)
.filter(InvoiceTransfer.invoice_id == invoice.id)
.all()
)
existing_by_txid = {transfer.txid: transfer for transfer in existing if transfer.txid}
seen_txids: set[str] = set()
changed = False
for transfer in transfers:
txid = transfer.txid
if not txid:
continue
seen_txids.add(txid)
stored = existing_by_txid.get(txid)
if stored is None:
db.add(
InvoiceTransfer(
invoice_id=invoice.id,
txid=txid,
amount_atomic=transfer.amount_atomic,
confirmations=transfer.confirmations,
timestamp=transfer.timestamp,
address=transfer.address,
)
)
changed = True
continue
if (
stored.amount_atomic != transfer.amount_atomic
or stored.confirmations != transfer.confirmations
or stored.timestamp != transfer.timestamp
or stored.address != transfer.address
):
stored.amount_atomic = transfer.amount_atomic
stored.confirmations = transfer.confirmations
stored.timestamp = transfer.timestamp
stored.address = transfer.address
changed = True
for stored in existing:
if stored.txid not in seen_txids:
db.delete(stored)
changed = True
return changed
def _xmr_to_atomic(amount: Decimal) -> int:
quantized = (Decimal(amount) * Decimal("1000000000000")).to_integral_value(
rounding=ROUND_DOWN
)
return int(quantized)
if __name__ == "__main__":
main()
+1308
View File
File diff suppressed because it is too large Load Diff
+279
View File
@@ -0,0 +1,279 @@
from datetime import datetime
from decimal import Decimal
from typing import Any, Literal
from uuid import UUID
from pydantic import (
AnyUrl,
BaseModel,
ConfigDict,
Field,
conint,
field_serializer,
field_validator,
model_validator,
)
from .formatting import format_xmr_amount
InvoiceStatus = Literal["pending", "payment_detected", "confirmed", "expired", "invalid"]
BtcpayCheckoutStyle = Literal["standard", "btcpay_classic"]
QrLogoMode = Literal["wownero", "none", "custom"]
class InvoiceCreate(BaseModel):
amount_xmr: Decimal | None = Field(default=None, gt=0)
amount_fiat: Decimal | None = Field(default=None, gt=0)
currency: str | None = None
confirmation_target: conint(ge=0, le=10) = 1
checkout_continue_url: AnyUrl | None = Field(
default=None,
description="Optional: after the invoice is confirmed, the hosted invoice page can offer a Continue button that navigates to this merchant URL.",
)
metadata: dict[str, Any] | None = None
expires_at: datetime | None = Field(
default=None,
description="Defaults to 60 minutes from creation if omitted.",
)
@field_validator("currency")
@classmethod
def _normalize_currency(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip().upper()
@model_validator(mode="after")
def _validate_amounts(self) -> "InvoiceCreate":
if self.amount_xmr is None and self.amount_fiat is None:
raise ValueError("amount_xmr or amount_fiat is required")
if self.amount_xmr is not None and self.amount_fiat is not None:
raise ValueError("Provide either amount_xmr or amount_fiat, not both")
if self.amount_fiat is not None and not self.currency:
raise ValueError("currency is required when amount_fiat is provided")
return self
@field_validator("checkout_continue_url")
@classmethod
def _validate_checkout_continue_url(cls, value: AnyUrl | None) -> AnyUrl | None:
if value is None:
return None
if value.scheme == "https":
return value
if value.scheme == "http" and value.host in ("localhost", "127.0.0.1"):
return value
raise ValueError("checkout_continue_url must use https (http is allowed for localhost)")
class DonationCreate(BaseModel):
amount_xmr: Decimal = Field(gt=0)
confirmation_target: conint(ge=0, le=10) = 1
class InvoiceResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
address: str
subaddress_index: int | None = None
amount_xmr: Decimal
status: InvoiceStatus
confirmation_target: int
confirmations: int = 0
paid_after_expiry: bool = False
paid_after_expiry_at: datetime | None = None
qr_logo: QrLogoMode | None = None
qr_logo_data_url: str | None = None
metadata: dict[str, Any] | None = Field(default=None, validation_alias="metadata_json")
warnings: list[str] | None = None
quote: dict[str, Any] | None = None
invoice_url: str | None = None
qr_url: str | None = None
created_at: datetime
archived_at: datetime | None = None
expires_at: datetime | None
detected_at: datetime | None
confirmed_at: datetime | None
@field_serializer("amount_xmr")
def _serialize_amount_xmr(self, value: Decimal) -> str:
return format_xmr_amount(value)
class InvoiceCreateUser(BaseModel):
amount_xmr: Decimal | None = Field(default=None, gt=0)
amount_fiat: Decimal | None = Field(default=None, gt=0)
currency: str | None = None
confirmation_target: conint(ge=0, le=10) = 1
checkout_continue_url: AnyUrl | None = Field(
default=None,
description="Optional: after the invoice is confirmed, the hosted invoice page can offer a Continue button that navigates to this merchant URL.",
)
metadata: dict[str, Any] | None = None
expires_at: datetime | None = Field(
default=None,
description="Defaults to 60 minutes from creation if omitted.",
)
@field_validator("currency")
@classmethod
def _normalize_currency(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip().upper()
@model_validator(mode="after")
def _validate_amounts(self) -> "InvoiceCreateUser":
if self.amount_xmr is None and self.amount_fiat is None:
raise ValueError("amount_xmr or amount_fiat is required")
if self.amount_xmr is not None and self.amount_fiat is not None:
raise ValueError("Provide either amount_xmr or amount_fiat, not both")
if self.amount_fiat is not None and not self.currency:
raise ValueError("currency is required when amount_fiat is provided")
return self
@field_validator("checkout_continue_url")
@classmethod
def _validate_checkout_continue_url(cls, value: AnyUrl | None) -> AnyUrl | None:
if value is None:
return None
if value.scheme == "https":
return value
if value.scheme == "http" and value.host in ("localhost", "127.0.0.1"):
return value
raise ValueError("checkout_continue_url must use https (http is allowed for localhost)")
class InvoiceListResponse(BaseModel):
items: list[InvoiceResponse]
total: int
class InvoiceStatusResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
address: str
subaddress_index: int | None = None
amount_xmr: Decimal
amount_paid_xmr: str | None = None
status: InvoiceStatus
confirmation_target: int
confirmations: int = 0
paid_after_expiry: bool = False
paid_after_expiry_at: datetime | None = None
qr_logo: QrLogoMode | None = None
qr_logo_data_url: str | None = None
created_at: datetime
expires_at: datetime | None
detected_at: datetime | None
confirmed_at: datetime | None
btcpay_amount: str | None = None
btcpay_currency: str | None = None
btcpay_checkout_style: BtcpayCheckoutStyle | None = None
btcpay_redirect_url: str | None = None
btcpay_redirect_automatically: bool | None = None
btcpay_order_id: str | None = None
btcpay_order_number: str | None = None
checkout_continue_available: bool = False
quote: dict[str, Any] | None = None
qr_url: str | None = None
@field_serializer("amount_xmr")
def _serialize_amount_xmr(self, value: Decimal) -> str:
return format_xmr_amount(value)
class ProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
payment_address: str
default_confirmation_target: conint(ge=0, le=10) = 1
default_qr_logo: QrLogoMode = "wownero"
default_qr_logo_data_url: str | None = None
btcpay_checkout_style: BtcpayCheckoutStyle = "btcpay_classic"
created_at: datetime
class ProfileUpdate(BaseModel):
btcpay_checkout_style: BtcpayCheckoutStyle | None = None
default_confirmation_target: conint(ge=0, le=10) | None = None
default_qr_logo: QrLogoMode | None = None
default_qr_logo_data_url: str | None = None
class WebhookCreate(BaseModel):
url: AnyUrl | None = None
events: list[
Literal[
"invoice.created",
"invoice.payment_detected",
"invoice.confirmed",
"invoice.expired",
]
] | None = None
event_urls: dict[
Literal[
"invoice.created",
"invoice.payment_detected",
"invoice.confirmed",
"invoice.expired",
],
AnyUrl,
] | None = None
class WebhookResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
url: AnyUrl | None = None
events: list[str]
event_urls: dict[str, AnyUrl] | None = None
active: bool
created_at: datetime
class WebhookDeliveryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
webhook_id: UUID | None = None
event: str
url: str
invoice_id: UUID | None = None
invoice_address: str | None = None
invoice_subaddress_index: int | None = None
invoice_amount_xmr: Decimal | None = None
invoice_status: InvoiceStatus | None = None
http_status: int | None = None
error_message: str | None = None
created_at: datetime
@field_serializer("invoice_amount_xmr")
def _serialize_invoice_amount_xmr(self, value: Decimal | None) -> str | None:
if value is None:
return None
return format_xmr_amount(value)
class LoginRequest(BaseModel):
payment_address: str = Field(min_length=1)
view_key: str = Field(min_length=1, max_length=256)
class LoginResponse(BaseModel):
api_key: str
webhook_secret: str
store_id: UUID
class ApiCredentialsResetRequest(BaseModel):
reset_api_key: bool = False
reset_webhook_secret: bool = False
class ApiCredentialsResetResponse(BaseModel):
api_key: str | None = None
webhook_secret: str | None = None
+89
View File
@@ -0,0 +1,89 @@
import hashlib
import secrets
from fastapi import Depends, Header, HTTPException, status
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from .config import (
API_KEYS,
API_KEY_ENCRYPTION_KEY,
)
from .db import get_db
from .models import User
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def _parse_authorization_api_key(authorization: str | None) -> str | None:
if not authorization:
return None
scheme, _, token = authorization.partition(" ")
if scheme.lower() not in ("apikey", "token"):
return None
token = token.strip()
return token or None
def require_api_key(
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None),
db: Session = Depends(get_db),
) -> str:
api_key = _parse_authorization_api_key(authorization) or x_api_key
if api_key is None or api_key not in API_KEYS:
if api_key is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
api_key_hash = hash_api_key(api_key)
user = db.query(User).filter(User.api_key_hash == api_key_hash).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
return api_key
def hash_password(password: str) -> str:
return _pwd_context.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
return _pwd_context.verify(password, password_hash)
def generate_api_key() -> str:
return f"wowcheckout_{secrets.token_urlsafe(32)}"
def generate_webhook_secret() -> str:
return f"whsec_{secrets.token_urlsafe(32)}"
def hash_api_key(api_key: str) -> str:
return hashlib.sha256(api_key.encode("utf-8")).hexdigest()
def encrypt_api_key(api_key: str) -> str:
return encrypt_secret(api_key)
def decrypt_api_key(api_key_encrypted: str) -> str:
return decrypt_secret(api_key_encrypted)
def encrypt_secret(value: str) -> str:
from cryptography.fernet import Fernet
return Fernet(API_KEY_ENCRYPTION_KEY).encrypt(value.encode("utf-8")).decode("utf-8")
def decrypt_secret(value_encrypted: str) -> str:
from cryptography.fernet import Fernet
return Fernet(API_KEY_ENCRYPTION_KEY).decrypt(
value_encrypted.encode("utf-8")
).decode("utf-8")
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from .models import User
from .security import decrypt_secret
from .subaddress_derivation import derive_subaddress
MAX_SUBADDRESS_INDEX = 100
def create_subaddress_for_user(
db: Session,
*,
user: User,
) -> tuple[str, int | None]:
locked = (
db.query(User)
.filter(
User.id == user.id,
)
.with_for_update()
.first()
)
if locked is None:
raise ValueError("User not found")
start_index = int(locked.subaddress_start_index or 0)
if start_index < 0 or start_index >= MAX_SUBADDRESS_INDEX:
start_index = 0
range_start = start_index + 1
range_end = MAX_SUBADDRESS_INDEX
next_index = int(locked.next_subaddress_index or range_start)
if next_index < range_start or next_index > range_end:
next_index = range_start
next_next_index = next_index + 1
if next_next_index > range_end:
next_next_index = range_start
locked.next_subaddress_index = next_next_index
db.flush()
view_key = decrypt_secret(locked.view_key_encrypted)
address = derive_subaddress(
payment_address=locked.payment_address,
view_key=view_key,
account_index=0,
address_index=next_index,
)
return address, int(next_index)
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from binascii import hexlify, unhexlify
import struct
from monero import base58, const, ed25519
from monero.address import Address
from monero.keccak import keccak_256
# Override monero library network constants for Wownero
from monero import const as _mc
_mc.NETS = ('main',)
_mc.MASTERADDR_NETBYTES = (53,) # Wownero mainnet address prefix
_mc.SUBADDR_NETBYTES = (63,) # Wownero subaddress prefix
_mc.INTADDRR_NETBYTES = (54,) # Wownero integrated address prefix
def derive_subaddress(
*,
payment_address: str,
view_key: str,
account_index: int,
address_index: int,
) -> str:
address = Address(payment_address)
view_key_bytes = unhexlify(view_key)
spend_pub_bytes = unhexlify(address.spend_key())
data = (
b"SubAddr\0"
+ view_key_bytes
+ struct.pack("<II", account_index, address_index)
)
m = ed25519.scalar_reduce(keccak_256(data).digest())
mG = ed25519.scalarmult_B(m)
D = ed25519.edwards_add(spend_pub_bytes, mG)
C = ed25519.scalarmult(view_key_bytes, D)
prefix = const.SUBADDR_NETBYTES[const.NETS.index(address.net)]
payload = bytearray([prefix]) + D + C
checksum = keccak_256(payload).digest()[:4]
return base58.encode(hexlify(payload + checksum))
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
import logging
import uuid
from datetime import datetime
import requests
from requests import RequestException
from sqlalchemy.orm import Session
from .formatting import format_xmr_amount
from .models import Invoice, User, Webhook, WebhookDelivery
from .security import decrypt_secret, encrypt_secret, generate_webhook_secret
logger = logging.getLogger(__name__)
def build_webhook_payload(event: str, invoice: Invoice) -> dict[str, object]:
return {
"event": event,
"invoice": {
"id": str(invoice.id),
"address": invoice.address,
"subaddress_index": invoice.subaddress_index,
"amount_xmr": format_xmr_amount(invoice.amount_xmr),
"status": invoice.status,
"confirmation_target": invoice.confirmation_target,
"confirmations": invoice.confirmations or 0,
"paid_after_expiry": bool(invoice.paid_after_expiry),
"paid_after_expiry_at": _isoformat(invoice.paid_after_expiry_at),
"metadata": invoice.metadata_json,
"created_at": _isoformat(invoice.created_at),
"expires_at": _isoformat(invoice.expires_at),
"detected_at": _isoformat(invoice.detected_at),
"confirmed_at": _isoformat(invoice.confirmed_at),
},
}
def dispatch_webhooks(
db: Session,
user_id: str,
event: str,
invoice: Invoice,
) -> None:
hooks = (
db.query(Webhook)
.filter(
Webhook.user_id == user_id,
Webhook.active.is_(True),
)
.all()
)
user = db.query(User).filter(User.id == user_id).first()
webhook_secret = None
if user is not None:
webhook_secret = _ensure_webhook_secret(db, user)
payload = build_webhook_payload(event, invoice)
deliveries: list[WebhookDelivery] = []
user_uuid = uuid.UUID(user_id)
for hook in hooks:
if event not in hook.events:
continue
hook_urls = hook.event_urls or {}
target_url = hook_urls.get(event, hook.url)
if not target_url:
logger.warning(
"Webhook URL missing for event",
extra={"webhook_id": str(hook.id), "event": event},
)
continue
status_code = None
error_message = None
try:
headers = {"X-Webhook-Secret": webhook_secret} if webhook_secret else None
response = requests.post(target_url, json=payload, headers=headers, timeout=5)
status_code = response.status_code
except RequestException as exc:
error_message = str(exc)
logger.warning(
"Webhook delivery failed",
extra={"webhook_id": str(hook.id), "event": event},
)
logger.debug("Webhook delivery error: %s", exc)
deliveries.append(
WebhookDelivery(
user_id=user_uuid,
webhook_id=hook.id,
event=event,
url=target_url,
invoice_id=invoice.id,
invoice_address=invoice.address,
invoice_subaddress_index=invoice.subaddress_index,
invoice_amount_xmr=invoice.amount_xmr,
invoice_status=invoice.status,
payload_json=payload,
http_status=status_code,
error_message=error_message,
)
)
if deliveries:
try:
db.add_all(deliveries)
db.commit()
except Exception as exc:
db.rollback()
logger.warning("Failed to save webhook delivery history", extra={"error": str(exc)})
def _isoformat(value: datetime | None) -> str | None:
return value.isoformat() if value is not None else None
def _ensure_webhook_secret(db: Session, user: User) -> str:
if user.webhook_secret_encrypted:
return decrypt_secret(user.webhook_secret_encrypted)
secret = generate_webhook_secret()
user.webhook_secret_encrypted = encrypt_secret(secret)
db.add(user)
db.commit()
db.refresh(user)
return secret
+13
View File
@@ -0,0 +1,13 @@
fastapi==0.115.0
gunicorn==23.0.0
uvicorn[standard]==0.30.6
SQLAlchemy==2.0.36
psycopg2-binary==2.9.9
pydantic==2.9.2
passlib[bcrypt]==1.7.4
itsdangerous==2.2.0
cryptography==42.0.8
email-validator==2.2.0
monero==1.1.1
qrcode==7.4.2
Pillow==11.1.0
+225
View File
@@ -0,0 +1,225 @@
services:
nginx:
build:
context: ./nginx
ports:
- "8180:80"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/certs:/etc/nginx/certs
- ./qr:/qr:ro
depends_on:
ui:
condition: service_started
api:
condition: service_started
restart: unless-stopped
api:
build:
context: ./api
environment:
DATABASE_URL: ${DATABASE_URL}
API_KEYS: ${API_KEYS}
API_KEY_ENCRYPTION_KEY: ${API_KEY_ENCRYPTION_KEY}
RATE_PROVIDER: ${RATE_PROVIDER:-kraken}
FOUNDER_PAYMENT_ADDRESS: ${FOUNDER_PAYMENT_ADDRESS}
FOUNDER_VIEW_KEY: ${FOUNDER_VIEW_KEY}
DONATIONS_ENABLED: ${DONATIONS_ENABLED:-false}
QR_STORAGE_DIR: ${QR_STORAGE_DIR:-/qr}
MONERO_WALLET_RPC_URLS: ${MONERO_WALLET_RPC_URLS:-http://wallet-rpc-reconciler-1:18083,http://wallet-rpc-reconciler-2:18083,http://wallet-rpc-reconciler-3:18083}
MONERO_WALLET_RPC_USER: ${MONERO_WALLET_RPC_USER}
MONERO_WALLET_RPC_PASSWORD: ${MONERO_WALLET_RPC_PASSWORD}
MONERO_WALLET_RPC_WALLET_PASSWORD: ${MONERO_WALLET_RPC_WALLET_PASSWORD}
MONERO_WALLET_RPC_WALLET_DIR: ${MONERO_WALLET_RPC_WALLET_DIR:-/wallets}
MONERO_DAEMON_URL: ${MONERO_DAEMON_URL:-http://192.168.88.235:34568}
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-5}
volumes:
- ./qr:/qr
depends_on:
db:
condition: service_healthy
restart: unless-stopped
reconciler:
build:
context: ./api
command: ["python", "-m", "app.reconciler"]
environment:
DATABASE_URL: ${DATABASE_URL}
API_KEYS: ${API_KEYS}
API_KEY_ENCRYPTION_KEY: ${API_KEY_ENCRYPTION_KEY}
INVOICE_RECONCILE_INTERVAL_SECONDS: ${INVOICE_RECONCILE_INTERVAL_SECONDS}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
MONERO_WALLET_RPC_URLS: ${MONERO_WALLET_RPC_URLS:-http://wallet-rpc-reconciler-1:18083,http://wallet-rpc-reconciler-2:18083,http://wallet-rpc-reconciler-3:18083}
MONERO_WALLET_RPC_USER: ${MONERO_WALLET_RPC_USER}
MONERO_WALLET_RPC_PASSWORD: ${MONERO_WALLET_RPC_PASSWORD}
MONERO_WALLET_RPC_WALLET_PASSWORD: ${MONERO_WALLET_RPC_WALLET_PASSWORD}
MONERO_WALLET_RPC_WALLET_DIR: ${MONERO_WALLET_RPC_WALLET_DIR:-/wallets}
MONERO_DAEMON_URL: ${MONERO_DAEMON_URL:-http://192.168.88.235:34568}
depends_on:
api:
condition: service_started
db:
condition: service_healthy
restart: unless-stopped
wownerod:
build:
context: ./docker/monero
args:
MONERO_VERSION: ${MONERO_VERSION:-0.11.3.0}
image: wownero-local:${MONERO_VERSION:-0.11.3.0}
entrypoint: ["wownerod"]
command:
[
"--non-interactive",
"--rpc-bind-ip",
"0.0.0.0",
"--rpc-bind-port",
"34568",
"--confirm-external-bind",
"--prune-blockchain",
"--data-dir",
"/data",
"--log-level",
"0"
]
volumes:
- wownerod_data:/data
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:34568/get_height >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 10
start_period: 60s
restart: unless-stopped
profiles: ["local-daemon"]
wallet-rpc-reconciler-1:
build:
context: ./docker/monero
args:
MONERO_VERSION: ${MONERO_VERSION:-0.11.3.0}
image: wownero-local:${MONERO_VERSION:-0.11.3.0}
command:
[
"wownero-wallet-rpc",
"--non-interactive",
"--rpc-bind-ip",
"0.0.0.0",
"--rpc-bind-port",
"18083",
"--confirm-external-bind",
"--daemon-address",
"${MONERO_DAEMON_URL:-http://192.168.88.235:34568}",
"--rpc-login",
"${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}",
"--wallet-dir",
"${MONERO_WALLET_RPC_WALLET_DIR:-/wallets}",
"--password",
"${MONERO_WALLET_RPC_WALLET_PASSWORD}",
"--log-level",
"3"
]
volumes:
- ./wallets/reconciler:/wallets
restart: unless-stopped
wallet-rpc-reconciler-2:
build:
context: ./docker/monero
args:
MONERO_VERSION: ${MONERO_VERSION:-0.11.3.0}
image: wownero-local:${MONERO_VERSION:-0.11.3.0}
command:
[
"wownero-wallet-rpc",
"--non-interactive",
"--rpc-bind-ip",
"0.0.0.0",
"--rpc-bind-port",
"18083",
"--confirm-external-bind",
"--daemon-address",
"${MONERO_DAEMON_URL:-http://192.168.88.235:34568}",
"--rpc-login",
"${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}",
"--wallet-dir",
"${MONERO_WALLET_RPC_WALLET_DIR:-/wallets}",
"--password",
"${MONERO_WALLET_RPC_WALLET_PASSWORD}",
"--log-level",
"3"
]
volumes:
- ./wallets/reconciler:/wallets
restart: unless-stopped
wallet-rpc-reconciler-3:
build:
context: ./docker/monero
args:
MONERO_VERSION: ${MONERO_VERSION:-0.11.3.0}
image: wownero-local:${MONERO_VERSION:-0.11.3.0}
command:
[
"wownero-wallet-rpc",
"--non-interactive",
"--rpc-bind-ip",
"0.0.0.0",
"--rpc-bind-port",
"18083",
"--confirm-external-bind",
"--daemon-address",
"${MONERO_DAEMON_URL:-http://192.168.88.235:34568}",
"--rpc-login",
"${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}",
"--wallet-dir",
"${MONERO_WALLET_RPC_WALLET_DIR:-/wallets}",
"--password",
"${MONERO_WALLET_RPC_WALLET_PASSWORD}",
"--log-level",
"3"
]
volumes:
- ./wallets/reconciler:/wallets
restart: unless-stopped
ui:
build:
context: ./ui
environment:
API_BASE_URL: ${API_BASE_URL:-http://nginx/api}
NEXT_PUBLIC_SITE_URL: ${SITE_URL:-http://localhost}
NEXT_PUBLIC_DONATIONS_ENABLED: ${DONATIONS_ENABLED:-false}
depends_on:
api:
condition: service_started
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
db-backup:
build:
context: ./docker/pg_backup
environment:
POSTGRES_HOST: db
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7}
volumes:
- ./backups/postgres:/backups
depends_on:
db:
condition: service_healthy
restart: unless-stopped
profiles: ["db-backup"]
volumes:
postgres_data:
wownerod_data:
+22
View File
@@ -0,0 +1,22 @@
FROM debian:bookworm-slim
ARG MONERO_VERSION=0.11.3.0
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl bzip2 \
&& rm -rf /var/lib/apt/lists/*
RUN set -eux; \
curl -fsSL -o /tmp/wownero.tar.bz2 \
"https://codeberg.org/wownero/wownero/releases/download/v${MONERO_VERSION}/wownero-x86_64-linux-gnu-v${MONERO_VERSION}.tar.bz2"; \
tar -xjf /tmp/wownero.tar.bz2 -C /tmp; \
cp /tmp/wownero-*/wownerod /usr/local/bin/; \
cp /tmp/wownero-*/wownero-wallet-rpc /usr/local/bin/; \
rm -rf /tmp/wownero* /tmp/wownero.tar.bz2
COPY entrypoint.sh /usr/local/bin/monero-wallet-rpc-entrypoint
RUN chmod +x /usr/local/bin/monero-wallet-rpc-entrypoint
EXPOSE 34568 18083
ENTRYPOINT ["/usr/local/bin/monero-wallet-rpc-entrypoint"]
+42
View File
@@ -0,0 +1,42 @@
#!/bin/sh
set -e
normalize_address() {
value="$1"
if [ -z "$value" ]; then
return
fi
case "$value" in
http://*)
value="${value#http://}"
;;
https://*)
value="${value#https://}"
;;
esac
value="${value%%/*}"
printf '%s' "$value"
}
args=""
while [ "$#" -gt 0 ]; do
case "$1" in
--daemon-address)
shift
normalized="$(normalize_address "$1")"
args="$args --daemon-address $normalized"
;;
--daemon-address=*)
value="${1#--daemon-address=}"
normalized="$(normalize_address "$value")"
args="$args --daemon-address=$normalized"
;;
*)
args="$args $1"
;;
esac
shift
done
# shellcheck disable=SC2086
exec $args
+12
View File
@@ -0,0 +1,12 @@
FROM postgres:16-alpine
RUN apk add --no-cache bash
COPY backup.sh /usr/local/bin/backup.sh
COPY pg_backup.cron /etc/cron.d/pg_backup
RUN chmod +x /usr/local/bin/backup.sh \
&& chmod 0644 /etc/cron.d/pg_backup \
&& crontab /etc/cron.d/pg_backup
CMD ["crond", "-f", "-l", "8"]
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
: "${POSTGRES_HOST:?}"
: "${POSTGRES_USER:?}"
: "${POSTGRES_PASSWORD:?}"
: "${POSTGRES_DB:?}"
backup_retention_days="${BACKUP_RETENTION_DAYS:-7}"
export PGPASSWORD="$POSTGRES_PASSWORD"
backup_dir="/backups"
timestamp="$(date +%Y%m%d_%H%M%S)"
backup_path="${backup_dir}/${POSTGRES_DB}_${timestamp}.dump"
mkdir -p "$backup_dir"
pg_dump -h "$POSTGRES_HOST" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -F c -f "$backup_path"
find "$backup_dir" -type f -name "*.dump" -mtime "+${backup_retention_days}" -delete
+1
View File
@@ -0,0 +1 @@
0 * * * * /usr/local/bin/backup.sh >> /var/log/pg_backup.log 2>&1
+9
View File
@@ -0,0 +1,9 @@
FROM nginx:1.27-alpine
RUN apk add --no-cache openssl
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
+46
View File
@@ -0,0 +1,46 @@
limit_req_zone $binary_remote_addr zone=global_rate_limit:10m rate=50r/s;
server {
listen 80;
server_name wowcheckout.such.software localhost;
limit_req zone=global_rate_limit burst=200 nodelay;
if ($request_uri ~* "//+") {
return 308 $scheme://$host$uri$is_args$args;
}
location /api/ {
proxy_pass http://api:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api {
return 301 /api/;
}
location = /openapi.json {
proxy_pass http://api:8000/openapi.json;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /qr/ {
alias /qr/;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
proxy_pass http://ui:3000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
set -eu
cert_dir="/etc/nginx/certs"
cert_path="${cert_dir}/dev.crt"
key_path="${cert_dir}/dev.key"
if [ ! -f "$cert_path" ] || [ ! -f "$key_path" ]; then
mkdir -p "$cert_dir"
rm -f "$cert_path" "$key_path"
openssl req \
-x509 \
-nodes \
-newkey rsa:2048 \
-days 365 \
-keyout "$key_path" \
-out "$cert_path" \
-subj "/CN=localhost"
fi
exec "$@"
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

+4
View File
@@ -0,0 +1,4 @@
API_BASE_URL=http://localhost:8000
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
NEXT_PUBLIC_SITE_URL=http://localhost
NEXT_PUBLIC_DONATIONS_ENABLED=false
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: ["next/core-web-vitals"],
};
+22
View File
@@ -0,0 +1,22 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["npm", "start"]
+812
View File
@@ -0,0 +1,812 @@
"use server";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
export type InvoiceState = {
error: string | null;
invoiceId: string | null;
address: string | null;
amount: string | null;
recipientName: string | null;
description: string | null;
subaddressIndex: number | null;
warnings: string[] | null;
};
export type ApiKeyState = {
apiKey: string;
error: string | null;
};
export type WebhookSecretState = {
webhookSecret: string | null;
error: string | null;
};
export type WebhookFormState = {
error: string | null;
success: string | null;
};
export type ArchiveInvoiceState = {
error: string | null;
success: string | null;
archivedId: string | null;
};
export type BtcpayCheckoutPreferenceState = {
style: "standard" | "btcpay_classic";
error: string | null;
success: string | null;
};
export type DefaultConfirmationTargetState = {
value: number;
error: string | null;
success: string | null;
};
export type DefaultQrLogoState = {
logo: "monero" | "none" | "custom";
logoDataUrl: string | null;
error: string | null;
success: string | null;
};
export type WebhookRedeliverState = {
error: string | null;
success: string | null;
};
const tourNoopMessage = "Tour mode only: no changes are saved.";
export async function createInvoiceTourAction(
_prevState: InvoiceState,
_formData: FormData
): Promise<InvoiceState> {
return {
error: tourNoopMessage,
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
export async function archiveInvoiceTourAction(
_prevState: ArchiveInvoiceState,
formData: FormData
): Promise<ArchiveInvoiceState> {
const archivedId = String(formData.get("invoice_id") ?? "").trim() || null;
return {
error: null,
success: tourNoopMessage,
archivedId,
};
}
export async function createWebhookTourAction(
_prevState: WebhookFormState,
_formData: FormData
): Promise<WebhookFormState> {
return { error: null, success: tourNoopMessage };
}
export async function deleteWebhookTourAction(
_prevState: WebhookFormState,
_formData: FormData
): Promise<WebhookFormState> {
return { error: null, success: tourNoopMessage };
}
export async function redeliverWebhookDeliveryTourAction(
_prevState: WebhookRedeliverState,
_formData: FormData
): Promise<WebhookRedeliverState> {
return { error: null, success: tourNoopMessage };
}
const webhookEventKeys = [
{ event: "invoice.created", key: "invoice_created" },
{ event: "invoice.payment_detected", key: "invoice_payment_detected" },
{ event: "invoice.confirmed", key: "invoice_confirmed" },
{ event: "invoice.expired", key: "invoice_expired" },
] as const;
export async function createInvoiceAction(
_prevState: InvoiceState,
formData: FormData
): Promise<InvoiceState> {
const amountMode = String(formData.get("amount_mode") ?? "xmr").trim();
const isFiat = amountMode === "fiat";
const instantConfirmation = formData.get("instant_confirmation") === "1";
const confirmationRaw = String(formData.get("confirmation_target") ?? "").trim();
let confirmationTarget: number | null = null;
if (instantConfirmation) {
confirmationTarget = 0;
} else if (confirmationRaw) {
const parsed = Number(confirmationRaw);
if (Number.isNaN(parsed) || parsed < 0 || parsed > 10) {
return {
error: "Confirmation target must be between 0 and 10.",
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
confirmationTarget = parsed;
}
const amountRaw = String(
formData.get(isFiat ? "amount_fiat" : "amount_xmr") ?? ""
).trim();
const amount = Number(amountRaw);
if (Number.isNaN(amount) || amount <= 0) {
return {
error: isFiat ? "Enter a valid fiat amount." : "Enter a valid WOW amount.",
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
const currency = String(formData.get("currency") ?? "").trim().toUpperCase();
if (isFiat && !currency) {
return {
error: "Currency is required when using a fiat amount.",
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
const recipientName = String(formData.get("recipient_name") ?? "").trim();
const description = String(formData.get("description") ?? "").trim();
const checkoutContinueUrlRaw = String(formData.get("checkout_continue_url") ?? "").trim();
if (checkoutContinueUrlRaw) {
try {
const parsed = new URL(checkoutContinueUrlRaw);
const isLocalhost =
parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLocalhost)) {
return {
error: "Continue URL must use https (http is allowed for localhost).",
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
} catch {
return {
error: "Continue URL must be a valid URL.",
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
}
const expiresAtRaw = String(formData.get("expires_at") ?? "").trim();
const expiresDate = String(formData.get("expires_date") ?? "").trim();
const expiresTime = String(formData.get("expires_time") ?? "").trim();
let expiresAt: string | null = null;
if (!expiresAtRaw && expiresTime && !expiresDate) {
return {
error: "Expiry date must include a date.",
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
if (!expiresAtRaw && expiresDate) {
const timeValue = expiresTime || "00:00";
expiresAt = `${expiresDate}T${timeValue}`;
} else if (expiresAtRaw) {
const normalized =
expiresAtRaw.length === 10 || expiresAtRaw.endsWith("T")
? `${expiresAtRaw.replace(/T$/, "")}T00:00`
: expiresAtRaw;
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) {
return {
error: "Expiry date must be a valid date and time.",
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
expiresAt = parsed.toISOString();
}
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
if (!apiKey) {
return {
error: "API key not found. Please sign in again.",
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
const metadata: Record<string, string> = {};
if (recipientName) {
metadata.recipient_name = recipientName;
}
if (description) {
metadata.description = description;
}
const qrLogoModeRaw = String(formData.get("qr_logo_mode") ?? "").trim();
const qrLogoDataUrl = String(formData.get("qr_logo_data_url") ?? "").trim();
const qrLogoMode =
qrLogoModeRaw === "account_default" ||
qrLogoModeRaw === "monero" ||
qrLogoModeRaw === "none" ||
qrLogoModeRaw === "custom"
? qrLogoModeRaw
: "account_default";
const qrPayload =
qrLogoMode === "account_default"
? null
: qrLogoMode === "custom"
? qrLogoDataUrl
? { logo: "custom", logo_data_url: qrLogoDataUrl }
: null
: { logo: qrLogoMode };
const response = await fetch(`${apiBaseUrl}/api/core/invoices`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `ApiKey ${apiKey}`,
},
body: JSON.stringify({
amount_xmr: isFiat ? null : amountRaw,
amount_fiat: isFiat ? amountRaw : null,
currency: isFiat ? currency : null,
...(confirmationTarget !== null ? { confirmation_target: confirmationTarget } : {}),
expires_at: expiresAt,
checkout_continue_url: checkoutContinueUrlRaw || null,
metadata:
Object.keys(metadata).length > 0 || qrPayload
? {
...(Object.keys(metadata).length > 0 ? metadata : {}),
...(qrPayload ? { qr: qrPayload } : {}),
}
: null,
}),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to create invoice.";
return {
error: message,
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
const data = (await response.json()) as {
id: string;
address: string;
subaddress_index?: number | null;
amount_xmr: string;
warnings?: string[] | null;
};
return {
error: null,
invoiceId: data.id,
address: data.address,
amount: data.amount_xmr,
recipientName: recipientName || null,
description: description || null,
subaddressIndex: data.subaddress_index ?? null,
warnings: data.warnings ?? null,
};
}
export async function resetApiKeyAction(
_prevState: ApiKeyState
): Promise<ApiKeyState> {
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
if (!apiKey) {
return { apiKey: _prevState.apiKey, error: "API key not found. Please sign in again." };
}
const response = await fetch(`${apiBaseUrl}/api/core/api-credentials/reset`, {
method: "POST",
headers: {
Authorization: `ApiKey ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ reset_api_key: true }),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to reset API key.";
return { apiKey: _prevState.apiKey, error: message };
}
const data = (await response.json()) as { api_key?: string | null };
if (!data.api_key) {
return { apiKey: _prevState.apiKey, error: "API key reset did not return a key." };
}
// Update the cookie with the new API key
const cookieStore = await cookies();
cookieStore.set("wowcheckout_api_key", data.api_key, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
});
return { apiKey: data.api_key, error: null };
}
export async function resetWebhookSecretAction(
_prevState: WebhookSecretState
): Promise<WebhookSecretState> {
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
if (!apiKey) {
return {
webhookSecret: _prevState.webhookSecret,
error: "API key not found. Please sign in again.",
};
}
const response = await fetch(`${apiBaseUrl}/api/core/api-credentials/reset`, {
method: "POST",
headers: {
Authorization: `ApiKey ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ reset_webhook_secret: true }),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to reset webhook secret.";
return { webhookSecret: _prevState.webhookSecret, error: message };
}
const data = (await response.json()) as { webhook_secret?: string | null };
if (!data.webhook_secret) {
return {
webhookSecret: _prevState.webhookSecret,
error: "Webhook secret reset did not return a secret.",
};
}
const cookieStore = await cookies();
cookieStore.set("wowcheckout_webhook_secret", data.webhook_secret, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
});
return { webhookSecret: data.webhook_secret, error: null };
}
export async function updateBtcpayCheckoutStyleAction(
_prevState: BtcpayCheckoutPreferenceState,
formData: FormData
): Promise<BtcpayCheckoutPreferenceState> {
const nextStyle = String(formData.get("btcpay_checkout_style") ?? "").trim();
if (nextStyle !== "standard" && nextStyle !== "btcpay_classic") {
return {
style: _prevState.style,
error: "Select a valid BTCPay checkout layout.",
success: null,
};
}
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
if (!apiKey) {
return {
style: _prevState.style,
error: "API key not found. Please sign in again.",
success: null,
};
}
const response = await fetch(`${apiBaseUrl}/api/core/profile`, {
method: "PATCH",
headers: {
Authorization: `ApiKey ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ btcpay_checkout_style: nextStyle }),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to update BTCPay preference.";
return { style: _prevState.style, error: message, success: null };
}
const data = (await response.json()) as { btcpay_checkout_style?: string | null };
const resolvedStyle =
data.btcpay_checkout_style === "btcpay_classic" ||
data.btcpay_checkout_style === "standard"
? data.btcpay_checkout_style
: nextStyle;
revalidatePath("/dashboard");
return {
style: resolvedStyle as "standard" | "btcpay_classic",
error: null,
success: "Preference saved.",
};
}
export async function updateDefaultConfirmationTargetAction(
_prevState: DefaultConfirmationTargetState,
formData: FormData
): Promise<DefaultConfirmationTargetState> {
const valueRaw = String(formData.get("default_confirmation_target") ?? "").trim();
const parsed = Number(valueRaw);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 10) {
return {
value: _prevState.value,
error: "Default confirmation target must be an integer between 0 and 10.",
success: null,
};
}
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
if (!apiKey) {
return {
value: _prevState.value,
error: "API key not found. Please sign in again.",
success: null,
};
}
const response = await fetch(`${apiBaseUrl}/api/core/profile`, {
method: "PATCH",
headers: {
Authorization: `ApiKey ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ default_confirmation_target: parsed }),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to update the default confirmation target.";
return { value: _prevState.value, error: message, success: null };
}
const data = (await response.json()) as { default_confirmation_target?: number | null };
const resolvedValue =
typeof data.default_confirmation_target === "number"
? data.default_confirmation_target
: parsed;
revalidatePath("/dashboard");
return { value: resolvedValue, error: null, success: "Default updated." };
}
export async function updateDefaultQrLogoAction(
_prevState: DefaultQrLogoState,
formData: FormData
): Promise<DefaultQrLogoState> {
const logo = String(formData.get("default_qr_logo") ?? "").trim();
const logoDataUrlRaw = String(formData.get("default_qr_logo_data_url") ?? "").trim();
const normalizedLogo =
logo === "none" || logo === "custom" || logo === "monero" ? logo : null;
if (!normalizedLogo) {
return {
logo: _prevState.logo,
logoDataUrl: _prevState.logoDataUrl,
error: "Select a valid QR logo mode.",
success: null,
};
}
const logoDataUrl =
normalizedLogo === "custom" ? (logoDataUrlRaw ? logoDataUrlRaw : null) : null;
if (normalizedLogo === "custom" && !logoDataUrl) {
return {
logo: _prevState.logo,
logoDataUrl: _prevState.logoDataUrl,
error: "Upload a logo image for custom mode.",
success: null,
};
}
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
if (!apiKey) {
return {
logo: _prevState.logo,
logoDataUrl: _prevState.logoDataUrl,
error: "API key not found. Please sign in again.",
success: null,
};
}
const response = await fetch(`${apiBaseUrl}/api/core/profile`, {
method: "PATCH",
headers: {
Authorization: `ApiKey ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
default_qr_logo: normalizedLogo,
default_qr_logo_data_url: logoDataUrl,
}),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to update QR settings.";
return {
logo: _prevState.logo,
logoDataUrl: _prevState.logoDataUrl,
error: message,
success: null,
};
}
const data = (await response.json()) as {
default_qr_logo?: string | null;
default_qr_logo_data_url?: string | null;
};
const resolvedLogo =
data.default_qr_logo === "none" ||
data.default_qr_logo === "custom" ||
data.default_qr_logo === "monero"
? data.default_qr_logo
: normalizedLogo;
const resolvedDataUrl =
resolvedLogo === "custom" ? data.default_qr_logo_data_url ?? logoDataUrl : null;
revalidatePath("/dashboard");
return {
logo: resolvedLogo as "monero" | "none" | "custom",
logoDataUrl: resolvedDataUrl ?? null,
error: null,
success: "QR default saved.",
};
}
export async function createWebhookAction(
_prevState: WebhookFormState,
formData: FormData
): Promise<WebhookFormState> {
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
if (!apiKey) {
return { error: "API key not found. Please sign in again.", success: null };
}
const defaultUrl = String(formData.get("default_url") ?? "").trim();
const events: string[] = [];
const eventUrls: Record<string, string> = {};
webhookEventKeys.forEach(({ event, key }) => {
const enabled = formData.get(`event_${key}`) === "on";
const override = String(formData.get(`event_url_${key}`) ?? "").trim();
if (enabled || override) {
events.push(event);
}
if (override) {
eventUrls[event] = override;
}
});
if (events.length === 0) {
return { error: "Select at least one invoice event.", success: null };
}
if (!defaultUrl) {
const missing = events.filter((event) => !eventUrls[event]);
if (missing.length > 0) {
return {
error: "Provide a default URL or an override URL for each selected event.",
success: null,
};
}
}
const payload: {
url?: string;
events?: string[];
event_urls?: Record<string, string>;
} = {
events,
};
if (defaultUrl) {
payload.url = defaultUrl;
}
if (Object.keys(eventUrls).length > 0) {
payload.event_urls = eventUrls;
}
const response = await fetch(`${apiBaseUrl}/api/core/webhooks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `ApiKey ${apiKey}`,
},
body: JSON.stringify(payload),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to create webhook.";
return { error: message, success: null };
}
revalidatePath("/dashboard");
return { error: null, success: "Webhook saved." };
}
export async function deleteWebhookAction(
_prevState: WebhookFormState,
formData: FormData
): Promise<WebhookFormState> {
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
if (!apiKey) {
return { error: "API key not found. Please sign in again.", success: null };
}
const webhookId = String(formData.get("webhook_id") ?? "").trim();
if (!webhookId) {
return { error: "Webhook id missing.", success: null };
}
const response = await fetch(`${apiBaseUrl}/api/core/webhooks/${webhookId}`, {
method: "DELETE",
headers: {
Authorization: `ApiKey ${apiKey}`,
},
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to remove webhook.";
return { error: message, success: null };
}
revalidatePath("/dashboard");
return { error: null, success: "Webhook removed." };
}
export async function archiveInvoiceAction(
_prevState: ArchiveInvoiceState,
formData: FormData
): Promise<ArchiveInvoiceState> {
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
if (!apiKey) {
return {
error: "API key not found. Please sign in again.",
success: null,
archivedId: null,
};
}
const invoiceId = String(formData.get("invoice_id") ?? "").trim();
if (!invoiceId) {
return { error: "Invoice id missing.", success: null, archivedId: null };
}
const response = await fetch(`${apiBaseUrl}/api/core/invoices/${invoiceId}`, {
method: "DELETE",
headers: {
Authorization: `ApiKey ${apiKey}`,
},
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to archive invoice.";
return { error: message, success: null, archivedId: null };
}
revalidatePath("/dashboard");
return { error: null, success: "Invoice archived until expiry.", archivedId: invoiceId };
}
export async function logoutAction() {
const cookieStore = await cookies();
cookieStore.set("wowcheckout_api_key", "", {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 0,
});
cookieStore.set("wowcheckout_webhook_secret", "", {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 0,
});
redirect("/");
}
export async function redeliverWebhookDeliveryAction(
_prevState: WebhookRedeliverState,
formData: FormData
): Promise<WebhookRedeliverState> {
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
if (!apiKey) {
return { error: "API key not found. Please sign in again.", success: null };
}
const deliveryId = String(formData.get("delivery_id") ?? "").trim();
if (!deliveryId) {
return { error: "Delivery id missing.", success: null };
}
const response = await fetch(
`${apiBaseUrl}/api/core/webhooks/deliveries/${encodeURIComponent(deliveryId)}/redeliver`,
{
method: "POST",
headers: { Authorization: `ApiKey ${apiKey}` },
cache: "no-store",
}
);
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to redeliver the webhook.";
return { error: message, success: null };
}
revalidatePath("/dashboard");
return { error: null, success: "Redelivery queued." };
}
@@ -0,0 +1,37 @@
import { cookies } from "next/headers";
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
export async function GET(request: Request) {
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
if (!apiKey) {
return new Response("Unauthorized", { status: 401 });
}
const sourceUrl = new URL(request.url);
const targetUrl = new URL(`${apiBaseUrl}/api/core/invoices/export.csv`);
sourceUrl.searchParams.forEach((value, key) => {
targetUrl.searchParams.set(key, value);
});
const response = await fetch(targetUrl.toString(), {
headers: { Authorization: `ApiKey ${apiKey}` },
cache: "no-store",
});
if (!response.ok) {
const message = await response.text().catch(() => "Unable to export invoices.");
return new Response(message, { status: response.status });
}
const headers = new Headers();
headers.set("Content-Type", response.headers.get("content-type") ?? "text/csv");
const disposition = response.headers.get("content-disposition");
headers.set(
"Content-Disposition",
disposition ?? 'attachment; filename="invoices.csv"'
);
return new Response(response.body, { status: 200, headers });
}
+481
View File
@@ -0,0 +1,481 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import Link from "next/link";
import { redirect } from "next/navigation";
import ApiKeySection from "../../../components/api-key-section";
import BtcpayCheckoutStyleSection from "../../../components/btcpay-checkout-style-section";
import DefaultConfirmationTargetSection from "../../../components/default-confirmation-target-section";
import DefaultQrLogoSection from "../../../components/default-qr-logo-section";
import InvoicePanel from "../../../components/invoice-panel";
import WebhookHistoryPanel from "../../../components/webhook-history-panel";
import WebhookSecretSection from "../../../components/webhook-secret-section";
import WebhookSection from "../../../components/webhook-section";
export const metadata: Metadata = {
title: "Dashboard",
};
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
const tabs = ["overview", "invoices", "webhooks", "profile"] as const;
const webhookTabs = ["settings", "history"] as const;
type DashboardTab = (typeof tabs)[number];
type WebhookTab = (typeof webhookTabs)[number];
type DashboardSearchParams = Record<string, string | string[] | undefined>;
type InvoiceSummary = {
id: string;
address: string;
subaddress_index?: number | null;
amount_xmr: string;
status: "pending" | "payment_detected" | "confirmed" | "expired" | "invalid";
confirmation_target: number;
paid_after_expiry?: boolean;
paid_after_expiry_at?: string | null;
created_at: string;
archived_at: string | null;
detected_at: string | null;
confirmed_at: string | null;
expires_at: string | null;
};
type WebhookSummary = {
id: string;
url: string;
events: string[];
event_urls?: Record<string, string> | null;
active: boolean;
created_at: string;
};
type WebhookDeliverySummary = {
id: string;
webhook_id: string | null;
event: string;
url: string;
invoice_id: string | null;
invoice_address: string | null;
invoice_subaddress_index: number | null;
invoice_amount_xmr: string | null;
invoice_status:
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid"
| null;
http_status: number | null;
error_message: string | null;
created_at: string;
};
type ProfileSummary = {
id: string;
payment_address: string;
default_confirmation_target: number;
default_qr_logo: "monero" | "none" | "custom";
default_qr_logo_data_url: string | null;
btcpay_checkout_style?: "standard" | "btcpay_classic";
created_at: string;
};
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<DashboardSearchParams>;
}) {
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
const webhookSecret = (await cookies()).get("wowcheckout_webhook_secret")?.value ?? null;
if (!apiKey) {
redirect("/?login=1");
}
const resolvedSearchParams = await searchParams;
const tabParam = resolvedSearchParams?.tab;
const activeTabValue = Array.isArray(tabParam) ? tabParam[0] : tabParam;
const activeTab: DashboardTab = tabs.includes(activeTabValue as DashboardTab)
? (activeTabValue as DashboardTab)
: "overview";
const webhookTabParam = resolvedSearchParams?.webhook_tab;
const activeWebhookTabValue = Array.isArray(webhookTabParam)
? webhookTabParam[0]
: webhookTabParam;
const activeWebhookTab: WebhookTab = webhookTabs.includes(
activeWebhookTabValue as WebhookTab
)
? (activeWebhookTabValue as WebhookTab)
: "settings";
const includeArchived =
activeTab === "invoices" &&
(resolvedSearchParams?.archived === "1" ||
resolvedSearchParams?.archived === "true");
const qParam = resolvedSearchParams?.q;
const queryValue = Array.isArray(qParam) ? qParam[0] : qParam;
const invoiceSearchQuery = activeTab === "invoices" ? (queryValue ?? "") : "";
const sortParam = resolvedSearchParams?.sort;
const sortValue = Array.isArray(sortParam) ? sortParam[0] : sortParam;
const invoiceSort =
activeTab === "invoices" && typeof sortValue === "string" && sortValue
? sortValue
: "created_at";
const orderParam = resolvedSearchParams?.order;
const orderValue = Array.isArray(orderParam) ? orderParam[0] : orderParam;
const invoiceOrder =
activeTab === "invoices" && typeof orderValue === "string" && orderValue
? orderValue
: "desc";
// API key is already available from the cookie
const apiKeyData = activeTab === "profile" ? { api_key: apiKey } : null;
let webhooksData: WebhookSummary[] = [];
let webhookHistory: WebhookDeliverySummary[] = [];
const invoiceLimit = 100;
let invoicesData: { items: InvoiceSummary[]; total: number } | null = null;
let offset = 0;
let total = 0;
const items: InvoiceSummary[] = [];
do {
const invoiceUrl = new URL(`${apiBaseUrl}/api/core/invoices`);
invoiceUrl.searchParams.set("limit", invoiceLimit.toString());
invoiceUrl.searchParams.set("offset", offset.toString());
if (includeArchived) {
invoiceUrl.searchParams.set("include_archived", "true");
}
if (invoiceSearchQuery) {
invoiceUrl.searchParams.set("q", invoiceSearchQuery);
}
if (invoiceSort) {
invoiceUrl.searchParams.set("sort", invoiceSort);
}
if (invoiceOrder) {
invoiceUrl.searchParams.set("order", invoiceOrder);
}
const response = await fetch(invoiceUrl.toString(), {
headers: {
Authorization: `ApiKey ${apiKey}`,
},
cache: "no-store",
});
if (response.status === 401) {
redirect("/?login=1");
}
if (!response.ok) {
break;
}
const data = (await response.json()) as {
items: InvoiceSummary[];
total: number;
};
total = data.total;
items.push(...data.items);
offset += invoiceLimit;
} while (offset < total);
invoicesData = { items, total };
if (
activeTab === "overview" ||
(activeTab === "webhooks" && activeWebhookTab === "settings")
) {
const response = await fetch(`${apiBaseUrl}/api/core/webhooks`, {
headers: {
Authorization: `ApiKey ${apiKey}`,
},
cache: "no-store",
});
if (response.status === 401) {
redirect("/?login=1");
}
if (response.ok) {
webhooksData = (await response.json()) as WebhookSummary[];
}
}
if (activeTab === "webhooks" && activeWebhookTab === "history") {
const historyUrl = new URL(`${apiBaseUrl}/api/core/webhooks/history`);
historyUrl.searchParams.set("limit", "50");
const response = await fetch(historyUrl.toString(), {
headers: {
Authorization: `ApiKey ${apiKey}`,
},
cache: "no-store",
});
if (response.status === 401) {
redirect("/?login=1");
}
if (response.ok) {
webhookHistory = (await response.json()) as WebhookDeliverySummary[];
}
}
const allInvoices = invoicesData?.items ?? [];
let profileData: ProfileSummary | null = null;
if (activeTab === "overview" || activeTab === "profile" || activeTab === "invoices") {
const response = await fetch(`${apiBaseUrl}/api/core/profile`, {
headers: {
Authorization: `ApiKey ${apiKey}`,
},
cache: "no-store",
});
if (response.status === 401) {
redirect("/?login=1");
}
if (response.ok) {
profileData = (await response.json()) as ProfileSummary;
}
}
const isToday = (value: string) => {
const date = new Date(value);
const today = new Date();
return (
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate()
);
};
const invoicesTodayCount = allInvoices.filter((invoice) =>
isToday(invoice.created_at)
).length;
const awaitingConfirmationCount = allInvoices.filter(
(invoice) => invoice.status === "payment_detected"
).length;
const confirmedCount = allInvoices.filter(
(invoice) => invoice.status === "confirmed"
).length;
const tabBaseClass =
"inline-flex items-center rounded-xl border border-stroke bg-white/70 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:-translate-y-0.5";
const tabActiveClass =
"border-ink bg-ink text-cream shadow-[0_12px_24px_rgba(16,18,23,0.18)]";
const subTabBaseClass =
"inline-flex items-center rounded-full border border-stroke bg-white/70 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:-translate-y-0.5";
const subTabActiveClass =
"border-ink bg-ink text-cream shadow-[0_10px_18px_rgba(16,18,23,0.18)]";
return (
<main className="px-[6vw] py-12 text-ink">
<section className="mx-auto grid w-full max-w-6xl gap-8 lg:grid-cols-[260px_minmax(0,1fr)]">
<aside className="grid gap-6">
<nav className="flex flex-col gap-2" aria-label="Dashboard sections">
{tabs.map((tab) => (
<Link
key={tab}
className={`${tabBaseClass} ${activeTab === tab ? tabActiveClass : ""}`}
href={`/dashboard?tab=${tab}`}
>
{tab}
</Link>
))}
</nav>
</aside>
<div className="grid gap-6">
{activeTab === "overview" ? (
<div className="rounded-2xl border border-stroke bg-white/80 p-8 shadow-card backdrop-blur">
<h1 className="font-serif text-3xl">Operational overview.</h1>
<p className="mt-2 text-ink-soft">
Track invoice flow and confirmation progress at a glance.
</p>
<div className="mt-6 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Primary address
</p>
<p className="mt-3 break-all font-mono text-sm text-ink">
{profileData?.payment_address ?? "Unavailable"}
</p>
<p className="mt-2 text-sm text-ink-soft">
This is the wallet address used for your invoices.
</p>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-3">
{[
{
title: "Invoices today",
value: invoicesTodayCount.toString(),
detail: "Totals will appear once invoices are created.",
},
{
title: "Invoices awaiting confirmation",
value: awaitingConfirmationCount.toString(),
detail: "Waiting for the configured confirmation target.",
},
{
title: "Confirmed invoices",
value: confirmedCount.toString(),
detail: "Confirmed on-chain invoices will display here.",
},
].map((item) => (
<div
key={item.title}
className="rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft"
>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
{item.title}
</p>
<h2 className="mt-3 text-2xl font-semibold">{item.value}</h2>
<p className="mt-2 text-sm text-ink-soft">{item.detail}</p>
</div>
))}
</div>
<div className="mt-4 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Webhook endpoints
</p>
{webhooksData.length === 0 ? (
<p className="mt-3 text-sm text-ink-soft">
No webhooks configured yet.{" "}
<Link className="font-semibold text-ink underline" href="/dashboard?tab=webhooks">
Add a webhook endpoint.
</Link>
</p>
) : (
<div className="mt-3 grid gap-3 text-sm text-ink">
{webhooksData.map((webhook) => (
<div
className="rounded-xl border border-stroke bg-white/80 px-4 py-3"
key={webhook.id}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="break-all font-semibold">{webhook.url}</p>
<span className="rounded-full border border-stroke bg-white/60 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
{webhook.active ? "Active" : "Paused"}
</span>
</div>
<p className="mt-2 text-xs text-ink-soft">
Events: {webhook.events.join(", ")}
</p>
</div>
))}
</div>
)}
</div>
<div className="mt-5 rounded-2xl border border-ink/10 bg-ink/10 px-4 py-3 text-sm font-semibold text-ink">
We never hold funds. All payments move from the customer to your
wallet.
</div>
</div>
) : null}
{activeTab === "invoices" ? (
<InvoicePanel
activeInvoices={allInvoices}
includeArchived={includeArchived}
searchQuery={invoiceSearchQuery}
sort={invoiceSort}
order={invoiceOrder}
defaultConfirmationTarget={profileData?.default_confirmation_target ?? 1}
/>
) : null}
{activeTab === "profile" ? (
<>
{apiKeyData ? (
<div className="rounded-2xl border border-stroke bg-white/80 p-8 shadow-card backdrop-blur">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="font-serif text-3xl">Profile settings.</h1>
<p className="mt-2 text-ink-soft">
Manage credentials and delivery settings for your integration.
</p>
</div>
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-2">
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
<h2 className="font-serif text-2xl">Store id</h2>
<p className="mt-2 min-h-[2.5rem] text-sm text-ink-soft">
Single-store per primary address. Use this id in the BTCPay
WooCommerce plugin store field.
</p>
<div className="mt-4">
<p className="break-all rounded-xl bg-ink/5 px-3 py-2 font-mono text-xs text-ink sm:text-sm">
{profileData?.id ?? "Unavailable"}
</p>
</div>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
<h2 className="font-serif text-2xl">API key</h2>
<p className="mt-2 min-h-[2.5rem] text-sm text-ink-soft">
Use this key with authenticated endpoints.
</p>
<div className="mt-4">
<ApiKeySection apiKey={apiKeyData.api_key} />
</div>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
<h2 className="font-serif text-2xl">Webhook secret</h2>
<p className="mt-2 min-h-[2.5rem] text-sm text-ink-soft">
Sent with each delivery as the{" "}
<code>X-Webhook-Secret</code> header.
</p>
<div className="mt-4">
<WebhookSecretSection webhookSecret={webhookSecret} />
</div>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
<BtcpayCheckoutStyleSection
initialStyle={
profileData?.btcpay_checkout_style === "btcpay_classic"
? "btcpay_classic"
: "btcpay_classic"
}
/>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
<DefaultConfirmationTargetSection
initialValue={profileData?.default_confirmation_target ?? 1}
/>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
<DefaultQrLogoSection
initialLogo={profileData?.default_qr_logo ?? "monero"}
initialLogoDataUrl={profileData?.default_qr_logo_data_url ?? null}
/>
</div>
</div>
</div>
) : (
<div className="rounded-2xl border border-stroke bg-white/80 p-8 shadow-card backdrop-blur">
<h1 className="font-serif text-3xl">API key</h1>
<p className="mt-2 text-ink-soft">
Sign in again to reveal your API key.
</p>
</div>
)}
</>
) : null}
{activeTab === "webhooks" ? (
<div className="rounded-2xl border border-stroke bg-white/80 p-8 shadow-card backdrop-blur">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="font-serif text-3xl">Webhook endpoints.</h1>
<p className="mt-2 text-ink-soft">
Relay invoice state updates to your systems. Optional overrides let
you target different URLs per event.
</p>
</div>
</div>
<nav className="mt-6 flex flex-wrap gap-2" aria-label="Webhook views">
{webhookTabs.map((tab) => (
<Link
key={tab}
className={`${subTabBaseClass} ${
activeWebhookTab === tab ? subTabActiveClass : ""
}`}
href={`/dashboard?tab=webhooks&webhook_tab=${tab}`}
>
{tab}
</Link>
))}
</nav>
{activeWebhookTab === "settings" ? (
<div className="mt-6">
<WebhookSection webhooks={webhooksData} />
</div>
) : null}
{activeWebhookTab === "history" ? (
<div className="mt-6">
<WebhookHistoryPanel deliveries={webhookHistory} />
</div>
) : null}
</div>
) : null}
</div>
</section>
</main>
);
}
+62
View File
@@ -0,0 +1,62 @@
import Image from "next/image";
import Link from "next/link";
import { Suspense } from "react";
import "../(marketing)/marketing.css";
import DonateModal from "../../components/donate-modal";
import NavLink from "../../components/nav-link";
import { logoutAction } from "./dashboard/actions";
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div>
<header className="site-header">
<div className="brand">
<Link href="/" aria-label="WOW Checkout home">
<Image
className="brand-logo"
src="/logo.png"
alt="WOW Checkout logo"
width={128}
height={128}
priority
/>
</Link>
<Link className="name" href="/">
WOW Checkout
</Link>
</div>
<nav className="nav">
<NavLink href="/invoice">Check Invoice</NavLink>
<NavLink href="/docs">Documentation</NavLink>
<NavLink href="/faq">FAQ</NavLink>
<NavLink href="/dashboard">Dashboard</NavLink>
<form action={logoutAction}>
<button type="submit">Log out</button>
</form>
</nav>
</header>
{children}
<footer className="site-footer">
<div className="site-footer-copy">
<p>wowcheckout.com - Non-custodial Wownero checkout software.</p>
<p>
<Link
className="underline underline-offset-4"
href="https://github.com/wowcheckout/wowcheckout"
>
GitHub
</Link>
</p>
</div>
<Suspense fallback={null}>
<DonateModal />
</Suspense>
</footer>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
export type LoginState = {
error: string | null;
};
export async function loginAction(
_prevState: LoginState,
formData: FormData
): Promise<LoginState> {
const paymentAddress = String(formData.get("payment_address") ?? "").trim();
const viewKey = String(formData.get("view_key") ?? "").trim();
if (!paymentAddress || !viewKey) {
return { error: "Primary address and secret view key are required." };
}
const response = await fetch(`${apiBaseUrl}/api/core/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payment_address: paymentAddress, view_key: viewKey }),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to sign in.";
return { error: message };
}
const data = (await response.json()) as {
api_key: string;
webhook_secret: string;
store_id?: string;
};
const cookieStore = await cookies();
cookieStore.set("wowcheckout_api_key", data.api_key, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
});
cookieStore.set("wowcheckout_webhook_secret", data.webhook_secret, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
});
redirect("/dashboard");
}
+198
View File
@@ -0,0 +1,198 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useFormState } from "react-dom";
import { loginAction, type LoginState } from "./actions";
const initialState: LoginState = { error: null };
const apiBaseUrl =
typeof window === "undefined" ? process.env.NEXT_PUBLIC_API_BASE_URL ?? "" : "";
const base58Pattern = /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/;
const viewKeyPattern = /^[0-9a-fA-F]{64}$/;
const validationDelayMs = 450;
const isLikelyPrimaryAddress = (value: string) => {
const trimmed = value.trim();
if (trimmed.length !== 95) {
return false;
}
if (!trimmed.startsWith("4")) {
return false;
}
return base58Pattern.test(trimmed);
};
const isLikelySecretViewKey = (value: string) => viewKeyPattern.test(value.trim());
export default function LoginForm() {
const [state, formAction] = useFormState(loginAction, initialState);
const [paymentAddress, setPaymentAddress] = useState("");
const [viewKey, setViewKey] = useState("");
const [validationStatus, setValidationStatus] = useState<
"idle" | "checking" | "valid" | "invalid" | "error"
>("idle");
const [validationMessage, setValidationMessage] = useState<string | null>(null);
const canSubmit = useMemo(() => {
if (!isLikelyPrimaryAddress(paymentAddress) || !isLikelySecretViewKey(viewKey)) {
return false;
}
return validationStatus !== "checking" && validationStatus !== "invalid";
}, [paymentAddress, validationStatus, viewKey]);
useEffect(() => {
if (!isLikelyPrimaryAddress(paymentAddress) || !isLikelySecretViewKey(viewKey)) {
setValidationStatus("idle");
setValidationMessage(null);
return;
}
const controller = new AbortController();
const timeoutId = window.setTimeout(async () => {
setValidationStatus("checking");
setValidationMessage(null);
try {
const response = await fetch(`${apiBaseUrl}/api/core/auth/validate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
payment_address: paymentAddress.trim(),
view_key: viewKey.trim(),
}),
cache: "no-store",
signal: controller.signal,
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message =
detail?.detail ?? "Address and view key did not pass validation.";
setValidationStatus("invalid");
setValidationMessage(message);
return;
}
setValidationStatus("valid");
} catch (error) {
if (controller.signal.aborted) {
return;
}
setValidationStatus("error");
setValidationMessage(
"Unable to validate the address and view key yet. You can still sign in to confirm."
);
}
}, validationDelayMs);
return () => {
controller.abort();
window.clearTimeout(timeoutId);
};
}, [paymentAddress, viewKey]);
return (
<form className="mt-6 grid gap-5" action={formAction}>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="payment_address"
>
Primary address
</label>
{isLikelyPrimaryAddress(paymentAddress) ? (
<span className="inline-flex items-center gap-1 text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-emerald-600">
<svg
className="h-3.5 w-3.5"
viewBox="0 0 16 16"
aria-hidden="true"
>
<path
d="M6.4 11.4 3.2 8.2l1.1-1.1 2.1 2.1 5.2-5.2 1.1 1.1-6.3 6.3z"
fill="currentColor"
/>
</svg>
Valid
</span>
) : null}
</div>
<textarea
className="min-h-[96px] w-full resize-none rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="payment_address"
name="payment_address"
rows={3}
autoComplete="off"
required
value={paymentAddress}
onChange={(event) => setPaymentAddress(event.target.value)}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="view_key"
>
Secret view key
</label>
{isLikelySecretViewKey(viewKey) ? (
<span className="inline-flex items-center gap-1 text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-emerald-600">
<svg
className="h-3.5 w-3.5"
viewBox="0 0 16 16"
aria-hidden="true"
>
<path
d="M6.4 11.4 3.2 8.2l1.1-1.1 2.1 2.1 5.2-5.2 1.1 1.1-6.3 6.3z"
fill="currentColor"
/>
</svg>
Valid
</span>
) : null}
</div>
<input
className="min-h-[48px] w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="view_key"
name="view_key"
type="password"
autoComplete="off"
required
value={viewKey}
onChange={(event) => setViewKey(event.target.value)}
/>
</div>
{validationStatus === "checking" ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-amber-700">
Checking address and view key
</p>
) : null}
{validationStatus === "valid" ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-emerald-700">
Address and view key match
</p>
) : null}
{validationStatus === "invalid" || validationStatus === "error" ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{validationMessage}
</p>
) : null}
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
<div className="flex items-center justify-end">
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-6 py-3 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:bg-ink/60 disabled:text-cream/70 disabled:shadow-none disabled:hover:translate-y-0"
type="submit"
disabled={!canSubmit}
>
Sign in
</button>
</div>
</form>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Login",
};
export default function LoginPage() {
redirect("/?login=1");
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Welcome",
};
export default async function WelcomePage() {
const session = (await cookies()).get("wowcheckout_session")?.value;
if (!session) {
redirect("/?login=1");
}
redirect("/dashboard");
}
@@ -0,0 +1,558 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Integration Recipes",
};
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL ?? process.env.SITE_URL ?? "http://localhost:3000";
const normalizedSiteUrl = siteUrl.replace(/\/+$/, "");
const publicApiBaseUrl = `${normalizedSiteUrl}/api/core`;
const codeBlockClass =
"overflow-x-auto rounded-2xl border border-stroke bg-ink px-6 py-5 text-xs leading-relaxed text-cream shadow-soft";
const sectionCardClass =
"rounded-2xl border border-stroke bg-white/70 p-7 shadow-soft backdrop-blur";
export default function IntegrationsPage() {
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid max-w-[54rem] gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Integration recipes
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Copy/paste examples for common backends.
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
Recipes follow the same pattern: create an invoice with a WOW amount, send
the customer to the hosted invoice page, then fulfill your order when the
invoice is confirmed.
</p>
<div className="flex flex-wrap items-center gap-3 text-sm">
<Link
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-xs font-semibold uppercase tracking-[0.18em] text-ink transition hover:-translate-y-0.5"
href="/docs"
>
Back to API docs
</Link>
</div>
<nav
className="flex flex-wrap items-center gap-3 rounded-2xl border border-stroke bg-white/60 px-5 py-4 text-sm text-ink-soft shadow-soft backdrop-blur"
aria-label="Recipe languages"
>
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Jump to:
</span>
{[
{ href: "#btcpay", label: "BTCPay" },
{ href: "#curl", label: "curl" },
{ href: "#node", label: "Node.js" },
{ href: "#php", label: "PHP" },
{ href: "#python", label: "Python" },
{ href: "#go", label: "Go" },
].map((item) => (
<a
key={item.href}
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-ink transition hover:-translate-y-0.5"
href={item.href}
>
{item.label}
</a>
))}
</nav>
</section>
<section className="mt-10 grid gap-6 lg:grid-cols-2">
<div className={sectionCardClass}>
<h2 className="font-serif text-2xl">Conceptual flow</h2>
<ol className="mt-3 list-decimal space-y-2 pl-5 text-ink-soft">
<li>
Create an invoice via{" "}
<span className="font-mono text-ink">POST /api/core/invoices</span>.
</li>
<li>
Optional: include{" "}
<span className="font-mono text-ink">checkout_continue_url</span> to show a
customer-facing Continue button after confirmation.
</li>
<li>
Redirect the customer to <span className="font-mono text-ink">invoice_url</span>.
</li>
<li>
Wait for <span className="font-mono text-ink">invoice.confirmed</span> via webhook
(recommended) or polling.
</li>
</ol>
<div className="mt-4 rounded-xl border border-stroke bg-white/60 p-4 text-sm text-ink-soft">
<p className="font-semibold text-ink">What to store</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
Your internal order id in{" "}
<span className="font-mono text-ink">metadata.order_id</span>
</li>
<li>
The returned <span className="font-mono text-ink">invoice.id</span>
</li>
</ul>
</div>
</div>
<div className={sectionCardClass}>
<h2 className="font-serif text-2xl">Environment variables</h2>
<p className="mt-2 text-ink-soft">
Keep secrets server-side. Never expose API keys or webhook secrets in browser
code.
</p>
<div className="mt-4 grid gap-3 text-sm">
<div className="rounded-xl border border-stroke bg-white/60 p-4">
<p className="font-mono text-xs font-semibold uppercase tracking-[0.18em] text-ink-soft">
WOWCHECKOUT_API_KEY
</p>
<p className="mt-2 text-ink-soft">Used to call authenticated endpoints.</p>
</div>
<div className="rounded-xl border border-stroke bg-white/60 p-4">
<p className="font-mono text-xs font-semibold uppercase tracking-[0.18em] text-ink-soft">
WOWCHECKOUT_WEBHOOK_SECRET
</p>
<p className="mt-2 text-ink-soft">
Used to verify the <span className="font-mono text-ink">X-Webhook-Secret</span>{" "}
header on webhook deliveries.
</p>
</div>
<div className="rounded-xl border border-stroke bg-white/60 p-4">
<p className="font-mono text-xs font-semibold uppercase tracking-[0.18em] text-ink-soft">
WOWCHECKOUT_API_BASE_URL
</p>
<p className="mt-2 text-ink-soft">
Public base URL, including <span className="font-mono text-ink">/api/core</span>:{" "}
<span className="font-mono text-ink">{publicApiBaseUrl}</span>
</p>
</div>
</div>
</div>
</section>
<section id="btcpay" className="mt-10 grid gap-6 scroll-mt-24">
<div className="grid max-w-[54rem] gap-2">
<h2 className="font-serif text-2xl">BTCPay compatibility (WooCommerce)</h2>
<p className="text-ink-soft">
Use the official WooCommerce Greenfield plugin with the compatibility
endpoints. Keys remain view-only, and invoices stay non-custodial.
</p>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className={sectionCardClass}>
<h3 className="font-serif text-xl">Setup steps</h3>
<ol className="mt-3 list-decimal space-y-2 pl-5 text-ink-soft">
<li>Sign in to WOW Checkout to get your API key.</li>
<li>
Store id: returned by the login response or via{" "}
<span className="font-mono text-ink">GET /api/v1/stores</span>. The
stores call returns a single store (one per primary address); use the{" "}
<span className="font-mono text-ink">id</span> field.
</li>
<li>Install the official BTCPay WooCommerce plugin.</li>
<li>
In WooCommerce → BTCPay settings, set:
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
Server URL:{" "}
<span className="font-mono text-ink">{normalizedSiteUrl}</span>{" "}
(the plugin appends <span className="font-mono text-ink">/api/v1</span>).
</li>
<li>Store ID: the id returned from the stores call.</li>
<li>API key: your WOW Checkout API key.</li>
<li>Payment method: WOW-CHAIN (shows as WOW_CHAIN in WooCommerce).</li>
<li>Modal checkout is supported; the hosted invoice page is recommended for clarity.</li>
</ul>
</li>
</ol>
</div>
<div className={sectionCardClass}>
<h3 className="font-serif text-xl">Behavior notes</h3>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>Authorization header: Authorization: token &lt;api_key&gt;.</li>
<li>
Status mapping: pending → New, payment detected → Processing, confirmed → final.
</li>
<li>Expired and Invalid statuses map directly.</li>
<li>Webhook deliveries use the BTCPay-Sig header.</li>
</ul>
</div>
</div>
<pre className={codeBlockClass}>
<code>{`# Verify compatibility endpoints
export WOWCHECKOUT_BTCPAY_URL="${normalizedSiteUrl}/api/v1"
export WOWCHECKOUT_API_KEY="wowcheckout_..."
curl -sS "$WOWCHECKOUT_BTCPAY_URL/stores" \\
-H "Authorization: token $WOWCHECKOUT_API_KEY"`}</code>
</pre>
</section>
<section id="curl" className="mt-12 grid gap-8 scroll-mt-24">
<div className="grid max-w-[52rem] gap-2">
<h2 className="font-serif text-2xl">curl</h2>
<p className="text-ink-soft">
Create an invoice, then poll status until it is confirmed.
</p>
</div>
<div className="grid gap-4">
<pre className={codeBlockClass}>
<code>{`# 1) Create invoice
export WOWCHECKOUT_API_BASE_URL="${publicApiBaseUrl}"
export WOWCHECKOUT_API_KEY="wowcheckout_..."
curl -sS -X POST "$WOWCHECKOUT_API_BASE_URL/invoices" \\
-H "Authorization: ApiKey $WOWCHECKOUT_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"amount_xmr": "0.15",
"confirmation_target": 2,
"metadata": { "order_id": "ORDER-1234" }
}'`}</code>
</pre>
<pre className={codeBlockClass}>
<code>{`# 1b) Create invoice from fiat (non-binding conversion)
curl -sS -X POST "$WOWCHECKOUT_API_BASE_URL/invoices" \\
-H "Authorization: ApiKey $WOWCHECKOUT_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"amount_fiat": "100.00",
"currency": "USD",
"confirmation_target": 2,
"metadata": { "order_id": "ORDER-1234" }
}'`}</code>
</pre>
<pre className={codeBlockClass}>
<code>{`# 2) Poll public status (no auth)
export INVOICE_ID="uuid-from-response"
curl -sS "$WOWCHECKOUT_API_BASE_URL/public/invoice/$INVOICE_ID"`}</code>
</pre>
</div>
</section>
<section id="node" className="mt-12 grid gap-8 scroll-mt-24">
<div className="grid max-w-[52rem] gap-2">
<h2 className="font-serif text-2xl">Node.js</h2>
<p className="text-ink-soft">
Example shows creating an invoice with <span className="font-mono text-ink">fetch</span>{" "}
and receiving webhooks with Express.
</p>
</div>
<div className="grid gap-4">
<pre className={codeBlockClass}>
<code>{`// create-invoice.mjs
const API_BASE_URL = process.env.WOWCHECKOUT_API_BASE_URL;
const API_KEY = process.env.WOWCHECKOUT_API_KEY;
export async function createInvoice({ amountXmr, orderId }) {
const response = await fetch(\`\${API_BASE_URL}/invoices\`, {
method: "POST",
headers: {
Authorization: \`ApiKey \${API_KEY}\`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount_xmr: String(amountXmr),
confirmation_target: 10,
metadata: { order_id: orderId },
}),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(\`wowcheckout create invoice failed: \${response.status} \${text}\`);
}
return await response.json(); // includes invoice_url
}`}</code>
</pre>
<pre className={codeBlockClass}>
<code>{`// webhook-server.mjs
import express from "express";
const WEBHOOK_SECRET = process.env.WOWCHECKOUT_WEBHOOK_SECRET;
const app = express();
app.use(express.json({ type: "application/json" }));
app.post("/wowcheckout/webhook", (req, res) => {
const headerSecret = req.get("x-webhook-secret") ?? "";
if (!WEBHOOK_SECRET || headerSecret !== WEBHOOK_SECRET) {
return res.sendStatus(401);
}
const event = req.body?.event;
const invoice = req.body?.invoice;
const orderId = invoice?.metadata?.order_id;
if (event === "invoice.confirmed" && orderId) {
// Mark your order paid here.
}
return res.sendStatus(204);
});
app.listen(3001, () => {
console.log("Listening on http://localhost:3001/wowcheckout/webhook");
});`}</code>
</pre>
</div>
</section>
<section id="php" className="mt-12 grid gap-8 scroll-mt-24">
<div className="grid max-w-[52rem] gap-2">
<h2 className="font-serif text-2xl">PHP</h2>
<p className="text-ink-soft">
Minimal create-invoice example and a webhook receiver endpoint.
</p>
</div>
<div className="grid gap-4">
<pre className={codeBlockClass}>
<code>{`<?php
// create_invoice.php
$apiBaseUrl = getenv("WOWCHECKOUT_API_BASE_URL");
$apiKey = getenv("WOWCHECKOUT_API_KEY");
$payload = json_encode([
"amount_xmr" => "0.15",
"confirmation_target" => 10,
"metadata" => ["order_id" => "ORDER-1234"],
]);
$ch = curl_init($apiBaseUrl . "/invoices");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: ApiKey " . $apiKey,
"Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($body === false || $status < 200 || $status >= 300) {
http_response_code(500);
echo "wowcheckout error: HTTP " . $status;
exit;
}
$invoice = json_decode($body, true);
echo $invoice["invoice_url"];`}</code>
</pre>
<pre className={codeBlockClass}>
<code>{`<?php
// webhook.php
$webhookSecret = getenv("WOWCHECKOUT_WEBHOOK_SECRET");
$headerSecret = $_SERVER["HTTP_X_WEBHOOK_SECRET"] ?? "";
if (!$webhookSecret || $headerSecret !== $webhookSecret) {
http_response_code(401);
exit;
}
$raw = file_get_contents("php://input");
$payload = json_decode($raw, true);
$event = $payload["event"] ?? null;
$invoice = $payload["invoice"] ?? null;
$orderId = $invoice["metadata"]["order_id"] ?? null;
if ($event === "invoice.confirmed" && $orderId) {
// Mark your order paid here.
}
http_response_code(204);`}</code>
</pre>
</div>
</section>
<section id="python" className="mt-12 grid gap-8 scroll-mt-24">
<div className="grid max-w-[52rem] gap-2">
<h2 className="font-serif text-2xl">Python</h2>
<p className="text-ink-soft">
Create an invoice with <span className="font-mono text-ink">requests</span> and
receive webhooks with FastAPI.
</p>
</div>
<div className="grid gap-4">
<pre className={codeBlockClass}>
<code>{`# create_invoice.py
import os
import requests
api_base_url = os.environ["WOWCHECKOUT_API_BASE_URL"].rstrip("/")
api_key = os.environ["WOWCHECKOUT_API_KEY"]
response = requests.post(
f"{api_base_url}/invoices",
headers={"Authorization": f"ApiKey {api_key}"},
json={
"amount_xmr": "0.15",
"confirmation_target": 2,
"metadata": {"order_id": "ORDER-1234"},
},
timeout=10,
)
response.raise_for_status()
invoice = response.json()
print(invoice["invoice_url"])`}</code>
</pre>
<pre className={codeBlockClass}>
<code>{`# webhook_server.py
import os
from fastapi import FastAPI, Header, HTTPException
app = FastAPI()
webhook_secret = os.environ["WOWCHECKOUT_WEBHOOK_SECRET"]
@app.post("/wowcheckout/webhook", status_code=204)
async def wowcheckout_webhook(payload: dict, x_webhook_secret: str | None = Header(default=None)):
if not x_webhook_secret or x_webhook_secret != webhook_secret:
raise HTTPException(status_code=401, detail="Invalid webhook secret")
event = payload.get("event")
invoice = payload.get("invoice") or {}
metadata = invoice.get("metadata") or {}
order_id = metadata.get("order_id")
if event == "invoice.confirmed" and order_id:
# Mark your order paid here.
pass
return None`}</code>
</pre>
</div>
</section>
<section id="go" className="mt-12 grid gap-8 scroll-mt-24">
<div className="grid max-w-[52rem] gap-2">
<h2 className="font-serif text-2xl">Go</h2>
<p className="text-ink-soft">
Create an invoice with <span className="font-mono text-ink">net/http</span> and
receive webhooks with a standard handler.
</p>
</div>
<div className="grid gap-4">
<pre className={codeBlockClass}>
<code>{`// create_invoice.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
type invoiceResponse struct {
ID string ` + "`json:\"id\"`" + `
InvoiceURL string ` + "`json:\"invoice_url\"`" + `
}
func main() {
apiBaseURL := strings.TrimRight(os.Getenv("WOWCHECKOUT_API_BASE_URL"), "/")
apiKey := os.Getenv("WOWCHECKOUT_API_KEY")
payload := map[string]any{
"amount_xmr": "0.15",
"confirmation_target": 2,
"metadata": map[string]any{
"order_id": "ORDER-1234",
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", apiBaseURL+"/invoices", bytes.NewReader(body))
req.Header.Set("Authorization", "ApiKey "+apiKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
data, _ := io.ReadAll(resp.Body)
panic(fmt.Sprintf("wowcheckout create invoice failed: %d %s", resp.StatusCode, string(data)))
}
var invoice invoiceResponse
if err := json.NewDecoder(resp.Body).Decode(&invoice); err != nil {
panic(err)
}
fmt.Println(invoice.InvoiceURL)
}`}</code>
</pre>
<pre className={codeBlockClass}>
<code>{`// webhook_server.go
package main
import (
"encoding/json"
"net/http"
"os"
)
type webhookPayload struct {
Event string ` + "`json:\"event\"`" + `
Invoice struct {
Metadata map[string]any ` + "`json:\"metadata\"`" + `
} ` + "`json:\"invoice\"`" + `
}
func main() {
webhookSecret := os.Getenv("WOWCHECKOUT_WEBHOOK_SECRET")
http.HandleFunc("/wowcheckout/webhook", func(w http.ResponseWriter, r *http.Request) {
headerSecret := r.Header.Get("X-Webhook-Secret")
if webhookSecret == "" || headerSecret != webhookSecret {
w.WriteHeader(http.StatusUnauthorized)
return
}
var payload webhookPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if payload.Event == "invoice.confirmed" {
if orderID, ok := payload.Invoice.Metadata["order_id"].(string); ok && orderID != "" {
// Mark your order paid here.
_ = orderID
}
}
w.WriteHeader(http.StatusNoContent)
})
_ = http.ListenAndServe(":3001", nil)
}`}</code>
</pre>
</div>
</section>
</main>
);
}
+858
View File
@@ -0,0 +1,858 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Fragment } from "react";
export const metadata: Metadata = {
title: "Docs",
};
const apiBaseUrl =
process.env.NEXT_PUBLIC_API_BASE_URL ??
process.env.API_BASE_URL ??
"http://localhost:8000";
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL ?? process.env.SITE_URL ?? "http://localhost:3000";
const normalizedSiteUrl = siteUrl.replace(/\/+$/, "");
const methodStyles: Record<string, string> = {
GET: "bg-emerald-900/10 text-emerald-800",
POST: "bg-amber-900/15 text-amber-800",
DELETE: "bg-red-900/10 text-red-700",
PUT: "bg-blue-900/10 text-blue-800",
PATCH: "bg-blue-900/10 text-blue-800",
};
const endpointGroups = [
{
title: "Login",
auth: "None",
items: [
{
method: "POST",
path: "/api/core/auth/login",
description: "Exchange a primary address and secret view key for an API key and webhook secret.",
},
],
},
{
title: "Public invoice status",
auth: "None",
items: [
{
method: "GET",
path: "/api/core/public/invoice/{invoice_id}",
description: "Read invoice status and payment details without auth.",
},
{
method: "GET",
path: "/api/core/public/invoice/{invoice_id}/continue",
description: "After confirmation, redirect to the merchant-provided Continue URL (if configured).",
},
],
},
{
title: "Invoice workflow",
auth: "API key",
items: [
{
method: "GET",
path: "/api/core/invoices",
description: "List invoices for the signed-in user (supports search and sorting).",
},
{
method: "GET",
path: "/api/core/invoices/export.csv",
description: "Export invoices as CSV (same filters as list endpoint).",
},
{
method: "GET",
path: "/api/core/invoices/{invoice_id}",
description: "Retrieve a single invoice for the signed-in user.",
},
{
method: "POST",
path: "/api/core/invoices",
description: "Create invoices for the signed-in user.",
},
{
method: "DELETE",
path: "/api/core/invoices/{invoice_id}",
description:
"Archive a pending invoice until it expires (payment-detected/confirmed invoices cannot be archived).",
},
],
},
{
title: "API key webhooks",
auth: "API key",
items: [
{
method: "GET",
path: "/api/core/webhooks",
description: "List active webhook registrations.",
},
{
method: "GET",
path: "/api/core/webhooks/history",
description: "List recent webhook delivery attempts and HTTP status.",
},
{
method: "POST",
path: "/api/core/webhooks/deliveries/{delivery_id}/redeliver",
description: "Manually redeliver a failed webhook delivery.",
},
{
method: "POST",
path: "/api/core/webhooks",
description: "Register webhook URLs (single or per-event) and event filters.",
},
{
method: "DELETE",
path: "/api/core/webhooks/{webhook_id}",
description: "Remove a webhook registration.",
},
],
},
{
title: "API key management",
auth: "API key",
items: [
{
method: "POST",
path: "/api/core/api-credentials/reset",
description: "Reset the API key, webhook secret, or both.",
},
{
method: "PATCH",
path: "/api/core/profile",
description:
"Update profile preferences like the BTCPay checkout layout or default confirmations.",
},
],
},
{
title: "BTCPay Greenfield stores",
auth: "API key (token)",
items: [
{
method: "GET",
path: "/api/v1/stores",
description: "List stores accessible to the API key.",
},
{
method: "GET",
path: "/api/v1/stores/{store_id}",
description: "Retrieve a store by id.",
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/payment-methods",
description: "List enabled payment methods (WOW-CHAIN only).",
},
],
},
{
title: "BTCPay Greenfield invoices",
auth: "API key (token)",
items: [
{
method: "POST",
path: "/api/v1/stores/{store_id}/invoices",
description: "Create a BTCPay-compatible invoice.",
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/invoices/{invoice_id}",
description: "Retrieve a BTCPay-compatible invoice.",
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/invoices/{invoice_id}/payment-methods",
description: "Return payment details for the invoice.",
},
{
method: "POST",
path: "/api/v1/stores/{store_id}/invoices/{invoice_id}/status",
description: "Mark an invoice as Invalid (compatibility only).",
},
],
},
{
title: "BTCPay Greenfield webhooks",
auth: "API key (token)",
items: [
{
method: "POST",
path: "/api/v1/stores/{store_id}/webhooks",
description: "Register a webhook with authorized events.",
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/webhooks",
description: "List registered webhooks for a store.",
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/webhooks/{webhook_id}",
description: "Retrieve a webhook by id.",
},
{
method: "PUT",
path: "/api/v1/stores/{store_id}/webhooks/{webhook_id}",
description: "Update a webhook (enabled, url, or events).",
},
{
method: "DELETE",
path: "/api/v1/stores/{store_id}/webhooks/{webhook_id}",
description: "Delete a webhook registration.",
},
],
},
{
title: "BTCPay server info",
auth: "API key (token)",
items: [
{
method: "GET",
path: "/api/v1/server/info",
description: "Return server info for compatibility checks.",
},
{
method: "GET",
path: "/api/v1/api-keys/current",
description: "Return the API key label and permissions.",
},
],
},
];
const endpointRequirements = [
{
method: "POST",
path: "/api/core/auth/login",
auth: "None",
required: ["payment_address", "view_key"],
optional: [],
notes: [
"Primary address only; subaddresses and integrated addresses are rejected.",
"Use the secret view key (never submit a spend key).",
],
},
{
method: "GET",
path: "/api/core/public/invoice/{invoice_id}",
auth: "None",
required: ["invoice_id (path)"],
optional: [],
notes: ["Returns invoice status and payment details."],
},
{
method: "GET",
path: "/api/core/public/invoice/{invoice_id}/continue",
auth: "None",
required: ["invoice_id (path)"],
optional: [],
notes: [
"Only available after an invoice is confirmed.",
"Returns a 302 redirect to the merchant-provided checkout_continue_url (if configured).",
],
},
{
method: "GET",
path: "/api/core/invoices",
auth: "API key",
required: [],
optional: [
"limit (default 50, 1-100)",
"offset (default 0)",
"status",
"include_archived (default false)",
"q (invoice id, subaddress, metadata)",
"sort (created_at, expires_at, amount_xmr, status, confirmations, confirmation_target)",
"order (asc or desc)",
"created_from (ISO timestamp)",
"created_to (ISO timestamp)",
],
notes: ["Returns invoices excluding archived entries."],
},
{
method: "GET",
path: "/api/core/invoices/export.csv",
auth: "API key",
required: [],
optional: [
"include_archived (default false)",
"status",
"q",
"sort",
"order",
"created_from",
"created_to",
],
notes: ["Returns CSV with invoice rows."],
},
{
method: "GET",
path: "/api/core/invoices/{invoice_id}",
auth: "API key",
required: ["invoice_id (path)"],
optional: [],
notes: ["Returns a single invoice (archived invoices are excluded)."],
},
{
method: "GET",
path: "/api/core/webhooks",
auth: "API key",
required: [],
optional: [],
notes: ["Returns the most recent webhooks first."],
},
{
method: "GET",
path: "/api/core/webhooks/history",
auth: "API key",
required: [],
optional: ["limit (default 50, 1-200)", "offset (default 0)"],
notes: ["Returns the most recent webhook deliveries first."],
},
{
method: "POST",
path: "/api/core/webhooks/deliveries/{delivery_id}/redeliver",
auth: "API key",
required: ["delivery_id (path)"],
optional: [],
notes: ["Only failed deliveries can be redelivered."],
},
{
method: "POST",
path: "/api/core/webhooks",
auth: "API key",
required: ["events (at least one) or event_urls (at least one)"],
optional: ["url", "event_urls", "events"],
notes: [
"Provide url for all selected events unless every event is mapped in event_urls.",
"Events must be one of: invoice.created, invoice.payment_detected, invoice.confirmed, invoice.expired.",
],
},
{
method: "DELETE",
path: "/api/core/webhooks/{webhook_id}",
auth: "API key",
required: ["webhook_id (path)"],
optional: [],
notes: ["Returns 204 on success."],
},
{
method: "POST",
path: "/api/core/api-credentials/reset",
auth: "API key",
required: ["reset_api_key or reset_webhook_secret"],
optional: [],
notes: ["At least one reset flag must be true."],
},
{
method: "PATCH",
path: "/api/core/profile",
auth: "API key",
required: [],
optional: ["btcpay_checkout_style", "default_confirmation_target"],
notes: [
"Set btcpay_checkout_style to standard or btcpay_classic.",
"Applies to hosted invoice pages served for BTCPay compatibility.",
],
},
{
method: "POST",
path: "/api/core/invoices",
auth: "API key",
required: ["amount_xmr or amount_fiat + currency"],
optional: [
"confirmation_target (default 2, 0-10)",
"checkout_continue_url (https URL, optional post-confirmation Continue button)",
"metadata",
"expires_at (ISO 8601)",
],
notes: [
"Fiat conversion is non-binding and does not lock a rate.",
"Creates a pending invoice and returns invoice details.",
],
},
{
method: "DELETE",
path: "/api/core/invoices/{invoice_id}",
auth: "API key",
required: [],
optional: [],
notes: [
"Only pending, expired, or invalid invoices can be archived. Returns 204 on success.",
],
},
{
method: "GET",
path: "/api/v1/stores",
auth: "API key (token)",
required: [],
optional: [],
notes: [
"Single-store per user: the list returns one store.",
"Store ids map to the authenticated user id.",
"Header format: Authorization: token <api_key>.",
],
},
{
method: "GET",
path: "/api/v1/stores/{store_id}",
auth: "API key (token)",
required: ["store_id (path)"],
optional: [],
notes: [
"Only the authenticated store id is valid.",
"Returns the store metadata expected by Greenfield clients.",
],
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/payment-methods",
auth: "API key (token)",
required: ["store_id (path)"],
optional: [],
notes: ["Returns WOW-CHAIN as the only enabled payment method."],
},
{
method: "POST",
path: "/api/v1/stores/{store_id}/invoices",
auth: "API key (token)",
required: ["store_id (path)", "amount", "currency"],
optional: ["metadata", "checkout"],
notes: [
"Fiat inputs are converted once at creation time and stored as metadata.",
"Invoices are defined and payable in WOW only.",
],
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/invoices/{invoice_id}",
auth: "API key (token)",
required: ["store_id (path)", "invoice_id (path)"],
optional: [],
notes: ["Returns BTCPay-compatible status fields."],
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/invoices/{invoice_id}/payment-methods",
auth: "API key (token)",
required: ["store_id (path)", "invoice_id (path)"],
optional: [],
notes: [
"Read-only payment details derived from observed on-chain data.",
"Returns a list even when only one payment method is enabled.",
],
},
{
method: "POST",
path: "/api/v1/stores/{store_id}/invoices/{invoice_id}/status",
auth: "API key (token)",
required: ["store_id (path)", "invoice_id (path)", "status"],
optional: [],
notes: ["Only supports status=Invalid for compatibility; no funds move."],
},
{
method: "POST",
path: "/api/v1/stores/{store_id}/webhooks",
auth: "API key (token)",
required: ["store_id (path)", "url", "authorizedEvents"],
optional: ["enabled", "automaticRedelivery"],
notes: [
"Deliveries include BTCPay-Sig: sha256=<hmac>.",
"Events follow BTCPay naming conventions for compatibility.",
],
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/webhooks",
auth: "API key (token)",
required: ["store_id (path)"],
optional: [],
notes: ["Returns the most recent webhooks first."],
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/webhooks/{webhook_id}",
auth: "API key (token)",
required: ["store_id (path)", "webhook_id (path)"],
optional: [],
notes: ["Returns a single webhook registration."],
},
{
method: "PUT",
path: "/api/v1/stores/{store_id}/webhooks/{webhook_id}",
auth: "API key (token)",
required: ["store_id (path)", "webhook_id (path)"],
optional: ["enabled", "automaticRedelivery", "url", "authorizedEvents"],
notes: ["Updates a webhook registration."],
},
{
method: "DELETE",
path: "/api/v1/stores/{store_id}/webhooks/{webhook_id}",
auth: "API key (token)",
required: ["store_id (path)", "webhook_id (path)"],
optional: [],
notes: ["Returns 204 on success."],
},
{
method: "GET",
path: "/api/v1/server/info",
auth: "API key (token)",
required: [],
optional: [],
notes: ["Version is set for compatibility with refund UI behavior."],
},
{
method: "GET",
path: "/api/v1/api-keys/current",
auth: "API key (token)",
required: [],
optional: [],
notes: [
"Permissions are store-scoped strings tied to one store id.",
"Refund permissions are intentionally omitted.",
],
},
];
const requirementsByEndpoint = Object.fromEntries(
endpointRequirements.map((entry) => [`${entry.method} ${entry.path}`, entry])
);
export default function DocsPage() {
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid max-w-[52rem] gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Documentation
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Wownero checkout documentation & API reference.
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
wowcheckout.com generates invoices, detects Wownero payments using view-only access,
and relays status events. Setup is explicit: bring your primary address and secret
view key - never spend keys, never custody.
</p>
<div className="flex flex-wrap gap-2">
<span className="rounded-full bg-ink/10 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft">
Customer → Merchant wallet
</span>
<span className="rounded-full bg-ink/10 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft">
No spend keys required
</span>
<span className="rounded-full bg-ink/10 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft">
OpenAPI schema available
</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<Link
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-xs font-semibold uppercase tracking-[0.18em] text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
href="/docs/integrations"
>
Integration recipes
</Link>
<span className="text-sm text-ink-soft">
Copy/paste examples for curl, Node, PHP, Python, and Go.
</span>
</div>
</section>
<section className="mt-10 rounded-2xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Start here
</p>
<ol className="mt-3 grid gap-3 text-sm text-ink-soft sm:grid-cols-4">
<li className="rounded-xl border border-stroke bg-white/70 p-4">
<p className="font-semibold text-ink">1) Sign in (view-only)</p>
<p className="mt-1">
Use your primary address + secret view key to retrieve an API key.
</p>
</li>
<li className="rounded-xl border border-stroke bg-white/70 p-4">
<p className="font-semibold text-ink">2) Create an invoice</p>
<p className="mt-1">
Call <span className="font-mono text-ink">POST /api/core/invoices</span> and
redirect the customer to the hosted invoice URL.
</p>
</li>
<li className="rounded-xl border border-stroke bg-white/70 p-4">
<p className="font-semibold text-ink">3) Optional: configure webhooks</p>
<p className="mt-1">
Subscribe to <span className="font-mono text-ink">invoice.confirmed</span> to
get status events pushed to your system.
</p>
</li>
<li className="rounded-xl border border-stroke bg-white/70 p-4">
<p className="font-semibold text-ink">4) Confirm and fulfill</p>
<p className="mt-1">
Wait for <span className="font-mono text-ink">invoice.confirmed</span> (webhook
or polling). You can also check the public status endpoint, then mark your
order paid.
</p>
</li>
</ol>
<div className="mt-4 flex flex-wrap items-center gap-3">
<Link
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/50 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-ink transition hover:-translate-y-0.5"
href="/docs/integrations"
>
See recipes
</Link>
<a
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/50 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-ink transition hover:-translate-y-0.5"
href={`${normalizedSiteUrl}/openapi.json`}
rel="noreferrer"
target="_blank"
>
OpenAPI JSON
</a>
</div>
</section>
<section className="mt-10 rounded-2xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h2 className="font-serif text-2xl">Fiat inputs (optional)</h2>
<p className="mt-2 text-ink-soft">
You can request an invoice using a fiat amount. The backend converts the
value to WOW using the current rate and returns a non-binding quote in the
response. The invoice is always stored in WOW.
</p>
<pre className="mt-4 overflow-x-auto rounded-2xl border border-stroke bg-ink px-6 py-5 text-xs leading-relaxed text-cream shadow-soft">
<code>{`POST /api/core/invoices
Authorization: ApiKey <api_key>
Content-Type: application/json
{
"amount_fiat": "100.00",
"currency": "USD",
"confirmation_target": 2,
"checkout_continue_url": "https://merchant.example/thanks",
"metadata": { "order_id": "ORDER-1234" }
}`}</code>
</pre>
</section>
<section className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="rounded-xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h3 className="mb-2 font-serif text-xl">Core principles</h3>
<p className="text-ink-soft">
All behavior is constrained by non-custodial rules.
</p>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>Merchants retain wallet access (your keys, your funds)</li>
<li>View-only access maximum</li>
<li>No fiat rails or execution of financial actions</li>
</ul>
</div>
<div className="rounded-xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h3 className="mb-2 font-serif text-xl">Subaddress-only invoices</h3>
<p className="text-ink-soft">
Every invoice uses a subaddress derived from your primary address for
clearer reconciliation.
</p>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>Subaddress index shown on each invoice</li>
<li>Wallet lookahead controls discovery</li>
<li>Subaddresses cycle through indices 1 to 100</li>
<li>Wallets may require adding the index to view funds</li>
</ul>
</div>
<div className="rounded-xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h3 className="mb-2 font-serif text-xl">Invoice lifecycle</h3>
<p className="text-ink-soft">
Clear state transitions without hidden automation.
</p>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>Pending → Payment detected → Confirmed</li>
<li>Pending → Expired</li>
<li>Pending → Invalid (manual)</li>
</ul>
</div>
</section>
<section className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="rounded-xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h3 className="mb-2 font-serif text-xl">Authentication</h3>
<p className="text-ink-soft">
Two auth modes keep integrations explicit and observable.
</p>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>API key for authenticated endpoints</li>
<li>Primary address + secret view key to retrieve API credentials</li>
<li>Headers: Authorization: ApiKey &lt;api_key&gt;</li>
<li>BTCPay compatibility: Authorization: token &lt;api_key&gt;</li>
</ul>
</div>
<div className="rounded-xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h3 className="mb-2 font-serif text-xl">Webhook events</h3>
<p className="text-ink-soft">Observable events for merchant systems.</p>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>invoice.created</li>
<li>invoice.payment_detected</li>
<li>invoice.confirmed</li>
<li>invoice.expired</li>
<li>Deliveries include the X-Webhook-Secret header</li>
<li>Webhooks are optional; poll /api/core/public/invoice/&lt;invoice_id&gt; without auth</li>
</ul>
</div>
<div className="rounded-xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h3 className="mb-2 font-serif text-xl">Base URL</h3>
<p className="text-ink-soft">
The API can be reached from:
</p>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>{normalizedSiteUrl}/api/core</li>
</ul>
</div>
</section>
<section className="mt-6 grid gap-6 sm:grid-cols-2">
<div className="rounded-xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h3 className="mb-2 font-serif text-xl">BTCPay compatibility (WooCommerce)</h3>
<p className="text-ink-soft">
A Greenfield-compatible subset keeps the official WooCommerce plugin working
without custodial behavior.
</p>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>
Base URL uses <span className="font-mono text-ink">/api/v1</span>
</li>
<li>Supported endpoints: stores, invoices, payment methods, webhooks</li>
<li>Invoices are defined in WOW; fiat inputs are quote-only metadata</li>
<li>Modal checkout is supported; the hosted invoice page is recommended for clarity</li>
</ul>
</div>
<div className="rounded-xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h3 className="mb-2 font-serif text-xl">BTCPay webhooks</h3>
<p className="text-ink-soft">Deliveries are signed with the BTCPay header format.</p>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>
<span className="font-mono text-ink">BTCPay-Sig: sha256=&lt;hmac&gt;</span>
</li>
<li>Event names follow BTCPay conventions for compatibility.</li>
</ul>
</div>
</section>
<section className="mt-12 grid gap-6">
<div className="grid max-w-[52rem] gap-2">
<h2 className="font-serif text-2xl">API reference</h2>
<p className="text-ink-soft">
Every endpoint exposed by the API is listed below, grouped by access
mode.
</p>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{endpointGroups.map((group, index) => {
const needsDivider =
group.title.startsWith("BTCPay") &&
endpointGroups[index - 1] &&
!endpointGroups[index - 1].title.startsWith("BTCPay");
return (
<Fragment key={group.title}>
{needsDivider ? (
<div className="h-px w-full bg-wownero/70 lg:col-span-2" aria-hidden="true"></div>
) : null}
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h3 className="font-serif text-xl">{group.title}</h3>
<p className="text-sm font-semibold text-sage">
Auth: {group.auth}
</p>
</div>
<span className="rounded-full bg-ink/10 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
{group.items.length} endpoints
</span>
</div>
<div className="mt-5 grid gap-3">
{group.items.map((endpoint) => {
const requirements =
requirementsByEndpoint[`${endpoint.method} ${endpoint.path}`];
return (
<div
className="grid gap-4 rounded-xl border border-ink/10 bg-white/60 p-4 sm:grid-cols-[auto_1fr]"
key={`${endpoint.method}-${endpoint.path}`}
>
<span
className={`inline-flex items-center justify-center rounded-lg px-2.5 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] ${
methodStyles[endpoint.method] ?? "bg-ink/10 text-ink-soft"
}`}
>
{endpoint.method}
</span>
<div className="grid gap-2">
<div className="grid gap-1">
<span className="font-mono text-sm text-ink">
{endpoint.path}
</span>
<span className="text-sm text-ink-soft">
{endpoint.description}
</span>
</div>
{requirements ? (
<details className="rounded-lg border border-ink/10 bg-white/70 px-3 py-2 text-sm text-ink-soft">
<summary className="cursor-pointer select-none text-xs font-semibold uppercase tracking-[0.18em] text-sage">
Requirements
</summary>
<div className="mt-2 grid gap-2">
<div>
<span className="font-semibold text-ink">
Required:
</span>{" "}
{requirements.required.length > 0
? requirements.required.join(", ")
: "None"}
</div>
{requirements.optional.length > 0 ? (
<div>
<span className="font-semibold text-ink">
Optional:
</span>{" "}
{requirements.optional.join(", ")}
</div>
) : null}
{requirements.notes.length > 0 ? (
<ul className="list-disc space-y-1 pl-5">
{requirements.notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
) : null}
</div>
</details>
) : null}
</div>
</div>
);
})}
</div>
</div>
</Fragment>
);
})}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-stroke bg-white/60 px-5 py-4 text-sm text-ink-soft shadow-soft backdrop-blur">
<span>Need the machine-readable schema?</span>
<a
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/50 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-ink transition hover:-translate-y-0.5"
href={`${normalizedSiteUrl}/openapi.json`}
rel="noreferrer"
target="_blank"
>
OpenAPI JSON
</a>
</div>
</section>
</main>
);
}
@@ -0,0 +1,331 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import InvoicePaymentDetails from "../../../../components/invoice-payment-details";
import InvoiceStatusAutoRefresh from "../../../../components/invoice-status-auto-refresh";
import DonationStatusActions from "../../../../components/donation-status-actions";
import InvoiceStatusLookup from "../../../../components/invoice-status-lookup";
import { formatRelativeTime } from "../../../../components/relative-time";
import StatusRefreshButton from "../../../../components/status-refresh-button";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Donation Request",
};
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
type InvoiceStatus =
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid";
type InvoiceStatusResponse = {
id: string;
address: string;
subaddress_index: number | null;
amount_xmr: string;
status: InvoiceStatus;
confirmation_target: number;
confirmations: number;
created_at: string;
expires_at: string | null;
detected_at: string | null;
confirmed_at: string | null;
};
const formatStatus = (status: InvoiceStatus) => {
if (status === "payment_detected") {
return "Payment detected";
}
if (status === "pending") {
return "Awaiting funds";
}
if (status === "confirmed") {
return "Confirmed";
}
if (status === "invalid") {
return "Invalid";
}
return "Expired";
};
const statusDescription = (
status: InvoiceStatus,
confirmationTarget: number,
confirmations: number
) => {
if (status === "pending") {
return "Awaiting on-chain detection of your donation.";
}
if (status === "payment_detected") {
return `Donation detected. ${confirmations}/${confirmationTarget} confirmations.`;
}
if (status === "confirmed") {
return `Donation confirmed on-chain at ${confirmationTarget} confirmations.`;
}
if (status === "invalid") {
return "Donation marked invalid. Do not send a payment.";
}
return "Donation expired before detection. Start a new donation if needed.";
};
const formatTimestamp = (value: string | null) => {
if (!value) {
return { label: "-", relative: null };
}
return {
label: new Date(value).toLocaleString(),
relative: formatRelativeTime(value),
};
};
const statusPillStyles: Record<InvoiceStatus, string> = {
pending: "bg-amber-100 text-amber-900",
payment_detected: "bg-emerald-100 text-emerald-900",
confirmed: "bg-ink/20 text-ink",
expired: "bg-red-100 text-red-700",
invalid: "bg-clay/15 text-clay",
};
export default async function DonateStatusDetailPage({
params,
}: {
params: Promise<{ invoiceId: string }>;
}) {
const donationsEnabled = process.env.NEXT_PUBLIC_DONATIONS_ENABLED === "true";
if (!donationsEnabled) {
notFound();
}
const { invoiceId } = await params;
const response = await fetch(
`${apiBaseUrl}/api/core/public/donation/${encodeURIComponent(invoiceId)}`,
{ cache: "no-store" }
);
if (response.status === 404) {
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
<div className="grid gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Donation status
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Donation invoice not found.
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
The donation id does not match a known invoice. Check the id and
try again.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-clay">
Need another lookup?
</p>
<div className="mt-4">
<InvoiceStatusLookup initialValue={invoiceId} compact />
</div>
</div>
</section>
</main>
);
}
if (!response.ok) {
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
<div className="grid gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Donation status
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Status unavailable.
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
We could not load this donation status. Refresh the page or try
again later.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
<InvoiceStatusLookup initialValue={invoiceId} compact />
</div>
</section>
</main>
);
}
const invoice = (await response.json()) as InvoiceStatusResponse;
const statusLabel = formatStatus(invoice.status);
const confirmations = Math.max(0, invoice.confirmations ?? 0);
const confirmationTarget = Math.max(0, invoice.confirmation_target);
const isInvalid = invoice.status === "invalid";
const hasDetectedPayment =
invoice.status === "payment_detected" || invoice.status === "confirmed";
const timelineItems: {
label: string;
timestamp: ReturnType<typeof formatTimestamp>;
state: "complete";
}[] = [];
timelineItems.push({
label: "Donation created",
timestamp: formatTimestamp(invoice.created_at),
state: "complete",
});
if (invoice.detected_at) {
timelineItems.push({
label: "Donation detected",
timestamp: formatTimestamp(invoice.detected_at),
state: "complete",
});
}
if (invoice.confirmed_at) {
timelineItems.push({
label: "Donation confirmed",
timestamp: formatTimestamp(invoice.confirmed_at),
state: "complete",
});
}
if (invoice.status === "expired") {
timelineItems.push({
label: "Donation expired",
timestamp: formatTimestamp(invoice.expires_at),
state: "complete",
});
}
const showConfirmedStamp = invoice.status === "confirmed";
const createdTimestamp = formatTimestamp(invoice.created_at);
const expiresTimestamp = formatTimestamp(invoice.expires_at);
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
<InvoiceStatusAutoRefresh intervalMs={30000} />
<div className="grid gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Make a donation
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Make a donation
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
{isInvalid
? "This donation is marked invalid. Do not pay."
: "Send the exact amount shown below to donate."}
</p>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
{statusDescription(invoice.status, confirmationTarget, confirmations)}
</p>
<details className="rounded-xl border border-stroke bg-white/60 px-4 py-3 text-sm text-ink-soft shadow-soft">
<summary className="cursor-pointer select-none text-xs font-semibold uppercase tracking-[0.18em] text-ink">
Details
</summary>
<div className="mt-3 grid gap-2">
<p className="font-mono text-xs text-ink">Donation ID: {invoiceId}</p>
<p>This status page is public. Anyone with the link can view the current state.</p>
</div>
</details>
</div>
<div className="relative overflow-hidden rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
{showConfirmedStamp ? (
<div
className="pointer-events-none absolute left-1/2 top-4 z-10 grid h-20 w-20 -translate-x-1/2 -rotate-[12deg] place-items-center rounded-full border-2 border-emerald-700 bg-cream/80 text-center text-[0.55rem] font-bold uppercase tracking-[0.2em] text-emerald-800 shadow-[0_10px_20px_rgba(16,18,23,0.18)]"
aria-hidden="true"
>
<span className="leading-tight">Payment confirmed</span>
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<span className="rounded-full bg-ink/10 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
Status
</span>
<div className="flex items-center gap-2">
<span
className={`rounded-full px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] ${statusPillStyles[invoice.status]}`}
>
{statusLabel}
</span>
<StatusRefreshButton label="Refresh" />
</div>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Confirmations
</p>
<p className="mt-1 text-lg font-semibold">
{confirmations}/{confirmationTarget}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Created
</p>
<p
className="mt-1 text-sm font-semibold"
title={createdTimestamp.relative ?? undefined}
>
{createdTimestamp.label}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Expires
</p>
<p
className="mt-1 text-sm font-semibold"
title={expiresTimestamp.relative ?? undefined}
>
{expiresTimestamp.label}
</p>
</div>
</div>
</div>
</section>
<InvoicePaymentDetails
address={invoice.address}
amount={invoice.amount_xmr}
hasDetectedPayment={hasDetectedPayment}
status={invoice.status}
confirmationTarget={confirmationTarget}
/>
<section className="mt-8 rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
<h2 className="font-serif text-2xl">Timeline</h2>
<ol className="mt-5 grid gap-3">
{[...timelineItems].reverse().map((item) => (
<li
className="flex items-center justify-between gap-6 rounded-xl border border-ink/20 bg-white/70 px-4 py-3"
key={item.label}
>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
{item.label}
</p>
<p
className="mt-1 text-sm font-semibold"
title={item.timestamp.relative ?? undefined}
>
{item.timestamp.label}
</p>
</div>
</li>
))}
</ol>
</section>
<section className="mt-6 grid gap-4">
<DonationStatusActions />
</section>
</main>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use server";
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
export type DonationState = {
error: string | null;
invoiceId: string | null;
};
export async function createDonationAction(
_prevState: DonationState,
formData: FormData
): Promise<DonationState> {
const donationsEnabled = process.env.NEXT_PUBLIC_DONATIONS_ENABLED === "true";
if (!donationsEnabled) {
return { error: "Donations are disabled.", invoiceId: null };
}
const amount = Number(formData.get("amount_xmr"));
if (Number.isNaN(amount) || amount <= 0) {
return { error: "Enter a valid WOW amount.", invoiceId: null };
}
const response = await fetch(`${apiBaseUrl}/api/core/donations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount_xmr: amount }),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to create a donation invoice.";
return { error: message, invoiceId: null };
}
const data = (await response.json()) as { id: string };
return { error: null, invoiceId: data.id };
}
+14
View File
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Donate",
};
export default function DonatePage() {
const donationsEnabled = process.env.NEXT_PUBLIC_DONATIONS_ENABLED === "true";
if (!donationsEnabled) {
notFound();
}
redirect("/?donate=1");
}
+278
View File
@@ -0,0 +1,278 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "FAQ",
description:
"Frequently asked questions about WOW Checkout: non-custodial Wownero invoices with view-only detection.",
};
const donationsEnabled = process.env.NEXT_PUBLIC_DONATIONS_ENABLED === "true";
type FaqItem = {
question: string;
answer: React.ReactNode;
};
function ComparisonYesNo({
value,
label,
}: {
value: boolean;
label: string;
}) {
return (
<span className="inline-flex items-center justify-center">
<span
className={[
"inline-flex h-7 w-7 items-center justify-center rounded-full text-sm font-semibold",
value ? "bg-sage/15 text-sage" : "bg-ink/10 text-ink-soft",
].join(" ")}
aria-label={label}
title={label}
>
{value ? "✓" : "✕"}
</span>
</span>
);
}
const faqItems: FaqItem[] = [
{
question: "Is WOW Checkout custodial?",
answer: (
<>
<p className="text-ink-soft">
No. Payments go directly from the customer to your Wownero wallet. WOW
Checkout only observes the blockchain to detect payments and update
invoice status.
</p>
</>
),
},
{
question: "What wallet access does WOW Checkout require?",
answer: (
<>
<p className="text-ink-soft">
The maximum permission we ask for is view-only access: your primary
address and your secret view key. We never request spend keys and we
never request signing access.
</p>
</>
),
},
{
question: "What does setup require?",
answer: (
<>
<p className="text-ink-soft">
Setup is explicit: provide your primary address and secret
view key, then create invoices via the dashboard or API. We never
request spend keys or signing access.
</p>
</>
),
},
{
question: "Do you require email or personal information?",
answer: (
<>
<p className="text-ink-soft">
No. We do not require email or personal information. Your primary
address is the identifier for access, paired with your secret view key.
</p>
</>
),
},
{
question: "What does WOW Checkout cost?",
answer: (
<>
<p className="text-ink-soft">
Currently, wowcheckout.com does not charge a service fee. You can also self-host the open source
stack if you prefer.
</p>
{donationsEnabled ? (
<p className="text-ink-soft">
If you find WOW Checkout useful, you can support development with{" "}
<Link className="underline underline-offset-4" href="/donate">
donations
</Link>
.
</p>
) : null}
</>
),
},
{
question: "Can WOW Checkout move funds, issue refunds, or reverse payments?",
answer: (
<>
<p className="text-ink-soft">
No. WOW Checkout does not sign transactions and cannot move funds on
your behalf. It is checkout software: create invoices, detect
payments, and relay status via API/webhooks.
</p>
</>
),
},
{
question: "What happens if WOW Checkout is down when a customer pays?",
answer: (
<>
<p className="text-ink-soft">
Your wallet can still receive a valid on-chain payment because the
payment is customer → merchant wallet. If the service is unavailable,
you may temporarily lose invoice status updates until it comes back
online.
</p>
</>
),
},
{
question: "How does WOW Checkout differ from hosted services (e.g. NOWPayments)?",
answer: (
<>
<p className="text-ink-soft">
The main differences are scope, custody model, and data collection: WOW Checkout is Wownero-only,
does not require an account or email, and uses view-only access for payment detection.
</p>
<ul className="mt-4 grid gap-2 text-sm text-ink-soft">
<li>
<span className="font-semibold text-ink">Wownero-only:</span> a focused
product without a multi-asset stack.
</li>
<li>
<span className="font-semibold text-ink">No account/email:</span> your primary address is the identifier.
</li>
<li>
<span className="font-semibold text-ink">No custody:</span> payments
go directly to your wallet.
</li>
<li>
<span className="font-semibold text-ink">View-only by design:</span>{" "}
we never ask for spend keys.
</li>
<li>
<span className="font-semibold text-ink">Clear failure modes:</span>{" "}
downtime can delay status updates, but it does not affect on-chain
validity of payments.
</li>
</ul>
<p className="mt-4 text-ink-soft">
If you use a third-party service, review what keys and data it requires, and whether it ever takes custody
or intermediates funds.
</p>
</>
),
},
{
question: "How does WOW Checkout differ from BTCPay Server?",
answer: (
<>
<p className="text-ink-soft">
BTCPay Server is a general-purpose, self-hosted payment server with a primary focus on Bitcoin
and optional integrations for other assets (including Wownero via a plugin). WOW Checkout is intentionally
focused on Wownero checkout with a view-only trust boundary and a minimal, deterministic core.
</p>
<p className="text-ink-soft">
Some hosted BTCPay deployments may not include the Wownero plugin by default. WOW Checkout provides a
small compatibility layer so teams can integrate without running a full BTCPay deployment.
</p>
<ul className="mt-4 grid gap-2 text-sm text-ink-soft">
<li>
<span className="font-semibold text-ink">Wownero-first UX:</span>{" "}
invoice and confirmation language is tailored to Wownero.
</li>
<li>
<span className="font-semibold text-ink">Fewer moving parts:</span>{" "}
a narrow scope keeps operations and audits simpler.
</li>
<li>
<span className="font-semibold text-ink">API/webhooks-first:</span>{" "}
designed to fit into existing order systems.
</li>
</ul>
</>
),
},
{
question: "Can I self-host WOW Checkout?",
answer: (
<>
<p className="text-ink-soft">
Yes. WOW Checkout is open source, and you can self-host it from the
official{" "}
<Link
className="underline underline-offset-4"
href="https://github.com/wowcheckout/wowcheckout"
>
GitHub repository
</Link>
.
</p>
<p className="text-ink-soft">
Start with{" "}
<Link className="underline underline-offset-4" href="/docs">
Documentation
</Link>{" "}
for setup and integration details.
</p>
</>
),
},
];
function FaqCard({ item }: { item: FaqItem }) {
return (
<details className="group rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur">
<summary className="cursor-pointer list-none select-none font-serif text-xl text-ink [&::-webkit-details-marker]:hidden">
<span className="inline-flex items-start justify-between gap-4">
<span>{item.question}</span>
<span className="mt-1 text-sm text-ink-soft transition group-open:rotate-45">
+
</span>
</span>
</summary>
<div className="mt-4 grid gap-3 text-sm leading-relaxed">{item.answer}</div>
</details>
);
}
export default function FaqPage() {
return (
<main className="text-ink">
<section className="px-[6vw] pb-20 pt-10">
<div className="grid gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-wownero">
FAQ
</p>
<h1 className="font-serif text-3xl">Direct answers.</h1>
<p className="max-w-[46rem] text-[1.02rem] leading-relaxed text-ink-soft">
WOW Checkout is non-custodial checkout software for accepting Wownero
payments. We focus on a conservative trust boundary: view-only
detection and merchant-owned funds.
</p>
</div>
<div className="mt-10 grid gap-4">
{faqItems.map((item) => (
<FaqCard key={item.question} item={item} />
))}
</div>
<div className="mt-12 rounded-3xl border border-stroke bg-card p-8 shadow-card backdrop-blur">
<h2 className="font-serif text-2xl">Still have questions?</h2>
<p className="mt-2 text-ink-soft">
Start with{" "}
<Link className="underline underline-offset-4" href="/docs">
Documentation
</Link>{" "}
for API details and integration guidance.
</p>
</div>
</section>
</main>
);
}
@@ -0,0 +1,345 @@
import type { Metadata } from "next";
import InvoicePaymentDetails from "../../../../components/invoice-payment-details";
import InvoiceStatusAutoRefresh from "../../../../components/invoice-status-auto-refresh";
import InvoiceStatusActions from "../../../../components/invoice-status-actions";
import InvoiceStatusLookup from "../../../../components/invoice-status-lookup";
import { formatRelativeTime } from "../../../../components/relative-time";
import StatusRefreshButton from "../../../../components/status-refresh-button";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Payment Request",
};
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
type InvoiceStatus =
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid";
type InvoiceStatusResponse = {
id: string;
address: string;
subaddress_index: number | null;
amount_xmr: string;
status: InvoiceStatus;
confirmation_target: number;
confirmations: number;
checkout_continue_available?: boolean;
qr_logo?: "monero" | "none" | "custom" | null;
qr_logo_data_url?: string | null;
created_at: string;
expires_at: string | null;
detected_at: string | null;
confirmed_at: string | null;
};
const formatStatus = (status: InvoiceStatus) => {
if (status === "payment_detected") {
return "Payment detected";
}
if (status === "pending") {
return "Awaiting funds";
}
if (status === "confirmed") {
return "Confirmed";
}
if (status === "invalid") {
return "Invalid";
}
return "Expired";
};
const statusDescription = (
status: InvoiceStatus,
confirmationTarget: number,
confirmations: number
) => {
if (status === "pending") {
return "Waiting for a payment to be detected on-chain.";
}
if (status === "payment_detected") {
return `Payment detected. ${confirmations}/${confirmationTarget} confirmations.`;
}
if (status === "confirmed") {
return `Payment confirmed on-chain at ${confirmationTarget} confirmations.`;
}
if (status === "invalid") {
return "Invoice marked invalid. Do not send payment.";
}
return "Invoice expired before detection. Create a new invoice if needed.";
};
const formatTimestamp = (value: string | null) => {
if (!value) {
return { label: "-", relative: null };
}
return {
label: new Date(value).toLocaleString(),
relative: formatRelativeTime(value),
};
};
const statusPillStyles: Record<InvoiceStatus, string> = {
pending: "bg-amber-100 text-amber-900",
payment_detected: "bg-emerald-100 text-emerald-900",
confirmed: "bg-ink/20 text-ink",
expired: "bg-red-100 text-red-700",
invalid: "bg-clay/15 text-clay",
};
export default async function InvoiceStatusDetailPage({
params,
}: {
params: Promise<{ invoiceId: string }>;
}) {
const { invoiceId } = await params;
const response = await fetch(
`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`,
{ cache: "no-store" }
);
if (response.status === 404) {
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
<div className="grid gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Invoice status
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Invoice not found.
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
The invoice id does not match a known invoice. Check the id and
try again.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-clay">
Need another lookup?
</p>
<div className="mt-4">
<InvoiceStatusLookup initialValue={invoiceId} compact />
</div>
</div>
</section>
</main>
);
}
if (!response.ok) {
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
<div className="grid gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Invoice status
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Status unavailable.
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
We could not load this invoice status. Refresh the page or try
again later.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
<InvoiceStatusLookup initialValue={invoiceId} compact />
</div>
</section>
</main>
);
}
const invoice = (await response.json()) as InvoiceStatusResponse;
const statusLabel = formatStatus(invoice.status);
const confirmations = Math.max(0, invoice.confirmations ?? 0);
const confirmationTarget = Math.max(0, invoice.confirmation_target);
const isInvalid = invoice.status === "invalid";
const hasDetectedPayment =
invoice.status === "payment_detected" || invoice.status === "confirmed";
const checkoutContinueAvailable =
invoice.status === "confirmed" && Boolean(invoice.checkout_continue_available);
const timelineItems: {
label: string;
timestamp: ReturnType<typeof formatTimestamp>;
state: "complete";
}[] = [];
timelineItems.push({
label: "Invoice created",
timestamp: formatTimestamp(invoice.created_at),
state: "complete",
});
if (invoice.detected_at) {
timelineItems.push({
label: "Payment detected",
timestamp: formatTimestamp(invoice.detected_at),
state: "complete",
});
}
if (invoice.confirmed_at) {
timelineItems.push({
label: "Payment confirmed",
timestamp: formatTimestamp(invoice.confirmed_at),
state: "complete",
});
}
if (invoice.status === "expired") {
timelineItems.push({
label: "Invoice expired",
timestamp: formatTimestamp(invoice.expires_at),
state: "complete",
});
}
const showConfirmedStamp = invoice.status === "confirmed";
const createdTimestamp = formatTimestamp(invoice.created_at);
const expiresTimestamp = formatTimestamp(invoice.expires_at);
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
<InvoiceStatusAutoRefresh intervalMs={30000} />
<div className="grid gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Payment request
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Payment request
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
{isInvalid
? "This invoice is marked invalid. Do not pay."
: "Send the exact amount shown below to pay this invoice."}
</p>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
{statusDescription(invoice.status, confirmationTarget, confirmations)}
</p>
<details className="rounded-xl border border-stroke bg-white/60 px-4 py-3 text-sm text-ink-soft shadow-soft">
<summary className="cursor-pointer select-none text-xs font-semibold uppercase tracking-[0.18em] text-ink">
Details
</summary>
<div className="mt-3 grid gap-2">
<p className="font-mono text-xs text-ink">Invoice ID: {invoiceId}</p>
<p>
This status page is public. Anyone with the link can view the current
invoice state.
</p>
</div>
</details>
</div>
<div className="relative overflow-hidden rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
{showConfirmedStamp ? (
<div
className="pointer-events-none absolute left-1/2 top-4 z-10 grid h-20 w-20 -translate-x-1/2 -rotate-[12deg] place-items-center rounded-full border-2 border-emerald-700 bg-cream/80 text-center text-[0.55rem] font-bold uppercase tracking-[0.2em] text-emerald-800 shadow-[0_10px_20px_rgba(16,18,23,0.18)]"
aria-hidden="true"
>
<span className="leading-tight">Payment confirmed</span>
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<span className="rounded-full bg-ink/10 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
Status
</span>
<div className="flex items-center gap-2">
<span
className={`rounded-full px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] ${statusPillStyles[invoice.status]}`}
>
{statusLabel}
</span>
<StatusRefreshButton label="Refresh" />
</div>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Confirmations
</p>
<p className="mt-1 text-lg font-semibold">
{confirmations}/{confirmationTarget}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Created
</p>
<p
className="mt-1 text-sm font-semibold"
title={createdTimestamp.relative ?? undefined}
>
{createdTimestamp.label}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Expires
</p>
<p
className="mt-1 text-sm font-semibold"
title={expiresTimestamp.relative ?? undefined}
>
{expiresTimestamp.label}
</p>
</div>
</div>
</div>
</section>
<InvoicePaymentDetails
address={invoice.address}
amount={invoice.amount_xmr}
hasDetectedPayment={hasDetectedPayment}
status={invoice.status}
confirmationTarget={confirmationTarget}
qrLogoMode={invoice.qr_logo ?? "monero"}
qrLogoDataUrl={invoice.qr_logo_data_url ?? null}
/>
<section className="mt-8 rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
<h2 className="font-serif text-2xl">Timeline</h2>
<ol className="mt-5 grid gap-3">
{[...timelineItems].reverse().map((item) => (
<li
className="flex items-center justify-between gap-6 rounded-xl border border-ink/20 bg-white/70 px-4 py-3"
key={item.label}
>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
{item.label}
</p>
<p
className="mt-1 text-sm font-semibold"
title={item.timestamp.relative ?? undefined}
>
{item.timestamp.label}
</p>
</div>
</li>
))}
</ol>
</section>
<section className="mt-6 grid gap-4">
{checkoutContinueAvailable ? (
<a
className="inline-flex w-fit items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
href={`/api/core/public/invoice/${encodeURIComponent(invoiceId)}/continue`}
rel="noreferrer"
>
Continue to merchant
</a>
) : null}
<InvoiceStatusActions />
</section>
</main>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import InvoiceStatusLookup from "../../../components/invoice-status-lookup";
export const metadata: Metadata = {
title: "Invoice Status",
};
export default function InvoiceStatusPage() {
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
<div className="grid gap-6">
<div className="grid gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Invoice status
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Check a Wownero invoice status.
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
Paste the invoice id to see detection and confirmation state.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
<InvoiceStatusLookup />
</div>
</div>
<div className="grid gap-6">
<div className="rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-clay">
View-only detection
</p>
<h2 className="mt-2 font-serif text-2xl">No sign-in required.</h2>
<p className="mt-2 text-ink-soft">
Anyone with the invoice id can view its status. Share this page with
customers who need a quick update.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-clay">
Example status
</p>
<h2 className="mt-2 font-serif text-2xl">What you will see.</h2>
<div className="mt-4 grid gap-3 text-sm text-ink-soft">
<div className="flex items-center gap-3">
<span className="timeline-dot is-active h-2.5 w-2.5 rounded-full bg-wownero"></span>
<span className="font-semibold text-ink">Detected</span>
<span>Seen on the network</span>
</div>
<div className="flex items-center gap-3">
<span className="h-2.5 w-2.5 rounded-full bg-wownero/60"></span>
<span className="font-semibold text-ink">Confirming</span>
<span>Waiting on confirmations</span>
</div>
<div className="flex items-center gap-3">
<span className="h-2.5 w-2.5 rounded-full bg-ink/20"></span>
<span className="font-semibold text-ink">Confirmed</span>
<span>Target reached</span>
</div>
</div>
</div>
</div>
</section>
</main>
);
}
+89
View File
@@ -0,0 +1,89 @@
import Image from "next/image";
import Link from "next/link";
import { Suspense } from "react";
import { cookies } from "next/headers";
import "./marketing.css";
import DonateModal from "../../components/donate-modal";
import LoginModal, { LoginTrigger } from "../../components/login-modal";
import NavLink from "../../components/nav-link";
import { logoutAction } from "../(app)/dashboard/actions";
export default async function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const apiKey = cookieStore.get("wowcheckout_api_key")?.value;
const isAuthenticated = Boolean(apiKey);
return (
<div>
<div className="ambient">
<span className="orb orb-a"></span>
<span className="orb orb-b"></span>
<span className="ambient-grid"></span>
</div>
<header className="site-header">
<div className="brand">
<Link href="/" aria-label="WOW Checkout home">
<Image
className="brand-logo"
src="/logo.png"
alt="WOW Checkout logo"
width={128}
height={128}
priority
/>
</Link>
<Link className="name" href="/">
WOW Checkout
</Link>
</div>
<nav className="nav">
<NavLink href="/invoice">Check Invoice</NavLink>
<NavLink href="/tour">Tour</NavLink>
<NavLink href="/docs">Documentation</NavLink>
<NavLink href="/faq">FAQ</NavLink>
{isAuthenticated ? (
<>
<NavLink href="/dashboard">Dashboard</NavLink>
<form action={logoutAction}>
<button type="submit">Log out</button>
</form>
</>
) : (
<LoginTrigger className="nav-primary">Log in</LoginTrigger>
)}
</nav>
</header>
{children}
<Suspense fallback={null}>
<LoginModal />
</Suspense>
<footer className="site-footer">
<div className="site-footer-copy">
<p>
wowcheckout.com - Non-custodial Wownero checkout software. Open source. Self-hostable.
</p>
<p>
<Link
className="underline underline-offset-4"
href="https://github.com/wowcheckout/wowcheckout"
>
GitHub
</Link>
</p>
</div>
<Suspense fallback={null}>
<DonateModal />
</Suspense>
</footer>
</div>
);
}
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
import type { Metadata } from "next";
import Link from "next/link";
import { LoginTrigger } from "../../components/login-modal";
export const metadata: Metadata = {
title: "Home",
};
export default function MarketingHomePage() {
return (
<main className="text-ink">
<section className="relative overflow-hidden px-[6vw] pb-16 pt-10 sm:pb-20">
<div className="grid items-center gap-12 lg:grid-cols-[1.1fr_0.9fr]">
<div className="grid max-w-[38rem] gap-6 animate-[rise_0.9s_ease_both]">
<h1 className="font-serif text-[clamp(2.6rem,2.2rem+2.3vw,4.3rem)] leading-[1.02]">
Accept Wownero payments. Funds go straight to your wallet.
</h1>
<p className="text-[1.08rem] leading-relaxed text-ink-soft">
Create invoices and detect on-chain payments using your primary address + secret view key.
Get a clear status as payments are seen and confirmed.
</p>
<ul className="grid gap-3 text-sm text-ink-soft">
<li className="flex items-start gap-3">
<span className="mt-1.5 h-2 w-2 rounded-full bg-wownero"></span>
<span>
View-only access: we can only detect incoming payments.
</span>
</li>
<li className="flex items-start gap-3">
<span className="mt-1.5 h-2 w-2 rounded-full bg-wownero"></span>
<span>
No account or email: your primary address is the identifier.
</span>
</li>
<li className="flex items-start gap-3">
<span className="mt-1.5 h-2 w-2 rounded-full bg-wownero"></span>
<span>BTCPay-compatible invoice endpoints for existing integrations.</span>
</li>
</ul>
<div className="grid gap-2">
<p className="inline-flex w-fit items-center rounded-full bg-wownero px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-ink shadow-[0_12px_24px_rgba(242,104,34,0.25)]">
Non-custodial · view-only access
</p>
<p className="text-sm text-ink-soft">
We can’t spend from your wallet. Payments go straight to your wallet.
</p>
</div>
<p className="text-sm font-semibold text-ink-soft">
Open source. Self-hostable.
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Link
className="inline-flex w-full items-center justify-center rounded-full bg-ink px-8 py-4 text-base font-semibold text-cream shadow-[0_18px_34px_rgba(16,18,23,0.2)] transition hover:-translate-y-0.5 sm:w-auto"
href="/login"
>
Log in
</Link>
<Link
className="inline-flex w-full items-center justify-center rounded-full border border-stroke bg-white/60 px-6 py-3 text-sm font-semibold text-ink transition hover:-translate-y-0.5 sm:w-auto"
href="/docs"
>
View documentation
</Link>
<Link
className="inline-flex w-full items-center justify-center rounded-full border border-stroke bg-white/60 px-6 py-3 text-sm font-semibold text-ink transition hover:-translate-y-0.5 sm:w-auto"
href="https://github.com/wowcheckout/wowcheckout#self-hosted-deployment-docker-compose"
target="_blank"
rel="noreferrer"
>
Self-host
</Link>
</div>
<p className="text-sm text-ink-soft">
Prefer to look around first?{" "}
<Link className="font-semibold text-ink underline underline-offset-4" href="/tour">
Take the tour
</Link>{" "}
(simulated, no login).
</p>
</div>
<div className="relative grid gap-4">
<div className="absolute -left-6 top-4 hidden h-[calc(100%-2rem)] w-px bg-gradient-to-b from-wownero/0 via-wownero/60 to-wownero/0 lg:block"></div>
<div className="rounded-3xl border border-stroke bg-card p-7 shadow-card backdrop-blur animate-[rise_1s_ease_both] [animation-delay:150ms]">
<div className="flex items-center justify-between">
<span className="rounded-full bg-wownero/15 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-wownero">
Detection
</span>
<span className="text-sm font-semibold text-sage">
View-only
</span>
</div>
<div className="mt-6 grid gap-4">
<p className="text-2xl font-semibold">
Detection timeline
</p>
<div className="relative grid gap-3 pl-6 text-sm text-ink-soft">
<span className="timeline-rail" aria-hidden="true"></span>
<span className="timeline-scan" aria-hidden="true"></span>
<div className="flex items-center gap-3">
<span className="timeline-dot is-active h-2.5 w-2.5 rounded-full bg-wownero"></span>
<span className="font-semibold text-ink">
Detected
</span>
<span className="text-ink-soft">Seen on the network (unconfirmed)</span>
</div>
<div className="flex items-center gap-3">
<span className="h-2.5 w-2.5 rounded-full bg-wownero/60"></span>
<span className="font-semibold text-ink">
Confirming
</span>
<span className="text-ink-soft">
Waiting for confirmations
</span>
</div>
<div className="flex items-center gap-3">
<span className="h-2.5 w-2.5 rounded-full bg-ink/20"></span>
<span className="font-semibold text-ink">
Confirmed
</span>
<span className="text-ink-soft">Target reached</span>
</div>
</div>
<p className="text-sm text-ink-soft">
First seen on-chain, then confirmed when it reaches your target.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-2 sm:gap-4">
<div className="rounded-2xl border border-stroke bg-white/70 p-4 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both] [animation-delay:260ms] sm:p-5">
<p className="text-[0.7rem] uppercase tracking-[0.18em] text-ink-soft sm:text-[0.75rem]">
View-only
</p>
<h3 className="mt-2 font-serif text-base sm:text-xl">
Watch-only by design.
</h3>
<p className="mt-2 text-xs text-ink-soft sm:text-sm">
View key access lets us detect payments - nothing more.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-4 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both] [animation-delay:320ms] sm:p-5">
<p className="text-[0.7rem] uppercase tracking-[0.18em] text-ink-soft sm:text-[0.75rem]">
Relay
</p>
<h3 className="mt-2 font-serif text-base sm:text-xl">
Status updates via API + webhooks.
</h3>
<p className="mt-2 text-xs text-ink-soft sm:text-sm">
Keep your systems updated without handing over control.
</p>
</div>
</div>
</div>
</div>
</section>
<section id="how" className="px-[6vw] py-16">
<div className="mb-10 grid gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-wownero">
When someone pays
</p>
<h2 className="font-serif text-3xl">
What happens, step by step.
</h2>
<p className="text-ink-soft">
You stay in control of the wallet. This just detects and reports what is on-chain.
</p>
</div>
<div className="grid gap-7 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both]">
<span className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-wownero/15 text-sm font-semibold text-wownero">
01
</span>
<h3 className="mb-2 font-serif text-xl">
Connect with a view key
</h3>
<p className="text-ink-soft">
Use your primary address and secret view key. No spend keys.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both] [animation-delay:100ms]">
<span className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-wownero/15 text-sm font-semibold text-wownero">
02
</span>
<h3 className="mb-2 font-serif text-xl">Create an invoice</h3>
<p className="text-ink-soft">
A unique subaddress is generated per invoice.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both] [animation-delay:200ms]">
<span className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-wownero/15 text-sm font-semibold text-wownero">
03
</span>
<h3 className="mb-2 font-serif text-xl">Wait for payment</h3>
<p className="text-ink-soft">
We watch the chain (view-only) for incoming payments.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both] [animation-delay:300ms]">
<span className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-wownero/15 text-sm font-semibold text-wownero">
04
</span>
<h3 className="mb-2 font-serif text-xl">Send status updates</h3>
<p className="text-ink-soft">
Webhooks fire when a payment is detected and when it reaches your confirmation target.
</p>
</div>
</div>
</section>
<section id="trust" className="px-[6vw] py-16">
<div className="mb-10 grid gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-wownero">
Trust
</p>
<h2 className="font-serif text-3xl">
What we can (and can’t) do.
</h2>
</div>
<div className="grid gap-7 lg:grid-cols-3">
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both] lg:col-span-2">
<div className="grid gap-6 sm:grid-cols-2">
<div>
<h3 className="mb-2 font-serif text-xl">What we do</h3>
<ul className="grid gap-2 text-sm text-ink-soft">
<li>Create invoices and generate subaddresses.</li>
<li>Detect on-chain payments with view-only keys.</li>
<li>Send status updates via API and webhooks.</li>
</ul>
</div>
<div>
<h3 className="mb-2 font-serif text-xl">What we never do</h3>
<ul className="grid gap-2 text-sm text-ink-soft">
<li>Never request spend keys or signing access.</li>
<li>Never move, pool, or intermediate funds.</li>
<li>Never touch bank accounts or fiat rails.</li>
</ul>
</div>
</div>
</div>
<div className="grid gap-7">
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both] [animation-delay:100ms]">
<h3 className="mb-2 font-serif text-xl">View-only boundary</h3>
<p className="text-ink-soft">
We only ask for a secret view key. That lets us detect payments - nothing more.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both] [animation-delay:200ms]">
<h3 className="mb-2 font-serif text-xl">Safe on failure</h3>
<p className="text-ink-soft">
If the service is unavailable, funds remain safe and payments stay valid on-chain.
</p>
</div>
</div>
</div>
</section>
<section id="cta" className="px-[6vw] pb-24 pt-10">
<div className="relative grid gap-5 overflow-hidden rounded-3xl bg-ink p-10 text-cream shadow-deep">
<div className="pointer-events-none absolute inset-0">
<div className="absolute -right-10 -top-10 h-48 w-48 rounded-full bg-[radial-gradient(circle,rgba(242,104,34,0.5),transparent_70%)] blur-2xl"></div>
<div className="absolute bottom-[-120px] left-[-40px] h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(93,122,106,0.4),transparent_70%)] blur-2xl"></div>
</div>
<h2 className="font-serif text-3xl">
Ready to try it?
</h2>
<p className="text-cream/80">
Log in to create an invoice, or read the docs to integrate via API and webhooks.
</p>
<div className="flex flex-wrap gap-3">
<LoginTrigger
className="inline-flex items-center justify-center rounded-full border border-cream/40 px-6 py-3 text-sm font-semibold text-cream transition hover:-translate-y-0.5"
>
Log in
</LoginTrigger>
<Link
className="inline-flex items-center justify-center rounded-full border border-cream/40 px-6 py-3 text-sm font-semibold text-cream transition hover:-translate-y-0.5"
href="/docs"
>
Read the docs
</Link>
</div>
<p className="text-sm text-cream/70">
Want to preview the dashboard first?{" "}
<Link className="font-semibold text-cream underline underline-offset-4" href="/tour">
Take the tour
</Link>{" "}
(simulated).
</p>
</div>
</section>
</main>
);
}
+418
View File
@@ -0,0 +1,418 @@
import type { Metadata } from "next";
import Link from "next/link";
import InvoicePanel from "../../../components/invoice-panel";
import WebhookHistoryPanel from "../../../components/webhook-history-panel";
import WebhookSection from "../../../components/webhook-section";
export const metadata: Metadata = {
title: "Tour",
};
const tabs = ["overview", "invoices", "webhooks"] as const;
const webhookTabs = ["settings", "history"] as const;
type TourTab = (typeof tabs)[number];
type WebhookTab = (typeof webhookTabs)[number];
type TourSearchParams = Record<string, string | string[] | undefined>;
type InvoiceSummary = {
id: string;
address: string;
subaddress_index?: number | null;
amount_xmr: string;
status: "pending" | "payment_detected" | "confirmed" | "expired" | "invalid";
confirmation_target: number;
paid_after_expiry?: boolean;
paid_after_expiry_at?: string | null;
metadata?: Record<string, string> | null;
created_at: string;
archived_at: string | null;
detected_at: string | null;
confirmed_at: string | null;
expires_at: string | null;
};
type WebhookSummary = {
id: string;
url: string;
events: string[];
event_urls?: Record<string, string> | null;
active: boolean;
created_at: string;
};
type WebhookDeliverySummary = {
id: string;
webhook_id: string | null;
event: string;
url: string;
invoice_id: string | null;
invoice_address: string | null;
invoice_subaddress_index: number | null;
invoice_amount_xmr: string | null;
invoice_status:
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid"
| null;
http_status: number | null;
error_message: string | null;
created_at: string;
};
const buildNowFixture = () => {
const now = Date.now();
const iso = (offsetMs: number) => new Date(now + offsetMs).toISOString();
const exampleAddress =
"4DEMO_ADDRESS_EXAMPLE_ONLY_DO_NOT_USE_FOR_PAYMENTS_XXXXXXXXXXXXXXXXXXXXXX";
const invoices: InvoiceSummary[] = [
{
id: "demo_inv_pending",
address: `${exampleAddress}_001`,
subaddress_index: 12,
amount_xmr: "0.125",
status: "pending",
confirmation_target: 2,
created_at: iso(-35 * 60 * 1000),
archived_at: null,
detected_at: null,
confirmed_at: null,
expires_at: iso(25 * 60 * 1000),
metadata: { recipient_name: "Example customer", description: "Order #1027" },
},
{
id: "demo_inv_detected",
address: `${exampleAddress}_002`,
subaddress_index: 13,
amount_xmr: "0.420",
status: "payment_detected",
confirmation_target: 2,
created_at: iso(-2 * 60 * 60 * 1000),
archived_at: null,
detected_at: iso(-18 * 60 * 1000),
confirmed_at: null,
expires_at: iso(55 * 60 * 1000),
metadata: { description: "Subscription renewal" },
},
{
id: "demo_inv_confirmed",
address: `${exampleAddress}_003`,
subaddress_index: 14,
amount_xmr: "1.000",
status: "confirmed",
confirmation_target: 1,
created_at: iso(-26 * 60 * 60 * 1000),
archived_at: null,
detected_at: iso(-25.5 * 60 * 60 * 1000),
confirmed_at: iso(-25.3 * 60 * 60 * 1000),
expires_at: iso(-25 * 60 * 60 * 1000),
metadata: { recipient_name: "ACME Inc.", description: "Invoice #8841" },
},
{
id: "demo_inv_expired",
address: `${exampleAddress}_004`,
subaddress_index: 15,
amount_xmr: "0.050",
status: "expired",
confirmation_target: 2,
created_at: iso(-5 * 24 * 60 * 60 * 1000),
archived_at: iso(-4.5 * 24 * 60 * 60 * 1000),
detected_at: null,
confirmed_at: null,
expires_at: iso(-4.8 * 24 * 60 * 60 * 1000),
metadata: { description: "Abandoned checkout" },
},
];
const webhooks: WebhookSummary[] = [
{
id: "demo_hook_1",
url: "https://example.com/wowcheckout/webhook",
events: ["invoice.created", "invoice.payment_detected", "invoice.confirmed"],
event_urls: { "invoice.expired": "https://example.com/wowcheckout/invoice-expired" },
active: true,
created_at: iso(-9 * 24 * 60 * 60 * 1000),
},
];
const deliveries: WebhookDeliverySummary[] = [
{
id: "demo_delivery_1",
webhook_id: "demo_hook_1",
event: "invoice.payment_detected",
url: "https://example.com/wowcheckout/webhook",
invoice_id: "demo_inv_detected",
invoice_address: `${exampleAddress}_002`,
invoice_subaddress_index: 13,
invoice_amount_xmr: "0.420",
invoice_status: "payment_detected",
http_status: 200,
error_message: null,
created_at: iso(-17 * 60 * 1000),
},
{
id: "demo_delivery_2",
webhook_id: "demo_hook_1",
event: "invoice.confirmed",
url: "https://example.com/wowcheckout/webhook",
invoice_id: "demo_inv_confirmed",
invoice_address: `${exampleAddress}_003`,
invoice_subaddress_index: 14,
invoice_amount_xmr: "1.000",
invoice_status: "confirmed",
http_status: 500,
error_message: "Upstream returned 500",
created_at: iso(-25.2 * 60 * 60 * 1000),
},
];
return {
paymentAddress: exampleAddress,
defaultConfirmationTarget: 2,
invoices,
webhooks,
deliveries,
};
};
export default async function TourPage({
searchParams,
}: {
searchParams: Promise<TourSearchParams>;
}) {
const resolvedSearchParams = await searchParams;
const tabParam = resolvedSearchParams?.tab;
const activeTabValue = Array.isArray(tabParam) ? tabParam[0] : tabParam;
const activeTab: TourTab = tabs.includes(activeTabValue as TourTab)
? (activeTabValue as TourTab)
: "overview";
const webhookTabParam = resolvedSearchParams?.webhook_tab;
const activeWebhookTabValue = Array.isArray(webhookTabParam)
? webhookTabParam[0]
: webhookTabParam;
const activeWebhookTab: WebhookTab = webhookTabs.includes(
activeWebhookTabValue as WebhookTab
)
? (activeWebhookTabValue as WebhookTab)
: "settings";
const includeArchived =
activeTab === "invoices" &&
(resolvedSearchParams?.archived === "1" ||
resolvedSearchParams?.archived === "true");
const qParam = resolvedSearchParams?.q;
const queryValue = Array.isArray(qParam) ? qParam[0] : qParam;
const invoiceSearchQuery = activeTab === "invoices" ? (queryValue ?? "") : "";
const sortParam = resolvedSearchParams?.sort;
const sortValue = Array.isArray(sortParam) ? sortParam[0] : sortParam;
const invoiceSort =
activeTab === "invoices" && typeof sortValue === "string" && sortValue
? sortValue
: "created_at";
const orderParam = resolvedSearchParams?.order;
const orderValue = Array.isArray(orderParam) ? orderParam[0] : orderParam;
const invoiceOrder =
activeTab === "invoices" && typeof orderValue === "string" && orderValue
? orderValue
: "desc";
const fixture = buildNowFixture();
const invoices = includeArchived
? fixture.invoices
: fixture.invoices.filter((invoice) => !invoice.archived_at);
const isToday = (value: string) => {
const date = new Date(value);
const today = new Date();
return (
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate()
);
};
const invoicesTodayCount = invoices.filter((invoice) => isToday(invoice.created_at)).length;
const awaitingConfirmationCount = invoices.filter(
(invoice) => invoice.status === "payment_detected"
).length;
const confirmedCount = invoices.filter((invoice) => invoice.status === "confirmed").length;
const tabBaseClass =
"inline-flex items-center rounded-xl border border-stroke bg-white/70 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:-translate-y-0.5";
const tabActiveClass =
"border-ink bg-ink text-cream shadow-[0_12px_24px_rgba(16,18,23,0.18)]";
const subTabBaseClass =
"inline-flex items-center rounded-full border border-stroke bg-white/70 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:-translate-y-0.5";
const subTabActiveClass =
"border-ink bg-ink text-cream shadow-[0_10px_18px_rgba(16,18,23,0.18)]";
return (
<main className="px-[6vw] py-12 text-ink">
<section className="mx-auto grid w-full max-w-6xl gap-4">
<div className="rounded-2xl border border-stroke bg-white/80 px-5 py-3 shadow-soft backdrop-blur">
<p className="text-sm font-semibold text-ink">
Tour mode uses simulated data. No wallet connection. No changes are saved.
</p>
</div>
</section>
<section className="mx-auto mt-6 grid w-full max-w-6xl gap-8 lg:grid-cols-[260px_minmax(0,1fr)]">
<aside className="grid gap-6">
<nav className="flex flex-col gap-2" aria-label="Tour sections">
{tabs.map((tab) => (
<Link
key={tab}
className={`${tabBaseClass} ${activeTab === tab ? tabActiveClass : ""}`}
href={`/tour?tab=${tab}`}
>
{tab}
</Link>
))}
</nav>
</aside>
<div className="grid gap-6">
{activeTab === "overview" ? (
<div className="rounded-2xl border border-stroke bg-white/80 p-8 shadow-card backdrop-blur">
<h1 className="font-serif text-3xl">Operational overview.</h1>
<p className="mt-2 text-ink-soft">
Track invoice flow and confirmation progress at a glance.
</p>
<div className="mt-6 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Primary address (example)
</p>
<p className="mt-3 break-all font-mono text-sm text-ink">
{fixture.paymentAddress}
</p>
<p className="mt-2 text-sm text-ink-soft">
This is an example address shown for the tour.
</p>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-3">
{[
{
title: "Invoices today",
value: invoicesTodayCount.toString(),
detail: "Totals update as invoices are created.",
},
{
title: "Invoices awaiting confirmation",
value: awaitingConfirmationCount.toString(),
detail: "Waiting for the configured confirmation target.",
},
{
title: "Confirmed invoices",
value: confirmedCount.toString(),
detail: "Confirmed on-chain invoices display here.",
},
].map((item) => (
<div
key={item.title}
className="rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft"
>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
{item.title}
</p>
<h2 className="mt-3 text-2xl font-semibold">{item.value}</h2>
<p className="mt-2 text-sm text-ink-soft">{item.detail}</p>
</div>
))}
</div>
<div className="mt-4 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Webhook endpoints
</p>
{fixture.webhooks.length === 0 ? (
<p className="mt-3 text-sm text-ink-soft">
No webhooks configured yet.{" "}
<Link className="font-semibold text-ink underline" href="/tour?tab=webhooks">
Add a webhook endpoint.
</Link>
</p>
) : (
<div className="mt-3 grid gap-3 text-sm text-ink">
{fixture.webhooks.map((webhook) => (
<div
className="rounded-xl border border-stroke bg-white/80 px-4 py-3"
key={webhook.id}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="break-all font-semibold">{webhook.url}</p>
<span className="rounded-full border border-stroke bg-white/60 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
{webhook.active ? "Active" : "Paused"}
</span>
</div>
<p className="mt-2 text-xs text-ink-soft">
Events: {webhook.events.join(", ")}
</p>
</div>
))}
</div>
)}
</div>
<div className="mt-5 rounded-2xl border border-ink/10 bg-ink/10 px-4 py-3 text-sm font-semibold text-ink">
We never hold funds. All payments move from the customer to your wallet.
</div>
</div>
) : null}
{activeTab === "invoices" ? (
<InvoicePanel
mode="tour"
basePath="/tour"
activeInvoices={invoices}
includeArchived={includeArchived}
searchQuery={invoiceSearchQuery}
sort={invoiceSort}
order={invoiceOrder}
defaultConfirmationTarget={fixture.defaultConfirmationTarget}
/>
) : null}
{activeTab === "webhooks" ? (
<div className="rounded-2xl border border-stroke bg-white/80 p-8 shadow-card backdrop-blur">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="font-serif text-3xl">Webhook endpoints.</h1>
<p className="mt-2 text-ink-soft">
Relay invoice state updates to your systems. Optional overrides let
you target different URLs per event.
</p>
</div>
</div>
<nav className="mt-6 flex flex-wrap gap-2" aria-label="Webhook views">
{webhookTabs.map((tab) => (
<Link
key={tab}
className={`${subTabBaseClass} ${
activeWebhookTab === tab ? subTabActiveClass : ""
}`}
href={`/tour?tab=webhooks&webhook_tab=${tab}`}
>
{tab}
</Link>
))}
</nav>
{activeWebhookTab === "settings" ? (
<div className="mt-6">
<WebhookSection mode="tour" webhooks={fixture.webhooks} />
</div>
) : null}
{activeWebhookTab === "history" ? (
<div className="mt-6">
<WebhookHistoryPanel mode="tour" deliveries={fixture.deliveries} />
</div>
) : null}
</div>
) : null}
</div>
</section>
</main>
);
}
+22
View File
@@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
}
img {
max-width: 100%;
display: block;
}
+295
View File
@@ -0,0 +1,295 @@
import type { Metadata } from "next";
import BtcpayModalBridge from "../../../components/btcpay-modal-bridge";
import BtcpayClassicCheckout from "../../../components/btcpay-classic-checkout";
import InvoicePaymentDetails from "../../../components/invoice-payment-details";
import InvoiceStatusAutoRefresh from "../../../components/invoice-status-auto-refresh";
import { formatRelativeTime } from "../../../components/relative-time";
import StatusRefreshButton from "../../../components/status-refresh-button";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Payment Request",
};
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
type InvoiceStatus =
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid";
type InvoiceStatusResponse = {
id: string;
address: string;
subaddress_index: number | null;
amount_xmr: string;
amount_paid_xmr?: string | null;
status: InvoiceStatus;
confirmation_target: number;
confirmations: number;
checkout_continue_available?: boolean;
created_at: string;
expires_at: string | null;
detected_at: string | null;
confirmed_at: string | null;
btcpay_amount?: string | null;
btcpay_currency?: string | null;
btcpay_checkout_style?: "standard" | "btcpay_classic" | null;
btcpay_redirect_url?: string | null;
btcpay_redirect_automatically?: boolean | null;
btcpay_order_id?: string | null;
btcpay_order_number?: string | null;
qr_logo?: "monero" | "none" | "custom" | null;
qr_logo_data_url?: string | null;
quote?: {
fiat_amount: string;
fiat_currency: string;
rate: string;
source: string;
quoted_at: string;
} | null;
};
const formatStatus = (status: InvoiceStatus) => {
if (status === "payment_detected") {
return "Payment detected";
}
if (status === "pending") {
return "Awaiting funds";
}
if (status === "confirmed") {
return "Confirmed";
}
if (status === "invalid") {
return "Invalid";
}
return "Expired";
};
const formatTimestamp = (value: string | null) => {
if (!value) {
return { label: "-", relative: null };
}
return {
label: new Date(value).toLocaleString(),
relative: formatRelativeTime(value),
};
};
const statusPillStyles: Record<InvoiceStatus, string> = {
pending: "bg-amber-100 text-amber-900",
payment_detected: "bg-emerald-100 text-emerald-900",
confirmed: "bg-ink/20 text-ink",
expired: "bg-red-100 text-red-700",
invalid: "bg-clay/15 text-clay",
};
export default async function BtcpayModalInvoicePage({
params,
}: {
params: Promise<{ invoiceId: string }>;
}) {
const { invoiceId } = await params;
const response = await fetch(
`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`,
{ cache: "no-store" }
);
if (response.status === 404) {
return (
<main className="min-h-screen bg-cream px-6 py-6 text-ink">
<BtcpayModalBridge invoiceId={invoiceId} status="invalid" />
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Payment request
</p>
<h1 className="mt-2 font-serif text-2xl">Invoice not found</h1>
</div>
</div>
<p className="mt-3 text-sm text-ink-soft">
The invoice id does not match a known invoice. Check the id and try
again.
</p>
</main>
);
}
if (!response.ok) {
return (
<main className="min-h-screen bg-cream px-6 py-6 text-ink">
<BtcpayModalBridge invoiceId={invoiceId} status="invalid" />
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Payment request
</p>
<h1 className="mt-2 font-serif text-2xl">Status unavailable</h1>
</div>
</div>
<p className="mt-3 text-sm text-ink-soft">
We could not load this invoice status. Refresh the page or try again
later.
</p>
</main>
);
}
const invoice = (await response.json()) as InvoiceStatusResponse;
const statusLabel = formatStatus(invoice.status);
const confirmations = Math.max(0, invoice.confirmations ?? 0);
const confirmationTarget = Math.max(0, invoice.confirmation_target);
const hasDetectedPayment =
invoice.status === "payment_detected" || invoice.status === "confirmed";
const createdTimestamp = formatTimestamp(invoice.created_at);
const expiresTimestamp = formatTimestamp(invoice.expires_at);
const isBtcpayInvoice = Boolean(invoice.btcpay_amount && invoice.btcpay_currency);
const useClassicCheckout =
isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic";
const shouldShowBtcpayActions = isBtcpayInvoice && invoice.status === "confirmed";
const checkoutContinueAvailable =
!isBtcpayInvoice &&
invoice.status === "confirmed" &&
Boolean(invoice.checkout_continue_available);
return (
<main className="min-h-screen bg-cream px-6 py-6 text-ink">
<InvoiceStatusAutoRefresh intervalMs={30000} />
<BtcpayModalBridge invoiceId={invoiceId} status={invoice.status} />
{useClassicCheckout ? (
<div className="mx-auto grid max-w-md gap-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Payment request
</p>
</div>
<div className="flex items-center gap-2">
<StatusRefreshButton label="Refresh" className="text-xs" />
</div>
</div>
<BtcpayClassicCheckout
invoiceId={invoiceId}
address={invoice.address}
amountXmr={invoice.amount_xmr}
amountPaidXmr={invoice.amount_paid_xmr ?? null}
btcpayAmount={invoice.btcpay_amount ?? null}
btcpayCurrency={invoice.btcpay_currency ?? null}
quote={invoice.quote ?? null}
status={invoice.status}
confirmationTarget={confirmationTarget}
redirectUrl={invoice.btcpay_redirect_url ?? null}
redirectAutomatically={invoice.btcpay_redirect_automatically ?? null}
orderId={invoice.btcpay_order_id ?? null}
orderNumber={invoice.btcpay_order_number ?? null}
qrLogoMode={invoice.qr_logo ?? "monero"}
qrLogoDataUrl={invoice.qr_logo_data_url ?? null}
/>
</div>
) : (
<>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Payment request
</p>
<h1 className="mt-2 font-serif text-2xl">Payment request</h1>
</div>
<div className="flex items-center gap-2">
<StatusRefreshButton label="Refresh" className="text-xs" />
</div>
</div>
<div className="mt-4 rounded-2xl border border-stroke bg-card p-5 shadow-card backdrop-blur">
<div className="flex items-center justify-between gap-3">
<span className="rounded-full bg-ink/10 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
Status
</span>
<span
className={`rounded-full px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] ${statusPillStyles[invoice.status]}`}
>
{statusLabel}
</span>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Confirmations
</p>
<p className="mt-1 text-lg font-semibold">
{confirmations}/{confirmationTarget}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Created
</p>
<p
className="mt-1 text-sm font-semibold"
title={createdTimestamp.relative ?? undefined}
>
{createdTimestamp.label}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Expires
</p>
<p
className="mt-1 text-sm font-semibold"
title={expiresTimestamp.relative ?? undefined}
>
{expiresTimestamp.label}
</p>
</div>
</div>
{checkoutContinueAvailable ? (
<a
className="mt-5 inline-flex w-fit items-center justify-center rounded-full bg-ink px-5 py-2 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
href={`/api/core/public/invoice/${encodeURIComponent(invoiceId)}/continue`}
rel="noreferrer"
>
Continue to merchant
</a>
) : null}
</div>
<div className="mt-5">
<InvoicePaymentDetails
address={invoice.address}
amount={invoice.amount_xmr}
hasDetectedPayment={hasDetectedPayment}
status={invoice.status}
confirmationTarget={confirmationTarget}
qrLogoMode={invoice.qr_logo ?? "monero"}
qrLogoDataUrl={invoice.qr_logo_data_url ?? null}
/>
</div>
{shouldShowBtcpayActions ? (
<div className="mt-6 grid gap-3">
<a
className="inline-flex items-center justify-center rounded-full bg-sage px-6 py-3 text-sm font-semibold text-cream shadow-[0_14px_22px_rgba(93,122,106,0.25)] transition hover:-translate-y-0.5"
href={`/i/${encodeURIComponent(invoiceId)}/receipt`}
>
View receipt
</a>
{invoice.btcpay_redirect_url ? (
<a
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white px-6 py-3 text-sm font-semibold text-sage transition hover:bg-cream"
href={invoice.btcpay_redirect_url}
target="_top"
rel="noreferrer"
>
Return to store
</a>
) : null}
</div>
) : null}
</>
)}
</main>
);
}
+276
View File
@@ -0,0 +1,276 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import BtcpayModalBridge from "../../../../components/btcpay-modal-bridge";
import CopyIconButton from "../../../../components/copy-icon-button";
import PrintButton from "../../../../components/print-button";
import { formatRelativeTime } from "../../../../components/relative-time";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Receipt",
};
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
type InvoiceStatus =
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid";
type InvoiceStatusResponse = {
id: string;
address: string;
amount_xmr: string;
amount_paid_xmr?: string | null;
status: InvoiceStatus;
confirmation_target: number;
confirmations: number;
created_at: string;
confirmed_at: string | null;
btcpay_amount?: string | null;
btcpay_currency?: string | null;
btcpay_redirect_url?: string | null;
btcpay_order_id?: string | null;
btcpay_order_number?: string | null;
quote?: {
fiat_amount: string;
fiat_currency: string;
rate: string;
source: string;
quoted_at: string;
} | null;
qr_url?: string | null;
};
const formatFiatAmount = (value: string, currency: string) => {
const amount = Number.parseFloat(value);
if (!Number.isFinite(amount)) {
return `${value} ${currency}`;
}
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
maximumFractionDigits: 2,
minimumFractionDigits: 2,
}).format(amount);
} catch {
return `${amount.toFixed(2)} ${currency}`;
}
};
const receiptTitle = (redirectUrl: string | null | undefined) => {
if (!redirectUrl) {
return "Receipt";
}
try {
const parsed = new URL(redirectUrl);
return parsed.hostname ? `${parsed.hostname}` : "Receipt";
} catch {
return "Receipt";
}
};
const resolveQrSrc = (invoiceId: string, qrUrl: string | null | undefined) => {
if (!qrUrl) {
return `/qr/${encodeURIComponent(invoiceId)}.png`;
}
if (qrUrl.startsWith("/qr/")) {
return qrUrl;
}
try {
const parsed = new URL(qrUrl);
if (parsed.pathname.startsWith("/qr/")) {
return parsed.pathname;
}
} catch {
// Ignore malformed URLs and fall back to the provided value.
}
return qrUrl;
};
const closeLinkClassName =
"rounded-md border border-neutral-200 bg-white px-3 py-2 text-xs font-semibold text-neutral-700 transition hover:bg-neutral-50";
export default async function BtcpayReceiptPage({
params,
}: {
params: Promise<{ invoiceId: string }>;
}) {
const { invoiceId } = await params;
const requestHeaders = await headers();
const forwardedProto = requestHeaders.get("x-forwarded-proto");
const forwardedHost = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host");
const response = await fetch(
`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`,
{
cache: "no-store",
headers: {
...(forwardedProto ? { "x-forwarded-proto": forwardedProto } : {}),
...(forwardedHost ? { "x-forwarded-host": forwardedHost } : {}),
},
}
);
if (response.status === 404) {
return (
<main className="min-h-screen bg-white px-4 py-10 text-ink">
<BtcpayModalBridge invoiceId={invoiceId} status="invalid" />
<div className="mx-auto w-full max-w-4xl">
<div className="flex justify-end">
<a className={closeLinkClassName} href={`/i/${encodeURIComponent(invoiceId)}`}>
Close
</a>
</div>
<div className="mt-6 text-center">
<h1 className="text-3xl font-semibold">Invoice not found</h1>
<p className="mt-3 text-sm text-ink-soft">
The invoice id does not match a known invoice. Check the id and try again.
</p>
</div>
</div>
</main>
);
}
if (!response.ok) {
return (
<main className="min-h-screen bg-white px-4 py-10 text-ink">
<BtcpayModalBridge invoiceId={invoiceId} status="invalid" />
<div className="mx-auto w-full max-w-4xl">
<div className="flex justify-end">
<a className={closeLinkClassName} href={`/i/${encodeURIComponent(invoiceId)}`}>
Close
</a>
</div>
<div className="mt-6 text-center">
<h1 className="text-3xl font-semibold">Status unavailable</h1>
<p className="mt-3 text-sm text-ink-soft">
We could not load this invoice status. Refresh the page or try again later.
</p>
</div>
</div>
</main>
);
}
const invoice = (await response.json()) as InvoiceStatusResponse;
const orderId = invoice.btcpay_order_number ?? invoice.btcpay_order_id ?? null;
const receiptTimestamp = invoice.confirmed_at ?? invoice.created_at;
const timestampLabel = new Date(receiptTimestamp).toLocaleString();
const timestampRelative = formatRelativeTime(receiptTimestamp);
const totalFiat =
invoice.btcpay_amount && invoice.btcpay_currency && invoice.btcpay_currency !== "WOW"
? formatFiatAmount(invoice.btcpay_amount, invoice.btcpay_currency)
: invoice.btcpay_amount && invoice.btcpay_currency
? `${invoice.btcpay_amount} ${invoice.btcpay_currency}`
: null;
const paidXmr = invoice.amount_paid_xmr ?? invoice.amount_xmr;
const qrSrc = resolveQrSrc(invoiceId, invoice.qr_url);
const storeTitle = receiptTitle(invoice.btcpay_redirect_url);
const amountPaidLabel = totalFiat ? totalFiat : `${paidXmr} WOW`;
const returnLabel = invoice.btcpay_redirect_url ? `Return to ${storeTitle}` : "Return to invoice";
const returnHref = invoice.btcpay_redirect_url ?? `/i/${encodeURIComponent(invoiceId)}`;
return (
<main className="min-h-screen bg-neutral-100 px-4 py-10 text-neutral-900">
<BtcpayModalBridge invoiceId={invoiceId} status={invoice.status} />
<div className="mx-auto w-full max-w-4xl">
<header className="text-center">
<h1 className="text-3xl font-semibold tracking-tight">{storeTitle}</h1>
</header>
{invoice.status !== "confirmed" ? (
<div className="mt-6 text-center">
{orderId ? <p className="mt-3 text-lg">Order ID: {orderId}</p> : null}
<p className="mt-1 text-base text-neutral-600" title={timestampRelative ?? undefined}>
{timestampLabel}
</p>
<p className="mt-10 text-sm text-neutral-600">
This receipt is available after the payment is confirmed.
</p>
</div>
) : (
<>
<div className="mt-8 rounded-xl bg-white p-6 shadow-soft">
<div className="grid gap-6 md:grid-cols-[auto_minmax(0,1fr)] md:items-start">
<div className="rounded-lg bg-neutral-100 p-4">
{qrSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img alt="Payment QR" src={qrSrc} className="h-56 w-56" />
) : null}
</div>
<div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
<div className="grid gap-5">
<div>
<p className="text-sm font-semibold text-neutral-500">Amount Paid</p>
<p className="mt-1 text-3xl font-semibold">{amountPaidLabel}</p>
</div>
<div>
<p className="text-sm font-semibold text-neutral-500">Date</p>
<p className="mt-1 text-base" title={timestampRelative ?? undefined}>
{timestampLabel}
</p>
</div>
{orderId ? (
<div>
<p className="text-sm font-semibold text-neutral-500">Order ID</p>
<p className="mt-1 text-base">{orderId}</p>
</div>
) : null}
</div>
<div className="md:self-start">
<PrintButton />
</div>
</div>
</div>
</div>
<section className="mt-8 rounded-xl bg-white p-6 shadow-soft">
<h2 className="text-xl font-semibold">Payment Details</h2>
<div className="mt-6 text-sm text-neutral-500">
<div className="grid grid-cols-3 gap-6 border-b border-neutral-200 pb-3">
<div>Date</div>
<div className="text-right">Paid</div>
<div className="text-right">Payment</div>
</div>
<div className="grid grid-cols-3 gap-6 pt-4 text-neutral-900">
<div title={timestampRelative ?? undefined}>{timestampLabel}</div>
<div className="text-right">{totalFiat ?? "-"}</div>
<div className="text-right">{paidXmr} WOW</div>
</div>
</div>
<div className="mt-7 flex flex-col gap-3 text-sm md:flex-row md:items-center">
<p className="font-semibold text-neutral-500 md:w-28">Destination</p>
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate rounded-md bg-neutral-100 px-3 py-2 font-mono text-xs">
{invoice.address}
</span>
<CopyIconButton value={invoice.address} label="Copy destination address" />
</div>
</div>
</section>
<div className="mt-10 flex justify-center">
<a
className="inline-flex items-center justify-center rounded-full border border-emerald-200 bg-white px-10 py-3 text-sm font-semibold text-emerald-700 shadow-soft transition hover:bg-emerald-50"
href={returnHref}
target={invoice.btcpay_redirect_url ? "_top" : undefined}
rel={invoice.btcpay_redirect_url ? "noreferrer" : undefined}
>
{returnLabel}
</a>
</div>
</>
)}
</div>
</main>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { Crimson_Pro, Space_Grotesk } from "next/font/google";
import "./globals.css";
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
display: "swap",
variable: "--font-sans",
});
const crimsonPro = Crimson_Pro({
subsets: ["latin"],
display: "swap",
variable: "--font-serif",
});
export const metadata: Metadata = {
title: {
default: "WOW Checkout | Home",
template: "WOW Checkout | %s",
},
description: "Non-custodial Wownero checkout with view-only detection.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${spaceGrotesk.variable} ${crimsonPro.variable}`}>
<body>{children}</body>
</html>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { MetadataRoute } from "next";
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL ??
process.env.SITE_URL ??
"http://localhost";
const normalizedSiteUrl = siteUrl.replace(/\/+$/, "");
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/dashboard", "/login", "/api", "/_next", "/invoice/", "/donate/"],
},
sitemap: `${normalizedSiteUrl}/sitemap.xml`,
};
}
+41
View File
@@ -0,0 +1,41 @@
import type { MetadataRoute } from "next";
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL ??
process.env.SITE_URL ??
"http://localhost";
const normalizedSiteUrl = siteUrl.replace(/\/+$/, "");
const donationsEnabled = process.env.NEXT_PUBLIC_DONATIONS_ENABLED === "true";
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date();
const entries: MetadataRoute.Sitemap = [
{
url: `${normalizedSiteUrl}/`,
lastModified,
},
{
url: `${normalizedSiteUrl}/docs`,
lastModified,
},
{
url: `${normalizedSiteUrl}/docs/integrations`,
lastModified,
},
{
url: `${normalizedSiteUrl}/faq`,
lastModified,
},
{
url: `${normalizedSiteUrl}/invoice`,
lastModified,
},
];
if (donationsEnabled) {
entries.push({
url: `${normalizedSiteUrl}/donate`,
lastModified,
});
}
return entries;
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
type ApiKeyCardProps = {
apiKey: string;
};
export default function ApiKeyCard({ apiKey }: ApiKeyCardProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(apiKey);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
};
return (
<div className="grid gap-4 rounded-2xl border border-stroke bg-white/80 p-6 shadow-soft backdrop-blur">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Your API key
</p>
<code className="mt-2 block break-all rounded-xl bg-ink/10 px-3 py-2 text-sm text-ink">
{apiKey}
</code>
</div>
<div className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
type="button"
onClick={handleCopy}
>
{copied ? "Copied" : "Copy API key"}
</button>
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useFormState } from "react-dom";
import { resetApiKeyAction, type ApiKeyState } from "../app/(app)/dashboard/actions";
import ApiKeyCard from "./api-key-card";
const initialState = (apiKey: string): ApiKeyState => ({
apiKey,
error: null,
});
type ApiKeySectionProps = {
apiKey: string;
};
export default function ApiKeySection({ apiKey }: ApiKeySectionProps) {
const [state, formAction] = useFormState(resetApiKeyAction, initialState(apiKey));
return (
<div className="grid gap-4">
<ApiKeyCard apiKey={state.apiKey} />
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
<form action={formAction} className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
type="submit"
>
Reset API key
</button>
</form>
</div>
);
}
@@ -0,0 +1,92 @@
"use client";
import { useEffect, useState } from "react";
import { useFormState } from "react-dom";
import { useRouter } from "next/navigation";
import {
updateBtcpayCheckoutStyleAction,
type BtcpayCheckoutPreferenceState,
} from "../app/(app)/dashboard/actions";
type BtcpayCheckoutStyle = "standard" | "btcpay_classic";
type BtcpayCheckoutStyleSectionProps = {
initialStyle: BtcpayCheckoutStyle;
};
const initialState = (
style: BtcpayCheckoutStyle
): BtcpayCheckoutPreferenceState => ({
style,
error: null,
success: null,
});
export default function BtcpayCheckoutStyleSection({
initialStyle,
}: BtcpayCheckoutStyleSectionProps) {
const router = useRouter();
const [state, formAction] = useFormState(
updateBtcpayCheckoutStyleAction,
initialState(initialStyle)
);
const [style, setStyle] = useState<BtcpayCheckoutStyle>(initialStyle);
useEffect(() => {
if (state.success) {
router.refresh();
}
}, [router, state.success]);
useEffect(() => {
setStyle(initialStyle);
}, [initialStyle]);
return (
<form action={formAction} className="grid gap-4">
<div>
<h3 className="font-serif text-2xl">BTCPay checkout layout</h3>
<p className="mt-2 text-sm text-ink-soft">
This setting only applies to BTCPay checkouts (invoices created via the
BTCPay compatibility endpoints).
</p>
</div>
<label className="grid gap-2 text-sm font-semibold text-ink">
Layout
<select
name="btcpay_checkout_style"
className="rounded-xl border border-stroke bg-white/80 px-3 py-2 text-sm text-ink"
value={style}
onChange={(event) =>
setStyle(event.target.value as BtcpayCheckoutStyle)
}
>
<option value="standard">Standard WOW Checkout</option>
<option value="btcpay_classic">BTCPay Classic</option>
</select>
</label>
<p className="text-xs text-ink-soft">
The classic layout mirrors the BTCPay checkout screen for drop-in migration.
</p>
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
{state.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{state.success}
</p>
) : null}
<div className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-ink px-5 py-2.5 text-sm font-semibold text-cream transition hover:-translate-y-0.5"
type="submit"
>
Save preference
</button>
</div>
</form>
);
}
+402
View File
@@ -0,0 +1,402 @@
"use client";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import QRCode from "qrcode";
import { formatXmrAmount } from "../lib/formatting";
type InvoiceStatus =
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid";
type QuotePayload = {
fiat_amount: string;
fiat_currency: string;
rate: string;
source: string;
quoted_at: string;
};
type BtcpayClassicCheckoutProps = {
invoiceId: string;
address: string;
amountXmr: string;
amountPaidXmr: string | null;
btcpayAmount: string | null;
btcpayCurrency: string | null;
quote: QuotePayload | null;
status: InvoiceStatus;
confirmationTarget: number;
redirectUrl: string | null;
redirectAutomatically: boolean | null;
orderId: string | null;
orderNumber: string | null;
qrLogoMode?: "monero" | "none" | "custom" | null;
qrLogoDataUrl?: string | null;
};
const buildMoneroUri = (address: string, amount: string) => {
const params = new URLSearchParams();
params.set("tx_amount", amount);
return `wownero:${address}?${params.toString()}`;
};
const formatFiatAmount = (value: string, currency: string) => {
const amount = Number.parseFloat(value);
if (!Number.isFinite(amount)) {
return `${value} ${currency}`;
}
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
maximumFractionDigits: 2,
minimumFractionDigits: 2,
}).format(amount);
} catch {
return `${amount.toFixed(2)} ${currency}`;
}
};
const formatReturnLabel = (redirectUrl: string) => {
try {
const parsed = new URL(redirectUrl);
return parsed.hostname ? `Return to ${parsed.hostname}` : "Return to store";
} catch {
return "Return to store";
}
};
const statusMessage = (
status: InvoiceStatus,
confirmationTarget: number
): string => {
if (status === "invalid") {
return "Invoice marked invalid. Do not send payment.";
}
if (status === "expired") {
return "Invoice expired. Do not send payment.";
}
if (status === "confirmed") {
return `Payment confirmed at ${confirmationTarget} confirmations.`;
}
if (status === "payment_detected") {
return "Payment detected. Awaiting confirmations.";
}
return "Awaiting funds.";
};
export default function BtcpayClassicCheckout({
invoiceId,
address,
amountXmr,
amountPaidXmr,
btcpayAmount,
btcpayCurrency,
quote,
status,
confirmationTarget,
redirectUrl,
redirectAutomatically,
orderId,
orderNumber,
qrLogoMode,
qrLogoDataUrl,
}: BtcpayClassicCheckoutProps) {
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [copiedField, setCopiedField] = useState<string | null>(null);
const formattedAmount = useMemo(() => formatXmrAmount(amountXmr), [amountXmr]);
const formattedPaidAmount = useMemo(() => {
if (!amountPaidXmr) {
return formattedAmount;
}
return formatXmrAmount(amountPaidXmr);
}, [amountPaidXmr, formattedAmount]);
const uri = useMemo(
() => buildMoneroUri(address, formattedAmount),
[address, formattedAmount]
);
const totalFiat =
btcpayAmount && btcpayCurrency && btcpayCurrency !== "WOW"
? formatFiatAmount(btcpayAmount, btcpayCurrency)
: null;
const exchangeRate =
quote?.rate && quote.fiat_currency
? `1 WOW = ${formatFiatAmount(quote.rate, quote.fiat_currency)}`
: null;
useEffect(() => {
if (status !== "confirmed" || !redirectUrl || !redirectAutomatically) {
return;
}
const timer = window.setTimeout(() => {
try {
window.top?.location.assign(redirectUrl);
} catch {
window.location.assign(redirectUrl);
}
}, 3000);
return () => {
window.clearTimeout(timer);
};
}, [redirectAutomatically, redirectUrl, status]);
const handleCopy = async (value: string, field: string) => {
try {
await navigator.clipboard.writeText(value);
setCopiedField(field);
window.setTimeout(() => {
setCopiedField((current) => (current === field ? null : current));
}, 1500);
} catch {
setCopiedField(null);
}
};
useEffect(() => {
let active = true;
const size = 260;
const canvas = document.createElement("canvas");
QRCode.toCanvas(canvas, uri, {
margin: 1,
width: size,
errorCorrectionLevel: "H",
})
.then(() => {
const context = canvas.getContext("2d");
if (!context) {
return null;
}
const mode = qrLogoMode ?? "monero";
const wantsLogo = mode !== "none";
if (wantsLogo) {
const blankSize = Math.round(size * 0.28);
const blankOffset = Math.round((size - blankSize) / 2);
context.fillStyle = "#ffffff";
context.fillRect(blankOffset, blankOffset, blankSize, blankSize);
}
return canvas.toDataURL();
})
.then((url) => {
if (active) {
setQrDataUrl(url ?? null);
}
})
.catch(() => {
if (active) {
setQrDataUrl(null);
}
});
return () => {
active = false;
};
}, [uri, qrLogoMode]);
const isActionDisabled = status === "expired" || status === "invalid";
const resolvedQrLogoMode = qrLogoMode ?? "monero";
const resolvedLogoSrc =
resolvedQrLogoMode === "custom"
? qrLogoDataUrl
: resolvedQrLogoMode === "monero"
? "/wownero-logo.svg"
: null;
if (status === "confirmed") {
return (
<div className="rounded-[28px] border border-stroke bg-white px-6 py-8 shadow-card">
<div className="text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-sage/15 text-sage">
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-9 w-9"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</div>
<p className="mt-4 text-sm font-semibold uppercase tracking-[0.2em] text-ink-soft">
Invoice paid
</p>
</div>
<dl className="mt-6 grid gap-3 text-sm text-ink">
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Invoice id</dt>
<dd className="max-w-[60%] truncate text-right font-semibold">{invoiceId}</dd>
</div>
{orderNumber || orderId ? (
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Order id</dt>
<dd className="font-semibold">{orderNumber ?? orderId}</dd>
</div>
) : null}
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Total price</dt>
<dd className="font-semibold">{formattedAmount} WOW</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Total fiat</dt>
<dd className="font-semibold">{totalFiat ?? "-"}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Exchange rate</dt>
<dd className="font-semibold">{exchangeRate ?? "-"}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Network cost</dt>
<dd className="font-semibold">Added by wallet</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Amount paid</dt>
<dd className="font-semibold">{formattedPaidAmount} WOW</dd>
</div>
</dl>
<div className="mt-8 grid gap-3">
<a
className="inline-flex items-center justify-center rounded-full bg-sage px-6 py-3 text-sm font-semibold text-cream shadow-[0_14px_22px_rgba(93,122,106,0.25)] transition hover:-translate-y-0.5"
href={`/i/${encodeURIComponent(invoiceId)}/receipt`}
>
View receipt
</a>
{redirectUrl ? (
<a
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white px-6 py-3 text-sm font-semibold text-sage transition hover:bg-cream"
href={redirectUrl}
target="_top"
rel="noreferrer"
>
{formatReturnLabel(redirectUrl)}
</a>
) : null}
</div>
</div>
);
}
return (
<div className="rounded-[28px] border border-stroke bg-white px-6 py-8 shadow-card">
<div className="text-center">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-ink-soft">
Payment request
</p>
<div className="mt-3 flex flex-wrap items-center justify-center gap-3">
<p className="text-[1.9rem] font-semibold text-ink">{formattedAmount} WOW</p>
<button
className="rounded-full border border-stroke bg-white/80 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
type="button"
onClick={() => handleCopy(formattedAmount, "amount")}
>
{copiedField === "amount" ? "Copied" : "Copy"}
</button>
</div>
<p className="mt-2 text-sm text-sage">{statusMessage(status, confirmationTarget)}</p>
</div>
<details className="group mt-5 rounded-2xl border border-stroke bg-cream/70 px-5 py-4">
<summary className="flex cursor-pointer items-center justify-center gap-2 text-sm font-semibold text-sage [&::-webkit-details-marker]:hidden">
<span className="group-open:hidden">View details</span>
<span className="hidden group-open:inline">Hide details</span>
</summary>
<dl className="mt-4 grid gap-3 text-sm text-ink">
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Total price</dt>
<dd className="font-semibold">{formattedAmount} WOW</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Total fiat</dt>
<dd className="font-semibold">{totalFiat ?? "-"}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Exchange rate</dt>
<dd className="font-semibold">{exchangeRate ?? "-"}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Network cost</dt>
<dd className="font-semibold">Added by wallet</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Amount due</dt>
<dd className="flex items-center gap-2 font-semibold">
<span>{formattedAmount} WOW</span>
<button
className="rounded-full border border-stroke bg-white/80 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
type="button"
onClick={() => handleCopy(formattedAmount, "amount")}
>
{copiedField === "amount" ? "Copied" : "Copy"}
</button>
</dd>
</div>
</dl>
</details>
<div className="mt-6 flex items-center justify-center">
{qrDataUrl ? (
<div className="relative rounded-2xl border border-ink/10 bg-white p-3 shadow-soft">
<Image
className="h-[260px] w-[260px]"
src={qrDataUrl}
alt="Payment request QR"
width={260}
height={260}
unoptimized
/>
{resolvedLogoSrc ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-white shadow-[0_8px_16px_rgba(16,18,23,0.18)]">
<Image
src={resolvedLogoSrc}
alt="QR logo"
width={38}
height={38}
unoptimized={resolvedQrLogoMode === "custom"}
/>
</span>
</div>
) : null}
</div>
) : null}
</div>
<div className="mt-6">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Address
</p>
<button
className="rounded-full border border-stroke bg-white/80 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
type="button"
onClick={() => handleCopy(address, "address")}
>
{copiedField === "address" ? "Copied" : "Copy"}
</button>
</div>
<p className="mt-3 break-all rounded-xl bg-ink/5 px-3 py-2 font-mono text-xs text-ink">
{address}
</p>
</div>
<a
className={`mt-6 inline-flex w-full items-center justify-center rounded-full px-6 py-3 text-sm font-semibold text-cream transition ${
isActionDisabled
? "cursor-not-allowed bg-ink/30"
: "bg-sage shadow-[0_14px_22px_rgba(93,122,106,0.25)] hover:-translate-y-0.5"
}`}
href={isActionDisabled ? undefined : uri}
aria-disabled={isActionDisabled}
>
Pay in wallet
</a>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { useEffect } from "react";
type InvoiceStatus =
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid";
type BtcpayModalBridgeProps = {
invoiceId: string;
status: InvoiceStatus;
};
const statusMap: Record<InvoiceStatus, string> = {
pending: "New",
payment_detected: "Processing",
confirmed: "Settled",
expired: "Expired",
invalid: "Invalid",
};
const postToParent = (payload: unknown) => {
if (typeof window === "undefined" || !window.parent) {
return;
}
window.parent.postMessage(payload, "*");
};
export default function BtcpayModalBridge({
invoiceId,
status,
}: BtcpayModalBridgeProps) {
useEffect(() => {
postToParent("loaded");
}, []);
useEffect(() => {
postToParent({
invoiceId,
status: statusMap[status] ?? "new",
});
}, [invoiceId, status]);
return null;
}
@@ -0,0 +1,20 @@
"use client";
export default function BtcpayModalCloseButton() {
const handleClick = () => {
if (typeof window === "undefined" || !window.parent) {
return;
}
window.parent.postMessage("close", "*");
};
return (
<button
className="rounded-full border border-stroke bg-white/80 px-3 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
type="button"
onClick={handleClick}
>
Close
</button>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useState } from "react";
type CopyIconButtonProps = {
value: string;
label: string;
className?: string;
};
export default function CopyIconButton({ value, label, className }: CopyIconButtonProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
setCopied(false);
}
};
return (
<button
type="button"
aria-label={label}
title={copied ? "Copied" : label}
onClick={handleCopy}
className={
className ??
"inline-flex h-7 w-7 items-center justify-center rounded-md border border-neutral-200 bg-white text-neutral-600 transition hover:bg-neutral-50"
}
>
{copied ? (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 6 9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path
d="M8 5h9a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M6 17H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</button>
);
}
+525
View File
@@ -0,0 +1,525 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useFormState } from "react-dom";
import QRCode from "qrcode";
import {
createInvoiceAction,
type InvoiceState,
} from "../app/(app)/dashboard/actions";
import { formatUsdAmount, formatXmrAmount } from "../lib/formatting";
import { useXmrUsdRate } from "../lib/use-xmr-usd-rate";
import { useXmrFiatRate } from "../lib/use-xmr-fiat-rate";
import {
FIAT_CURRENCY_SUGGESTIONS,
getCurrencyFlag,
} from "../lib/fiat-currencies";
const initialState: InvoiceState = {
error: null,
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
const buildMoneroUri = (state: InvoiceState): string | null => {
if (!state.address || !state.amount) {
return null;
}
const params = new URLSearchParams();
params.set("tx_amount", state.amount);
if (state.recipientName) {
params.set("recipient_name", state.recipientName);
}
if (state.description) {
params.set("tx_description", state.description);
}
return `wownero:${state.address}?${params.toString()}`;
};
export default function CreateInvoiceCard() {
const [state, formAction] = useFormState(createInvoiceAction, initialState);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [qrColor, setQrColor] = useState("#1b1b1a");
const [qrLogo, setQrLogo] = useState<string | null>(null);
const [origin, setOrigin] = useState<string | null>(null);
const [amountMode, setAmountMode] = useState<"xmr" | "fiat">("xmr");
const [amountInput, setAmountInput] = useState("");
const [fiatCurrency, setFiatCurrency] = useState("USD");
const { rate: usdRate } = useXmrUsdRate();
const { rate: fiatRate, status: fiatRateStatus } = useXmrFiatRate(
amountMode === "fiat" ? fiatCurrency : null
);
const labelClass = "text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft";
const inputClass =
"w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10";
const secondaryButton =
"inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5";
useEffect(() => {
setOrigin(window.location.origin);
}, []);
const publicInvoiceUrl = useMemo(() => {
if (!state.invoiceId) {
return null;
}
if (origin) {
return new URL(`/invoice/${state.invoiceId}`, origin).toString();
}
return `/invoice/${state.invoiceId}`;
}, [origin, state.invoiceId]);
const uri = useMemo(() => buildMoneroUri(state), [state]);
const amountValue = useMemo(() => {
if (amountMode !== "xmr") {
return null;
}
const raw = amountInput || state.amount || "";
const parsed = Number.parseFloat(raw);
return Number.isFinite(parsed) ? parsed : null;
}, [amountInput, amountMode, state.amount]);
const usdEstimate = useMemo(() => {
if (!usdRate || amountValue === null || amountValue <= 0) {
return null;
}
return formatUsdAmount(usdRate * amountValue);
}, [usdRate, amountValue]);
const fiatAmountValue = useMemo(() => {
if (amountMode !== "fiat") {
return null;
}
const parsed = Number.parseFloat(amountInput);
return Number.isFinite(parsed) ? parsed : null;
}, [amountInput, amountMode]);
const xmrEstimate = useMemo(() => {
if (!fiatRate || fiatAmountValue === null || fiatAmountValue <= 0) {
return null;
}
const estimate = fiatAmountValue / fiatRate;
return formatXmrAmount(estimate.toFixed(12));
}, [fiatAmountValue, fiatRate]);
const showFiatEstimate =
amountMode === "fiat" && fiatAmountValue !== null && fiatAmountValue > 0;
const fiatEstimateLabel = xmrEstimate
? `~${xmrEstimate} WOW`
: fiatRateStatus === "loading"
? "Fetching estimate..."
: "Estimate unavailable";
const fiatCurrencyFlag = getCurrencyFlag(fiatCurrency);
useEffect(() => {
let active = true;
if (!uri) {
setQrDataUrl(null);
return undefined;
}
QRCode.toDataURL(uri, {
margin: 1,
width: 220,
errorCorrectionLevel: "H",
color: {
dark: qrColor,
light: "#ffffff",
},
})
.then((url) => {
if (active) {
setQrDataUrl(url);
}
})
.catch(() => {
if (active) {
setQrDataUrl(null);
}
});
return () => {
active = false;
};
}, [uri, qrColor]);
const handleLogoChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
setQrLogo(null);
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result === "string") {
setQrLogo(result);
}
};
reader.readAsDataURL(file);
};
return (
<div className="rounded-2xl border border-stroke bg-white/80 p-6 shadow-card backdrop-blur">
<h3 className="font-serif text-xl">Create test invoice</h3>
<p className="mt-2 text-ink-soft">
Generate an invoice address and draft invoice for validation.
</p>
<form className="mt-6 grid gap-4" action={formAction}>
<div className="grid gap-2">
<label className={labelClass} htmlFor="payment_address">
Primary address
</label>
<input
className={inputClass}
id="payment_address"
name="payment_address"
type="text"
placeholder="Enter your primary address"
required
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="amount_mode">
Amount type
</label>
<select
className={inputClass}
id="amount_mode"
name="amount_mode"
value={amountMode}
onChange={(event) => {
const value = event.target.value;
if (value === "xmr" || value === "fiat") {
setAmountMode(value);
}
}}
>
<option value="xmr">WOW</option>
<option value="fiat">Fiat</option>
</select>
</div>
{amountMode === "xmr" ? (
<div className="grid gap-2">
<label className={labelClass} htmlFor="amount_xmr">
Amount (WOW)
</label>
<input
className={inputClass}
id="amount_xmr"
name="amount_xmr"
type="number"
step="0.000001"
min="0"
placeholder="0.10"
required
onChange={(event) => setAmountInput(event.target.value)}
/>
{usdEstimate ? (
<>
<p className="text-sm text-ink-soft">
Approx. USD reference: ~{usdEstimate}
</p>
<details className="w-fit text-xs text-ink-soft">
<summary className="cursor-pointer select-none underline underline-offset-4">
About this estimate
</summary>
<p className="mt-2 max-w-[46ch] leading-relaxed">
Reference only, uses CoinGecko spot rate. Not a quote or guarantee.
</p>
</details>
</>
) : null}
</div>
) : (
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_140px]">
<div className="grid gap-2">
<label className={labelClass} htmlFor="amount_fiat">
Amount (fiat)
</label>
<input
className={inputClass}
id="amount_fiat"
name="amount_fiat"
type="number"
step="0.01"
min="0"
placeholder="100.00"
required
onChange={(event) => setAmountInput(event.target.value)}
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="currency">
Currency
</label>
<input
className={inputClass}
id="currency"
name="currency"
type="text"
placeholder="USD"
list="fiat-currency-options"
value={fiatCurrency}
onChange={(event) => setFiatCurrency(event.target.value.toUpperCase())}
required
/>
</div>
<datalist id="fiat-currency-options">
{FIAT_CURRENCY_SUGGESTIONS.map((code) => (
<option
key={code}
value={code}
label={`${getCurrencyFlag(code) ?? ""} ${code}`.trim()}
/>
))}
</datalist>
<p className="text-sm text-ink-soft sm:col-span-2">
Fiat inputs create a WOW invoice using a non-binding rate at request time.
</p>
{showFiatEstimate ? (
<div className="sm:col-span-2">
<p className="text-sm text-ink-soft">
Approx. WOW value: {fiatEstimateLabel}
{fiatCurrencyFlag ? ` · ${fiatCurrencyFlag} ${fiatCurrency}` : ""}
</p>
{xmrEstimate ? (
<details className="w-fit text-xs text-ink-soft">
<summary className="cursor-pointer select-none underline underline-offset-4">
About this estimate
</summary>
<p className="mt-2 max-w-[46ch] leading-relaxed">
Reference only, uses CoinGecko spot rate. Not a quote or guarantee.
</p>
</details>
) : null}
</div>
) : null}
</div>
)}
<div className="grid gap-2">
<label className={labelClass} htmlFor="confirmation_target">
Confirmation target
</label>
<input
className={inputClass}
id="confirmation_target"
name="confirmation_target"
type="number"
min="0"
max="10"
step="1"
defaultValue={10}
required
/>
<p className="text-sm text-ink-soft">Expiry defaults to 60 minutes.</p>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="expires_date">
Expiry date
</label>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_160px]">
<input
className={inputClass}
id="expires_date"
name="expires_date"
type="date"
/>
<input
className={inputClass}
id="expires_time"
name="expires_time"
type="time"
/>
</div>
<p className="text-sm text-ink-soft">
Leave blank to keep the default 60-minute expiry.
</p>
</div>
<div className="flex items-center gap-3 rounded-xl border border-stroke bg-white/60 px-4 py-3 text-sm font-semibold text-ink">
<label className="flex items-center gap-3" htmlFor="instant_confirmation">
<input
className="h-4 w-4 rounded border-stroke text-ink focus:ring-ink/20"
id="instant_confirmation"
name="instant_confirmation"
type="checkbox"
value="1"
/>
Instant confirmation (0 confirmations)
</label>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="recipient_name">
Recipient name
</label>
<input
className={inputClass}
id="recipient_name"
name="recipient_name"
type="text"
placeholder="Your store"
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="description">
Description
</label>
<input
className={inputClass}
id="description"
name="description"
type="text"
placeholder="Order 1042"
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="checkout_continue_url">
Continue URL (optional)
</label>
<input
className={inputClass}
id="checkout_continue_url"
name="checkout_continue_url"
type="url"
placeholder="https://merchant.example/thanks"
/>
<p className="text-sm text-ink-soft">
After confirmation, the hosted invoice page shows a &quot;Continue&quot; button
that navigates to this URL.
</p>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="qr_color">
QR accent color
</label>
<input
className="h-12 w-full rounded-xl border border-stroke bg-white/80 px-3 py-2"
id="qr_color"
name="qr_color"
type="color"
value={qrColor}
onChange={(event) => setQrColor(event.target.value)}
aria-label="QR accent color"
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="qr_logo">
Logo overlay
</label>
<input
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink"
id="qr_logo"
name="qr_logo"
type="file"
accept="image/*"
onChange={handleLogoChange}
/>
<p className="text-sm text-ink-soft">Logo overlays the QR center for branding.</p>
{qrLogo ? (
<button
className={secondaryButton}
type="button"
onClick={() => setQrLogo(null)}
>
Remove logo
</button>
) : null}
</div>
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
{state.invoiceId ? (
<div className="grid gap-3 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Invoice created
</p>
<code className="break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{state.invoiceId}
</code>
{publicInvoiceUrl ? (
<>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Status link
</p>
<Link
className="break-all text-sm font-semibold text-ink underline"
href={publicInvoiceUrl}
target="_blank"
rel="noreferrer"
>
{publicInvoiceUrl}
</Link>
</>
) : null}
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Address
</p>
<code className="break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{state.address}
</code>
{state.subaddressIndex !== null ? (
<>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Subaddress index
</p>
<code className="rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{state.subaddressIndex}
</code>
</>
) : null}
{state.warnings && state.warnings.length > 0 ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
{state.warnings.map((warning) => (
<p key={warning}>{warning}</p>
))}
</div>
) : null}
{uri ? (
<>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Payment request
</p>
<code className="break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{uri}
</code>
</>
) : null}
{qrDataUrl ? (
<div className="relative mx-auto mt-2 h-[220px] w-[220px] rounded-2xl border border-stroke bg-white p-2">
<Image
className="h-full w-full rounded-xl border border-ink/10 bg-white"
src={qrDataUrl}
alt="Payment request QR"
width={220}
height={220}
unoptimized
/>
{qrLogo ? (
<Image
className="absolute left-1/2 top-1/2 h-[54px] w-[54px] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-white bg-white object-cover"
src={qrLogo}
alt="Logo overlay"
width={54}
height={54}
unoptimized
/>
) : null}
</div>
) : null}
</div>
) : null}
<div className="flex justify-end">
<button className={secondaryButton} type="submit">
Create invoice
</button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,85 @@
"use client";
import { useEffect } from "react";
import { useFormState } from "react-dom";
import { useRouter } from "next/navigation";
import {
updateDefaultConfirmationTargetAction,
type DefaultConfirmationTargetState,
} from "../app/(app)/dashboard/actions";
const buildInitialState = (value: number): DefaultConfirmationTargetState => ({
value,
error: null,
success: null,
});
export default function DefaultConfirmationTargetSection({
initialValue,
}: {
initialValue: number;
}) {
const router = useRouter();
const [state, formAction] = useFormState(
updateDefaultConfirmationTargetAction,
buildInitialState(initialValue)
);
useEffect(() => {
if (state.success) {
router.refresh();
}
}, [router, state.success]);
return (
<div className="grid gap-4">
<div>
<h2 className="font-serif text-2xl">Default confirmations</h2>
<p className="mt-2 text-sm text-ink-soft">
Used as the default confirmation target for new invoices.
</p>
</div>
<form className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]" action={formAction}>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="default_confirmation_target"
>
Confirmation target
</label>
<input
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="default_confirmation_target"
name="default_confirmation_target"
type="number"
min={0}
max={10}
step={1}
defaultValue={state.value}
required
/>
</div>
<div className="flex items-end justify-end">
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-6 py-3 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
type="submit"
>
Save
</button>
</div>
</form>
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
{state.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{state.success}
</p>
) : null}
</div>
);
}
+179
View File
@@ -0,0 +1,179 @@
"use client";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import { useFormState } from "react-dom";
import { useRouter } from "next/navigation";
import {
updateDefaultQrLogoAction,
type DefaultQrLogoState,
} from "../app/(app)/dashboard/actions";
const buildInitialState = (logo: "monero" | "none" | "custom", logoDataUrl: string | null) => ({
logo,
logoDataUrl,
error: null,
success: null,
});
export default function DefaultQrLogoSection({
initialLogo,
initialLogoDataUrl,
}: {
initialLogo: "monero" | "none" | "custom";
initialLogoDataUrl: string | null;
}) {
const router = useRouter();
const [logo, setLogo] = useState<"monero" | "none" | "custom">(initialLogo);
const [logoDataUrl, setLogoDataUrl] = useState<string | null>(initialLogoDataUrl);
const [state, formAction] = useFormState(
updateDefaultQrLogoAction,
buildInitialState(initialLogo, initialLogoDataUrl)
);
useEffect(() => {
if (state.success) {
router.refresh();
}
}, [router, state.success]);
useEffect(() => {
setLogo(initialLogo);
setLogoDataUrl(initialLogoDataUrl);
}, [initialLogo, initialLogoDataUrl]);
const previewSrc = useMemo(() => {
if (logo === "monero") {
return "/wownero-logo.svg";
}
if (logo === "custom") {
return logoDataUrl;
}
return null;
}, [logo, logoDataUrl]);
const handleLogoFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
setLogoDataUrl(null);
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result === "string") {
setLogoDataUrl(result);
}
};
reader.readAsDataURL(file);
};
return (
<div className="grid gap-4">
<div>
<h2 className="font-serif text-2xl">Default QR logo</h2>
<p className="mt-2 text-sm text-ink-soft">
Applies to new invoices unless overridden at creation time.
</p>
</div>
<form className="grid gap-4" action={formAction}>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="default_qr_logo"
>
Logo mode
</label>
<select
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="default_qr_logo"
name="default_qr_logo"
value={logo}
onChange={(event) => {
const value = event.target.value;
if (value === "none" || value === "custom" || value === "monero") {
setLogo(value);
}
}}
>
<option value="monero">Wownero logo</option>
<option value="none">No logo</option>
<option value="custom">Custom image</option>
</select>
</div>
{logo === "custom" ? (
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="default_qr_logo_file"
>
Custom logo image
</label>
<input
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink"
id="default_qr_logo_file"
type="file"
accept="image/*"
onChange={handleLogoFileChange}
/>
<p className="text-sm text-ink-soft">
Stored as a data URL for use on public invoice pages. Keep it small.
</p>
{logoDataUrl ? (
<button
className="inline-flex w-fit items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
type="button"
onClick={() => setLogoDataUrl(null)}
>
Remove image
</button>
) : null}
</div>
) : null}
<input
type="hidden"
name="default_qr_logo_data_url"
value={logo === "custom" ? logoDataUrl ?? "" : ""}
/>
{previewSrc ? (
<div className="flex items-center gap-3 rounded-2xl border border-stroke bg-white/70 p-4 shadow-soft">
<span className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-[0_6px_12px_rgba(16,18,23,0.18)]">
<Image
src={previewSrc}
alt="QR logo preview"
width={32}
height={32}
unoptimized={logo === "custom"}
/>
</span>
<p className="text-sm text-ink-soft">Preview (used in the QR center).</p>
</div>
) : null}
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
{state.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{state.success}
</p>
) : null}
<div className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-6 py-3 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
type="submit"
>
Save
</button>
</div>
</form>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFormState } from "react-dom";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
createDonationAction,
type DonationState,
} from "../app/(marketing)/donate/actions";
import { formatUsdAmount } from "../lib/formatting";
import { useXmrUsdRate } from "../lib/use-xmr-usd-rate";
const donateParam = "donate";
const initialState: DonationState = {
error: null,
invoiceId: null,
};
export default function DonateModal() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const donationsEnabled = process.env.NEXT_PUBLIC_DONATIONS_ENABLED === "true";
const isOpen = searchParams.get(donateParam) === "1";
const [state, formAction] = useFormState(createDonationAction, initialState);
const formRef = useRef<HTMLFormElement | null>(null);
const [amountInput, setAmountInput] = useState("");
const { rate: usdRate } = useXmrUsdRate();
const openModal = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
params.set(donateParam, "1");
const query = params.toString();
router.push(query ? `${pathname}?${query}` : pathname);
}, [pathname, router, searchParams]);
const closeModal = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
params.delete(donateParam);
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname);
}, [pathname, router, searchParams]);
useEffect(() => {
if (!state.invoiceId) {
return;
}
closeModal();
router.push(`/donate/${state.invoiceId}`);
}, [closeModal, router, state.invoiceId]);
useEffect(() => {
if (!isOpen) {
return;
}
formRef.current?.reset();
setAmountInput("");
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeModal();
}
};
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", handleKeyDown);
};
}, [closeModal, isOpen]);
const amountValue = useMemo(() => {
const parsed = Number.parseFloat(amountInput);
return Number.isFinite(parsed) ? parsed : null;
}, [amountInput]);
const usdEstimate = useMemo(() => {
if (!usdRate || amountValue === null || amountValue <= 0) {
return null;
}
return formatUsdAmount(usdRate * amountValue);
}, [usdRate, amountValue]);
if (!donationsEnabled) {
return null;
}
return (
<div className="flex items-center">
<button
className="rounded-full border border-stroke bg-white/40 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.18em] text-clay transition hover:text-ink"
type="button"
onClick={openModal}
>
Donate
</button>
{isOpen ? (
<div
className="fixed inset-0 z-30 grid place-items-center"
role="dialog"
aria-modal="true"
aria-labelledby="donate-title"
>
<div className="absolute inset-0 bg-ink/60 backdrop-blur" onClick={closeModal} />
<div
className="relative w-[min(420px,92vw)] rounded-3xl border border-stroke bg-card p-7 shadow-deep"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Support wowcheckout.com
</p>
<h3 id="donate-title" className="mt-2 font-serif text-xl">
If wowcheckout.com has been helpful, a donation is appreciated.
</h3>
</div>
<button
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-ink transition hover:-translate-y-0.5"
type="button"
onClick={closeModal}
aria-label="Close donation modal"
>
Close
</button>
</div>
<p className="mt-3 text-ink-soft">
Choose an amount and we will open an invoice page with payment details and status.
</p>
<form className="mt-4 grid gap-4" action={formAction} ref={formRef}>
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="donation_amount"
>
Amount (WOW)
</label>
<input
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="donation_amount"
name="amount_xmr"
type="number"
step="0.000001"
min="0"
placeholder="0.10"
required
onChange={(event) => setAmountInput(event.target.value)}
/>
<div className="min-h-[2.75rem]">
{usdEstimate ? (
<>
<p className="text-sm text-ink-soft">
Approx. USD reference: ~{usdEstimate}
</p>
<details className="w-fit text-xs text-ink-soft">
<summary className="cursor-pointer select-none underline underline-offset-4">
About this estimate
</summary>
<p className="mt-2 max-w-[46ch] leading-relaxed">
Reference only, uses CoinGecko spot rate. Not a quote or guarantee.
</p>
</details>
</>
) : null}
</div>
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
<div className="flex flex-wrap justify-end gap-3">
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
type="button"
onClick={closeModal}
>
Cancel
</button>
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
type="submit"
>
Donate
</button>
</div>
</form>
<p className="mt-4 text-sm text-ink-soft">
Payments go directly to the founder wallet. This checkout never
moves funds.
</p>
</div>
</div>
) : null}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import Link from "next/link";
export default function DonationStatusActions() {
return (
<div className="flex flex-wrap gap-3">
<Link
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
href="/"
>
Back to home
</Link>
</div>
);
}
File diff suppressed because it is too large Load Diff
+256
View File
@@ -0,0 +1,256 @@
"use client";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import QRCode from "qrcode";
import { formatUsdAmount, formatXmrAmount } from "../lib/formatting";
import { useXmrUsdRate } from "../lib/use-xmr-usd-rate";
type InvoicePaymentDetailsProps = {
address: string;
amount: string;
hasDetectedPayment: boolean;
status: "pending" | "payment_detected" | "confirmed" | "expired" | "invalid";
confirmationTarget: number;
qrLogoMode?: "monero" | "none" | "custom" | null;
qrLogoDataUrl?: string | null;
};
const buildMoneroUri = (address: string, amount: string) => {
const params = new URLSearchParams();
params.set("tx_amount", amount);
return `wownero:${address}?${params.toString()}`;
};
export default function InvoicePaymentDetails({
address,
amount,
hasDetectedPayment,
status,
confirmationTarget,
qrLogoMode,
qrLogoDataUrl,
}: InvoicePaymentDetailsProps) {
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [copiedField, setCopiedField] = useState<string | null>(null);
const { rate: usdRate } = useXmrUsdRate();
const isInvalid = status === "invalid";
const shouldPromptPayment = !hasDetectedPayment && !isInvalid;
const formattedAmount = useMemo(() => formatXmrAmount(amount), [amount]);
const uri = useMemo(
() => buildMoneroUri(address, formattedAmount),
[address, formattedAmount]
);
const amountValue = useMemo(() => {
const parsed = Number.parseFloat(amount);
return Number.isFinite(parsed) ? parsed : null;
}, [amount]);
const usdEstimate = useMemo(() => {
if (!usdRate || amountValue === null || amountValue <= 0) {
return null;
}
return formatUsdAmount(usdRate * amountValue);
}, [usdRate, amountValue]);
const handleCopy = async (value: string, field: string) => {
try {
await navigator.clipboard.writeText(value);
setCopiedField(field);
window.setTimeout(() => {
setCopiedField((current) => (current === field ? null : current));
}, 1500);
} catch {
setCopiedField(null);
}
};
useEffect(() => {
let active = true;
const size = 200;
const canvas = document.createElement("canvas");
QRCode.toCanvas(canvas, uri, {
margin: 1,
width: size,
errorCorrectionLevel: "H",
})
.then(() => {
const context = canvas.getContext("2d");
if (!context) {
return null;
}
const mode = qrLogoMode ?? "monero";
const wantsLogo = mode !== "none";
if (wantsLogo) {
const blankSize = Math.round(size * 0.28);
const blankOffset = Math.round((size - blankSize) / 2);
context.fillStyle = "#ffffff";
context.fillRect(blankOffset, blankOffset, blankSize, blankSize);
}
return canvas.toDataURL();
})
.then((url) => {
if (active) {
setQrDataUrl(url ?? null);
}
})
.catch(() => {
if (active) {
setQrDataUrl(null);
}
});
return () => {
active = false;
};
}, [uri, qrLogoMode]);
const resolvedQrLogoMode = qrLogoMode ?? "monero";
const resolvedLogoSrc =
resolvedQrLogoMode === "custom"
? qrLogoDataUrl
: resolvedQrLogoMode === "monero"
? "/wownero-logo.svg"
: null;
return (
<details
className="group rounded-2xl border border-stroke bg-card p-8 shadow-card backdrop-blur"
open={shouldPromptPayment}
>
<summary className="cursor-pointer list-none [&::-webkit-details-marker]:hidden">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-clay">
Payment details
</p>
<h2 className="mt-2 font-serif text-2xl">
{isInvalid
? "Invoice marked invalid. Do not send payment."
: hasDetectedPayment
? status === "confirmed"
? `Payment confirmed on-chain at ${confirmationTarget} confirmations.`
: "Payment detected. Confirmations are in progress."
: "Send the exact amount to this address."}
</h2>
</div>
<span className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition group-open:hidden">
Show details
</span>
<span className="hidden rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition group-open:inline-flex">
Hide details
</span>
</div>
</summary>
<div className="mt-6 grid gap-6 lg:grid-cols-2">
<div className="rounded-2xl border border-stroke bg-white/60 p-5 shadow-soft">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Amount (WOW)
</p>
<button
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
type="button"
onClick={() => handleCopy(formattedAmount, "amount")}
>
{copiedField === "amount" ? "Copied" : "Copy"}
</button>
</div>
<p className="mt-3 text-2xl font-semibold text-ink">{formattedAmount}</p>
<p className="mt-2 text-sm text-ink-soft">
Your wallet adds the network fee on top. Do not subtract it from the
amount shown.
</p>
{usdEstimate ? (
<>
<p className="mt-2 text-sm text-ink-soft">
Approx. USD reference: ~{usdEstimate}
</p>
<details className="mt-1 w-fit text-xs text-ink-soft">
<summary className="cursor-pointer select-none underline underline-offset-4">
About this estimate
</summary>
<p className="mt-2 max-w-[46ch] leading-relaxed">
Reference only, uses CoinGecko spot rate. Not a quote or guarantee.
</p>
</details>
</>
) : null}
</div>
<div className="rounded-2xl border border-stroke bg-white/60 p-5 shadow-soft">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Payment address
</p>
<button
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
type="button"
onClick={() => handleCopy(address, "address")}
>
{copiedField === "address" ? "Copied" : "Copy"}
</button>
</div>
<p className="mt-3 break-words rounded-xl bg-ink/10 px-3 py-2 font-mono text-xs text-ink sm:text-sm">
{address}
</p>
</div>
</div>
<div className="mt-6 rounded-2xl border border-stroke bg-white/60 p-5 shadow-soft">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Payment request URI
</p>
<button
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
type="button"
onClick={() => handleCopy(uri, "uri")}
>
{copiedField === "uri" ? "Copied" : "Copy"}
</button>
</div>
<p className="mt-3 break-words rounded-xl bg-ink/10 px-3 py-2 font-mono text-xs text-ink sm:text-sm">
{uri}
</p>
{shouldPromptPayment ? (
<p className="mt-4 text-sm text-ink-soft">
After you pay, this page updates automatically once the payment is
detected on-chain.
</p>
) : null}
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<p className="max-w-xl text-sm text-ink-soft">
{isInvalid
? "This invoice is marked invalid. Do not send a payment to this address."
: hasDetectedPayment
? "Details remain available if you need to reference the address or request URI."
: "Tip: use the QR or payment request URI to avoid copy errors."}
</p>
{qrDataUrl ? (
<div className="relative h-[200px] w-[200px] rounded-2xl border border-stroke bg-white p-2 shadow-soft">
<Image
className="h-full w-full rounded-xl border border-ink/10 bg-white"
src={qrDataUrl}
alt="Payment request QR"
width={200}
height={200}
unoptimized
/>
{resolvedLogoSrc ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-[0_6px_12px_rgba(16,18,23,0.18)]">
<Image
src={resolvedLogoSrc}
alt="QR logo"
width={32}
height={32}
unoptimized={resolvedQrLogoMode === "custom"}
/>
</span>
</div>
) : null}
</div>
) : null}
</div>
</details>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import Link from "next/link";
export default function InvoiceStatusActions() {
return (
<div className="flex flex-wrap gap-3">
<Link
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
href="/invoice"
>
Check another invoice
</Link>
</div>
);
}
@@ -0,0 +1,23 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
type InvoiceStatusAutoRefreshProps = {
intervalMs?: number;
};
export default function InvoiceStatusAutoRefresh({
intervalMs = 30000,
}: InvoiceStatusAutoRefreshProps) {
const router = useRouter();
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, intervalMs);
return () => clearInterval(interval);
}, [intervalMs, router]);
return null;
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
type InvoiceStatusLookupProps = {
initialValue?: string;
compact?: boolean;
};
export default function InvoiceStatusLookup({
initialValue = "",
compact = false,
}: InvoiceStatusLookupProps) {
const router = useRouter();
const [invoiceId, setInvoiceId] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmed = invoiceId.trim();
if (!trimmed) {
setError("Enter an invoice id to continue.");
return;
}
setError(null);
router.push(`/invoice/${trimmed}`);
};
return (
<form
className={`grid gap-3 ${compact ? "text-sm" : "text-base"}`}
onSubmit={handleSubmit}
>
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="invoice_id"
>
Invoice ID
</label>
<div className="flex flex-wrap items-center gap-3">
<input
className="min-w-[220px] flex-1 rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="invoice_id"
name="invoice_id"
type="text"
value={invoiceId}
onChange={(event) => setInvoiceId(event.target.value)}
placeholder="e.g. 9d2f2b3c-8a77-4c2b-b9d4-..."
autoComplete="off"
spellCheck={false}
required
/>
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-3 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
type="submit"
>
Check status
</button>
</div>
{compact ? null : (
<p className="text-sm text-ink-soft">
Read-only status view. Refresh the page to see new detections.
</p>
)}
{error ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700"
role="alert"
>
{error}
</p>
) : null}
</form>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useCallback } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import LoginForm from "../app/(app)/login/login-form";
const loginParam = "login";
type LoginTriggerProps = {
className?: string;
children: React.ReactNode;
};
export function LoginTrigger({ className, children }: LoginTriggerProps) {
const router = useRouter();
const pathname = usePathname();
const openModal = useCallback(() => {
const params =
typeof window === "undefined"
? new URLSearchParams()
: new URLSearchParams(window.location.search);
params.set(loginParam, "1");
const query = params.toString();
router.push(query ? `${pathname}?${query}` : pathname);
}, [pathname, router]);
return (
<button
className={className}
type="button"
onClick={openModal}
aria-haspopup="dialog"
aria-controls="login-modal"
>
{children}
</button>
);
}
export default function LoginModal() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const isOpen = searchParams.get(loginParam) === "1";
const closeModal = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
params.delete(loginParam);
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname);
}, [pathname, router, searchParams]);
useEffect(() => {
if (!isOpen) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeModal();
}
};
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", handleKeyDown);
};
}, [closeModal, isOpen]);
if (!isOpen) {
return null;
}
return (
<div
className="fixed inset-0 z-40 grid place-items-center"
id="login-modal"
role="dialog"
aria-modal="true"
aria-labelledby="login-title"
>
<div className="absolute inset-0 bg-ink/60 backdrop-blur" onClick={closeModal} />
<div
className="relative w-[min(480px,92vw)] rounded-3xl border border-stroke bg-card p-7 shadow-deep"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Merchant access
</p>
<h3 id="login-title" className="mt-2 font-serif text-xl">
Sign in to your dashboard.
</h3>
</div>
<button
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-ink transition hover:-translate-y-0.5"
type="button"
onClick={closeModal}
aria-label="Close login modal"
>
Close
</button>
</div>
<p className="mt-3 text-ink-soft">
Use your primary address and secret view key to access checkout tools.
Your primary address is the only identifier we need - no email or other
identifying information required.
<br />
Your secret view key is stored encrypted at rest and used only for payment detection.
</p>
<LoginForm />
<p className="mt-4 text-sm font-semibold text-sage">
We never require more than view-only access to keep your funds safe.
</p>
</div>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
function normalizePath(path: string) {
if (!path.startsWith("/")) {
return `/${path}`;
}
if (path === "/") {
return "/";
}
return path.replace(/\/+$/, "");
}
export default function NavLink({
href,
children,
className,
exact,
}: {
href: string;
children: React.ReactNode;
className?: string;
exact?: boolean;
}) {
const pathname = usePathname() ?? "/";
const currentPath = normalizePath(pathname);
const targetPath = normalizePath(href);
const isActive = exact
? currentPath === targetPath
: currentPath === targetPath || currentPath.startsWith(`${targetPath}/`);
return (
<Link
href={href}
className={className}
aria-current={isActive ? "page" : undefined}
data-active={isActive ? "true" : undefined}
>
{children}
</Link>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
type PrintButtonProps = {
className?: string;
};
export default function PrintButton({ className }: PrintButtonProps) {
return (
<button
type="button"
onClick={() => window.print()}
className={
className ??
"inline-flex items-center justify-center rounded-md border border-emerald-200 bg-white px-4 py-2 text-sm font-semibold text-emerald-600 transition hover:bg-emerald-50"
}
>
Print
</button>
);
}
+51
View File
@@ -0,0 +1,51 @@
type RelativeTimeInput = string | number | Date | null | undefined;
const relativeTimeFormatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const parseRelativeTime = (value: RelativeTimeInput) => {
if (value === null || value === undefined) {
return null;
}
const date = value instanceof Date ? value : new Date(value);
const timestamp = date.getTime();
if (Number.isNaN(timestamp)) {
return null;
}
return timestamp;
};
export const formatRelativeTime = (value: RelativeTimeInput) => {
const timestamp = parseRelativeTime(value);
if (timestamp === null) {
return null;
}
const diffSeconds = Math.round((timestamp - Date.now()) / 1000);
const absSeconds = Math.abs(diffSeconds);
if (absSeconds < 60) {
return relativeTimeFormatter.format(diffSeconds, "second");
}
const diffMinutes = Math.round(diffSeconds / 60);
if (Math.abs(diffMinutes) < 60) {
return relativeTimeFormatter.format(diffMinutes, "minute");
}
const diffHours = Math.round(diffMinutes / 60);
if (Math.abs(diffHours) < 24) {
return relativeTimeFormatter.format(diffHours, "hour");
}
const diffDays = Math.round(diffHours / 24);
if (Math.abs(diffDays) < 30) {
return relativeTimeFormatter.format(diffDays, "day");
}
const diffMonths = Math.round(diffDays / 30);
if (Math.abs(diffMonths) < 12) {
return relativeTimeFormatter.format(diffMonths, "month");
}
const diffYears = Math.round(diffDays / 365);
return relativeTimeFormatter.format(diffYears, "year");
};
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
type SecretCardProps = {
label: string;
value: string;
buttonLabel: string;
};
export default function SecretCard({ label, value, buttonLabel }: SecretCardProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
};
return (
<div className="grid gap-4 rounded-2xl border border-stroke bg-white/80 p-6 shadow-soft backdrop-blur">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
{label}
</p>
<code className="mt-2 block break-all rounded-xl bg-ink/10 px-3 py-2 text-sm text-ink">
{value}
</code>
</div>
<div className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
type="button"
onClick={handleCopy}
>
{copied ? "Copied" : buttonLabel}
</button>
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
type StatusRefreshButtonProps = {
label?: string;
className?: string;
};
export default function StatusRefreshButton({
label = "Refresh",
className = "",
}: StatusRefreshButtonProps) {
const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = () => {
setIsRefreshing(true);
router.refresh();
setTimeout(() => setIsRefreshing(false), 500);
};
return (
<button
className={`inline-flex items-center justify-center rounded-full border border-stroke bg-white/50 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-70 ${className}`}
type="button"
onClick={handleRefresh}
disabled={isRefreshing}
>
{isRefreshing ? "Refreshing..." : label}
</button>
);
}
+189
View File
@@ -0,0 +1,189 @@
"use client";
import { useEffect } from "react";
import { useFormState } from "react-dom";
import { useRouter } from "next/navigation";
import {
redeliverWebhookDeliveryAction,
redeliverWebhookDeliveryTourAction,
type WebhookRedeliverState,
} from "../app/(app)/dashboard/actions";
type WebhookDeliverySummary = {
id: string;
webhook_id: string | null;
event: string;
url: string;
invoice_id: string | null;
invoice_address: string | null;
invoice_subaddress_index: number | null;
invoice_amount_xmr: string | null;
invoice_status:
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid"
| null;
http_status: number | null;
error_message: string | null;
created_at: string;
};
const initialState: WebhookRedeliverState = { error: null, success: null };
export default function WebhookHistoryPanel({
mode = "live",
deliveries,
}: {
mode?: "live" | "tour";
deliveries: WebhookDeliverySummary[];
}) {
const router = useRouter();
const [state, formAction] = useFormState(
mode === "tour" ? redeliverWebhookDeliveryTourAction : redeliverWebhookDeliveryAction,
initialState
);
useEffect(() => {
if (state.success && mode !== "tour") {
router.refresh();
}
}, [mode, router, state.success]);
const formatTimestamp = (value: string) => new Date(value).toLocaleString();
return (
<div className="grid gap-4">
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
{state.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{state.success}
</p>
) : null}
{deliveries.length === 0 ? (
<p className="text-sm text-ink-soft">No webhook calls recorded yet.</p>
) : (
<div className="grid gap-3">
{deliveries.map((delivery) => {
const statusLabel =
delivery.http_status !== null ? delivery.http_status.toString() : "No response";
const isFailed =
delivery.http_status === null ||
(typeof delivery.http_status === "number" && delivery.http_status >= 400);
const hasInvoiceDetails =
Boolean(delivery.invoice_id) ||
Boolean(delivery.invoice_address) ||
Boolean(delivery.invoice_amount_xmr) ||
delivery.invoice_subaddress_index !== null;
return (
<div
key={delivery.id}
className="rounded-xl border border-stroke bg-white/80 px-4 py-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
{delivery.event}
</p>
<p className="mt-2 break-all text-sm font-semibold text-ink">
{delivery.url}
</p>
<p className="mt-2 text-xs text-ink-soft">
{formatTimestamp(delivery.created_at)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-stroke bg-white/60 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
{statusLabel}
</span>
{isFailed ? (
<form action={formAction}>
<input type="hidden" name="delivery_id" value={delivery.id} />
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink transition hover:-translate-y-0.5"
type="submit"
>
Redeliver
</button>
</form>
) : null}
</div>
</div>
{hasInvoiceDetails ? (
<details className="mt-3 rounded-lg border border-stroke bg-white/70 px-3 py-2">
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-[0.12em] text-ink">
View invoice details
</summary>
<div className="mt-3 grid gap-2 text-xs text-ink-soft">
{delivery.invoice_id ? (
<div>
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
Invoice id
</p>
<p className="mt-1 break-all text-xs text-ink">
{delivery.invoice_id}
</p>
</div>
) : null}
{delivery.invoice_address ? (
<div>
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
Subaddress
</p>
<p className="mt-1 break-all text-xs text-ink">
{delivery.invoice_address}
</p>
</div>
) : null}
{delivery.invoice_subaddress_index !== null ? (
<div>
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
Subaddress index
</p>
<p className="mt-1 text-xs text-ink">
{delivery.invoice_subaddress_index}
</p>
</div>
) : null}
{delivery.invoice_amount_xmr ? (
<div>
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
Invoice amount (WOW)
</p>
<p className="mt-1 text-xs text-ink">
{delivery.invoice_amount_xmr}
</p>
</div>
) : null}
{delivery.invoice_status ? (
<div>
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
Invoice status
</p>
<p className="mt-1 text-xs text-ink">
{delivery.invoice_status}
</p>
</div>
) : null}
</div>
</details>
) : null}
{delivery.error_message ? (
<p className="mt-2 text-xs text-ink-soft">
Delivery error: {delivery.error_message}
</p>
) : null}
</div>
);
})}
</div>
)}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useFormState } from "react-dom";
import {
resetWebhookSecretAction,
type WebhookSecretState,
} from "../app/(app)/dashboard/actions";
import SecretCard from "./secret-card";
const initialState = (webhookSecret: string | null): WebhookSecretState => ({
webhookSecret,
error: null,
});
type WebhookSecretSectionProps = {
webhookSecret: string | null;
};
export default function WebhookSecretSection({
webhookSecret,
}: WebhookSecretSectionProps) {
const [state, formAction] = useFormState(
resetWebhookSecretAction,
initialState(webhookSecret)
);
return (
<div className="grid gap-4">
{state.webhookSecret ? (
<SecretCard
label="Your webhook secret"
value={state.webhookSecret}
buttonLabel="Copy webhook secret"
/>
) : (
<div className="rounded-2xl border border-stroke bg-white/80 p-6 text-sm text-ink-soft shadow-soft backdrop-blur">
Webhook secret unavailable. Reset to generate a new secret.
</div>
)}
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
<form action={formAction} className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
type="submit"
>
Reset webhook secret
</button>
</form>
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
"use client";
import { useFormState } from "react-dom";
import {
createWebhookAction,
createWebhookTourAction,
deleteWebhookAction,
deleteWebhookTourAction,
type WebhookFormState,
} from "../app/(app)/dashboard/actions";
type WebhookSummary = {
id: string;
url: string;
events: string[];
event_urls?: Record<string, string> | null;
active: boolean;
created_at: string;
};
const initialState: WebhookFormState = { error: null, success: null };
const webhookEvents = [
{ event: "invoice.created", key: "invoice_created", label: "invoice.created" },
{
event: "invoice.payment_detected",
key: "invoice_payment_detected",
label: "invoice.payment_detected",
},
{ event: "invoice.confirmed", key: "invoice_confirmed", label: "invoice.confirmed" },
{ event: "invoice.expired", key: "invoice_expired", label: "invoice.expired" },
];
type WebhookSectionProps = {
mode?: "live" | "tour";
webhooks: WebhookSummary[];
};
export default function WebhookSection({ mode = "live", webhooks }: WebhookSectionProps) {
const [createState, createAction] = useFormState(
mode === "tour" ? createWebhookTourAction : createWebhookAction,
initialState
);
const [deleteState, deleteAction] = useFormState(
mode === "tour" ? deleteWebhookTourAction : deleteWebhookAction,
initialState
);
return (
<div className="grid gap-8">
{mode === "tour" ? (
<div className="rounded-2xl border border-stroke bg-white/70 p-5 text-sm text-ink-soft shadow-soft">
Tour mode: webhook changes are simulated. No endpoints are saved.
</div>
) : null}
<form className="grid gap-6" action={createAction}>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="default_url"
>
Default webhook URL
</label>
<input
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="default_url"
name="default_url"
type="url"
placeholder="https://wowcheckout.com/webhooks"
/>
<p className="text-sm text-ink-soft">
Used for any selected event without a specific override URL.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
{webhookEvents.map(({ event, key, label }) => (
<div key={event} className="rounded-xl border border-stroke bg-white/70 p-4">
<label className="flex items-center gap-3 text-sm font-semibold text-ink">
<input
className="h-4 w-4 rounded border-stroke text-ink focus:ring-ink/20"
type="checkbox"
name={`event_${key}`}
defaultChecked
/>
<span>{label}</span>
</label>
<input
className="mt-3 w-full rounded-xl border border-stroke bg-white/80 px-3 py-2 text-sm text-ink outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id={`event_url_${key}`}
name={`event_url_${key}`}
type="url"
placeholder="Optional override URL"
/>
</div>
))}
</div>
{createState.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{createState.error}
</p>
) : null}
{createState.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{createState.success}
</p>
) : null}
<div className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
type="submit"
>
Save webhook
</button>
</div>
</form>
<div className="rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Hint
</p>
<p className="mt-2 text-sm text-ink-soft">
Webhooks are optional. You can poll{" "}
<code>/api/core/public/invoice/&lt;invoice_id&gt;</code> without
authentication to read invoice status.
</p>
</div>
<div className="grid gap-4">
<div>
<h3 className="font-serif text-xl">Active webhooks</h3>
</div>
{deleteState.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{deleteState.error}
</p>
) : null}
{deleteState.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{deleteState.success}
</p>
) : null}
{webhooks.length === 0 ? (
<p className="text-sm font-semibold text-ink-soft">
No webhooks configured yet.
</p>
) : (
<form className="grid gap-4" action={deleteAction}>
{webhooks.map((hook) => (
<div
key={hook.id}
className="flex flex-wrap items-start justify-between gap-4 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft backdrop-blur"
>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Webhook
</p>
<code className="mt-2 block break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{hook.url || "Per-event URLs only"}
</code>
<div className="mt-4 grid gap-3">
{hook.events.map((event) => (
<div key={event} className="grid gap-1">
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
{event}
</span>
<code className="break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{hook.event_urls?.[event] ?? hook.url}
</code>
</div>
))}
</div>
</div>
<div className="flex">
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
type="submit"
name="webhook_id"
value={hook.id}
>
Remove
</button>
</div>
</div>
))}
</form>
)}
</div>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
export const FIAT_CURRENCY_SUGGESTIONS = [
"USD",
"EUR",
"GBP",
"CAD",
"AUD",
"NZD",
"CHF",
"JPY",
"CNY",
"HKD",
"SGD",
"INR",
"BRL",
"MXN",
"SEK",
"NOK",
"DKK",
"ZAR",
"PLN",
"KRW",
];
const CURRENCY_FLAGS: Record<string, string> = {
USD: "🇺🇸",
EUR: "🇪🇺",
GBP: "🇬🇧",
CAD: "🇨🇦",
AUD: "🇦🇺",
NZD: "🇳🇿",
CHF: "🇨🇭",
JPY: "🇯🇵",
CNY: "🇨🇳",
HKD: "🇭🇰",
SGD: "🇸🇬",
INR: "🇮🇳",
BRL: "🇧🇷",
MXN: "🇲🇽",
SEK: "🇸🇪",
NOK: "🇳🇴",
DKK: "🇩🇰",
ZAR: "🇿🇦",
PLN: "🇵🇱",
KRW: "🇰🇷",
};
export const getCurrencyFlag = (
code: string | null | undefined
): string | null => {
if (!code) {
return null;
}
return CURRENCY_FLAGS[code.trim().toUpperCase()] ?? null;
};
+24
View File
@@ -0,0 +1,24 @@
export const formatXmrAmount = (value: string | number): string => {
const raw = typeof value === "number" ? value.toString() : value;
if (!raw) {
return "";
}
const [whole, fraction] = raw.split(".");
if (!fraction) {
return raw;
}
const trimmedFraction = fraction.replace(/0+$/, "");
return trimmedFraction ? `${whole}.${trimmedFraction}` : whole;
};
export const formatUsdAmount = (value: number): string => {
if (!Number.isFinite(value)) {
return "";
}
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
minimumFractionDigits: 2,
}).format(value);
};
+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useState } from "react";
const RATE_TTL_MS = 5 * 60 * 1000;
const BASE_URL = "https://api.coingecko.com/api/v3/simple/price";
type CachedRate = {
rate: number;
updatedAt: number;
};
const cachedRates = new Map<string, CachedRate>();
const inflight = new Map<string, Promise<number | null>>();
const fetchRate = async (currency: string): Promise<number | null> => {
try {
const params = new URLSearchParams({
ids: "wownero",
vs_currencies: currency,
});
const response = await fetch(`${BASE_URL}?${params.toString()}`, {
cache: "no-store",
});
if (!response.ok) {
return null;
}
const data = (await response.json()) as {
wownero?: Record<string, number>;
};
const rate = data?.wownero?.[currency];
if (typeof rate !== "number" || !Number.isFinite(rate)) {
return null;
}
cachedRates.set(currency, { rate, updatedAt: Date.now() });
return rate;
} catch {
return null;
}
};
type RateStatus = "idle" | "loading" | "ready" | "error";
export const useXmrFiatRate = (currency: string | null) => {
const normalized = currency?.trim().toLowerCase() ?? "";
const cached = normalized ? cachedRates.get(normalized) : null;
const [rate, setRate] = useState<number | null>(cached?.rate ?? null);
const [updatedAt, setUpdatedAt] = useState<number | null>(cached?.updatedAt ?? null);
const [status, setStatus] = useState<RateStatus>(() => {
if (!normalized) {
return "idle";
}
return cached?.rate ? "ready" : "loading";
});
useEffect(() => {
let active = true;
if (!normalized) {
setRate(null);
setUpdatedAt(null);
setStatus("idle");
return;
}
const now = Date.now();
const cachedEntry = cachedRates.get(normalized);
const hasFreshRate =
cachedEntry && now - cachedEntry.updatedAt < RATE_TTL_MS;
if (hasFreshRate) {
setRate(cachedEntry.rate);
setUpdatedAt(cachedEntry.updatedAt);
setStatus("ready");
return;
}
setStatus("loading");
const request =
inflight.get(normalized) ??
fetchRate(normalized).finally(() => {
inflight.delete(normalized);
});
inflight.set(normalized, request);
request.then((nextRate) => {
if (!active) {
return;
}
const nextCached = cachedRates.get(normalized);
if (nextRate !== null && nextCached) {
setRate(nextCached.rate);
setUpdatedAt(nextCached.updatedAt);
setStatus("ready");
return;
}
setStatus("error");
});
return () => {
active = false;
};
}, [normalized]);
return { rate, updatedAt, status, source: "coingecko" as const };
};
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
const RATE_TTL_MS = 5 * 60 * 1000;
const RATE_URL =
"https://api.coingecko.com/api/v3/simple/price?ids=wownero&vs_currencies=usd";
let cachedRate: number | null = null;
let cachedAt: number | null = null;
let inflight: Promise<number | null> | null = null;
const fetchRate = async (): Promise<number | null> => {
try {
const response = await fetch(RATE_URL, { cache: "no-store" });
if (!response.ok) {
return null;
}
const data = (await response.json()) as {
wownero?: { usd?: number };
};
const rate = data?.wownero?.usd;
if (typeof rate !== "number" || !Number.isFinite(rate)) {
return null;
}
cachedRate = rate;
cachedAt = Date.now();
return rate;
} catch {
return null;
}
};
export const useXmrUsdRate = () => {
const [rate, setRate] = useState<number | null>(cachedRate);
const [updatedAt, setUpdatedAt] = useState<number | null>(cachedAt);
useEffect(() => {
let active = true;
const now = Date.now();
const hasFreshRate =
cachedRate !== null && cachedAt !== null && now - cachedAt < RATE_TTL_MS;
if (hasFreshRate) {
setRate(cachedRate);
setUpdatedAt(cachedAt);
return;
}
const request =
inflight ??
(inflight = fetchRate().finally(() => {
inflight = null;
}));
request.then((nextRate) => {
if (!active) {
return;
}
if (nextRate !== null) {
setRate(nextRate);
setUpdatedAt(cachedAt);
}
});
return () => {
active = false;
};
}, []);
return { rate, updatedAt, source: "coingecko" as const };
};

Some files were not shown because too many files have changed in this diff Show More