Integrate BTCPay Server: auto-settling invoice deposits
- lib/btcpay.ts: Greenfield API client (createBtcPayInvoice, getBtcPayInvoiceStatus) - /api/btcpay/invoice: creates per-deposit invoice (USD amount + handle in metadata) - /api/btcpay/status/[id]: polls invoice status + returns BTC address/amount - add-funds page: BTCPay tab with amount picker, live address display, 10s auto-poll, settlement auto-credits localStorage ledger - WalletContext: creditBtcPayInvoice() with duplicate-invoice guard - .env.example: BTCPAY_URL, BTCPAY_API_KEY, BTCPAY_STORE_ID, NEXT_PUBLIC_BTCPAY_ENABLED Made-with: Cursor
This commit is contained in:
File diff suppressed because it is too large
Load Diff
44
app/api/btcpay/invoice/route.ts
Normal file
44
app/api/btcpay/invoice/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createBtcPayInvoice, isBtcPayConfigured } from "@/lib/btcpay";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
if (!isBtcPayConfigured()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "BTCPay not configured — set BTCPAY_URL, BTCPAY_API_KEY, BTCPAY_STORE_ID in .env.local" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try { body = await req.json(); } catch {
|
||||||
|
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { usdAmount, handle } = (body as Record<string, unknown>) ?? {};
|
||||||
|
|
||||||
|
if (typeof usdAmount !== "number" || usdAmount <= 0 || !Number.isFinite(usdAmount)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "usdAmount must be a positive number" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof handle !== "string" || !handle.trim()) {
|
||||||
|
return NextResponse.json({ ok: false, error: "handle is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (usdAmount < 0.5) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Minimum deposit is $0.50" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (usdAmount > 50000) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Maximum single deposit is $50,000" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createBtcPayInvoice(usdAmount, handle.trim());
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json({ ok: false, error: result.error }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
invoiceId: result.invoice.id,
|
||||||
|
checkoutLink: result.invoice.checkoutLink,
|
||||||
|
status: result.invoice.status,
|
||||||
|
expiresAt: result.invoice.expirationTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
32
app/api/btcpay/status/[invoiceId]/route.ts
Normal file
32
app/api/btcpay/status/[invoiceId]/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getBtcPayInvoiceStatus, isBtcPayConfigured } from "@/lib/btcpay";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: { invoiceId: string } },
|
||||||
|
) {
|
||||||
|
if (!isBtcPayConfigured()) {
|
||||||
|
return NextResponse.json({ ok: false, error: "BTCPay not configured" }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { invoiceId } = params;
|
||||||
|
if (!invoiceId || invoiceId.length < 4) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Invalid invoice ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getBtcPayInvoiceStatus(invoiceId);
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json({ ok: false, error: result.error }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
status: result.status,
|
||||||
|
usdAmount: result.usdAmount,
|
||||||
|
btcAddress: result.paymentMethod?.destination ?? null,
|
||||||
|
btcAmount: result.paymentMethod?.due ?? null,
|
||||||
|
btcRate: result.paymentMethod?.rate ?? null,
|
||||||
|
totalPaid: result.paymentMethod?.totalPaid ?? null,
|
||||||
|
settled: result.status === "Settled",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ interface WalletContextType {
|
|||||||
earnLuxCredits: (amount: number) => void;
|
earnLuxCredits: (amount: number) => void;
|
||||||
spendUsdStoreCredit: (amount: number) => boolean;
|
spendUsdStoreCredit: (amount: number) => boolean;
|
||||||
verifyBtcDeposit: (txid: string) => Promise<{ ok: boolean; error?: string; creditedUsd?: number }>;
|
verifyBtcDeposit: (txid: string) => Promise<{ ok: boolean; error?: string; creditedUsd?: number }>;
|
||||||
|
creditBtcPayInvoice: (invoiceId: string, usdAmount: number) => { ok: boolean; error?: string };
|
||||||
vault: VaultState;
|
vault: VaultState;
|
||||||
collectKey: (key: string) => boolean;
|
collectKey: (key: string) => boolean;
|
||||||
addVaultReceipt: (receipt: VaultReceipt) => void;
|
addVaultReceipt: (receipt: VaultReceipt) => void;
|
||||||
@@ -141,6 +142,23 @@ export const WalletProvider = ({ children }: WalletProviderProps) => {
|
|||||||
[handle],
|
[handle],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const creditBtcPayInvoice = useCallback(
|
||||||
|
(invoiceId: string, usdAmount: number) => {
|
||||||
|
if (!handle) return { ok: false, error: "Sign in first" };
|
||||||
|
const id = String(invoiceId || "").trim();
|
||||||
|
if (!id) return { ok: false, error: "Invalid invoice ID" };
|
||||||
|
const safe = Number.isFinite(usdAmount) ? Math.max(0, Math.round(usdAmount * 100) / 100) : 0;
|
||||||
|
if (safe <= 0) return { ok: false, error: "Invalid amount" };
|
||||||
|
const row = getLedgerRow(handle);
|
||||||
|
if (row.claimedTxids.includes(id)) return { ok: false, error: "Invoice already credited" };
|
||||||
|
const nextUsd = Math.round((row.usd + safe) * 100) / 100;
|
||||||
|
setLedgerRow(handle, { usd: nextUsd, lux: row.lux, claimedTxids: [...row.claimedTxids, id] });
|
||||||
|
setUsdStoreCredit(nextUsd);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
[handle],
|
||||||
|
);
|
||||||
|
|
||||||
const collectKey = (key: string) => {
|
const collectKey = (key: string) => {
|
||||||
const trimmed = String(key || "").trim();
|
const trimmed = String(key || "").trim();
|
||||||
if (!trimmed) return false;
|
if (!trimmed) return false;
|
||||||
@@ -167,6 +185,7 @@ export const WalletProvider = ({ children }: WalletProviderProps) => {
|
|||||||
earnLuxCredits,
|
earnLuxCredits,
|
||||||
spendUsdStoreCredit,
|
spendUsdStoreCredit,
|
||||||
verifyBtcDeposit,
|
verifyBtcDeposit,
|
||||||
|
creditBtcPayInvoice,
|
||||||
vault,
|
vault,
|
||||||
collectKey,
|
collectKey,
|
||||||
addVaultReceipt,
|
addVaultReceipt,
|
||||||
@@ -179,6 +198,7 @@ export const WalletProvider = ({ children }: WalletProviderProps) => {
|
|||||||
earnLuxCredits,
|
earnLuxCredits,
|
||||||
spendUsdStoreCredit,
|
spendUsdStoreCredit,
|
||||||
verifyBtcDeposit,
|
verifyBtcDeposit,
|
||||||
|
creditBtcPayInvoice,
|
||||||
vault,
|
vault,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
149
lib/btcpay.ts
Normal file
149
lib/btcpay.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* BTCPay Server Greenfield API v1 client.
|
||||||
|
*
|
||||||
|
* Env vars required (set in .env.local):
|
||||||
|
* BTCPAY_URL e.g. http://10.30.20.237
|
||||||
|
* BTCPAY_API_KEY generated in BTCPay → Account → Manage account → API keys
|
||||||
|
* BTCPAY_STORE_ID found in BTCPay → Settings → General (the alphanumeric ID in the URL)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BtcPayInvoiceStatus =
|
||||||
|
| "New"
|
||||||
|
| "Processing"
|
||||||
|
| "Expired"
|
||||||
|
| "Invalid"
|
||||||
|
| "Settled";
|
||||||
|
|
||||||
|
export type BtcPayInvoice = {
|
||||||
|
id: string;
|
||||||
|
status: BtcPayInvoiceStatus;
|
||||||
|
amount: string;
|
||||||
|
currency: string;
|
||||||
|
checkoutLink: string;
|
||||||
|
createdTime: number;
|
||||||
|
expirationTime: number;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BtcPayPaymentMethod = {
|
||||||
|
paymentMethod: string; // "BTC"
|
||||||
|
destination: string; // bc1q... address
|
||||||
|
paymentLink: string; // bitcoin:... URI
|
||||||
|
rate: string; // BTC/USD rate at time of invoice
|
||||||
|
due: string; // BTC amount customer must send
|
||||||
|
amount: string; // total BTC amount
|
||||||
|
totalPaid: string; // BTC paid so far
|
||||||
|
};
|
||||||
|
|
||||||
|
function btcPayConfig() {
|
||||||
|
const url = (process.env.BTCPAY_URL ?? "").replace(/\/$/, "");
|
||||||
|
const apiKey = process.env.BTCPAY_API_KEY ?? "";
|
||||||
|
const storeId = process.env.BTCPAY_STORE_ID ?? "";
|
||||||
|
return { url, apiKey, storeId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBtcPayConfigured(): boolean {
|
||||||
|
const { url, apiKey, storeId } = btcPayConfig();
|
||||||
|
return Boolean(url && apiKey && storeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(apiKey: string) {
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `token ${apiKey}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new BTCPay invoice.
|
||||||
|
* `usdAmount` is the dollar amount the customer wants to deposit.
|
||||||
|
* BTCPay converts this to the BTC equivalent at current rate.
|
||||||
|
*/
|
||||||
|
export async function createBtcPayInvoice(
|
||||||
|
usdAmount: number,
|
||||||
|
handle: string,
|
||||||
|
): Promise<{ ok: true; invoice: BtcPayInvoice } | { ok: false; error: string }> {
|
||||||
|
const { url, apiKey, storeId } = btcPayConfig();
|
||||||
|
if (!url || !apiKey || !storeId) {
|
||||||
|
return { ok: false, error: "BTCPay not configured — set BTCPAY_URL, BTCPAY_API_KEY, BTCPAY_STORE_ID" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}/api/v1/stores/${storeId}/invoices`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: authHeaders(apiKey),
|
||||||
|
body: JSON.stringify({
|
||||||
|
amount: usdAmount.toFixed(2),
|
||||||
|
currency: "USD",
|
||||||
|
metadata: {
|
||||||
|
cyberlux_handle: handle,
|
||||||
|
source: "cyberlux-deposit-desk",
|
||||||
|
},
|
||||||
|
checkout: {
|
||||||
|
speedPolicy: "LowSpeed", // 1 confirmation required
|
||||||
|
paymentMethods: ["BTC"],
|
||||||
|
defaultPaymentMethod: "BTC",
|
||||||
|
expirationMinutes: 60,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
return { ok: false, error: `BTCPay error ${res.status}: ${text.slice(0, 120)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = (await res.json()) as BtcPayInvoice;
|
||||||
|
return { ok: true, invoice };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: `Network error reaching BTCPay: ${String(err).slice(0, 100)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch current status and payment method details for an invoice.
|
||||||
|
*/
|
||||||
|
export async function getBtcPayInvoiceStatus(invoiceId: string): Promise<{
|
||||||
|
ok: true;
|
||||||
|
status: BtcPayInvoiceStatus;
|
||||||
|
usdAmount: number;
|
||||||
|
paymentMethod?: BtcPayPaymentMethod;
|
||||||
|
} | { ok: false; error: string }> {
|
||||||
|
const { url, apiKey, storeId } = btcPayConfig();
|
||||||
|
if (!url || !apiKey || !storeId) {
|
||||||
|
return { ok: false, error: "BTCPay not configured" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch invoice
|
||||||
|
const invRes = await fetch(`${url}/api/v1/stores/${storeId}/invoices/${invoiceId}`, {
|
||||||
|
headers: authHeaders(apiKey),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!invRes.ok) {
|
||||||
|
return { ok: false, error: `Invoice not found (${invRes.status})` };
|
||||||
|
}
|
||||||
|
const invoice = (await invRes.json()) as BtcPayInvoice;
|
||||||
|
|
||||||
|
// Fetch payment methods to get the BTC address
|
||||||
|
const pmRes = await fetch(
|
||||||
|
`${url}/api/v1/stores/${storeId}/invoices/${invoiceId}/payment-methods`,
|
||||||
|
{ headers: authHeaders(apiKey), cache: "no-store" },
|
||||||
|
);
|
||||||
|
let paymentMethod: BtcPayPaymentMethod | undefined;
|
||||||
|
if (pmRes.ok) {
|
||||||
|
const methods = (await pmRes.json()) as BtcPayPaymentMethod[];
|
||||||
|
paymentMethod = methods.find((m) => m.paymentMethod === "BTC");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: invoice.status,
|
||||||
|
usdAmount: parseFloat(invoice.amount),
|
||||||
|
paymentMethod,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: `Network error: ${String(err).slice(0, 100)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user