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" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@revolut/checkout": "^1.1.20",
"@scure/base": "^1.2.1", "@scure/base": "^1.2.1",
"@snort/shared": "^1.0.17", "@snort/shared": "^1.0.17",
"@snort/system": "^1.6.1", "@snort/system": "^1.6.1",

View File

@ -138,11 +138,16 @@ export interface UserSshKey {
export interface VmPayment { export interface VmPayment {
id: string; id: string;
invoice: string;
created: string; created: string;
expires: string; expires: string;
amount: number; amount: number;
is_paid: boolean; is_paid: boolean;
data: {
lightning?: string;
revolut?: {
token: string;
};
};
} }
export interface PatchVm { export interface PatchVm {
@ -161,6 +166,12 @@ export interface TimeSeriesData {
disk_read: number; disk_read: number;
} }
export interface PaymentMethod {
name: string;
currencies: Array<string>;
metadata?: Record<string, string>;
}
export class LNVpsApi { export class LNVpsApi {
constructor( constructor(
readonly url: string, readonly url: string,
@ -295,9 +306,9 @@ export class LNVpsApi {
return data; return data;
} }
async renewVm(vm_id: number) { async renewVm(vm_id: number, method: string) {
const { data } = await this.#handleResponse<ApiResponse<VmPayment>>( 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; return data;
} }
@ -309,6 +320,13 @@ export class LNVpsApi {
return data; 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) { async connect_terminal(id: number) {
const u = `${this.url}/api/v1/console/${id}`; const u = `${this.url}/api/v1/console/${id}`;
const auth = await this.#auth_event(u, "GET"); const auth = await this.#auth_event(u, "GET");

View File

@ -1,7 +1,16 @@
interface Price { currency: string, amount: number } interface Price {
type Cost = Price & { interval_type?: string, other_price?: Array<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) { function intervalName(n: string) {
switch (n) { switch (n) {
case "day": case "day":
@ -16,13 +25,17 @@ export default function CostLabel({ cost, converted }: { cost: Cost, converted?:
return ( return (
<div> <div>
{converted && "~"} {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.currency === "BTC" ? "sats" : cost.currency}
{cost.interval_type && <>/{intervalName(cost.interval_type)}</>} {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} /> <CostLabel cost={a} converted={true} />
</div>)} </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; onPaid?: () => void;
}) { }) {
const login = useLogin(); const login = useLogin();
const ln = `lightning:${payment.invoice}`; const invoice = payment.data.lightning;
const ln = `lightning:${invoice}`;
async function checkPayment(api: LNVpsApi) { async function checkPayment(api: LNVpsApi) {
try { try {
@ -49,7 +50,7 @@ export default function VpsPayment({
/> />
{(payment.amount / 1000).toLocaleString()} sats {(payment.amount / 1000).toLocaleString()} sats
<div className="monospace select-all break-all text-center text-sm"> <div className="monospace select-all break-all text-center text-sm">
{payment.invoice} {invoice}
</div> </div>
</div> </div>
); );

View File

@ -1,16 +1,19 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; 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 VpsPayment from "../components/vps-payment";
import useLogin from "../hooks/login"; import useLogin from "../hooks/login";
import { AsyncButton } from "../components/button"; import { AsyncButton } from "../components/button";
import CostLabel from "../components/cost"; import CostLabel from "../components/cost";
import { RevolutPayWidget } from "../components/revolut";
export function VmBillingPage() { export function VmBillingPage() {
const location = useLocation() as { state?: VmInstance }; const location = useLocation() as { state?: VmInstance };
const params = useParams(); const params = useParams();
const login = useLogin(); const login = useLogin();
const navigate = useNavigate(); const navigate = useNavigate();
const [methods, setMethods] = useState<Array<PaymentMethod>>();
const [method, setMethod] = useState<PaymentMethod>();
const [payment, setPayment] = useState<VmPayment>(); const [payment, setPayment] = useState<VmPayment>();
const [state, setState] = useState<VmInstance | undefined>(location?.state); const [state, setState] = useState<VmInstance | undefined>(location?.state);
@ -18,13 +21,85 @@ export function VmBillingPage() {
if (!state) return; if (!state) return;
const newState = await login?.api.getVm(state.id); const newState = await login?.api.getVm(state.id);
setState(newState); setState(newState);
setMethod(undefined);
setMethods(undefined);
return newState; 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 () { async function () {
if (!login?.api || !state) return; 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); setPayment(p);
}, },
[login?.api, state], [login?.api, state],
@ -32,7 +107,7 @@ export function VmBillingPage() {
useEffect(() => { useEffect(() => {
if (params["action"] === "renew" && login && state) { if (params["action"] === "renew" && login && state) {
renew(); loadPaymentMethods();
} }
}, [login, state, params, renew]); }, [login, state, params, renew]);
@ -54,14 +129,20 @@ export function VmBillingPage() {
Expires: {expireDate.toDateString()} ({Math.floor(days)} days) Expires: {expireDate.toDateString()} ({Math.floor(days)} days)
</div> </div>
)} )}
{days < 0 && params["action"] !== "renew" && ( {days < 0 && !methods && (
<div className="text-red-500 text-xl">Expired</div> <div className="text-red-500 text-xl">Expired</div>
)} )}
{!payment && ( {!methods && (
<div> <div>
<AsyncButton onClick={renew}>Extend Now</AsyncButton> <AsyncButton onClick={loadPaymentMethods}>Extend Now</AsyncButton>
</div> </div>
)} )}
{methods && !method && (
<>
<div className="text-xl">Payment Method:</div>
{methods.map((v) => paymentMethod(v))}
</>
)}
{payment && ( {payment && (
<> <>
<h3>Renew VPS</h3> <h3>Renew VPS</h3>
@ -69,11 +150,7 @@ export function VmBillingPage() {
payment={payment} payment={payment}
onPaid={async () => { onPaid={async () => {
setPayment(undefined); setPayment(undefined);
if (!login?.api || !state) return; onPaid();
const s = await reloadVmState();
if (params["action"] === "renew") {
navigate("/vm", { state: s });
}
}} }}
/> />
</> </>

View File

@ -620,6 +620,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@rollup/rollup-android-arm-eabi@npm:4.20.0":
version: 4.20.0 version: 4.20.0
resolution: "@rollup/rollup-android-arm-eabi@npm:4.20.0" resolution: "@rollup/rollup-android-arm-eabi@npm:4.20.0"
@ -2593,6 +2600,7 @@ __metadata:
resolution: "lnvps_web@workspace:." resolution: "lnvps_web@workspace:."
dependencies: dependencies:
"@eslint/js": "npm:^9.8.0" "@eslint/js": "npm:^9.8.0"
"@revolut/checkout": "npm:^1.1.20"
"@scure/base": "npm:^1.2.1" "@scure/base": "npm:^1.2.1"
"@snort/shared": "npm:^1.0.17" "@snort/shared": "npm:^1.0.17"
"@snort/system": "npm:^1.6.1" "@snort/system": "npm:^1.6.1"