Skip to content

[UX-C] 成員回饋迴路:回條、我的帳單、綁定出路、個別催繳 (#45 Task 4-16) - #51

Open
poterpan wants to merge 14 commits into
mainfrom
ux/45-member-feedback
Open

[UX-C] 成員回饋迴路:回條、我的帳單、綁定出路、個別催繳 (#45 Task 4-16)#51
poterpan wants to merge 14 commits into
mainfrom
ux/45-member-feedback

Conversation

@poterpan

Copy link
Copy Markdown
Owner

Closes #45

批次 C 的第二批(第一批是 PR #50:通知 slot 底座、遷移危險修復、P0-5 退回回條)。本 PR 完成 Task 4–16,#45 至此全部收尾。

這是唯一新增對外訊息的批次,每一則新訊息都走 claimNotification

  • receipt slot 以 (payment, event) 為單位——退回與確認是兩個 slot;成員重新送出時釋放,所以真正的再次退回會再通知、重試不會。
  • nudge slot 以 (period, user) 為單位——自動路徑(匯入/新增訂閱/重新同步)不 force,只有管理員按下催繳才 delete-then-claim(沿用 billing.ts / scheduled.ts 既有模式)。

投遞方式決策:全部走帳單頻道 @,不加 DM。adapter 只有 channel API,加 DM 需要每位收件人多一次 REST 呼叫、且會多一種「對方關閉私訊」的無回饋失敗模式;系統既有的成員面訊息本來就都在那個頻道。

完成的項目

項目 內容
P0-6 成員繳費頁 8 處英文技術錯誤中文化。字串留在 worker——只有這條路由知道「為什麼拒絕」,未來 Discord 深連結也要拿到同一句
C1 CSV 匯入/新增訂閱都能通知新成員。根因是 ensureFirstPayment 在建立當下就開好帳單,所以之後的同步 diff 是空的、原本的勾選框永遠不出現
C2 成員×期別頁可單獨催繳這一位(force=true,管理員刻意再 @ 一次是繞過去重的唯一正當路徑)
C3 /我的帳單 — 成員自助查待繳與最近 6 筆
C4 綁錯名字可自助解綁重綁(兩段確認、ephemeral、寫稽核)
C5a/b Discord 繳費提示可取得一次性網頁連結;失效頁改指這條真的走得通的路
C6 繳費頁金額改讀本期帳單,與 Discord 同源
C7/C8 三個繳費入口的能力差異事先講清楚
C9 未綁定成員在後台可見(計數 pill + 篩選);同步彈窗說出 @ 不到的人
Task 4 確認回條可設定(預設關);一鍵全部核准只發一則

順帶修掉 healthcheck P0-8/繳費 指令描述原本把三個欄位都說成「可選」,實際至少要填一項。PAY_COMMAND 改成 payCommand(proofEnabled) 工廠——沒有 R2 時根本不註冊「截圖」選項,成員就不會交出一個會被靜默丟棄的檔案。批次 D 請跳過此項。

安全性註記(自助解綁)

unbindDiscordId 的 UPDATE 條件是 id + workspace_id + 呼叫者自己的 discord_id 三重——一個人只能釋放自己那一列。解綁後回到「未綁定」,那正是綁定前就有的狀態,沒有取得任何新能力(認領未綁定的名字本來就是既有自助綁定流程的既有暴露面)。changes !== 1 視為 not_bound,輸掉 race 不謊報成功。

一個實作上的修正

計劃檔的 sendMemberNudgecatch throw,但現行 Notifier 契約是「拒送回 false、不 throw」——照抄會把「Discord 回非 2xx」誤記成已通知且不還 slot。實作改成兩條路都釋放 slot,與 receipt.ts 一致。

下游影響

POST /admin/billing/:period/sync 的回應多了 unbound / unbound_names,且 notified 現在受每期去重影響——同一期重複套用不會再重複 @(P2-4)。

驗收

423 passed / 57 files(PR #50 之後 382 → 423);pnpm -r typecheck 綠;admin + web build 綠。packages/adminpackages/web 無測試框架(本 PR 未引入),改以結構性 grep + tsc + build 驗收。

審查註記

Codex 跨引擎審查因額度用盡尚未執行,owner 指定 23:15 後補送。

- 新增 workspace 設定 receipt_notify_verified(預設 false):退回一定通知(P0-5 的死路),
  確認則 opt-in —— 每張帳單都貼一則會洗版
- 閘門放在 claim 之前,所以之後才打開設定仍能補發(不會被已佔用的 slot 擋住)
- verify-all 把整批 paymentIds 一次交給 announcePaymentReceipt:N 筆帳單合併成
  「一則」頻道訊息,但每筆的 slot 都各自佔用
- 單筆 verify 與 verify-all 都誠實回報 notified 筆數,Discord 失敗不轉成 500
- 後台設定頁新增「審核結果通知」卡片,文案講明退回不可關、確認預設關

測試 382 → 386
字串留在 worker:這條路由才知道為什麼拒絕,且任何 client(今天的上傳頁、
未來的 Discord 深連結)都該拿到同一句。code 欄位保留給程式判斷。

InvalidImage 帶 reason(type / size),讓 Discord 與 web 各自用貼合入口的措辭,
message 本身維持技術字串供日誌使用。

八處成員可見字串中文化:連結失效/已使用、表單格式、渠道無效、三者皆空、
本期已登記、圖片格式與大小。web/App.tsx 的同句補上句號與 worker 一字不差。

測試 386 → 389
過去 GET /upload/:token 回的是「方案定價」,造成兩個問題:
已繳清的訂閱仍被列出,且忽略單筆帳單的金額覆寫——頁面總額會與 Discord 提示對不起來。

改為回 lines(本期 pending/rejected 的帳單),與 POST 實際會結清的完全同一份來源。
web 端 SubscriptionChoice → PayableLine,key 改用 payment_id。

測試 389 → 391
PAY_COMMAND 改成 payCommand(proofEnabled) 工廠:
- 沒有 R2 時根本不註冊「截圖」選項,成員就不會交出一個會被靜默丟棄的檔案(C7)
- 指令描述改講真正的規則「至少填一項」,取代原本把三個欄位都說成「可選」(P0-8)

繳費按鈕的提示先講清楚這個入口能做什麼(C8):只能選渠道,想附截圖/備註要用
/繳費;沒開 R2 時不提截圖,改講備註。沒有渠道設定時的訊息同樣依 R2 分岔。

register-commands.mjs 同步(PROOF_ENABLED=0 可關掉截圖選項)。

測試 391 → 396
成員 web 頁原本只能由管理員手動貼連結,近乎不可達。繳費提示多一顆
「改用網頁上傳(可附截圖)」按鈕:按下才鑄 token(瀏覽提示不會產生垃圾 token),
回一則只有本人看得到的連結。

沒有 R2 或 WEB_ORIGIN 就不顯示按鈕;期別一樣要通過 listOpenPayablePeriods 閘門,
不會為不能繳的月份鑄 token。dispatch 必須排在 PAY_BUTTON_PREFIX 之前(前綴重疊)。
上一個 commit 讓 Discord 的繳費提示能發新連結之後,這句話才成立——
原本叫成員「重新點繳費按鈕」,但那條路當時拿不到網頁連結,等於第二條死路。

不硬塞可點元素:token 無效時查不到 workspace,也就組不出 Discord 深連結,
憑空放按鈕只會再死一次。
原本綁錯只會一直看到「你已綁定為 X」,沒有任何出路。改成兩段式確認:
「這不是我」→ 紅色「確定解除綁定」→ 解綁後直接回到名字選單。

安全性:unbindDiscordId 的 UPDATE 以呼叫者自己的 discord_id 為條件(id + workspace
+ 自己的 discord_id 三重),所以只能釋放自己那一列;解綁後回到「未綁定」——那正是
綁定前就有的狀態,沒有取得任何新能力。changes !== 1 視為 not_bound,輸掉 race 不謊報。
全程 ephemeral,兩個動作都寫 audit(member.unbind / member.bind)。
複用 listOpenPayablePeriods + listSettleablePayments,數字與「繳費」按鈕同源,
不可能對不起來。歷史只回最近 6 筆——足以回答「我上個月到底繳了沒」,再多是後台的工作。

狀態用中文,paid 明寫為「已繳待驗」:單講「已繳」會被讀成錢已到位,那正是它不代表的意思。
未綁定的帳號回一句怎麼綁,而不是失敗。

測試 406 → 411
未綁定的人收不到開繳/催繳的 @,而 onboarding 完全靠那個 @——原本這件事
在後台完全看不出來。

/admin/users 已經回傳 discord_id,所以篩選在前端完成,不新增端點。
刻意只在表格外圍插入(Card 與 .tbl 之間),表格內部只改一行 shown.map,
降低與批次 B(#44)表格改寫的衝突面。
根因:ensureFirstPayment 在建立訂閱/匯入的當下就把帳單開好了,所以之後的
「重新同步」diff 是空的、原本那個通知勾選框永遠不會出現——新成員從頭到尾
收不到任何訊息。

修法不是去改 diff,而是在產生帳單的地方就提供通知選項:
- ImportModal 套用後依 subs_added + subs_reactivated 的成員發通知
- SubAddModal 建立訂閱後通知該成員(訂閱已建立成功,通知沒送成不算失敗,
  顯示原因後停在原地讓管理員知道)
- nudgeSummary 共用文案,三個呼叫點講同一句話,並照實列出通知不到的未綁定成員
sendPaymentNudge 本來就吃任意 people[],只是唯一呼叫點綁在同步套用上。

按鈕放在成員×期別頁而不是成員列表:催繳一定要有期別脈絡,而成員表沒有;
這個畫面的 outstanding(待繳/已退回)已經算好,出現條件是現成的。
force=true——管理員是刻意要求再 @ 一次,那是繞過每期去重的唯一正當路徑。
原本只顯示「已通知 N 位新成員」,未綁定的人被靜默略過——管理員會以為
每個人都收到了。現在預覽階段就列出 @ 不到的名字並指路(成員頁的「未綁定」
篩選、或請他們點綁定按鈕),套用後的結果也照實回報 unbound 筆數。
新增 core/nudge.ts 的 sendMemberNudge:以 (workspace, period, user) 為去重鍵,
自動路徑(匯入/新增訂閱/重新同步)不 force,只有管理員按下催繳才 delete-then-claim
(沿用 billing.ts / scheduled.ts 既有模式)。

- 期別未開繳回 opened:false,不發也不佔 slot
- 沒有頻道或 bot token 不佔 slot(同 receipt.ts 規則)
- 送失敗(回 false 或 throw)都釋放 slot:契約是「拒送回 false 不 throw」,
  只 catch throw 會把 Discord 非 2xx 誤記成已通知
- 誠實回報 unbound / unbound_names,未綁定的人 @ 不到要說出來
- sendPaymentNudge 加 kind(added / remind)分岔開頭句
- 新增 POST /admin/notifications/nudge;sync 的通知改走同一條 core

下游影響:sync 回應多了 unbound / unbound_names,且 notified 現在受每期去重
影響——同一期重複套用不會再重複 @(P2-4)。

測試 411 → 423
測試徽章 355 → 423;migrations 範圍 0006 → 0007(#48 的 0006 已上線,
本批次是 0007);DEPLOY.md 的首次部署範圍同步。

功能條目補四條(中英各自對應):審核結果回條、/我的帳單、自助解綁重綁、
入職通知與個別催繳。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[UX-C] 成員回饋迴路

1 participant