Skip to content
54 changes: 53 additions & 1 deletion src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,27 @@ fn group_error(group: &mut String) -> Option<&'static str> {
None
}

/// Normalizes the currency and billing cycle, or names the one that cannot be
/// stored. The currency is held to the ISO 4217 form of three letters, the only
/// one `Intl.NumberFormat` accepts, so a theme may pass it on without guarding
/// against a throw. Each cycle length is stored in a single spelling, see
/// `cycle_name`.
fn billing_error(currency: Option<&mut String>, cycle: Option<&mut String>) -> Option<&'static str> {
if let Some(code) = currency {
*code = code.trim().to_ascii_uppercase();
if !(code.len() == 3 && code.bytes().all(|b| b.is_ascii_uppercase())) {
return Some("货币要填三个字母的代码,如 USD、CNY、HKD");
}
}
if let Some(cycle) = cycle.filter(|c| *c != "once") {
let Some(months) = crate::cycle_months(cycle) else {
return Some("付款周期要是整月,在 1 个月到 100 年之间");
};
*cycle = crate::cycle_name(months);
}
None
}

/// Normalizes a patch, or names the first value that cannot be stored. The one
/// check both the single and the batch write pass through, so the two accept
/// exactly the same values.
Expand All @@ -695,7 +716,9 @@ fn patch_error(node: &mut NodePatch) -> Option<&'static str> {
return Some(message);
}
}
node_limits(node.traffic_reset_day, node.price, node.traffic_limit).or_else(|| pins(node))
node_limits(node.traffic_reset_day, node.price, node.traffic_limit)
.or_else(|| billing_error(node.currency.as_mut(), node.billing_cycle.as_mut()))
.or_else(|| pins(node))
}

