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:
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