feat: revolut pay
All checks were successful
continuous-integration/drone/push Build is passing

ref: LNVPS/api#24
This commit is contained in:
2025-03-11 12:42:16 +00:00
parent 57cc619b8c
commit c1312d97f1
7 changed files with 207 additions and 25 deletions

View File

@ -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",

View File

@ -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<string>;
metadata?: Record<string, string>;
}
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<ApiResponse<VmPayment>>(
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<Array<PaymentMethod>>
>(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");

View File

@ -1,7 +1,16 @@
interface Price { currency: string, amount: number }
type Cost = Price & { interval_type?: string, other_price?: Array<Price> }
interface Price {
currency: string;
amount: number;
}
type Cost = Price & { interval_type?: string; other_price?: Array<Price> };
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 (
<div>
{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 => <div key={a.currency} className="text-xs">
{cost.other_price &&
cost.other_price.map((a) => (
<div key={a.currency} className="text-xs">
<CostLabel cost={a} converted={true} />
</div>)}
</div>
))}
</div>
);
}

View File

@ -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<string>;
onPaid: () => void;
onCancel?: () => void;
mode?: string;
}
export function RevolutPayWidget({
pubkey,
loadOrder,
amount,
onPaid,
onCancel,
mode,
}: RevolutProps) {
const ref = useRef<HTMLDivElement | null>(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 <div ref={ref}></div>;
}

View File

@ -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
<div className="monospace select-all break-all text-center text-sm">
{payment.invoice}
{invoice}
</div>
</div>
);

View File

@ -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<Array<PaymentMethod>>();
const [method, setMethod] = useState<PaymentMethod>();
const [payment, setPayment] = useState<VmPayment>();
const [state, setState] = useState<VmInstance | undefined>(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 (
<div
key={v.name}
className={className}
onClick={() => {
setMethod(v);
renew(v.name);
}}
>
<div>
{v.name.toUpperCase()} ({v.currencies.join(",")})
</div>
<div className="rounded-lg p-2 bg-green-800">Pay Now</div>
</div>
);
}
case "revolut": {
const pkey = v.metadata?.["pubkey"];
if (!pkey) return <b>Missing Revolut pubkey</b>;
return (
<div key={v.name} className={className}>
<div>
{v.name.toUpperCase()} ({v.currencies.join(",")})
</div>
{state && (
<RevolutPayWidget
pubkey={pkey}
amount={state.template.cost_plan}
onPaid={() => {
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;
}}
/>
)}
</div>
);
}
}
}
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)
</div>
)}
{days < 0 && params["action"] !== "renew" && (
{days < 0 && !methods && (
<div className="text-red-500 text-xl">Expired</div>
)}
{!payment && (
{!methods && (
<div>
<AsyncButton onClick={renew}>Extend Now</AsyncButton>
<AsyncButton onClick={loadPaymentMethods}>Extend Now</AsyncButton>
</div>
)}
{methods && !method && (
<>
<div className="text-xl">Payment Method:</div>
{methods.map((v) => paymentMethod(v))}
</>
)}
{payment && (
<>
<h3>Renew VPS</h3>
@ -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();
}}
/>
</>

View File

@ -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"