/// Normalizes the values set by hand, or names the one that cannot stand. Each
Expand Down Expand Up @@ -762,6 +785,7 @@ pub async fn create_node(
if let Some(message) =
node_limits(Some(node.traffic_reset_day), Some(node.price), Some(node.traffic_limit))
.or_else(|| group_error(&mut node.group))
.or_else(|| billing_error(Some(&mut node.currency), Some(&mut node.billing_cycle)))
{
return bad(message);
}
Expand Down Expand Up @@ -2871,6 +2895,11 @@ mod tests {
json!({"name": "x", "traffic_reset_day": 99}),
json!({"name": "x", "price": -5.0}),
json!({"name": "x", "traffic_limit": -1}),
json!({"name": "x", "currency": "USDT"}),
json!({"name": "x", "currency": "港币"}),
json!({"name": "x", "billing_cycle": "0m"}),
json!({"name": "x", "billing_cycle": "1201m"}),
json!({"name": "x", "billing_cycle": "weekly"}),
] {
let created = create_node(
Admin,
Expand All @@ -2892,6 +2921,29 @@ mod tests {
assert_eq!(app.db.nodes().unwrap().len(), 1, "nothing was created");
}

/// A length with a name is stored under it, so a theme built for hub 1.3.0
/// still labels it.
#[tokio::test]
async fn currency_and_cycle_are_stored_in_one_spelling() {
let app = std::sync::Arc::new(app());
let id = node(&app, "n", true);
for (sent, currency, cycle) in [
(json!({"currency": " hkd ", "billing_cycle": "60m"}), "HKD", "60m"),
(json!({"billing_cycle": "12m"}), "HKD", "yearly"),
(json!({"billing_cycle": "once"}), "HKD", "once"),
] {
let put = update_node(
Admin,
State(app.clone()),
Path(id),
Ok(Json(serde_json::from_value(sent).unwrap())),
);
assert_eq!(put.await.status(), StatusCode::OK);
let stored = app.db.node(id).unwrap().unwrap();
assert_eq!((stored.currency.as_str(), stored.billing_cycle.as_str()), (currency, cycle));
}
}

/// A stream outlives the request that opened it, so everything the handshake
/// tested must be re-read rather than captured -- both answers, not one. The
/// admin frame carries every node's token in the clear, and the public frame
Expand Down
37 changes: 28 additions & 9 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -673,17 +673,34 @@ fn new_password(db: &Db) -> Result<String> {
Ok(password)
}

/// Cycles stored under a name. Any other length is stored as `<n>m`; the names
/// remain because themes built for hub 1.3.0 and earlier recognize only these.
const NAMED_CYCLES: [(&str, u32); 6] = [
("monthly", 1),
("quarterly", 3),
("semiannual", 6),
("yearly", 12),
("biennial", 24),
("triennial", 36),
];

/// The longest cycle accepted, in months: 100 years.
const MAX_CYCLE: u32 = 1_200;

/// Billing cycles as whole months. `once` has none, so it never rolls over.
fn cycle_months(cycle: &str) -> Option<u32> {
Some(match cycle {
"monthly" => 1,
"quarterly" => 3,
"semiannual" => 6,
"yearly" => 12,
"biennial" => 24,
"triennial" => 36,
_ => return None,
})
match NAMED_CYCLES.iter().find(|(name, _)| *name == cycle) {
Some(&(_, months)) => Some(months),
None => cycle.strip_suffix('m')?.parse().ok().filter(|m| (1..=MAX_CYCLE).contains(m)),
}
}

/// The stored spelling of a cycle of `months`: its name where it has one.
fn cycle_name(months: u32) -> String {
NAMED_CYCLES
.iter()
.find(|(_, m)| *m == months)
.map_or_else(|| format!("{months}m"), |(name, _)| (*name).into())
}

/// A node still reporting past its expiry date has been renewed, so the date is
Expand Down Expand Up @@ -798,6 +815,8 @@ mod tests {
assert_eq!(renewed(d("2026-01-31"), "monthly", d("2026-02-01")), Some(d("2026-02-28")));
// Years overdue: cycles are added until the date is in the future.
assert_eq!(renewed(d("2024-03-10"), "yearly", d("2026-08-28")), Some(d("2027-03-10")));
// A length without a name rolls the same way.
assert_eq!(renewed(d("2026-03-10"), "60m", d("2026-08-28")), Some(d("2031-03-10")));
// Not yet due, and one-off billing: both left unchanged.
assert_eq!(renewed(d("2026-09-01"), "monthly", d("2026-08-28")), None);
assert_eq!(renewed(d("2020-01-01"), "once", d("2026-08-28")), None);
Expand Down
127 changes: 105 additions & 22 deletions web-admin/src/components/Admin.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { memo, useCallback, useEffect, useId, useRef, useState } from "react"
import { flushSync } from "react-dom"
import { ArrowUpCircle, Bell, CalendarClock, Check, ChevronDown, ChevronRight, Copy, Database, Download, GripVertical, Layers, Palette, Pencil, Plus, Radio, RefreshCw, Search, Send, Server, Settings, Shield, SlidersHorizontal, Trash2, Upload } from "lucide-react"
import { ArrowUpCircle, Bell, CalendarClock, Check, ChevronDown, ChevronRight, CircleQuestionMark, Copy, Database, Download, GripVertical, Layers, Palette, Pencil, Plus, Radio, RefreshCw, Search, Send, Server, Settings, Shield, SlidersHorizontal, Trash2, Upload } from "lucide-react"
import { toast } from "sonner"

import { Badge } from "@/components/ui/badge"
Expand All @@ -15,7 +15,7 @@ import { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { api, badIfaceName, behind, changes, configFields, configForm, configOverrides, configSections, configValues, currentIface, fits, GIB, groupsOf, ifaceChoice, ifaceSpec, inGroup, outdatedAgents, provisioningSite, shortAddress, trafficCorrection, upload, type ConfigField, type IfaceChoice, type Node, type PingTask, type Source } from "@/lib/api"
import { bytes, CYCLES, FOREVER, money, uptime } from "@/lib/format"
import { bytes, cycleMonths, FOREVER, money, uptime } from "@/lib/format"

// Counters the panel can correct after migration or an accounting error.
const TRAFFIC_FIELDS = [
Expand Down Expand Up @@ -377,16 +377,48 @@ function Command({ className = "", children }: { className?: string; children: R
)
}

function Field({ label, hint, className = "", children }: { label: string; hint?: string; className?: string; children: React.ReactNode }) {
function Field({ label, hint, help, className = "", children }: {
label: string
hint?: string
help?: React.ReactNode
className?: string
children: React.ReactNode
}) {
const title = <Label className="text-sm font-medium">{label}</Label>
return (
<div className={`space-y-2 ${className}`}>
<Label className="text-sm font-medium">{label}</Label>
{help ? <div className="flex items-center gap-1.5">{title}<Help>{help}</Help></div> : title}
{children}
{hint && <p className="text-xs leading-relaxed text-muted-foreground">{hint}</p>}
</div>
)
}

// A tap shows no tooltip on its own, so a click opens it as well. The trigger's
// own handlers would close it on press and on click; both are prevented.
function Help({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<Tooltip open={open} onOpenChange={setOpen}>
<TooltipTrigger asChild>
<button
type="button"
aria-label="说明"
className="text-muted-foreground hover:text-foreground"
onPointerDown={(e) => e.preventDefault()}
onClick={(e) => {
e.preventDefault()
setOpen(true)
}}
>
<CircleQuestionMark className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-64 space-y-1 text-left">{children}</TooltipContent>
</Tooltip>
)
}

// A titled option with its control at the right. `toggle` makes the whole row a
// label, so a click anywhere flips the Switch it holds; `below` opens beneath it
// in the same card.
Expand Down Expand Up @@ -855,6 +887,20 @@ function NodeForm({ node, nodes, onClose, onSaved }: {
)
}

const CURRENCY_NAMES = new Intl.DisplayNames(["zh-CN"], { type: "currency" })

// The name confirms a code the hub can only check the shape of. DisplayNames
// echoes back a code outside ISO 4217 and throws on anything but three letters,
// which the hub refuses with its own message.
function currencyHint(code: string) {
try {
const name = CURRENCY_NAMES.of(code)
return name === code ? "未知代码,照原样显示" : name
} catch {
return undefined
}
}

function BillingForm({ node, onClose, onSaved }: {
node: Node
onClose: () => void
Expand All @@ -864,18 +910,25 @@ function BillingForm({ node, onClose, onSaved }: {
// Text rather than a number: a numeric state cannot represent an empty field,
// so clearing it would snap back to 0 mid-entry. Empty means free.
const [price, setPrice] = useState(node.price > 0 ? String(node.price) : "")
// Whole years are entered in years, the way a five-year plan is sold.
const months = cycleMonths(node.billing_cycle)
const [unit, setUnit] = useState(months === 0 ? "once" : months % 12 ? "months" : "years")
const [count, setCount] = useState(String(months % 12 ? months : months / 12 || 1))
const [saving, setSaving] = useState(false)
const set = <K extends keyof Node>(k: K, v: Node[K]) => setForm((f) => ({ ...f, [k]: v }))

async function save() {
// The hub refuses a length out of range and stores a named one by name, so
// an unchanged length is compared in months, not in spelling.
const cycle = unit === "once" ? "once" : `${Number(count) * (unit === "years" ? 12 : 1)}m`
setSaving(true)
try {
await api(`/nodes/${node.id}`, {
method: "PUT",
body: JSON.stringify(changes(node, {
price: Math.max(0, Number(price) || 0),
currency: form.currency,
billing_cycle: form.billing_cycle,
billing_cycle: cycleMonths(cycle) === months ? node.billing_cycle : cycle,
expires_at: form.expires_at || null,
})),
})
Expand Down Expand Up @@ -908,27 +961,57 @@ function BillingForm({ node, onClose, onSaved }: {
placeholder="免费"
/>
</Field>
<Field label="货币">
<Select value={form.currency} onValueChange={(v) => set("currency", v)}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent position="popper">
{["USD", "CNY", "EUR", "GBP", "JPY"].map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
<Field
label="货币"
hint={currencyHint(form.currency.toUpperCase())}
help={
<>
<p>填三个字母的货币代码,大小写都行。</p>
<p>
例如:
{["美元 USD", "人民币 CNY", "港币 HKD", "新台币 TWD", "欧元 EUR", "日元 JPY"].map((c, i) => (
<span key={c}>{i > 0 && "、"}<span className="whitespace-nowrap">{c}</span></span>
))}
</p>
</>
}
>
{/* Uppercased by CSS: rewriting the value mid-composition would
break an input method, and the hub stores it uppercased. */}
<Input
maxLength={3}
autoCapitalize="characters"
spellCheck={false}
className="uppercase"
value={form.currency}
onChange={(e) => set("currency", e.target.value)}
placeholder="USD"
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="付款周期">
<Select value={form.billing_cycle} onValueChange={(v) => set("billing_cycle", v)}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent position="popper">
{Object.entries(CYCLES).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2">
{unit !== "once" && (
<Input
type="number"
min="1"
step="1"
aria-label="周期长度"
className="w-16"
value={count}
onChange={(e) => setCount(e.target.value)}
/>
)}
<Select value={unit} onValueChange={setUnit}>
<SelectTrigger className="min-w-0 flex-1"><SelectValue /></SelectTrigger>
<SelectContent position="popper">
<SelectItem value="months">月</SelectItem>
<SelectItem value="years">年</SelectItem>
<SelectItem value="once">一次性</SelectItem>
</SelectContent>
</Select>
</div>
</Field>
<Field label="到期时间">
<Input type="date" value={form.expires_at ?? ""} onChange={(e) => set("expires_at", e.target.value)} />
Expand Down
14 changes: 6 additions & 8 deletions web-admin/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,10 @@ export function money(amount: number, currency: string): string {
return `${SYMBOLS[currency] ?? ""}${amount.toFixed(2)}${SYMBOLS[currency] ? "" : ` ${currency}`}`
}

export const CYCLES: Record<string, string> = {
monthly: "月付",
quarterly: "季付",
semiannual: "半年付",
yearly: "年付",
biennial: "两年付",
triennial: "三年付",
once: "一次性",
// The hub stores these lengths under a name and any other as `<n>m`.
const NAMED_CYCLES: Record<string, number> = { monthly: 1, quarterly: 3, semiannual: 6, yearly: 12, biennial: 24, triennial: 36 }

/** A billing cycle in months: 0 for one-off, NaN when unrecognized. */
export function cycleMonths(cycle: string): number {
return cycle === "once" ? 0 : NAMED_CYCLES[cycle] ?? Number(/^(\d+)m$/.exec(cycle)?.[1])
}
Loading