From c1312d97f14094f5d51c83708f191a85217ab3f8 Mon Sep 17 00:00:00 2001 From: kieran Date: Tue, 11 Mar 2025 12:42:16 +0000 Subject: [PATCH] feat: revolut pay ref: https://git.v0l.io/LNVPS/api/issues/24 --- package.json | 1 + src/api.ts | 24 +++++++- src/components/cost.tsx | 29 +++++++--- src/components/revolut.tsx | 64 +++++++++++++++++++++ src/components/vps-payment.tsx | 5 +- src/pages/vm-billing.tsx | 101 +++++++++++++++++++++++++++++---- yarn.lock | 8 +++ 7 files changed, 207 insertions(+), 25 deletions(-) create mode 100644 src/components/revolut.tsx diff --git a/package.json b/package.json index caf0b86..7e6320e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@revolut/checkout": "^1.1.20", "@scure/base": "^1.2.1", "@snort/shared": "^1.0.17", "@snort/system": "^1.6.1", diff --git a/src/api.ts b/src/api.ts index 72039b5..95195d8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -138,11 +138,16 @@ export interface UserSshKey { export interface VmPayment { id: string; - invoice: string; created: string; expires: string; amount: number; is_paid: boolean; + data: { + lightning?: string; + revolut?: { + token: string; + }; + }; } export interface PatchVm { @@ -161,6 +166,12 @@ export interface TimeSeriesData { disk_read: number; } +export interface PaymentMethod { + name: string; + currencies: Array; + metadata?: Record; +} + export class LNVpsApi { constructor( readonly url: string, @@ -295,9 +306,9 @@ export class LNVpsApi { return data; } - async renewVm(vm_id: number) { + async renewVm(vm_id: number, method: string) { const { data } = await this.#handleResponse>( - await this.#req(`/api/v1/vm/${vm_id}/renew`, "GET"), + await this.#req(`/api/v1/vm/${vm_id}/renew?method=${method}`, "GET"), ); return data; } @@ -309,6 +320,13 @@ export class LNVpsApi { return data; } + async getPaymentMethods() { + const { data } = await this.#handleResponse< + ApiResponse> + >(await this.#req("/api/v1/payment/methods", "GET")); + return data; + } + async connect_terminal(id: number) { const u = `${this.url}/api/v1/console/${id}`; const auth = await this.#auth_event(u, "GET"); diff --git a/src/components/cost.tsx b/src/components/cost.tsx index ffb631b..640685f 100644 --- a/src/components/cost.tsx +++ b/src/components/cost.tsx @@ -1,7 +1,16 @@ -interface Price { currency: string, amount: number } -type Cost = Price & { interval_type?: string, other_price?: Array } +interface Price { + currency: string; + amount: number; +} +type Cost = Price & { interval_type?: string; other_price?: Array }; -export default function CostLabel({ cost, converted }: { cost: Cost, converted?: boolean }) { +export default function CostLabel({ + cost, + converted, +}: { + cost: Cost; + converted?: boolean; +}) { function intervalName(n: string) { switch (n) { case "day": @@ -16,13 +25,17 @@ export default function CostLabel({ cost, converted }: { cost: Cost, converted?: return (
{converted && "~"} - {cost.currency !== "BTC" ? cost.amount.toFixed(2) : Math.floor(cost.amount * 1e8).toLocaleString()} - {" "} + {cost.currency !== "BTC" + ? cost.amount.toFixed(2) + : Math.floor(cost.amount * 1e8).toLocaleString()}{" "} {cost.currency === "BTC" ? "sats" : cost.currency} {cost.interval_type && <>/{intervalName(cost.interval_type)}} - {cost.other_price && cost.other_price.map(a =>
- -
)} + {cost.other_price && + cost.other_price.map((a) => ( +
+ +
+ ))}
); } diff --git a/src/components/revolut.tsx b/src/components/revolut.tsx new file mode 100644 index 0000000..c700e38 --- /dev/null +++ b/src/components/revolut.tsx @@ -0,0 +1,64 @@ +import RevolutCheckout, { Mode } from "@revolut/checkout"; +import { useEffect, useRef } from "react"; + +interface RevolutProps { + amount: { + currency: string; + amount: number; + }; + pubkey: string; + loadOrder: () => Promise; + onPaid: () => void; + onCancel?: () => void; + mode?: string; +} + +export function RevolutPayWidget({ + pubkey, + loadOrder, + amount, + onPaid, + onCancel, + mode, +}: RevolutProps) { + const ref = useRef(null); + async function load(pubkey: string, ref: HTMLDivElement) { + const { revolutPay } = await RevolutCheckout.payments({ + locale: "auto", + mode: (mode ?? "sandbox") as Mode, + publicToken: pubkey, + }); + ref.innerHTML = ""; + revolutPay.mount(ref, { + sessionToken: "", + currency: amount.currency, + totalAmount: amount.amount, + createOrder: async () => { + const id = await loadOrder(); + return { + publicId: id, + }; + }, + buttonStyle: { + cashback: false, + }, + }); + revolutPay.on("payment", (payload) => { + console.debug(payload); + if (payload.type === "success") { + onPaid(); + } + if (payload.type === "cancel") { + onCancel?.(); + } + }); + } + + useEffect(() => { + if (ref.current) { + load(pubkey, ref.current); + } + }, [pubkey, ref]); + + return
; +} diff --git a/src/components/vps-payment.tsx b/src/components/vps-payment.tsx index 6a5ea04..e17bb36 100644 --- a/src/components/vps-payment.tsx +++ b/src/components/vps-payment.tsx @@ -11,7 +11,8 @@ export default function VpsPayment({ onPaid?: () => void; }) { const login = useLogin(); - const ln = `lightning:${payment.invoice}`; + const invoice = payment.data.lightning; + const ln = `lightning:${invoice}`; async function checkPayment(api: LNVpsApi) { try { @@ -49,7 +50,7 @@ export default function VpsPayment({ /> {(payment.amount / 1000).toLocaleString()} sats
- {payment.invoice} + {invoice}
); diff --git a/src/pages/vm-billing.tsx b/src/pages/vm-billing.tsx index 959d7ea..6606d43 100644 --- a/src/pages/vm-billing.tsx +++ b/src/pages/vm-billing.tsx @@ -1,16 +1,19 @@ import { useCallback, useEffect, useState } from "react"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; -import { VmInstance, VmPayment } from "../api"; +import { PaymentMethod, VmInstance, VmPayment } from "../api"; import VpsPayment from "../components/vps-payment"; import useLogin from "../hooks/login"; import { AsyncButton } from "../components/button"; import CostLabel from "../components/cost"; +import { RevolutPayWidget } from "../components/revolut"; export function VmBillingPage() { const location = useLocation() as { state?: VmInstance }; const params = useParams(); const login = useLogin(); const navigate = useNavigate(); + const [methods, setMethods] = useState>(); + const [method, setMethod] = useState(); const [payment, setPayment] = useState(); const [state, setState] = useState(location?.state); @@ -18,13 +21,85 @@ export function VmBillingPage() { if (!state) return; const newState = await login?.api.getVm(state.id); setState(newState); + setMethod(undefined); + setMethods(undefined); return newState; } - const renew = useCallback( + async function onPaid() { + setMethod(undefined); + setMethods(undefined); + const s = reloadVmState(); + if (params["action"] === "renew") { + navigate("/vm", { state: s }); + } + } + + function paymentMethod(v: PaymentMethod) { + const className = + "flex items-center justify-between px-3 py-2 bg-neutral-900 rounded-xl cursor-pointer"; + + switch (v.name) { + case "lightning": { + return ( +
{ + setMethod(v); + renew(v.name); + }} + > +
+ {v.name.toUpperCase()} ({v.currencies.join(",")}) +
+
Pay Now
+
+ ); + } + case "revolut": { + const pkey = v.metadata?.["pubkey"]; + if (!pkey) return Missing Revolut pubkey; + return ( +
+
+ {v.name.toUpperCase()} ({v.currencies.join(",")}) +
+ {state && ( + { + onPaid(); + }} + loadOrder={async () => { + if (!login?.api || !state) { + throw new Error("Not logged in"); + } + const p = await login.api.renewVm(state.id, v.name); + return p.data.revolut!.token; + }} + /> + )} +
+ ); + } + } + } + + const loadPaymentMethods = useCallback( async function () { if (!login?.api || !state) return; - const p = await login?.api.renewVm(state.id); + const p = await login?.api.getPaymentMethods(); + setMethods(p); + }, + [login?.api, state], + ); + + const renew = useCallback( + async function (m: string) { + if (!login?.api || !state) return; + const p = await login?.api.renewVm(state.id, m); setPayment(p); }, [login?.api, state], @@ -32,7 +107,7 @@ export function VmBillingPage() { useEffect(() => { if (params["action"] === "renew" && login && state) { - renew(); + loadPaymentMethods(); } }, [login, state, params, renew]); @@ -54,14 +129,20 @@ export function VmBillingPage() { Expires: {expireDate.toDateString()} ({Math.floor(days)} days) )} - {days < 0 && params["action"] !== "renew" && ( + {days < 0 && !methods && (
Expired
)} - {!payment && ( + {!methods && (
- Extend Now + Extend Now
)} + {methods && !method && ( + <> +
Payment Method:
+ {methods.map((v) => paymentMethod(v))} + + )} {payment && ( <>

Renew VPS

@@ -69,11 +150,7 @@ export function VmBillingPage() { payment={payment} onPaid={async () => { setPayment(undefined); - if (!login?.api || !state) return; - const s = await reloadVmState(); - if (params["action"] === "renew") { - navigate("/vm", { state: s }); - } + onPaid(); }} /> diff --git a/yarn.lock b/yarn.lock index 8aa6f7e..eb9c521 100644 --- a/yarn.lock +++ b/yarn.lock @@ -620,6 +620,13 @@ __metadata: languageName: node linkType: hard +"@revolut/checkout@npm:^1.1.20": + version: 1.1.20 + resolution: "@revolut/checkout@npm:1.1.20" + checksum: 10c0/8c1053a434ff759a4101f757e08a341331993e4be210fdd6c5def7e452ead9710dba300efe30fd8570a6686bd445ecd15716f5a5c93996a6d60713946e6317a0 + languageName: node + linkType: hard + "@rollup/rollup-android-arm-eabi@npm:4.20.0": version: 4.20.0 resolution: "@rollup/rollup-android-arm-eabi@npm:4.20.0" @@ -2593,6 +2600,7 @@ __metadata: resolution: "lnvps_web@workspace:." dependencies: "@eslint/js": "npm:^9.8.0" + "@revolut/checkout": "npm:^1.1.20" "@scure/base": "npm:^1.2.1" "@snort/shared": "npm:^1.0.17" "@snort/system": "npm:^1.6.1"