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

ref: LNVPS/api#18
This commit is contained in:
2025-03-11 15:59:32 +00:00
parent c1312d97f1
commit 26d36adbeb
9 changed files with 149 additions and 116 deletions

View File

@ -1 +1,2 @@
#VITE_API_URL="http://localhost:8000" VITE_API_URL="http://localhost:8000"
VITE_REVOLUT_MODE="sandbox"

View File

@ -20,6 +20,7 @@
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"iso-3166-1": "^2.1.1",
"marked": "^15.0.7", "marked": "^15.0.7",
"qr-code-styling": "^1.8.4", "qr-code-styling": "^1.8.4",
"react": "^18.3.1", "react": "^18.3.1",

View File

@ -24,6 +24,7 @@ export interface AccountDetail {
email?: string; email?: string;
contact_nip17: boolean; contact_nip17: boolean;
contact_email: boolean; contact_email: boolean;
country_code: string;
} }
export interface VmCostPlan { export interface VmCostPlan {
@ -141,6 +142,7 @@ export interface VmPayment {
created: string; created: string;
expires: string; expires: string;
amount: number; amount: number;
tax: number;
is_paid: boolean; is_paid: boolean;
data: { data: {
lightning?: string; lightning?: string;

View File

@ -1,64 +1,65 @@
import RevolutCheckout, { Mode } from "@revolut/checkout"; import RevolutCheckout, { Mode } from "@revolut/checkout";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { VmCostPlan } from "../api";
interface RevolutProps { interface RevolutProps {
amount: { amount: VmCostPlan | {
currency: string; amount: number;
amount: number; currency: string;
}; tax?: number;
pubkey: string; };
loadOrder: () => Promise<string>; pubkey: string;
onPaid: () => void; loadOrder: () => Promise<string>;
onCancel?: () => void; onPaid: () => void;
mode?: string; onCancel?: () => void;
mode?: string;
} }
export function RevolutPayWidget({ export function RevolutPayWidget({
pubkey, pubkey,
loadOrder, loadOrder,
amount, amount,
onPaid, onPaid,
onCancel, onCancel,
mode, mode,
}: RevolutProps) { }: RevolutProps) {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
async function load(pubkey: string, ref: HTMLDivElement) { async function load(pubkey: string, ref: HTMLDivElement) {
const { revolutPay } = await RevolutCheckout.payments({ const { revolutPay } = await RevolutCheckout.payments({
locale: "auto", locale: "auto",
mode: (mode ?? "sandbox") as Mode, mode: (mode ?? "prod") as Mode,
publicToken: pubkey, publicToken: pubkey,
}); });
ref.innerHTML = ""; ref.innerHTML = "";
revolutPay.mount(ref, { revolutPay.mount(ref, {
sessionToken: "", currency: amount.currency,
currency: amount.currency, totalAmount: amount.amount,
totalAmount: amount.amount, createOrder: async () => {
createOrder: async () => { const id = await loadOrder();
const id = await loadOrder(); return {
return { publicId: id,
publicId: id, };
}; },
}, buttonStyle: {
buttonStyle: { cashback: false,
cashback: false, },
}, });
}); revolutPay.on("payment", (payload) => {
revolutPay.on("payment", (payload) => { console.debug(payload);
console.debug(payload); if (payload.type === "success") {
if (payload.type === "success") { onPaid();
onPaid(); }
} if (payload.type === "cancel") {
if (payload.type === "cancel") { onCancel?.();
onCancel?.(); }
} });
});
}
useEffect(() => {
if (ref.current) {
load(pubkey, ref.current);
} }
}, [pubkey, ref]);
return <div ref={ref}></div>; useEffect(() => {
if (ref.current) {
load(pubkey, ref.current);
}
}, [pubkey, ref]);
return <div ref={ref}></div>;
} }

View File

@ -48,7 +48,12 @@ export default function VpsPayment({
avatar="/logo.jpg" avatar="/logo.jpg"
className="cursor-pointer rounded-xl overflow-hidden" className="cursor-pointer rounded-xl overflow-hidden"
/> />
{(payment.amount / 1000).toLocaleString()} sats <div className="flex flex-col items-center">
<div>{((payment.amount + payment.tax) / 1000).toLocaleString()} sats</div>
{payment.tax > 0 && <div className="text-xs">
including {(payment.tax / 1000).toLocaleString()} sats tax
</div>}
</div>
<div className="monospace select-all break-all text-center text-sm"> <div className="monospace select-all break-all text-center text-sm">
{invoice} {invoice}
</div> </div>

View File

@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { AccountDetail } from "../api"; import { AccountDetail } from "../api";
import { AsyncButton } from "../components/button"; import { AsyncButton } from "../components/button";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { default as iso } from "iso-3166-1";
export function AccountSettings() { export function AccountSettings() {
const login = useLogin(); const login = useLogin();
@ -13,63 +14,73 @@ export function AccountSettings() {
login?.api.getAccount().then(setAcc); login?.api.getAccount().then(setAcc);
}, [login]); }, [login]);
function notifications() { if (!acc) return;
return ( return <div className="flex flex-col gap-4">
<> <h3>
<h3>Notification Settings</h3> Account Settings
<div className="flex gap-2 items-center"> </h3>
<input
type="checkbox"
checked={acc?.contact_email ?? false}
onChange={(e) => {
setAcc((s) =>
s ? { ...s, contact_email: e.target.checked } : undefined,
);
}}
/>
Email
<input
type="checkbox"
checked={acc?.contact_nip17 ?? false}
onChange={(e) => {
setAcc((s) =>
s ? { ...s, contact_nip17: e.target.checked } : undefined,
);
}}
/>
Nostr DM
</div>
<div className="flex gap-2 items-center">
<h4>Email</h4>
<input
type="text"
disabled={!editEmail}
value={acc?.email}
onChange={(e) =>
setAcc((s) => (s ? { ...s, email: e.target.value } : undefined))
}
/>
{!editEmail && (
<Icon name="pencil" onClick={() => setEditEmail(true)} />
)}
</div>
<div>
<AsyncButton
onClick={async () => {
if (login?.api && acc) {
await login.api.updateAccount(acc);
const newAcc = await login.api.getAccount();
setAcc(newAcc);
setEditEmail(false);
}
}}
>
Save
</AsyncButton>
</div>
</>
);
}
return <>{notifications()}</>; <div className="flex gap-2 items-center">
<h4>Country</h4>
<select value={acc?.country_code}
onChange={(e) =>
setAcc((s) => (s ? { ...s, country_code: e.target.value } : undefined))
}
>
{iso.all().map(c => <option value={c.alpha3}>{c.country}</option>)}
</select>
</div>
<h3>Notification Settings</h3>
<div className="flex gap-2 items-center">
<input
type="checkbox"
checked={acc?.contact_email ?? false}
onChange={(e) => {
setAcc((s) =>
s ? { ...s, contact_email: e.target.checked } : undefined,
);
}}
/>
Email
<input
type="checkbox"
checked={acc?.contact_nip17 ?? false}
onChange={(e) => {
setAcc((s) =>
s ? { ...s, contact_nip17: e.target.checked } : undefined,
);
}}
/>
Nostr DM
</div>
<div className="flex gap-2 items-center">
<h4>Email</h4>
<input
type="text"
disabled={!editEmail}
value={acc?.email}
onChange={(e) =>
setAcc((s) => (s ? { ...s, email: e.target.value } : undefined))
}
/>
{!editEmail && (
<Icon name="pencil" onClick={() => setEditEmail(true)} />
)}
</div>
<div>
<AsyncButton
onClick={async () => {
if (login?.api && acc) {
await login.api.updateAccount(acc);
const newAcc = await login.api.getAccount();
setAcc(newAcc);
setEditEmail(false);
}
}}
>
Save
</AsyncButton>
</div>
</div>
} }

View File

@ -34,8 +34,8 @@ export default function HomePage() {
{offers?.custom_template && ( {offers?.custom_template && (
<VpsCustomOrder templates={offers.custom_template} /> <VpsCustomOrder templates={offers.custom_template} />
)} )}
<small className="text-neutral-400"> <small className="text-neutral-400 text-center">
All VPS come with 1x IPv4 and 1x IPv6 address and unmetered traffic All VPS come with 1x IPv4 and 1x IPv6 address and unmetered traffic, all prices are excluding taxes.
</small> </small>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="text-center"> <div className="text-center">

View File

@ -67,6 +67,7 @@ export function VmBillingPage() {
</div> </div>
{state && ( {state && (
<RevolutPayWidget <RevolutPayWidget
mode={import.meta.env.VITE_REVOLUT_MODE}
pubkey={pkey} pubkey={pkey}
amount={state.template.cost_plan} amount={state.template.cost_plan}
onPaid={() => { onPaid={() => {
@ -122,7 +123,10 @@ export function VmBillingPage() {
</Link> </Link>
<div className="text-xl bg-neutral-900 rounded-xl px-3 py-4 flex justify-between items-center"> <div className="text-xl bg-neutral-900 rounded-xl px-3 py-4 flex justify-between items-center">
<div>Renewal for #{state.id}</div> <div>Renewal for #{state.id}</div>
<CostLabel cost={state.template.cost_plan} /> <div>
<CostLabel cost={state.template.cost_plan} />
<span className="text-sm">ex. tax</span>
</div>
</div> </div>
{days > 0 && ( {days > 0 && (
<div> <div>

View File

@ -2451,6 +2451,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"iso-3166-1@npm:^2.1.1":
version: 2.1.1
resolution: "iso-3166-1@npm:2.1.1"
checksum: 10c0/65daf0283d22b2848d733a50ba6116a01df3e4d77970bb31d0df0696b4b386bdad0946dea53c9bb1e748e5e5e74608c07b6f4fe044908a5cffbf46f315385400
languageName: node
linkType: hard
"isomorphic-ws@npm:^5.0.0": "isomorphic-ws@npm:^5.0.0":
version: 5.0.0 version: 5.0.0
resolution: "isomorphic-ws@npm:5.0.0" resolution: "isomorphic-ws@npm:5.0.0"
@ -2618,6 +2625,7 @@ __metadata:
eslint-plugin-react-hooks: "npm:^5.1.0-rc.0" eslint-plugin-react-hooks: "npm:^5.1.0-rc.0"
eslint-plugin-react-refresh: "npm:^0.4.9" eslint-plugin-react-refresh: "npm:^0.4.9"
globals: "npm:^15.9.0" globals: "npm:^15.9.0"
iso-3166-1: "npm:^2.1.1"
marked: "npm:^15.0.7" marked: "npm:^15.0.7"
postcss: "npm:^8.4.41" postcss: "npm:^8.4.41"
prettier: "npm:^3.3.3" prettier: "npm:^3.3.3"