{s.user_name}
{s.plan_name}
- {s.status}
+ {SUB_STATUS_LABEL[s.status] ?? s.status}
{s.start_date}
{s.billing_day}
@@ -171,7 +179,7 @@ export function Subscriptions() {
{del && (
setDel(null)}
onConfirm={async () => { await api.deleteSubscription(del.id); setDel(null); reload(); }}
/>
@@ -208,7 +216,7 @@ function SubAddModal({ onClose, onDone }: { onClose: () => void; onDone: () => v
options={(users.data?.users ?? []).map((u) => ({ value: String(u.id), label: u.display_name }))} />
set("plan_id", v)}
- options={(plans.data?.plans ?? []).filter((p) => p.active).map((p) => ({ value: String(p.id), label: `${p.name}(NT$${p.monthly_amount})` }))} />
+ options={(plans.data?.plans ?? []).filter((p) => p.active).map((p) => ({ value: String(p.id), label: `${p.name}(NT$${p.monthly_amount.toLocaleString()})` }))} />
{/* type=date, not a bare text box with a placeholder: everywhere else in the app a date is
picked (Settings and the payment modals all use type="month"). */}
set("start_date", e.target.value)} disabled={busy} />
@@ -231,10 +239,10 @@ function SubEditModal({ sub, onClose, onDone }: { sub: Subscription; onClose: ()
return (
{err && {err}
}
- set("status", e.target.value)} disabled={busy}>active paused cancelled
+ set("status", e.target.value)} disabled={busy}>{SUB_STATUSES.map((st) => {st.label} )}
set("start_date", e.target.value)} disabled={busy} />
- 每月幾號為這個訂閱結帳。29–31 在短月會落空,所以上限是 28。
+ 每月幾號為這個訂閱結帳。29-31 在短月會落空,所以上限是 28。
set("billing_day", e.target.value)} disabled={busy} />
@@ -259,7 +267,7 @@ export function Plans() {
return (
<>
{error && }
- setEdit(null)}>新增方案}>
+ setEdit(null)}>新增方案…}>
{providers.length > 1 && (
setPFilter("")}>全部
@@ -271,14 +279,14 @@ export function Plans() {
方案清單
- 名稱 provider 月費 身分組 ID 啟用 操作
+ 名稱 供應商 月費 身分組 ID 啟用 操作
{loading && 載入中… }
{shown.map((p) => (
{p.name}
- {p.provider}
- NT${p.monthly_amount}
+ {p.provider}
+ NT${p.monthly_amount.toLocaleString()}
{p.discord_role_id ?? "—"}
{p.active ? "✓" : "—"}
@@ -309,7 +317,7 @@ function PlanModal({ plan, providers, onClose, onDone }: { plan: Plan | null; pr
async function save() {
const provider = f.provider.trim().toLowerCase();
if (!f.name || !f.monthly_amount) { setErr("請填名稱與月費"); return; }
- if (!provider) { setErr("請填 provider"); return; }
+ if (!provider) { setErr("請填供應商"); return; }
setBusy(true); setErr(null);
try {
const body: any = { name: f.name, provider, monthly_amount: Number(f.monthly_amount), discord_role_id: f.discord_role_id || undefined, active: f.active ? 1 : 0 };
@@ -321,12 +329,12 @@ function PlanModal({ plan, providers, onClose, onDone }: { plan: Plan | null; pr
{err && {err}
}
set("name", e.target.value)} disabled={busy} />
-
+
set("provider", e.target.value)} disabled={busy} placeholder="openai / anthropic / gemini …" />
{providers.map((pv) => )}
set("monthly_amount", e.target.value)} disabled={busy} />
- set("discord_role_id", e.target.value)} disabled={busy} />
+ set("discord_role_id", e.target.value)} disabled={busy} />
set("active", e.target.checked ? 1 : 0)} disabled={busy} /> 啟用
儲存
@@ -360,10 +368,10 @@ export function ChannelTags() {
return (
<>
{(error || actErr) && }
- setEdit(null)}>新增渠道}>
+ setEdit(null)}>新增渠道…}>
- 支付渠道清單
+ 繳費渠道清單
名稱 類型 排序 狀態 操作
{loading && 載入中… }
@@ -393,7 +401,7 @@ export function ChannelTags() {
{del && (
setDel(null)}
onConfirm={async () => { await api.deleteChannelTag(del.id); setDel(null); reload(); }}
/>
diff --git a/packages/admin/src/views/MemberReview.tsx b/packages/admin/src/views/MemberReview.tsx
index 4cf1514..babbbd1 100644
--- a/packages/admin/src/views/MemberReview.tsx
+++ b/packages/admin/src/views/MemberReview.tsx
@@ -7,7 +7,7 @@ import { PaymentDetail } from "./PaymentDetail";
* Aggregate review for ONE member × ONE period — where a payment-submission notification lands
* (#payments?user=&period=). One member submit settles one payment row per active
* subscription, all sharing one screenshot, so this shows the screenshot once and lets the owner
- * approve the whole period with a single tap (一鍵全部核准); per-row 核准/退回 stay available for
+ * approve the whole period with a single tap (一鍵全部驗證); per-row 驗證/退回 stay available for
* the mixed cases. Laid out mobile-first: everything stacks, actions are thumb-sized.
*/
export function MemberReview({ userId, period, tags, onBack }: {
@@ -39,7 +39,7 @@ export function MemberReview({ userId, period, tags, onBack }: {
setBusy(true); setErr(null); setDone(null);
try { setDone(await fn()); }
catch (e) { setErr((e as Error).message); }
- // Reload on failure too: 一鍵全部核准 commits row by row, so an aborted batch really did verify
+ // Reload on failure too: 一鍵全部驗證 commits row by row, so an aborted batch really did verify
// part of the list (the banner says how many) and the rows must not stay stale.
list.reload();
setBusy(false);
@@ -71,7 +71,7 @@ export function MemberReview({ userId, period, tags, onBack }: {
{proofKeys.length === 0 && !proofExpired && (
submitted
? 無憑證,純聲明 — 請依備註與帳戶自行核對。
- : 本期尚未回報繳費。
+ : 此期尚未回報繳費。
)}
{notes.length > 0 && 成員備註:{notes.join(";")}
}
@@ -84,20 +84,20 @@ export function MemberReview({ userId, period, tags, onBack }: {
onClick={() => run(async () => {
const r = await api.verifyAll(userId, period);
return outstanding.length > 0
- ? `已核准 ${r.verified} 筆,另 ${outstanding.length} 筆(待繳/已退回)需逐筆處理`
- : `已核准 ${r.verified} 筆`;
+ ? `已驗證 ${r.verified} 筆,另 ${outstanding.length} 筆(待繳/已退回)需逐筆處理`
+ : `已驗證 ${r.verified} 筆`;
})}
>
{/* 全部 is only true when every row is 已繳待驗; otherwise name the subset the sweep covers. */}
{busy ? "處理中…" : outstanding.length > 0
- ? `核准已繳待驗(${reviewable.length} 筆)`
- : `一鍵全部核准(${reviewable.length} 筆)`}
+ ? `驗證已繳待驗(${reviewable.length} 筆)`
+ : `一鍵全部驗證(${reviewable.length} 筆)`}
{outstanding.length > 0 && (
run(async () => {
// force: the admin is deliberately asking for another ping, which is the one
// sanctioned way past the per-period nudge dedup (core/nudge.ts).
@@ -130,9 +130,9 @@ export function MemberReview({ userId, period, tags, onBack }: {
{["pending", "paid", "rejected"].includes(p.status) && (
-
run(async () => { await api.verify(p.id, null); return null; })}>
- 核准
+ 驗證
)}
{["pending", "paid"].includes(p.status) && (
diff --git a/packages/admin/src/views/PaymentDetail.tsx b/packages/admin/src/views/PaymentDetail.tsx
index e497102..9b3dc48 100644
--- a/packages/admin/src/views/PaymentDetail.tsx
+++ b/packages/admin/src/views/PaymentDetail.tsx
@@ -1,6 +1,6 @@
import { useState } from "react";
import { api, type ChannelTag, type Payment } from "../api";
-import { Modal, Field, Money, StatusBadge, IconWarning, ConfirmDanger } from "../ui";
+import { Modal, Field, Money, StatusBadge, IconWarning, ConfirmDanger, SOURCE_LABEL } from "../ui";
export function PaymentDetail({ payment, tags, onClose, onDone }: { payment: Payment; tags: ChannelTag[]; onClose: () => void; onDone: () => void }) {
const [busy, setBusy] = useState(false);
@@ -26,8 +26,8 @@ export function PaymentDetail({ payment, tags, onClose, onDone }: { payment: Pay
狀態
金額
應繳日 {payment.due_date}
-
來源 {payment.source}
- {payment.payment_note && (<>
使用者備註 {payment.payment_note} >)}
+
來源 {SOURCE_LABEL[payment.source] ?? payment.source}
+ {payment.payment_note && (<>
成員備註 {payment.payment_note} >)}
{payment.declared_channel_tag_name && (<>
申報渠道 {payment.declared_channel_tag_name} >)}
{payment.channel_tag_name && (<>
認定渠道 {payment.channel_tag_name} >)}
{payment.rejected_reason && (<>
退回原因 {payment.rejected_reason} >)}
@@ -53,7 +53,7 @@ export function PaymentDetail({ payment, tags, onClose, onDone }: { payment: Pay
)}
- {canVerify && run(() => api.verify(payment.id, tagId === "" ? null : Number(tagId)))}>標記已驗證 }
+ {canVerify && run(() => api.verify(payment.id, tagId === "" ? null : Number(tagId)))}>驗證 }
{payment.status === "verified" && run(() => api.unverify(payment.id))}>撤回驗證 }
{payment.screenshot_key && run(() => api.deleteProof(payment.id))}>刪除截圖 }
@@ -78,7 +78,7 @@ export function PaymentDetail({ payment, tags, onClose, onDone }: { payment: Pay
// paid/verified/rejected all carry real activity; only pending is re-creatable by a resync.
message={payment.status !== "pending"
? "這筆已有繳費/審核紀錄,刪除後將從對帳與紀錄中消失且無法復原(稽核紀錄仍會保留)。"
- : "刪除這筆待繳紀錄後,「重新同步本期」會在該訂閱仍為啟用時把它補回來(稽核紀錄仍會保留)。"}
+ : "刪除這筆待繳紀錄後,「重新同步此期帳單」會在該訂閱仍為啟用時把它補回來(稽核紀錄仍會保留)。"}
confirmLabel="確認刪除"
busyLabel="刪除中…"
onClose={() => setConfirmDel(false)}
diff --git a/packages/admin/src/views/Payments.tsx b/packages/admin/src/views/Payments.tsx
index 5e0e7f7..af2799c 100644
--- a/packages/admin/src/views/Payments.tsx
+++ b/packages/admin/src/views/Payments.tsx
@@ -120,10 +120,10 @@ export function Payments() {
phone instead of four stacked bands. Below 1000px .toolbar__acts is a nowrap scroller
with an edge fade; above it, it is a plain flex row and looks unchanged. */}
- setSync(true)}>重新同步本期
- setRetract(true)}>收回本期開繳
- setShowLink(true)}>產生上傳連結
- setShowManual(true)}>手動補登
+ setSync(true)}>重新同步此期帳單…
+ setRetract(true)}>收回此期開繳…
+ setShowLink(true)}>產生上傳連結…
+ setShowManual(true)}>手動補登…
@@ -154,7 +154,7 @@ export function Payments() {
}}
>
- { e.stopPropagation(); window.location.hash = `payments?user=${p.user_id}&period=${p.period}`; }}>
{p.user_name}
@@ -221,7 +221,7 @@ function SyncModal({ period, onClose, onDone }: { period: string; onClose: () =>
try {
const r = await api.syncPeriodBills(period, { dry_run: false, notify_added: notify && boundAdds.length > 0 }) as any;
setDone(
- `已套用:新增 ${r.applied.added}、移除 ${r.applied.removed}、改價 ${r.applied.repriced}、保留 ${r.applied.frozen}`
+ `✓ 已套用:新增 ${r.applied.added}、移除 ${r.applied.removed}、改價 ${r.applied.repriced}、保留 ${r.applied.frozen}`
+ (r.notified ? `;已通知 ${r.notified} 位` : "")
+ (r.unbound ? `;${r.unbound} 位未綁定、通知不到` : "")
);
@@ -230,7 +230,7 @@ function SyncModal({ period, onClose, onDone }: { period: string; onClose: () =>
}
return (
-
+
{err && {err}
}
{busy && !diff && 計算差異中… }
{done && {done}
}
@@ -241,10 +241,10 @@ function SyncModal({ period, onClose, onDone }: { period: string; onClose: () =>
-
+
{diff.add.length > 0 && `${a.user_name}·${a.plan_name} NT$${a.amount.toLocaleString()}`)} />}
- {diff.remove.length > 0 && `${a.user_name}·${a.plan_name} NT$${a.amount.toLocaleString()}`)} />}
+ {diff.remove.length > 0 && `${a.user_name}·${a.plan_name} NT$${a.amount.toLocaleString()}`)} />}
{diff.reprice.length > 0 && `${a.user_name}·${a.plan_name} ${a.from}→${a.to}`)} />}
{boundAdds.length > 0 && (
@@ -259,7 +259,7 @@ function SyncModal({ period, onClose, onDone }: { period: string; onClose: () =>
)}
{changes === 0
- ? 本期已是最新,無需變更。
+ ? 此期已是最新,無需變更。
: 確認套用 }
>
)}
@@ -268,7 +268,7 @@ function SyncModal({ period, onClose, onDone }: { period: string; onClose: () =>
}
/**
- * 收回本期開繳 — the way back from a mis-opened month. Two steps like SyncModal: a preview of what
+ * 收回此期開繳 — the way back from a mis-opened month. Two steps like SyncModal: a preview of what
* the retract would delete, then a red confirm. The frozen (paid/verified) count is spelled out
* because "收回" easily reads as "wipe the month", which is exactly what it does NOT do.
*/
@@ -291,32 +291,32 @@ function RetractModal({ period, onClose, onDone }: { period: string; onClose: ()
setBusy(true); setErr(null);
try {
const r = await api.retractPeriodBilling(period, { dry_run: false }) as RetractApplied;
- setDone(`已收回 ${period}:刪除 ${r.applied.removed} 筆帳單、保留 ${r.applied.frozen} 筆已繳。此期已回到未開繳狀態。`);
+ setDone(`✓ 已收回 ${period}:刪除 ${r.applied.removed} 筆帳單、保留 ${r.applied.frozen} 筆已繳待驗/已驗證。此期已回到未開繳狀態。`);
onDone();
} catch (e) { setErr((e as Error).message); setBusy(false); }
}
return (
-
+
{err && {err}
}
- {busy && !preview && 計算中… }
+ {busy && !preview && 計算差異中… }
{done && {done}
}
{preview && !preview.opened && !done && 此期尚未發起繳費,無需收回。
}
{preview && preview.opened && !done && (
<>
-
+
{preview.removed.length > 0
? `${a.user_name}·${a.plan_name} NT$${a.amount.toLocaleString()}`)} />
/* Still worth doing: the marker alone is what keeps the period "opened". */
- : 本期沒有可刪除的未繳/已退回帳單,收回只會把期別改回「未開繳」。
}
+ : 此期沒有可刪除的未繳/已退回帳單,收回只會把期別改回「未開繳」。
}
{/* One source line per sentence would render an ASCII space between them (JSX joins
adjacent text lines), which reads as a gap in a run of CJK. */}
- 收回後本期回到「未開繳」:刪掉的帳單不會被「重新同步本期」補回來,日後可以再次發起繳費(屆時會重新發送開繳通知)。此期先前用「產生上傳連結」發出去的一次性連結會立即失效 ,對方點開只會看到連結無效。
- {preview.frozen_count > 0 && `已繳/已驗證的 ${preview.frozen_count} 筆一律原樣保留,重開本期也不會重複開帳單。`}
+ 收回後此期回到「未開繳」:刪掉的帳單不會被「重新同步此期帳單」補回來,日後可以再次發起繳費(屆時會重新發送開繳通知)。此期先前用「產生上傳連結」發出去的一次性連結會立即失效 ,對方點開只會看到連結無效。
+ {preview.frozen_count > 0 && `已繳/已驗證的 ${preview.frozen_count} 筆一律原樣保留,重開此期也不會重複產生帳單。`}
已經發出的 Discord 開繳通知不會撤回,必要時請自行到頻道說明。
確認收回
@@ -335,7 +335,7 @@ function QuickVerify({ id, onDone }: { id: number; onDone: () => void }) {
catch { setErr(true); setBusy(false); }
}
return (
-
+
{busy ? "…" : err ? <> 重試> : <> 驗證>}
);
@@ -354,6 +354,9 @@ function ManualModal({ tags, onClose, onDone }: { tags: ChannelTag[]; onClose: (
async function submit() {
if (!subId) { setErr("請選擇訂閱"); return; }
+ // can be cleared; an empty period reaches the worker's English guard
+ // (§A.14 only lets that string stay English because the UI is supposed to prevent it).
+ if (!PERIOD_RE.test(period)) { setErr("請選擇期別。"); return; }
setBusy(true); setErr(null);
try {
await api.manualPayment({ subscription_id: Number(subId), period, amount: amount ? Number(amount) : undefined, status: statusV, verified_channel_tag_id: tagId ? Number(tagId) : undefined, payment_note: note || undefined });
@@ -398,6 +401,7 @@ function LinkModal({ onClose }: { onClose: () => void }) {
async function gen() {
if (!userId) { setErr("請選擇成員"); return; }
+ if (!PERIOD_RE.test(period)) { setErr("請選擇期別。"); return; }
setBusy(true); setErr(null);
try {
const r = await api.uploadLink({ user_id: Number(userId), period });
diff --git a/packages/admin/src/views/PushStatus.tsx b/packages/admin/src/views/PushStatus.tsx
index b25c5c4..37b519a 100644
--- a/packages/admin/src/views/PushStatus.tsx
+++ b/packages/admin/src/views/PushStatus.tsx
@@ -10,7 +10,7 @@ import { DiffList } from "../components/DiffList";
*
* There is deliberately NO "重置" for 開繳通知: that row IS the definition of "this period is open"
* (worker core/notify.ts isBillingOpened), so deleting it alone leaves every bill standing in a
- * period nobody can pay. The whole operation is 收回本期開繳 on the payments page; the note below
+ * period nobody can pay. The whole operation is 收回此期開繳 on the payments page; the note below
* the table points there, and the API returns 409 if anything tries anyway.
*/
export function PushStatus({ period }: { period: string }) {
@@ -42,7 +42,7 @@ export function PushStatus({ period }: { period: string }) {
)}
- 本期推播通知狀態
+ {period} 推播通知狀態
通知 狀態 操作
@@ -64,7 +64,7 @@ export function PushStatus({ period }: { period: string }) {
- 要把 {period} 改回「未開繳」(成員暫時無法繳費)?請到「繳費審核」使用收回本期開繳 ——
+ 要把 {period} 改回「未開繳」(成員暫時無法繳費)?請到「繳費審核」使用收回此期開繳 ——
它會一併刪掉未繳/已退回的帳單,不會只留下對不起來的半套狀態。
@@ -220,7 +220,7 @@ function OverdueModal({ period, onClose, onDone }: { period: string; onClose: ()
`${p.user_name} NT$${p.total.toLocaleString()}${p.discord_id ? "" : "(未綁定,@ 不到)"}`)} />
這會在繳費頻道公開 @ {period} 所有 未繳成員(含已退回的),不受 「逾期天數({view.p.overdue_days} 天)」限制;
- 每日自動催繳只會 @ 已超過逾期天數的人,所以這份名單通常比較長。送出後本期的催繳發送紀錄會更新為現在。
+ 每日自動催繳只會 @ 已超過逾期天數的人,所以這份名單通常比較長。送出後此期的催繳發送紀錄會更新為現在。
確認催繳這 {view.p.people.length} 位
>
@@ -245,7 +245,7 @@ function ResetOverdueModal({ period, onClose, onDone }: { period: string; onClos
return (
setToast(null), 2400); }
async function save() {
+ // min/max on are only hints — nothing stops a typed 29 from being
+ // submitted, and the worker's guard answers in English (routes/admin.ts, §A.14 allowlist:
+ // that string is only allowed to stay English because the UI is supposed to block it first).
+ const bd = Number(form.billing_day);
+ if (!Number.isInteger(bd) || bd < 1 || bd > 28) { setErr("每月結帳日請填 1-28 的整數。"); return; }
+ const od = Number(form.overdue_days);
+ if (!Number.isInteger(od) || od < 0) { setErr("逾期天數請填 0 或正整數。"); return; }
+ const rm = Number(form.proof_retention_months);
+ if (!Number.isInteger(rm) || rm < 1) { setErr("截圖保存月數請填 1 以上的整數。"); return; }
setBusy(true); setErr(null);
try {
await api.updateWorkspace({
@@ -195,8 +204,8 @@ export function Settings() {
: 截圖未啟用 )}>
@@ -274,7 +283,7 @@ export function Settings() {
-
+
@@ -285,10 +294,10 @@ export function Settings() {
setLive((s) => ({ ...s, pay: id }))} />
-
setLive((s) => ({ ...s, bind: id }))} />
+
setLive((s) => ({ ...s, bind: id }))} />
setLive((s) => ({ ...s, cmd: at }))} />
-
+
@@ -384,7 +393,7 @@ function ImportModal({ onClose }: { onClose: () => void }) {
const r = await api.importMembers(file, { startDate: start || undefined, dryRun: false });
const d = r.diff;
setDiff(d);
- setDone(`✓ 已套用:新增 ${d.users_created.length} 人 / 新增 ${d.subs_added.length} 訂閱 / 恢復 ${d.subs_reactivated.length} / 暫停 ${d.subs_paused.length}`);
+ setDone(`✓ 已套用:新增 ${d.users_created.length} 人、新增 ${d.subs_added.length} 訂閱、恢復 ${d.subs_reactivated.length}、暫停 ${d.subs_paused.length}`);
// 匯入建立的帳單來自 ensureFirstPayment,不會出現在之後的同步 diff —— 通知要在這裡發,
// 否則新成員從頭到尾不會收到任何訊息 (C1)。
const ids = [...new Set(
@@ -413,7 +422,10 @@ function ImportModal({ onClose }: { onClose: () => void }) {
欄位需為「姓名, 帳號, 方案名…」;方案名須與系統方案一致。方案格
TRUE =訂閱、
FALSE =暫停該訂閱、
留空 =不變動。起算月份留空=當月。
setFile(e.target.files?.[0] ?? null)} disabled={busy} />
-
setStart(e.target.value)} placeholder="2026-06-01" disabled={busy} />
+ {/* type=date, like every other date in the app (#44 B14): a free-text box here let a typo reach
+ the worker, whose guard answers in English — a string the §A.14 allowlist only permits to
+ stay English because the UI is supposed to make it unreachable. */}
+
setStart(e.target.value)} disabled={busy} />
{busy ? "計算差異中…" : "預覽差異"}
>
)}
@@ -441,7 +453,7 @@ function ImportModal({ onClose }: { onClose: () => void }) {
{diff.affected_pending_bills.length > 0 && (
<>
- 被暫停的訂閱在 {diff.period} 還有 {diff.affected_pending_bills.length} 筆未繳帳單。匯入不會 變更任何帳單;請到「繳費審核」按重新同步本期帳單 清理。
+ 被暫停的訂閱在 {diff.period} 還有 {diff.affected_pending_bills.length} 筆未繳帳單。匯入不會 變更任何帳單;請到「繳費審核」按「重新同步此期帳單」 清理。
(null);
const payload = () => ({ period, amounts: plans.map((p) => ({ plan_id: p.id, amount: Number(amounts[p.id]) })) });
- const invalid = plans.some((p) => !/^\d+$/.test((amounts[p.id] ?? "").trim()));
+ // can be CLEARED, and an empty period reaches the worker's English guard
+ // (§A.14 only lets that string stay English because the UI is supposed to prevent it).
+ const invalid = !/^\d{4}-(0[1-9]|1[0-2])$/.test(period)
+ || plans.some((p) => !/^\d+$/.test((amounts[p.id] ?? "").trim()));
async function runPreview() {
setBusy(true); setErr(null);
@@ -530,7 +545,7 @@ function InitiateModal({ plans, billingDay, dirty, onClose }: { plans: { id: num
? "已在頻道發出開繳通知。"
: `未發送通知:${NOTIFY_REASON_TEXT[r.notify_reason] ?? "通知未送出"}。` +
(r.notify_reason === "send_failed"
- ? "本期已開繳、帳單已建立,可到「儀表板 → 推播狀態 → 重發開繳通知」補送。"
+ ? "此期已開繳、帳單已建立,可到「儀表板 → 推播狀態 → 重發開繳通知」補送。"
: ""))
);
} catch (e) { setErr((e as Error).message); }
@@ -575,7 +590,7 @@ function InitiateModal({ plans, billingDay, dirty, onClose }: { plans: { id: num
-
+
{preview.plan_changes.length > 0 && (
`${c.plan_name} NT$${c.from.toLocaleString()} → NT$${c.to.toLocaleString()}`)} />
diff --git a/packages/web/index.html b/packages/web/index.html
index cc3d9e3..79f1bc4 100644
--- a/packages/web/index.html
+++ b/packages/web/index.html
@@ -7,7 +7,7 @@
- ChipPot · 繳費上傳
+ ChipPot · 繳費登記
s + x.amount, 0);
const canSubmit = !!file || !!note.trim() || channelId != null;
+ // §A.12 的正規句:欄位順序固定為 渠道 → 截圖 → 備註;沒有 R2 時截圖欄整個不存在,句子也跟著少一項。
+ const atLeastOne = info?.proof_enabled === false ? "渠道、備註至少填一項。" : "渠道、截圖、備註至少填一項。";
async function submit() {
if (!token) return;
- if (!canSubmit) return; // the button is disabled on the same condition (:158) — belt only
+ if (!canSubmit) {
+ setError(atLeastOne); // the submit button is disabled on the same condition — belt only
+ return;
+ }
setError(null);
setStage("submitting");
const blob = file ? await compressImage(file) : null;
@@ -157,7 +162,7 @@ export default function App() {
{busy ? "上傳中…" : "送出繳費"}
- {info?.proof_enabled === false ? "渠道或備註至少填一項。" : "渠道、截圖或備註至少填一項。"}此連結僅限你本人本期使用,送出後即失效。
+ {atLeastOne}此連結僅限你本人、此月份使用,送出後即失效。
);
@@ -189,20 +194,20 @@ function Stub({
return (
- 期別
+ 月份
{period}
嗨,{name || "夥伴"}
{subs.map((s) => (
{s.plan_name}
- NT${s.amount}
+ NT${s.amount.toLocaleString()}
))}
{subs.length > 0 && (
合計
- NT${total}
+ NT${total.toLocaleString()}
)}
diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts
index a0446f9..df5767e 100644
--- a/packages/web/src/api.ts
+++ b/packages/web/src/api.ts
@@ -58,6 +58,6 @@ export async function submitPayment(
const body = (await res.json().catch(() => ({}))) as { error?: string };
return res.ok ? { ok: true } : { ok: false, error: body.error ?? `錯誤 ${res.status}` };
} catch {
- return { ok: false, error: "連線失敗,請稍後再試" };
+ return { ok: false, error: "連線失敗,請稍後再試。" };
}
}
diff --git a/packages/worker/scripts/register-commands.mjs b/packages/worker/scripts/register-commands.mjs
index c76aeb9..6bb57fe 100644
--- a/packages/worker/scripts/register-commands.mjs
+++ b/packages/worker/scripts/register-commands.mjs
@@ -53,7 +53,7 @@ const commands = [
},
{
name: "發起繳費", type: 1,
- description: "(管理員)確認本期各方案金額並發出開繳通知",
+ description: "(管理員)確認指定期別各方案金額並發出開繳通知",
default_member_permissions: "32",
},
{
diff --git a/packages/worker/src/adapters/discord/handler.ts b/packages/worker/src/adapters/discord/handler.ts
index dbefe9d..d89504f 100644
--- a/packages/worker/src/adapters/discord/handler.ts
+++ b/packages/worker/src/adapters/discord/handler.ts
@@ -173,7 +173,7 @@ async function computePayResult(i: DiscordInteraction, env: Env, ctx: ExecutionC
// command settles one period, so if several are owed we send them to the button (which can pick).
const periods = await listOpenPayablePeriods(env.DB, ws, userId);
if (periods.length === 0) {
- return opened ? "✅ 你已登記繳費,目前沒有待繳項目。" : `本期(${current})繳費尚未開放,待管理員發出開繳通知後即可繳費。`;
+ return opened ? "✅ 你已登記繳費,目前沒有待繳項目。" : `${current} 的繳費尚未開放,待管理員發出開繳通知後即可繳費。`;
}
if (periods.length > 1) return `你有多個月份待繳:${periods.join("、")}。請改用下方「繳費」按鈕選擇要繳的月份。`;
const period = periods[0]!;
@@ -209,7 +209,7 @@ async function computePayResult(i: DiscordInteraction, env: Env, ctx: ExecutionC
// At-least-one rule (slash): 渠道 / 截圖 / 備註.
if (!declaredChannelTagId && !proof && !note) {
if (screenshotIgnored) return "本站未開啟截圖功能,請改用「渠道」或「備註」登記繳費。";
- return "請至少選擇「渠道」、附上「截圖」或填寫「備註」其中一項。";
+ return "渠道、截圖、備註至少填一項。";
}
const r = await settleUserPeriod(env, {
@@ -217,9 +217,9 @@ async function computePayResult(i: DiscordInteraction, env: Env, ctx: ExecutionC
declaredChannelTagId, paymentNote: note, proof,
waitUntil: (p) => ctx.waitUntil(p), // notify in the background (followup reply stays snappy)
});
- if (r.paidCount === 0) return `本期(${period})已登記繳費,無需重複操作。`;
+ if (r.paidCount === 0) return `${period} 已登記繳費,無需重複操作。`;
const ignoredNote = screenshotIgnored ? "(本站未開啟截圖功能,已記錄你的繳費宣告)" : "";
- return `✅ 已登記本期(${period})繳費 NT$${r.totalAmount.toLocaleString()}(共 ${r.paidCount} 筆)。管理員確認收款後完成。${ignoredNote}`;
+ return `✅ 已登記 ${period} 繳費 NT$${r.totalAmount.toLocaleString()}(共 ${r.paidCount} 筆)。管理員確認收款後完成。${ignoredNote}`;
}
// ── 發起繳費 (admin): modal open + modal submit ──────────────────────────────
@@ -297,8 +297,8 @@ function initiateNoticeNote(reason: string, period: string): string {
case "already_sent": return `${period} 的開繳通知先前已發送,未重複發送。`;
case "no_channel": return "尚未設定繳費頻道 ID,未發出開繳通知。";
case "no_bot_token": return "尚未設定 Discord bot token,未發出開繳通知。";
- case "no_plans": return "沒有任何「啟用中方案 × 有效訂閱」,未發出開繳通知(本期仍維持未開繳)。";
- case "send_failed": return "開繳通知發送失敗(本期已開繳、帳單已建立),請稍後到後台「推播狀態 → 重發開繳通知」再試一次。";
+ case "no_plans": return `沒有任何「啟用中方案 × 有效訂閱」,未發出開繳通知(${period} 仍維持未開繳)。`;
+ case "send_failed": return `開繳通知發送失敗(${period} 已開繳、帳單已建立),請稍後到後台「推播狀態 → 重發開繳通知」再試一次。`;
default: return "未發出開繳通知。";
}
}
@@ -575,7 +575,7 @@ async function buildPayPrompt(
const periods = await listOpenPayablePeriods(env.DB, ws, userId);
if (periods.length === 0) {
return {
- content: opened ? "✅ 你已登記繳費,目前沒有待繳項目。" : `本期(${current})繳費尚未開放,待管理員發出開繳通知後即可繳費。`,
+ content: opened ? "✅ 你已登記繳費,目前沒有待繳項目。" : `${current} 的繳費尚未開放,待管理員發出開繳通知後即可繳費。`,
components: [],
};
}
@@ -712,7 +712,7 @@ async function handlePaySelect(i: DiscordInteraction, env: Env, ctx: ExecutionCo
}
// Defense in depth: even with a crafted select, don't settle before billing is opened.
if (!(await isBillingOpened(env.DB, ws, period))) {
- return updateErr("本期繳費尚未開放,待管理員發出開繳通知後即可繳費。");
+ return updateErr(`${period} 的繳費尚未開放,待管理員發出開繳通知後即可繳費。`);
}
try {
@@ -721,7 +721,7 @@ async function handlePaySelect(i: DiscordInteraction, env: Env, ctx: ExecutionCo
waitUntil: (p) => ctx.waitUntil(p), // notify in the background — keep the interaction < 3s
});
if (r.paidCount === 0) {
- return json({ type: RT_UPDATE_MESSAGE, data: { content: "✅ 你本期已登記繳費,無需重複操作。", components: [] } });
+ return json({ type: RT_UPDATE_MESSAGE, data: { content: `✅ ${period} 已登記繳費,無需重複操作。`, components: [] } });
}
return json({
type: RT_UPDATE_MESSAGE,
diff --git a/packages/worker/src/adapters/discord/notify.ts b/packages/worker/src/adapters/discord/notify.ts
index 9a44816..bf0008f 100644
--- a/packages/worker/src/adapters/discord/notify.ts
+++ b/packages/worker/src/adapters/discord/notify.ts
@@ -53,6 +53,9 @@ export const discordNotifier: Notifier = {
return `・${mention} ${plans}(合計 NT$${p.total.toLocaleString()})`;
})
.join("\n");
+ // Hardcoded on purpose for now (#46): unlike 開繳/催繳/常駐繳費訊息, this message has no
+ // workspace-settings template. 設定 → Discord 訊息文字 says so. Making it editable is a settings
+ // schema change (deferred with the rest of the 期別 lifecycle work).
const head = kind === "remind" ? `🔔 ${period} 繳費提醒:` : `📋 已將你加入 ${period} 繳費名單:`;
const content = `${head}\n${list}\n請點下方按鈕繳費。`;
// Pin mentions to exactly the added members' ids — template/display-name text can't ping.
diff --git a/packages/worker/src/core/billing.ts b/packages/worker/src/core/billing.ts
index 6dd1949..241cfcc 100644
--- a/packages/worker/src/core/billing.ts
+++ b/packages/worker/src/core/billing.ts
@@ -199,7 +199,7 @@ export async function initiateBillingOpened(
// The notice lines are computed BEFORE anything is claimed. The billing_opened row is not a send
// log — it IS "this period is open" — so a call with nothing to announce must never leave one
- // behind: that state ("opened, but nobody was ever told") is only undoable via 收回本期開繳.
+ // behind: that state ("opened, but nobody was ever told") is only undoable via 收回此期開繳.
// previewBillingInitiate already calls this case no_plans, and the two must say the same thing.
const lines: PlanOpenLine[] = subs.results
.map((s) => planById.get(s.plan_id))
@@ -407,7 +407,7 @@ export async function previewBillingInitiate(
return { period, opened, will_notify: notify_reason === "ok", notify_reason, plan_changes, create, reprice, frozen_count };
}
-// ── "重新同步本期帳單" (reconcile a period's bills to the current roster) ──────────
+// ── "重新同步此期帳單" (reconcile a period's bills to the current roster) ──────────
export interface ReconcileLine {
payment_id?: number;
@@ -445,7 +445,7 @@ async function sweepOrphanProofs(env: Env, removed: ReconcileLine[]): Promise {
* log, it IS the definition of "this period is open" (isBillingOpened, core/db.ts
* listOpenPayablePeriods), so deleting it alone leaves pending bills standing in a period members
* can no longer pay — a half retract. routes/admin.ts:250 409s the same request for the same
- * reason; releasing it is only ever correct as part of 收回本期開繳 (core/billing.ts).
+ * reason; releasing it is only ever correct as part of 收回此期開繳 (core/billing.ts).
*/
export type ReleasableKey = NotificationKey & { type: Exclude };
diff --git a/packages/worker/src/core/payments.ts b/packages/worker/src/core/payments.ts
index 6b191bd..137546c 100644
--- a/packages/worker/src/core/payments.ts
+++ b/packages/worker/src/core/payments.ts
@@ -52,7 +52,9 @@ export class InvalidPaymentTransition extends Error {
public readonly paymentId: number,
public readonly to: PaymentStatus
) {
- super(`payment ${paymentId} cannot transition to '${to}'`);
+ // Surfaced verbatim in the admin error banner (routes/admin.ts returns e.message on 409), so it
+ // is copy, not a log line. paymentId/to stay on the instance for logs and tests.
+ super("這筆的狀態已被變動(可能已由其他人處理),請重新載入後再試。");
this.name = "InvalidPaymentTransition";
}
}
@@ -163,7 +165,7 @@ export interface VerifyUserPeriodResult {
}
/**
- * 一鍵全部核准: verify every payment this member has in the period that is waiting for review
+ * 一鍵全部驗證: verify every payment this member has in the period that is waiting for review
* (status 'paid' — the 已繳待驗 queue). One member submit settles one row per active subscription
* sharing one screenshot, so the owner should be able to approve them together.
*
diff --git a/packages/worker/src/core/receipt.ts b/packages/worker/src/core/receipt.ts
index 67e8018..eea1b7a 100644
--- a/packages/worker/src/core/receipt.ts
+++ b/packages/worker/src/core/receipt.ts
@@ -8,7 +8,7 @@ import {
export interface ReceiptRequest {
workspaceId: number;
kind: ReceiptKind;
- /** Bills to announce. Callers pass one member's rows: a 退回 is one bill, 一鍵全部核准 is one
+ /** Bills to announce. Callers pass one member's rows: a 退回 is one bill, 一鍵全部驗證 is one
* member × one period. Anything outside the first row's (user, period) is dropped. */
paymentIds: number[];
reason?: string | null;
@@ -60,7 +60,7 @@ export async function announcePaymentReceipt(
const head = rows[0]!;
const mine = rows.filter((r) => r.user_id === head.user_id && r.period === head.period);
- // Claim per bill, send once: 一鍵全部核准 verifies N rows and must produce ONE message, not N.
+ // Claim per bill, send once: 一鍵全部驗證 verifies N rows and must produce ONE message, not N.
const claimed: ReceiptRow[] = [];
for (const r of mine) {
const won = await claimNotification(env.DB, {
diff --git a/packages/worker/src/core/scheduled.ts b/packages/worker/src/core/scheduled.ts
index 2c793b8..be676c5 100644
--- a/packages/worker/src/core/scheduled.ts
+++ b/packages/worker/src/core/scheduled.ts
@@ -194,7 +194,7 @@ export async function sendOverdueForPeriod(
// says only "these people have already been reminded", and after a refused send nobody was.
// Keeping it would mute this period's reminders permanently: every later claim (cron included)
// would just lose and report already_sent, with no error surfacing anywhere. That is the exact
- // trap 收回本期開繳 avoids by deleting this row, so a failed send releases it for the same reason.
+ // trap 收回此期開繳 avoids by deleting this row, so a failed send releases it for the same reason.
// The release goes by the row id we won, so it can never delete a concurrent claim's row.
await releaseSlot(env.DB, slot.id!);
return { notified: 0, outcome: "send_failed", overdue_days: settings.overdue_days, people };
diff --git a/packages/worker/src/env.ts b/packages/worker/src/env.ts
index d461137..817f96e 100644
--- a/packages/worker/src/env.ts
+++ b/packages/worker/src/env.ts
@@ -16,6 +16,13 @@ export interface Env {
}
export interface WorkspaceSettings {
+ /**
+ * Dead setting (#46): parsed and round-tripped, but nothing reads it — every date in the system is
+ * computed in Asia/Taipei, hardcoded in core/time.ts. Kept (not deleted) because removing it would
+ * change the settings schema and break the stored-settings round-trip env.test.ts asserts.
+ * There is deliberately no admin UI field for it: an editable control that changes nothing is worse
+ * than no control. Making the timezone real means changing core/time.ts, not this line.
+ */
timezone: string;
discord_guild_id: string;
discord_billing_channel_id: string;
@@ -49,8 +56,8 @@ export const DEFAULT_SETTINGS: WorkspaceSettings = {
overdue_days: 3,
proof_retention_months: 24,
admin_discord_ids: [],
- overdue_template: "⏰ **{period} 催繳**\n以下夥伴本期尚有未繳(共 {count} 位),請儘速處理 🙏\n{list}",
- billing_opened_template: "📢 **{period} 開始繳費**\n{plans}\n\n請點下方「繳費」按鈕,或使用 `/繳費` 指令(可附截圖)。",
+ overdue_template: "⏰ **{period} 催繳**\n以下夥伴尚有未繳(共 {count} 位),請儘速處理 🙏\n{list}",
+ billing_opened_template: "📢 **{period} 開繳**\n{plans}\n\n請點下方「繳費」按鈕,或使用 `/繳費` 指令(可附截圖)。",
payment_message_template: "💳 **AI 訂閱繳費**\n點下方「繳費」按鈕選擇繳費渠道送出(一次涵蓋你所有訂閱),或使用 `/繳費` 指令(可附截圖/備註)。",
payment_bark_key: "",
payment_bark_server: "https://api.day.app",
diff --git a/packages/worker/src/routes/admin.ts b/packages/worker/src/routes/admin.ts
index 1362dea..f1eda40 100644
--- a/packages/worker/src/routes/admin.ts
+++ b/packages/worker/src/routes/admin.ts
@@ -18,6 +18,13 @@ import { sendOverdueForPeriod } from "../core/scheduled";
import { renderTemplate } from "../core/templates";
import { sendTestNotification } from "../core/payment-notify";
+// Error copy (#46 / §A.14): the admin SPA renders `data.error` straight into .error-banner, so
+// every message an admin can reach BY CLICKING answers in zh-TW. The ASCII ones left below are
+// English BY DESIGN, not an oversight: they guard shapes the UI cannot produce — `period` sits
+// behind , `billing_day`/amounts behind , the required-
+// field checks behind client-side validation, and the nudge/import body shapes are built by our
+// own code. They only ever surface to someone calling the API by hand, so they stay diagnostic.
+// Do not "finish the job" by translating them.
// Single-workspace MVP: default to the seeded workspace, overridable via ?workspace_id=.
const DEFAULT_WORKSPACE_ID = 1;
const UPLOAD_TOKEN_TTL_MS = 30 * 60 * 1000;
@@ -51,7 +58,7 @@ async function readJson(req: Request): Promise {
async function getWorkspace(_req: Request, env: Env, ctx: RouteCtx): Promise {
const row = await env.DB.prepare("SELECT * FROM workspaces WHERE id = ?").bind(wsId(ctx))
.first<{ id: number; name: string; billing_day: number; settings: string }>();
- if (!row) return errorResponse(404, "not found");
+ if (!row) return errorResponse(404, "找不到工作區設定");
return json({ workspace: { ...row, settings: parseSettings(row.settings) }, r2_configured: !!env.BUCKET });
}
@@ -59,7 +66,7 @@ async function updateWorkspace(req: Request, env: Env, ctx: RouteCtx): Promise();
- if (!before) return errorResponse(404, "not found");
+ if (!before) return errorResponse(404, "找不到工作區設定");
const b = await readJson<{ billing_day?: number; settings?: Record }>(req) ?? {};
if (b.billing_day !== undefined && (!Number.isInteger(b.billing_day) || b.billing_day < 1 || b.billing_day > 28)) {
return errorResponse(400, "billing_day must be 1..28");
@@ -111,7 +118,7 @@ async function billingInitiate(req: Request, env: Env, ctx: RouteCtx): Promise {
const id = Number(ctx.params.id);
const before = await env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(id).first();
- if (!before) return errorResponse(404, "not found");
+ if (!before) return errorResponse(404, "找不到這位成員");
const b = await readJson<{ display_name?: string; discord_id?: string; email?: string; note?: string }>(req) ?? {};
// Distinguish "field omitted" (keep current binding) from "field present-and-empty" (explicit
// unbind). Without this, a display-name-only PATCH would silently null discord_id.
@@ -404,7 +411,7 @@ async function deleteUser(_req: Request, env: Env, ctx: RouteCtx): Promise();
@@ -481,7 +488,7 @@ async function createPlan(req: Request, env: Env, ctx: RouteCtx): Promise {
const id = Number(ctx.params.id);
const before = await env.DB.prepare("SELECT * FROM plans WHERE id = ?").bind(id).first();
- if (!before) return errorResponse(404, "not found");
+ if (!before) return errorResponse(404, "找不到這個方案");
const b = await readJson<{ name?: string; provider?: string; monthly_amount?: number; discord_role_id?: string; active?: number }>(req) ?? {};
await env.DB.prepare(
`UPDATE plans SET name = COALESCE(?, name), provider = COALESCE(?, provider), monthly_amount = COALESCE(?, monthly_amount),
@@ -496,7 +503,7 @@ async function deletePlan(_req: Request, env: Env, ctx: RouteCtx): Promise();
if ((ref?.c ?? 0) > 0) return errorResponse(409, "此方案仍有訂閱(含已取消的歷史訂閱):請先刪除這些訂閱,或將方案改為「停用」以保留歷史");
await env.DB.prepare("DELETE FROM plans WHERE id = ? AND workspace_id = ?").bind(id, ws).run();
@@ -538,10 +545,10 @@ async function createSubscription(req: Request, env: Env, ctx: RouteCtx): Promis
async function updateSubscription(req: Request, env: Env, ctx: RouteCtx): Promise {
const id = Number(ctx.params.id);
const before = await env.DB.prepare("SELECT * FROM subscriptions WHERE id = ?").bind(id).first();
- if (!before) return errorResponse(404, "not found");
+ if (!before) return errorResponse(404, "找不到這筆訂閱");
const b = await readJson<{ status?: string; start_date?: string; billing_day?: number; custom_cycle?: number }>(req) ?? {};
if (b.status && !["active", "paused", "cancelled"].includes(b.status)) {
- return errorResponse(400, "invalid status");
+ return errorResponse(400, "狀態無效。");
}
await env.DB.prepare(
`UPDATE subscriptions SET status = COALESCE(?, status), start_date = COALESCE(?, start_date),
@@ -576,7 +583,7 @@ async function createChannelTag(req: Request, env: Env, ctx: RouteCtx): Promise<
async function updateChannelTag(req: Request, env: Env, ctx: RouteCtx): Promise {
const id = Number(ctx.params.id);
const before = await env.DB.prepare("SELECT * FROM channel_tags WHERE id = ?").bind(id).first();
- if (!before) return errorResponse(404, "not found");
+ if (!before) return errorResponse(404, "找不到這個渠道");
const b = await readJson<{ name?: string; type?: string; active?: number; sort_order?: number }>(req) ?? {};
await env.DB.prepare(
`UPDATE channel_tags SET name = COALESCE(?, name), type = COALESCE(?, type),
@@ -591,7 +598,7 @@ async function deleteChannelTag(_req: Request, env: Env, ctx: RouteCtx): Promise
const id = Number(ctx.params.id);
const ws = wsId(ctx);
const tag = await env.DB.prepare("SELECT * FROM channel_tags WHERE id = ? AND workspace_id = ?").bind(id, ws).first();
- if (!tag) return errorResponse(404, "not found");
+ if (!tag) return errorResponse(404, "找不到這個渠道");
const ref = await env.DB.prepare(
"SELECT COUNT(*) AS c FROM payments WHERE workspace_id = ? AND (verified_channel_tag_id = ? OR declared_channel_tag_id = ?)"
).bind(ws, id, id).first<{ c: number }>();
@@ -645,12 +652,12 @@ async function listPayments(_req: Request, env: Env, ctx: RouteCtx): Promise {
const id = Number(ctx.params.id);
const before = await getPayment(env.DB, id);
- if (!before || before.workspace_id !== wsId(ctx)) return errorResponse(404, "not found");
+ if (!before || before.workspace_id !== wsId(ctx)) return errorResponse(404, "找不到這筆繳費紀錄");
const b = await readJson<{ verified_channel_tag_id?: number }>(req) ?? {};
// Default the verified channel to the user's declared channel when the admin doesn't override.
const tagId = b.verified_channel_tag_id ?? before.declared_channel_tag_id ?? null;
if (tagId != null && !(await tagBelongsToWorkspace(env, before.workspace_id, tagId))) {
- return errorResponse(400, "invalid channel tag");
+ return errorResponse(400, "渠道無效,請重新選擇。");
}
try {
const after = await verifyPayment(env.DB, id, { verifiedBy: actorOf(ctx), verifiedChannelTagId: tagId });
@@ -667,7 +674,7 @@ async function verifyPaymentHandler(req: Request, env: Env, ctx: RouteCtx): Prom
}
/**
- * 一鍵全部核准: verify every reviewable payment this member has in the period. A single member
+ * 一鍵全部驗證: verify every reviewable payment this member has in the period. A single member
* submit settles one row per active subscription (one shared screenshot), so the review that used
* to take N clicks is one call. Channel defaulting and the state machine match single verify — see
* verifyUserPeriod. Audit trail: one payment.verify per row (identical to single verify) plus one
@@ -691,7 +698,7 @@ async function verifyAllHandler(req: Request, env: Env, ctx: RouteCtx): Promise<
const period = b.period;
const user = await env.DB.prepare("SELECT id FROM users WHERE id = ? AND workspace_id = ?")
.bind(userId, ws).first<{ id: number }>();
- if (!user) return errorResponse(404, "not found");
+ if (!user) return errorResponse(404, "找不到這位成員");
const actor = actorOf(ctx);
const paymentIds: number[] = [];
@@ -719,7 +726,7 @@ async function verifyAllHandler(req: Request, env: Env, ctx: RouteCtx): Promise<
// matters more than the summary row (the per-row audits above are already committed).
await writeSummary({ partial: true, error: message })
.catch((err) => console.error("verify_all partial summary audit failed", err));
- return errorResponse(500, `批次核准中斷,已核准 ${paymentIds.length} 筆:${message}`, {
+ return errorResponse(500, `批次驗證中斷,已驗證 ${paymentIds.length} 筆:${message}`, {
verified: paymentIds.length, payment_ids: paymentIds,
});
}
@@ -735,7 +742,7 @@ async function verifyAllHandler(req: Request, env: Env, ctx: RouteCtx): Promise<
async function rejectPaymentHandler(req: Request, env: Env, ctx: RouteCtx): Promise {
const id = Number(ctx.params.id);
const before = await getPayment(env.DB, id);
- if (!before || before.workspace_id !== wsId(ctx)) return errorResponse(404, "not found");
+ if (!before || before.workspace_id !== wsId(ctx)) return errorResponse(404, "找不到這筆繳費紀錄");
const b = await readJson<{ rejected_reason?: string }>(req) ?? {};
try {
const after = await rejectPayment(env.DB, id, { rejectedReason: b.rejected_reason ?? null, verifiedBy: actorOf(ctx) });
@@ -757,10 +764,10 @@ async function rejectPaymentHandler(req: Request, env: Env, ctx: RouteCtx): Prom
async function overrideAmountHandler(req: Request, env: Env, ctx: RouteCtx): Promise {
const id = Number(ctx.params.id);
const before = await getPayment(env.DB, id);
- if (!before || before.workspace_id !== wsId(ctx)) return errorResponse(404, "not found");
+ if (!before || before.workspace_id !== wsId(ctx)) return errorResponse(404, "找不到這筆繳費紀錄");
const b = await readJson<{ amount?: number }>(req);
if (typeof b?.amount !== "number" || !Number.isInteger(b.amount) || b.amount < 0) {
- return errorResponse(400, "integer amount required");
+ return errorResponse(400, "金額必須是整數。");
}
const after = await overrideAmount(env.DB, id, b.amount);
await writeAudit(env.DB, { workspaceId: before.workspace_id, actor: actorOf(ctx), action: "amount.override", entityType: "payment", entityId: id, before: { amount: before.amount }, after: { amount: after.amount } });
@@ -770,7 +777,7 @@ async function overrideAmountHandler(req: Request, env: Env, ctx: RouteCtx): Pro
async function unverifyPaymentHandler(_req: Request, env: Env, ctx: RouteCtx): Promise {
const id = Number(ctx.params.id);
const before = await getPayment(env.DB, id);
- if (!before || before.workspace_id !== wsId(ctx)) return errorResponse(404, "not found");
+ if (!before || before.workspace_id !== wsId(ctx)) return errorResponse(404, "找不到這筆繳費紀錄");
try {
const after = await unverifyPayment(env.DB, id);
await writeAudit(env.DB, { workspaceId: before.workspace_id, actor: actorOf(ctx), action: "payment.unverify", entityType: "payment", entityId: id, before, after });
@@ -789,18 +796,18 @@ async function manualPayment(req: Request, env: Env, ctx: RouteCtx): Promise();
- if (!sub) return errorResponse(400, "invalid subscription");
+ if (!sub) return errorResponse(400, "找不到這筆訂閱。");
const amount = b.amount ?? sub.amount;
const now = nowUtcIso();
const period_start = `${b.period}-01`;
@@ -830,7 +837,7 @@ async function manualPayment(req: Request, env: Env, ctx: RouteCtx): Promise {
const id = Number(ctx.params.id);
const p = await getPayment(env.DB, id);
- if (!p || p.workspace_id !== wsId(ctx)) return errorResponse(404, "not found");
+ if (!p || p.workspace_id !== wsId(ctx)) return errorResponse(404, "找不到這筆繳費紀錄");
if (p.screenshot_key && env.BUCKET) await env.BUCKET.delete(p.screenshot_key);
await env.DB.prepare("UPDATE payments SET screenshot_key = NULL, proof_deleted_at = ?, updated_at = ? WHERE id = ?")
.bind(taipeiDate(), nowUtcIso(), id).run();
@@ -841,7 +848,7 @@ async function deleteProof(_req: Request, env: Env, ctx: RouteCtx): Promise {
const id = Number(ctx.params.id);
const p = await getPayment(env.DB, id);
- if (!p || p.workspace_id !== wsId(ctx)) return errorResponse(404, "not found");
+ if (!p || p.workspace_id !== wsId(ctx)) return errorResponse(404, "找不到這筆繳費紀錄");
await env.DB.batch([
env.DB.prepare("DELETE FROM upload_tokens WHERE workspace_id = ? AND subscription_id = ? AND period = ?").bind(p.workspace_id, p.subscription_id, p.period),
env.DB.prepare("DELETE FROM payments WHERE id = ?").bind(id),
@@ -864,9 +871,9 @@ async function createUploadLink(req: Request, env: Env, ctx: RouteCtx): Promise<
if (!PERIOD_RE.test(b.period)) return errorResponse(400, "period must be YYYY-MM");
const ws = wsId(ctx);
const user = await env.DB.prepare("SELECT id FROM users WHERE id = ? AND workspace_id = ?").bind(b.user_id, ws).first();
- if (!user) return errorResponse(400, "invalid user");
+ if (!user) return errorResponse(400, "找不到這位成員。");
// Fail loud rather than mint a token only to hand back a broken relative link.
- if (!env.WEB_ORIGIN) return errorResponse(500, "WEB_ORIGIN is not configured");
+ if (!env.WEB_ORIGIN) return errorResponse(500, "尚未設定 WEB_ORIGIN,無法產生上傳連結;請在 Worker 環境變數設定後重新部署。");
const { raw, expiresAt } = await issueUploadToken(env.DB, {
workspaceId: ws, userId: b.user_id, period: b.period, subscriptionId: b.subscription_id ?? null,
ttlMs: UPLOAD_TOKEN_TTL_MS,
@@ -882,11 +889,11 @@ async function createUploadLink(req: Request, env: Env, ctx: RouteCtx): Promise<
async function discordPaymentMessage(_req: Request, env: Env, ctx: RouteCtx): Promise {
const ws = wsId(ctx);
const row = await env.DB.prepare("SELECT settings FROM workspaces WHERE id = ?").bind(ws).first<{ settings: string }>();
- if (!row) return errorResponse(404, "not found");
+ if (!row) return errorResponse(404, "找不到工作區設定");
const settings = parseSettings(row.settings);
const channelId = settings.discord_billing_channel_id;
- if (!channelId) return errorResponse(400, "discord_billing_channel_id is not set");
- if (!env.DISCORD_BOT_TOKEN) return errorResponse(400, "bot token not configured");
+ if (!channelId) return errorResponse(400, "尚未設定繳費頻道 ID,請到「設定 → Discord 串接」填入。");
+ if (!env.DISCORD_BOT_TOKEN) return errorResponse(400, "尚未設定 Discord Bot Token,請在 Worker 環境變數設定後重新部署。");
const body = {
content: renderTemplate(settings.payment_message_template, { period: taipeiPeriod() }),
@@ -900,7 +907,7 @@ async function discordPaymentMessage(_req: Request, env: Env, ctx: RouteCtx): Pr
messageId = created.id ?? "";
ok = created.ok && !!messageId; // the id is what we persist, so a 2xx without one is still a failure here
}
- if (!ok) return errorResponse(502, "failed to post Discord message");
+ if (!ok) return errorResponse(502, "Discord 訊息張貼失敗,請確認機器人在該頻道有發言權限。");
await env.DB.prepare("UPDATE workspaces SET settings = json_set(settings, '$.discord_payment_message_id', ?), updated_at = ? WHERE id = ?")
.bind(messageId, nowUtcIso(), ws).run();
@@ -911,13 +918,16 @@ async function discordPaymentMessage(_req: Request, env: Env, ctx: RouteCtx): Pr
async function discordBindMessage(_req: Request, env: Env, ctx: RouteCtx): Promise {
const ws = wsId(ctx);
const row = await env.DB.prepare("SELECT settings FROM workspaces WHERE id = ?").bind(ws).first<{ settings: string }>();
- if (!row) return errorResponse(404, "not found");
+ if (!row) return errorResponse(404, "找不到工作區設定");
const settings = parseSettings(row.settings);
const channelId = settings.discord_billing_channel_id;
- if (!channelId) return errorResponse(400, "discord_billing_channel_id is not set");
- if (!env.DISCORD_BOT_TOKEN) return errorResponse(400, "bot token not configured");
+ if (!channelId) return errorResponse(400, "尚未設定繳費頻道 ID,請到「設定 → Discord 串接」填入。");
+ if (!env.DISCORD_BOT_TOKEN) return errorResponse(400, "尚未設定 Discord Bot Token,請在 Worker 環境變數設定後重新部署。");
const body = {
+ // Hardcoded on purpose for now (#46): unlike 開繳/催繳/常駐繳費訊息, this message has no
+ // workspace-settings template. 設定 → Discord 訊息文字 says so. Making it editable is a settings
+ // schema change (deferred with the rest of the 期別 lifecycle work).
content: "👋 還沒綁定的成員,點下方按鈕綁定你的 Discord 帳號;綁定後開繳/催繳才能 @ 到你。",
components: [bindButtonRow(ws)],
};
@@ -929,7 +939,7 @@ async function discordBindMessage(_req: Request, env: Env, ctx: RouteCtx): Promi
messageId = created.id ?? "";
ok = created.ok && !!messageId; // the id is what we persist, so a 2xx without one is still a failure here
}
- if (!ok) return errorResponse(502, "failed to post Discord message");
+ if (!ok) return errorResponse(502, "Discord 訊息張貼失敗,請確認機器人在該頻道有發言權限。");
await env.DB.prepare("UPDATE workspaces SET settings = json_set(settings, '$.discord_bind_message_id', ?), updated_at = ? WHERE id = ?")
.bind(messageId, nowUtcIso(), ws).run();
@@ -940,17 +950,17 @@ async function discordBindMessage(_req: Request, env: Env, ctx: RouteCtx): Promi
async function discordRegisterCommands(_req: Request, env: Env, ctx: RouteCtx): Promise {
const ws = wsId(ctx);
const row = await env.DB.prepare("SELECT settings FROM workspaces WHERE id = ?").bind(ws).first<{ settings: string }>();
- if (!row) return errorResponse(404, "not found");
+ if (!row) return errorResponse(404, "找不到工作區設定");
const settings = parseSettings(row.settings);
const guildId = settings.discord_guild_id;
- if (!guildId) return errorResponse(400, "discord_guild_id is not set");
- if (!env.DISCORD_APPLICATION_ID) return errorResponse(400, "DISCORD_APPLICATION_ID is not set");
- if (!env.DISCORD_BOT_TOKEN) return errorResponse(400, "bot token not configured");
+ if (!guildId) return errorResponse(400, "尚未設定伺服器 ID(Guild),請到「設定 → Discord 串接」填入。");
+ if (!env.DISCORD_APPLICATION_ID) return errorResponse(400, "尚未設定 DISCORD_APPLICATION_ID,請在 Worker 環境變數設定後重新部署。");
+ if (!env.DISCORD_BOT_TOKEN) return errorResponse(400, "尚未設定 Discord Bot Token,請在 Worker 環境變數設定後重新部署。");
// The registered payload mirrors this deployment: no R2 → no 截圖 option to offer.
const commands = [payCommand(!!env.BUCKET), MY_BILLS_COMMAND, INITIATE_COMMAND, BIND_COMMAND];
const res = await registerGuildCommands(env.DISCORD_BOT_TOKEN, env.DISCORD_APPLICATION_ID, guildId, commands);
- if (!res.ok) return errorResponse(502, "failed to register commands");
+ if (!res.ok) return errorResponse(502, "指令註冊失敗,請確認 Bot Token 與伺服器 ID 是否正確。");
const registeredAt = nowUtcIso();
await env.DB.prepare("UPDATE workspaces SET settings = json_set(settings, '$.discord_commands_registered_at', ?), updated_at = ? WHERE id = ?")
diff --git a/packages/worker/src/routes/upload.ts b/packages/worker/src/routes/upload.ts
index fc45623..71e6ba8 100644
--- a/packages/worker/src/routes/upload.ts
+++ b/packages/worker/src/routes/upload.ts
@@ -68,7 +68,8 @@ export async function handleUpload(req: Request, env: Env, ctx: RouteCtx): Promi
}
if (!hasFile && !note && declaredChannelTagId === null) {
- return errorResponse(400, "請至少附上截圖、填寫備註,或選擇渠道。");
+ // §A.12 的正規句,與網頁與 Discord 三處同一句、同一欄位順序;沒有 R2 時頁面沒有截圖欄。
+ return errorResponse(400, env.BUCKET ? "渠道、截圖、備註至少填一項。" : "渠道、備註至少填一項。");
}
let proof: { body: ArrayBuffer; ext: string; contentType: string } | null = null;
@@ -83,9 +84,9 @@ export async function handleUpload(req: Request, env: Env, ctx: RouteCtx): Promi
// Same gate the Discord path enforces (adapters/discord/handler.ts): a one-time link must not
// settle a period members cannot otherwise pay. createUploadLink mints a token for any period,
// opened or not, so without this an admin-issued link would settle the cron-created bills of a
- // period that was never opened — or one that 收回本期開繳 has since closed.
+ // period that was never opened — or one that 收回此期開繳 has since closed.
if (!(await isBillingOpened(env.DB, tok.workspace_id, tok.period))) {
- return errorResponse(409, "本期繳費尚未開放,待管理員發出開繳通知後即可繳費。", { code: "payment" });
+ return errorResponse(409, `${tok.period} 的繳費尚未開放,待管理員發出開繳通知後即可繳費。`, { code: "payment" });
}
try {
diff --git a/packages/worker/test/adapters/discord-initiate-cap.test.ts b/packages/worker/test/adapters/discord-initiate-cap.test.ts
index 001f8d6..a86b655 100644
--- a/packages/worker/test/adapters/discord-initiate-cap.test.ts
+++ b/packages/worker/test/adapters/discord-initiate-cap.test.ts
@@ -57,7 +57,7 @@ describe("/發起繳費 的方案數上限", () => {
expect(replied).toContain("6");
expect(replied).toContain("後台");
- // 拒絕就要是真的拒絕:定價沒被改、本期沒被開繳、也沒有半張帳單。
+ // 拒絕就要是真的拒絕:定價沒被改、此期沒被開繳、也沒有半張帳單。
const p = await env.DB.prepare("SELECT monthly_amount FROM plans WHERE id = ?").bind(98500).first<{ monthly_amount: number }>();
expect(p!.monthly_amount).toBe(100);
const marker = await env.DB.prepare(
diff --git a/packages/worker/test/adapters/discord-pay.test.ts b/packages/worker/test/adapters/discord-pay.test.ts
index ab4b6ff..cb6a129 100644
--- a/packages/worker/test/adapters/discord-pay.test.ts
+++ b/packages/worker/test/adapters/discord-pay.test.ts
@@ -144,7 +144,9 @@ describe("/繳費 with no R2 ignores the screenshot", () => {
vi.unstubAllGlobals();
(env as any).BUCKET = prevB;
(env as any).DISCORD_APPLICATION_ID = prevApp;
- expect(captured).toContain("已登記本期");
+ // 報的必須是「這一期」,不是任何一個看起來像 YYYY-MM 的字串——
+ // 只斷言格式的話,回報錯月份的 bug 也會通過。
+ expect(captured).toContain(`已登記 ${PERIOD}`);
expect(captured).toContain("已記錄你的繳費宣告");
});
});
diff --git a/packages/worker/test/core/billing-initiate-no-plans.test.ts b/packages/worker/test/core/billing-initiate-no-plans.test.ts
index c5ed0dc..1fa2a45 100644
--- a/packages/worker/test/core/billing-initiate-no-plans.test.ts
+++ b/packages/worker/test/core/billing-initiate-no-plans.test.ts
@@ -4,10 +4,10 @@ import { initiateBillingOpened, previewBillingInitiate } from "../../src/core/bi
import type { Notifier } from "../../src/core/notify";
/**
- * 沒有任何「會出現在公告裡」的方案時,發起繳費不能把本期標記成已開繳。
+ * 沒有任何「會出現在公告裡」的方案時,發起繳費不能把此期標記成已開繳。
* 公告名單只收「啟用中的方案」,所以訂閱掛在停用方案上的 workspace 會落在這個洞裡:
- * 舊實作先搶下 billing_opened 名額、才發現沒東西可公告,結果本期永遠是「已開繳但從沒通知過」,
- * 只能靠收回本期開繳救回來。預覽早就把這個情況叫做 no_plans,apply 必須說同一句話。
+ * 舊實作先搶下 billing_opened 名額、才發現沒東西可公告,結果此期永遠是「已開繳但從沒通知過」,
+ * 只能靠收回此期開繳救回來。預覽早就把這個情況叫做 no_plans,apply 必須說同一句話。
*/
const TS = "2026-05-01T00:00:00.000Z";
const WS = 9870, PLAN_OFF = 9870, USER = 9870, SUB = 9870;
@@ -51,7 +51,7 @@ describe("發起繳費:沒有可公告的方案時", () => {
const r = await initiateBillingOpened(env, WS, PERIOD, { amounts: [] }, "owner@x", notifier);
expect(r).toMatchObject({ sent: false, notifyReason: "no_plans" });
expect(sends).toBe(before);
- // 這是這個修正的重點:沒有公告 = 沒有開繳紀錄,本期仍然是「未開繳」,可以修好方案後重來一次。
+ // 這是這個修正的重點:沒有公告 = 沒有開繳紀錄,此期仍然是「未開繳」,可以修好方案後重來一次。
expect(await markerRows(WS, PERIOD)).toBe(0);
});
diff --git a/packages/worker/test/core/billing-initiate-preview.test.ts b/packages/worker/test/core/billing-initiate-preview.test.ts
index 899ced4..16a11a0 100644
--- a/packages/worker/test/core/billing-initiate-preview.test.ts
+++ b/packages/worker/test/core/billing-initiate-preview.test.ts
@@ -69,7 +69,7 @@ describe("previewBillingInitiate", () => {
expect(p.plan_changes).toEqual([]); // 價格已在上一步寫入,現在沒有差異
});
- it("已繳的帳單算進 frozen_count,不出現在 reprice", async () => {
+ it("已繳待驗的帳單算進 frozen_count,不出現在 reprice", async () => {
await env.DB.prepare("UPDATE payments SET status='paid' WHERE workspace_id=? AND period=? AND subscription_id=?")
.bind(WS, PERIOD, SUB_B).run();
const p = await previewBillingInitiate(env, WS, PERIOD, { amounts: [{ plan_id: PLAN_B, amount: 999 }] });
diff --git a/packages/worker/test/core/import.test.ts b/packages/worker/test/core/import.test.ts
index 8d58a59..4cea066 100644
--- a/packages/worker/test/core/import.test.ts
+++ b/packages/worker/test/core/import.test.ts
@@ -247,7 +247,7 @@ describe("importRoster TRUE on paused / cancelled subscriptions", () => {
const st = await env.DB.prepare("SELECT status FROM subscriptions WHERE id=?").bind(S_PAUSED).first<{ status: string }>();
expect(st?.status).toBe("active");
// No bill anywhere: not for the sub's old start month, not for the import period. The current
- // period's bill is 重新同步本期帳單's job (reconcilePeriodBills).
+ // period's bill is 重新同步此期帳單's job (reconcilePeriodBills).
const pays = await env.DB.prepare("SELECT COUNT(*) c FROM payments WHERE subscription_id=?").bind(S_PAUSED).first<{ c: number }>();
expect(pays?.c).toBe(0);
});
diff --git a/packages/worker/test/core/notify-send-failure.test.ts b/packages/worker/test/core/notify-send-failure.test.ts
index 0d7fe0b..7d47160 100644
--- a/packages/worker/test/core/notify-send-failure.test.ts
+++ b/packages/worker/test/core/notify-send-failure.test.ts
@@ -55,7 +55,7 @@ describe("發起繳費:通知送不出去時", () => {
// 寫入的部分照樣發生,而且照實回報 —— 這通呼叫真的建了帳單、真的改了定價。
expect(r.createdPayments).toBe(1);
expect(r.updatedPlans).toBe(1);
- // marker 不回收:帳單已經在那裡了,回收會讓本期變成「有帳單卻不能繳」的半套狀態
+ // marker 不回收:帳單已經在那裡了,回收會讓此期變成「有帳單卻不能繳」的半套狀態
// (同 routes/admin.ts notificationsReset 的 409 所擋的情況)。補救路徑是「重發開繳通知」。
expect(await isBillingOpened(env.DB, WS, P_INIT)).toBe(true);
expect(await lastAudit("billing.initiate")).toMatchObject({ period: P_INIT, sent: false, notify_reason: "send_failed" });
@@ -109,7 +109,7 @@ describe("催繳:通知送不出去時", () => {
expect(r.outcome).toBe("send_failed");
expect(r.notified).toBe(0);
// 名額若留著,這期就永遠不會再被催繳(claimNotification 之後只會回 already_sent,
- // 而且沒有任何地方會報錯)—— 跟收回本期開繳釋放 overdue slot 是同一個理由。
+ // 而且沒有任何地方會報錯)—— 跟收回此期開繳釋放 overdue slot 是同一個理由。
expect(await overdueSlots(P_OVERDUE)).toBe(0);
});
diff --git a/packages/worker/test/core/payments-verify-all.test.ts b/packages/worker/test/core/payments-verify-all.test.ts
index f34f86a..02c64d8 100644
--- a/packages/worker/test/core/payments-verify-all.test.ts
+++ b/packages/worker/test/core/payments-verify-all.test.ts
@@ -66,7 +66,7 @@ beforeAll(async () => {
]);
});
-describe("verifyUserPeriod (一鍵全部核准)", () => {
+describe("verifyUserPeriod (一鍵全部驗證)", () => {
it("verifies every 'paid' row of that member × period, each keeping its own declared channel", async () => {
const r = await verifyUserPeriod(env.DB, { workspaceId: WS, userId: U, period: PERIOD, verifiedBy: ACTOR });
expect(r.verified.map((v) => v.after.id).sort()).toEqual([P_TAG, P_NOTAG, P_FOREIGN, P_TAG2].sort());
diff --git a/packages/worker/test/core/payments.test.ts b/packages/worker/test/core/payments.test.ts
index 6f0616f..00fc4a0 100644
--- a/packages/worker/test/core/payments.test.ts
+++ b/packages/worker/test/core/payments.test.ts
@@ -102,3 +102,15 @@ describe("payment state machine", () => {
expect(p.amount).toBe(300);
});
});
+
+// #46: this is a reproducible race (two admins, or a stale list), so the message the admin sees must
+// say what to do — while the id/target stay on the error object for logs.
+describe("InvalidPaymentTransition copy (#46)", () => {
+ it("explains the race in zh-TW and keeps its fields", () => {
+ const e = new InvalidPaymentTransition(1042, "verified");
+ expect(e.message).toBe("這筆的狀態已被變動(可能已由其他人處理),請重新載入後再試。");
+ expect(e.paymentId).toBe(1042);
+ expect(e.to).toBe("verified");
+ expect(e.name).toBe("InvalidPaymentTransition");
+ });
+});
diff --git a/packages/worker/test/routes/admin.test.ts b/packages/worker/test/routes/admin.test.ts
index 860860f..2d5d722 100644
--- a/packages/worker/test/routes/admin.test.ts
+++ b/packages/worker/test/routes/admin.test.ts
@@ -657,3 +657,49 @@ describe("POST /admin/discord/bind-message", () => {
expect(s?.m).toBe("msg-bind-1");
});
});
+
+// #46: the admin SPA renders `data.error` straight into .error-banner, so anything reachable by
+// clicking must be zh-TW. Format validators behind type="month"/type="number" stay English on purpose.
+describe("admin error messages are zh-TW (#46)", () => {
+ it("404s in Chinese for an unknown member", async () => {
+ const res = await call("PATCH", "/admin/users/999999", { display_name: "x" });
+ expect(res!.status).toBe(404);
+ expect(((await res!.json()) as any).error).toContain("找不到這位成員");
+ });
+
+ it("404s in Chinese for an unknown subscription, plan, channel and payment", async () => {
+ const cases: [string, string, string][] = [
+ ["PATCH", "/admin/subscriptions/999999", "找不到這筆訂閱"],
+ ["PATCH", "/admin/plans/999999", "找不到這個方案"],
+ ["PATCH", "/admin/channel-tags/999999", "找不到這個渠道"],
+ ["POST", "/admin/payments/999999/unverify", "找不到這筆繳費紀錄"],
+ ];
+ for (const [method, path, expected] of cases) {
+ const res = await call(method, path, {});
+ expect(res!.status, path).toBe(404);
+ expect(((await res!.json()) as any).error, path).toContain(expected);
+ }
+ });
+
+ it("names the missing Discord channel setting in Chinese", async () => {
+ // The handler checks the channel first, then the token — so the token must be present for the
+ // channel branch to be the one that answers. Same save/restore pattern the file uses elsewhere.
+ const prevToken = (env as any).DISCORD_BOT_TOKEN;
+ (env as any).DISCORD_BOT_TOKEN = "test-bot-token";
+ const prev = await env.DB.prepare(
+ "SELECT json_extract(settings,'$.discord_billing_channel_id') AS c FROM workspaces WHERE id = 1"
+ ).first<{ c: string | null }>();
+ await env.DB.prepare("UPDATE workspaces SET settings = json_set(settings, '$.discord_billing_channel_id', '') WHERE id = 1").run();
+ const res = await call("POST", "/admin/discord/bind-message");
+ await env.DB.prepare("UPDATE workspaces SET settings = json_set(settings, '$.discord_billing_channel_id', ?) WHERE id = 1").bind(prev?.c ?? "").run();
+ if (prevToken === undefined) delete (env as any).DISCORD_BOT_TOKEN; else (env as any).DISCORD_BOT_TOKEN = prevToken;
+ expect(res!.status).toBe(400);
+ expect(((await res!.json()) as any).error).toContain("繳費頻道 ID");
+ });
+
+ it("keeps format validators in English (unreachable from the UI)", async () => {
+ const res = await call("POST", "/admin/billing/initiate", { period: "not-a-period", amounts: [] });
+ expect(res!.status).toBe(400);
+ expect(((await res!.json()) as any).error).toBe("period must be YYYY-MM");
+ });
+});
diff --git a/packages/worker/test/routes/notifications-danger.test.ts b/packages/worker/test/routes/notifications-danger.test.ts
index 9f7fb8b..877c31a 100644
--- a/packages/worker/test/routes/notifications-danger.test.ts
+++ b/packages/worker/test/routes/notifications-danger.test.ts
@@ -108,10 +108,10 @@ describe("POST /admin/notifications/resend", () => {
});
describe("POST /admin/notifications/reset", () => {
- it("拒絕重置 billing_opened,並指向收回本期開繳", async () => {
+ it("拒絕重置 billing_opened,並指向收回此期開繳", async () => {
const r = await call("POST", "/admin/notifications/reset", { type: "billing_opened", period: OPENED });
expect(r!.status).toBe(409);
- expect(((await r!.json()) as any).error).toContain("收回本期開繳");
+ expect(((await r!.json()) as any).error).toContain("收回此期開繳");
// marker 必須還在 —— 這正是這個 409 要保護的東西
const row = await env.DB.prepare(
"SELECT 1 AS ok FROM notification_logs WHERE workspace_id = 1 AND type = 'billing_opened' AND period = ?"
diff --git a/packages/worker/test/routes/payments-review.test.ts b/packages/worker/test/routes/payments-review.test.ts
index 459952b..28c5344 100644
--- a/packages/worker/test/routes/payments-review.test.ts
+++ b/packages/worker/test/routes/payments-review.test.ts
@@ -316,7 +316,7 @@ describe("POST /admin/payments/:id/reject 會發回條", () => {
});
/**
- * Task 4: 一鍵全部核准 verifies N bills but must post ONE receipt, not N — and only when the
+ * Task 4: 一鍵全部驗證 verifies N bills but must post ONE receipt, not N — and only when the
* workspace opted into verify receipts.
*/
describe("POST /admin/payments/verify-all 的回條只發一則", () => {
@@ -336,7 +336,7 @@ describe("POST /admin/payments/verify-all 的回條只發一則", () => {
]);
});
- it("兩筆一起核准,只送出一則回條,但兩筆的 slot 都佔用", async () => {
+ it("兩筆一起驗證,只送出一則回條,但兩筆的 slot 都佔用", async () => {
const posts = vi.fn(async () => new Response(JSON.stringify({ id: "m-9405" }), { status: 200 }));
vi.stubGlobal("fetch", posts);
const res = await call("POST", "/admin/payments/verify-all", { user_id: U_V, period: PERIOD_V });
diff --git a/packages/worker/test/routes/upload.test.ts b/packages/worker/test/routes/upload.test.ts
index be31c38..f00d360 100644
--- a/packages/worker/test/routes/upload.test.ts
+++ b/packages/worker/test/routes/upload.test.ts
@@ -130,7 +130,8 @@ describe("upload submit", () => {
it("answers an empty submission in zh-TW", async () => {
const res = await handleUpload(uploadReq(RAW_OK, {}), env, ctxFor(RAW_OK));
- expect(((await res.json()) as any).error).toBe("請至少附上截圖、填寫備註,或選擇渠道。");
+ // §A.12 的正規句;沒有 R2 的部署少一項(測試環境依 BUCKET 是否綁定而定)。
+ expect(((await res.json()) as any).error).toBe(env.BUCKET ? "渠道、截圖、備註至少填一項。" : "渠道、備註至少填一項。");
});
it("rejects an empty submission (no screenshot, note, or channel)", async () => {