diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md
index 41c8999..d83118c 100644
--- a/docs/DEPLOY.md
+++ b/docs/DEPLOY.md
@@ -75,6 +75,10 @@ web build 的 `VITE_API_BASE`、以及 Cloudflare Access application 的 domain
## 2. 建立 Cloudflare 資源(D1 / R2)
+> **R2 為選填。** 若不需要「成員上傳繳費截圖」,可跳過建立 R2,並移除 `wrangler.toml` 的 `[[r2_buckets]]` 區塊。
+> 未綁 R2 時停用:成員上傳/後台檢視繳費截圖、截圖自動保存清理;其餘(宣告繳費、後台審核、對帳、Discord 通知)一切正常。
+> 後台首次進入會跳一次提示,設定頁也會顯示「截圖儲存(R2):未啟用」。
+
### 路徑一(後台操作)
1. 進 [Cloudflare 後台](https://dash.cloudflare.com) → **Storage & Databases → D1**。
diff --git a/docs/superpowers/plans/2026-06-07-r2-optional.md b/docs/superpowers/plans/2026-06-07-r2-optional.md
new file mode 100644
index 0000000..a7b7528
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-07-r2-optional.md
@@ -0,0 +1,621 @@
+# R2 改為選填 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 讓 Cloudflare R2 變成選填——未綁 R2 時截圖相關功能優雅停用,宣告繳費/審核/對帳/通知全照常;後端回報 R2 狀態,後台首次提示一次 + 設定頁常駐狀態。
+
+**Architecture:** 以 `!!env.BUCKET` 偵測 R2 是否設定(forker 不在 wrangler.toml 註冊 binding 即為未設定)。`src/env.ts` 的 `BUCKET` 改選填、4 處使用點加 runtime guard;`test/env.d.ts` 把測試用 `BUCKET` 覆寫為必填(測試環境一定有 R2,避免測試檔大量改動)。後端在 `/admin/workspace` 回 `r2_configured`、`/upload/:token` 回 `proof_enabled`,前端據此隱藏截圖 UI 並顯示提示/狀態。
+
+**Tech Stack:** Cloudflare Workers + R2 + D1,pnpm monorepo,TypeScript,Vitest(`@cloudflare/vitest-pool-workers`),React SPA(Vite)。
+
+**Spec:** `docs/superpowers/specs/2026-06-07-r2-optional-design.md`
+
+**前置:** 分支 `feat/r2-optional`(已建立,off main)。每個 Task 結束 commit。全程基準:`pnpm -r typecheck`、`pnpm --filter @chippot/worker test`、`pnpm -r build` 維持綠燈;worker 測試在「無 `.dev.vars`」下也須全綠(CI 條件)。
+
+---
+
+## File Structure
+
+| 檔案 | 責任 | Task |
+|---|---|---|
+| `packages/worker/src/env.ts` | `BUCKET?: R2Bucket`(選填) | 1 |
+| `packages/worker/test/env.d.ts` | 測試 env 覆寫 `BUCKET: R2Bucket`(必填) | 1 |
+| `packages/worker/src/core/storage.ts` | settle 存/補償 proof 加 `env.BUCKET` guard | 1 |
+| `packages/worker/src/routes/images.ts` | 無 BUCKET → 404 | 1 |
+| `packages/worker/src/core/retention.ts` | 無 BUCKET → 早退回 0 | 1 |
+| `packages/worker/src/routes/admin.ts` | deleteProof guard;getWorkspace 回 `r2_configured` | 1 |
+| `packages/worker/src/routes/upload.ts` | handleUploadInfo 回 `proof_enabled` | 1 |
+| worker 測試(settle/images/retention/admin/upload) | 無 R2 行為測試 | 1 |
+| `packages/worker/src/adapters/discord/handler.ts` | `/繳費` 無 R2 忽略附件 + 提示 | 2 |
+| `packages/worker/test/adapters/discord-pay.test.ts` | Discord 無 R2 測試 | 2 |
+| `packages/web/src/api.ts` + `App.tsx` | `proof_enabled` + 隱藏截圖欄位 | 3 |
+| `packages/admin/src/api.ts` + `App.tsx` + `views/Settings.tsx` | workspace 型別 + 首次提示 + 常駐狀態 | 4 |
+| `packages/worker/wrangler.toml` + `docs/DEPLOY.md` | R2 標選填 | 5 |
+
+---
+
+## Task 1: 後端 R2 選填(binding optional + guards + 偵測旗標)
+
+**Files:**
+- Modify: `packages/worker/src/env.ts`
+- Modify: `packages/worker/test/env.d.ts`
+- Modify: `packages/worker/src/core/storage.ts`
+- Modify: `packages/worker/src/routes/images.ts`
+- Modify: `packages/worker/src/core/retention.ts`
+- Modify: `packages/worker/src/routes/admin.ts`
+- Modify: `packages/worker/src/routes/upload.ts`
+- Test: `test/core/settle.test.ts`, `test/routes/images.test.ts`, `test/core/retention.test.ts`, `test/routes/admin.test.ts`, `test/routes/upload.test.ts`
+
+### Implementation
+
+- [ ] **Step 1: `env.ts` 把 BUCKET 改選填**
+
+`packages/worker/src/env.ts` 第 3 行 ` BUCKET: R2Bucket;` 改為:
+```ts
+ // Optional: omit the [[r2_buckets]] binding in wrangler.toml to run without screenshots.
+ BUCKET?: R2Bucket;
+```
+
+- [ ] **Step 2: `test/env.d.ts` 覆寫 BUCKET 為必填(測試環境一定有 R2)**
+
+`packages/worker/test/env.d.ts` 的 `interface Env extends AppEnv { ... }` 內,新增一行(覆寫 app 的選填為必填,讓既有測試直接存取 `env.BUCKET` 不報 TS 錯):
+```ts
+ interface Env extends AppEnv {
+ TEST_MIGRATIONS: D1Migration[];
+ BUCKET: R2Bucket; // tests always provide R2; override the app's optional binding
+ }
+```
+
+- [ ] **Step 3: `storage.ts` 三處 guard**
+
+`packages/worker/src/core/storage.ts`,`settleUserPeriod` 內:
+
+(a) step 3 存 proof(約 186-190):
+```ts
+ let key: string | null = null;
+ if (input.proof) {
+ key = buildScreenshotKey(workspaceId, period, userId, input.proof.ext, crypto.randomUUID());
+ await putObject(env.BUCKET, key, input.proof.body, input.proof.contentType);
+ }
+```
+改為(無 BUCKET 不存、key 維持 null、has_proof=0):
+```ts
+ let key: string | null = null;
+ if (input.proof && env.BUCKET) {
+ key = buildScreenshotKey(workspaceId, period, userId, input.proof.ext, crypto.randomUUID());
+ await putObject(env.BUCKET, key, input.proof.body, input.proof.contentType);
+ }
+```
+
+(b) catch 補償(約 201):`if (key) await deleteObject(env.BUCKET, key).catch(() => {});` → `if (key && env.BUCKET) await deleteObject(env.BUCKET, key).catch(() => {});`
+
+(c) TOCTOU 補償(約 218):`if (paidRows.results.length === 0 && key) {` → `if (paidRows.results.length === 0 && key && env.BUCKET) {`
+
+> (b)(c) 的 key 只在 BUCKET 存在時才會非 null,加 `&& env.BUCKET` 純粹讓 TS 在選填型別下通過 narrowing。
+
+- [ ] **Step 4: `images.ts` 無 BUCKET → 404**
+
+`packages/worker/src/routes/images.ts`,在 `const obj = await env.BUCKET.get(key);`(約 24)之前插入:
+```ts
+ if (!env.BUCKET) return errorResponse(404, "not found");
+```
+
+- [ ] **Step 5: `retention.ts` 無 BUCKET → 早退**
+
+`packages/worker/src/core/retention.ts`,`runRetention` 函式體最前(`const cutoffIso = ...` 之前)插入:
+```ts
+ if (!env.BUCKET) return 0; // R2 not configured — nothing to retain
+```
+
+- [ ] **Step 6: `admin.ts` deleteProof guard + getWorkspace 回 r2_configured**
+
+`packages/worker/src/routes/admin.ts`:
+
+(a) `deleteProof`(約 459):`if (p.screenshot_key) await env.BUCKET.delete(p.screenshot_key);` → `if (p.screenshot_key && env.BUCKET) await env.BUCKET.delete(p.screenshot_key);`
+
+(b) `getWorkspace`(約 52):`return json({ workspace: { ...row, settings: parseSettings(row.settings) } });` →
+```ts
+ return json({ workspace: { ...row, settings: parseSettings(row.settings) }, r2_configured: !!env.BUCKET });
+```
+
+- [ ] **Step 7: `upload.ts` handleUploadInfo 回 proof_enabled**
+
+`packages/worker/src/routes/upload.ts`,`handleUploadInfo` 的 `return json({...})`(約 22-28)加一欄:
+```ts
+ return json({
+ valid: true,
+ period: tok.period,
+ user: { display_name: user?.display_name ?? "" },
+ subscriptions,
+ channel_tags,
+ proof_enabled: !!env.BUCKET,
+ });
+```
+
+- [ ] **Step 8: typecheck(確認選填化 + guards 後 src 與 test 皆綠)**
+
+Run: `cd /Users/poterpan/Documents/Coding/Project/chippot && pnpm --filter @chippot/worker typecheck`
+Expected: 無錯(src 因 guards 通過;test 因 env.d.ts 覆寫通過)。
+
+### Tests
+
+- [ ] **Step 9: settle 無 R2 測試**
+
+`test/core/settle.test.ts`,在 `describe("settleUserPeriod — Discord direct path", ...)` 內新增:
+```ts
+ it("settles without a proof object when R2 is not configured (has_proof=0)", async () => {
+ const prev = (env as any).BUCKET;
+ (env as any).BUCKET = undefined;
+ const r = await settleUserPeriod(env, {
+ workspaceId: WS, userId: WS, period: "2027-05", source: "user_slash",
+ proof: { body: new Uint8Array([1, 2, 3]), ext: "png", contentType: "image/png" },
+ });
+ (env as any).BUCKET = prev;
+ expect(r.paidCount).toBe(2);
+ expect(r.screenshotKey).toBeNull();
+ const rows = await env.DB.prepare("SELECT has_proof, screenshot_key FROM payments WHERE workspace_id=? AND period='2027-05'").bind(WS).all<{ has_proof: number; screenshot_key: string | null }>();
+ expect(rows.results.every((p) => p.has_proof === 0 && p.screenshot_key === null)).toBe(true);
+ });
+```
+
+- [ ] **Step 10: images 無 R2 測試**
+
+`test/routes/images.test.ts`,在 `describe("protected image endpoint", ...)` 內新增:
+```ts
+ it("404s when R2 is not configured", async () => {
+ const prev = (env as any).BUCKET;
+ (env as any).BUCKET = undefined;
+ const res = await handleImage(new Request("https://x"), env, ctxFor(KEY));
+ (env as any).BUCKET = prev;
+ expect(res.status).toBe(404);
+ });
+```
+
+- [ ] **Step 11: retention 無 R2 測試**
+
+`test/core/retention.test.ts`,在 `describe("runRetention reference-counting", ...)` 內新增:
+```ts
+ it("is a no-op when R2 is not configured (returns 0, keeps the row + object)", async () => {
+ const KEY = "noR2-key-9023";
+ await env.DB.prepare(`INSERT INTO payments (workspace_id,subscription_id,period,period_start,period_end,due_date,amount,status,has_proof,screenshot_key,paid_at,source,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).bind(WS, SUB, "2023-05", "2023-05-01", "2023-05-31", "2023-05-05", 315, "verified", 1, KEY, OLD, "user_web", TS, TS).run();
+ await putObject(env.BUCKET, KEY, new Uint8Array([4]), "image/png");
+ const prev = (env as any).BUCKET;
+ (env as any).BUCKET = undefined;
+ const cleared = await runRetention(env, WS, 24, NOW);
+ (env as any).BUCKET = prev;
+ expect(cleared).toBe(0);
+ const row = await env.DB.prepare("SELECT screenshot_key FROM payments WHERE workspace_id=? AND period='2023-05'").bind(WS).first<{ screenshot_key: string | null }>();
+ expect(row?.screenshot_key).toBe(KEY);
+ expect(await getObject(env.BUCKET, KEY)).not.toBeNull();
+ });
+```
+
+- [ ] **Step 12: admin getWorkspace + deleteProof 無 R2 測試**
+
+`test/routes/admin.test.ts`,在 `describe("admin API", ...)` 內新增兩個測試:
+```ts
+ it("reports r2_configured from the BUCKET binding", async () => {
+ const on = (await (await call("GET", "/admin/workspace"))!.json()) as any;
+ expect(on.r2_configured).toBe(true);
+ const prev = (env as any).BUCKET;
+ (env as any).BUCKET = undefined;
+ const off = (await (await call("GET", "/admin/workspace"))!.json()) as any;
+ (env as any).BUCKET = prev;
+ expect(off.r2_configured).toBe(false);
+ });
+
+ it("delete-proof still clears the DB column when R2 is not configured", async () => {
+ const key = "1/2026-10/1/noR2.png";
+ const ins = await env.DB.prepare(
+ `INSERT INTO payments (workspace_id,subscription_id,period,period_start,period_end,due_date,amount,status,has_proof,screenshot_key,source,created_at,updated_at)
+ SELECT 1, s.id, '2026-10', '2026-10-01','2026-10-31','2026-10-05', 315, 'paid', 1, ?, 'user_web', ?, ?
+ FROM subscriptions s WHERE s.workspace_id = 1 ORDER BY s.id LIMIT 1`
+ ).bind(key, nowUtcIso(), nowUtcIso()).run();
+ const pid = ins.meta.last_row_id as number;
+ const prev = (env as any).BUCKET;
+ (env as any).BUCKET = undefined;
+ const res = await call("POST", `/admin/payments/${pid}/delete-proof`);
+ (env as any).BUCKET = prev;
+ expect(res!.status).toBe(200);
+ const p = await getPayment(env.DB, pid);
+ expect(p?.screenshot_key).toBeNull();
+ });
+```
+
+- [ ] **Step 13: upload proof_enabled 測試**
+
+`test/routes/upload.test.ts`,在 `describe("upload info", ...)` 內新增:
+```ts
+ it("reports proof_enabled per R2 configuration", async () => {
+ const on = (await (await handleUploadInfo(new Request("https://x"), env, ctxFor(RAW_OK))).json()) as any;
+ expect(on.proof_enabled).toBe(true);
+ const prev = (env as any).BUCKET;
+ (env as any).BUCKET = undefined;
+ const off = (await (await handleUploadInfo(new Request("https://x"), env, ctxFor(RAW_OK))).json()) as any;
+ (env as any).BUCKET = prev;
+ expect(off.proof_enabled).toBe(false);
+ });
+```
+
+- [ ] **Step 14: 跑全 worker 測試(含無 .dev.vars)**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+mv packages/worker/.dev.vars packages/worker/.dev.vars.off 2>/dev/null; true
+pnpm --filter @chippot/worker test 2>&1 | grep -E "Test Files|Tests "
+mv packages/worker/.dev.vars.off packages/worker/.dev.vars 2>/dev/null; true
+```
+Expected: 全綠、無 failed。基數為合併後 main 的 161,本任務加 6 個測試 → `Tests 167 passed (167)`。ALWAYS restore .dev.vars。(若基數略有出入,以「全綠、無 failed」為準。)
+
+- [ ] **Step 15: Commit**
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+git add packages/worker/src/env.ts packages/worker/test/env.d.ts packages/worker/src/core/storage.ts packages/worker/src/routes/images.ts packages/worker/src/core/retention.ts packages/worker/src/routes/admin.ts packages/worker/src/routes/upload.ts packages/worker/test
+git commit -m "feat(worker): make R2 optional — guard BUCKET usage + report status
+
+BUCKET becomes an optional binding; settle skips proof storage (has_proof=0),
+images 404, retention no-ops, delete-proof still clears the DB column when R2 is
+absent. /admin/workspace reports r2_configured and /upload/:token reports
+proof_enabled. Test env overrides BUCKET as required so existing tests are unaffected."
+```
+
+---
+
+## Task 2: Discord `/繳費` 無 R2 時忽略截圖 + 提示
+
+**Files:**
+- Modify: `packages/worker/src/adapters/discord/handler.ts`
+- Test: `packages/worker/test/adapters/discord-pay.test.ts`
+
+- [ ] **Step 1: 寫失敗測試**
+
+`packages/worker/test/adapters/discord-pay.test.ts`:把第 2 行 import 補上 `vi`:
+```ts
+import { beforeAll, describe, expect, it, vi } from "vitest";
+```
+在檔案末端新增 describe:
+```ts
+describe("/繳費 with no R2 ignores the screenshot", () => {
+ it("tells the member to use channel/note when only a screenshot is given and R2 is off", async () => {
+ const prevB = (env as any).BUCKET;
+ const prevApp = (env as any).DISCORD_APPLICATION_ID;
+ (env as any).BUCKET = undefined;
+ (env as any).DISCORD_APPLICATION_ID = "app-9024";
+ let captured = "";
+ vi.stubGlobal("fetch", vi.fn(async (_url: string, init: any) => { captured = JSON.parse(init.body).content; return new Response("{}", { status: 200 }); }));
+ const i: DiscordInteraction = {
+ type: 2, id: "1", token: "tok", guild_id: GUILD, ...member(DISC),
+ data: { name: "繳費", options: [{ name: "截圖", value: "att1" }], resolved: { attachments: { att1: { url: "https://cdn.discordapp.com/x.png", content_type: "image/png", size: 100 } } } },
+ } as any;
+ const res = await routeInteraction(i, env, CTX);
+ expect((await res.json() as any).type).toBe(5); // deferred
+ await Promise.all(tasks.splice(0));
+ vi.unstubAllGlobals();
+ (env as any).BUCKET = prevB;
+ (env as any).DISCORD_APPLICATION_ID = prevApp;
+ expect(captured).toContain("未開啟截圖功能");
+ });
+});
+```
+
+- [ ] **Step 2: 跑測試確認失敗**
+
+Run: `cd /Users/poterpan/Documents/Coding/Project/chippot && pnpm --filter @chippot/worker test test/adapters/discord-pay.test.ts 2>&1 | grep -E "未開啟|FAIL|Tests "`
+Expected: 失敗(目前無此訊息;附件在無 R2 時仍會嘗試下載/被忽略但無提示)。
+
+- [ ] **Step 3: 實作 handler 變更**
+
+`packages/worker/src/adapters/discord/handler.ts` 的 `computePayResult`,把「Optional screenshot」區塊(約 183-202)改為:
+```ts
+ // Optional screenshot — only when R2 is configured; otherwise ignore the attachment.
+ let proof: { body: ArrayBuffer; ext: string; contentType: string } | null = null;
+ const attachOpt = getOption(i, "截圖");
+ const attachment = attachOpt?.value ? i.data?.resolved?.attachments?.[attachOpt.value] : undefined;
+ const screenshotIgnored = !!attachment && !env.BUCKET;
+ if (attachment && env.BUCKET) {
+ const ct = attachment.content_type ?? "";
+ try { assertImageOk(ct, attachment.size ?? 0); }
+ catch (e) { if (e instanceof InvalidImage) return "截圖格式不支援或檔案過大,請改用備註或渠道。"; throw e; }
+ if (!isDiscordCdnUrl(attachment.url)) return "截圖來源無效。";
+ const res = await fetch(attachment.url);
+ if (!res.ok) return "下載截圖失敗,請稍後再試。";
+ const body = await res.arrayBuffer();
+ try { assertImageOk(ct, body.byteLength); } catch { return "截圖檔案過大。"; }
+ proof = { body, ext: extForContentType(ct), contentType: ct };
+ }
+
+ // At-least-one rule (slash): 渠道 / 截圖 / 備註.
+ if (!declaredChannelTagId && !proof && !note) {
+ if (screenshotIgnored) return "本站未開啟截圖功能,請改用「渠道」或「備註」登記繳費。";
+ return "請至少選擇「渠道」、附上「截圖」或填寫「備註」其中一項。";
+ }
+```
+並把成功訊息(約 209):
+```ts
+ return `✅ 已登記本期(${period})繳費 NT$${r.totalAmount.toLocaleString()}(共 ${r.paidCount} 筆)。管理員確認收款後完成。`;
+```
+改為:
+```ts
+ const ignoredNote = screenshotIgnored ? "(本站未開啟截圖功能,已記錄你的繳費宣告)" : "";
+ return `✅ 已登記本期(${period})繳費 NT$${r.totalAmount.toLocaleString()}(共 ${r.paidCount} 筆)。管理員確認收款後完成。${ignoredNote}`;
+```
+
+- [ ] **Step 4: 跑測試確認通過(含無 .dev.vars)**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+pnpm --filter @chippot/worker typecheck
+mv packages/worker/.dev.vars packages/worker/.dev.vars.off 2>/dev/null; true
+pnpm --filter @chippot/worker test 2>&1 | grep -E "Test Files|Tests "
+mv packages/worker/.dev.vars.off packages/worker/.dev.vars 2>/dev/null; true
+```
+Expected: typecheck 無錯;全綠、無 failed(167 + 1 → `Tests 168 passed (168)`)。
+
+- [ ] **Step 5: Commit**
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+git add packages/worker/src/adapters/discord/handler.ts packages/worker/test/adapters/discord-pay.test.ts
+git commit -m "feat(discord): ignore /繳費 screenshot attachment when R2 is not configured
+
+Skip the attachment download when there's no R2 and tell the member to use channel/
+note (or append a note on success). The core storage guard already drops the proof;
+this avoids a wasted download and gives clear feedback."
+```
+
+---
+
+## Task 3: web 上傳頁依 proof_enabled 隱藏截圖
+
+**Files:**
+- Modify: `packages/web/src/api.ts`
+- Modify: `packages/web/src/App.tsx`
+
+- [ ] **Step 1: `api.ts` TokenInfo 加 proof_enabled**
+
+`packages/web/src/api.ts` 的 `TokenInfo` interface(約 14-20)加一欄:
+```ts
+export interface TokenInfo {
+ valid: boolean;
+ period?: string;
+ user?: { display_name: string };
+ subscriptions?: SubscriptionChoice[];
+ channel_tags?: ChannelTag[];
+ proof_enabled?: boolean;
+}
+```
+
+- [ ] **Step 2: `App.tsx` 隱藏截圖欄位 + 調整文案**
+
+`packages/web/src/App.tsx`:把截圖上傳區塊(``,約 126-143)包在條件內——僅當 `info.proof_enabled !== false` 時顯示:
+```tsx
+ {info.proof_enabled !== false && (
+
+ )}
+```
+並把底部說明(約 159):
+```tsx
+
渠道、截圖或備註至少填一項。此連結僅限你本人本期使用,送出後即失效。
+```
+改為依 proof_enabled 切換文案:
+```tsx
+ {info.proof_enabled === false ? "渠道或備註至少填一項。" : "渠道、截圖或備註至少填一項。"}此連結僅限你本人本期使用,送出後即失效。
+```
+(`info` 在此 scope 為已驗證的 `TokenInfo`;若該段落用的變數名不同,依實際的 token-info 變數調整,但條件與文案如上。)
+
+> `submitPayment` 不需改:截圖欄位隱藏時 `file` 恆為 null,現有 `if (blob) fd.append(...)` 本就不會附截圖。
+
+- [ ] **Step 3: typecheck + build**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+pnpm --filter @chippot/web typecheck && VITE_API_BASE=https://example.workers.dev pnpm --filter @chippot/web build 2>&1 | tail -2
+```
+Expected: 無錯、`✓ built`。
+
+- [ ] **Step 4: Commit**
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+git add packages/web/src/api.ts packages/web/src/App.tsx
+git commit -m "feat(web): hide screenshot upload when R2 is disabled (proof_enabled)
+
+The upload page reads proof_enabled from the token info and hides the screenshot
+field + adjusts the helper copy when R2 is not configured; channel/note still work."
+```
+
+---
+
+## Task 4: admin 首次提示 + 設定頁常駐 R2 狀態
+
+**Files:**
+- Modify: `packages/admin/src/api.ts`
+- Modify: `packages/admin/src/App.tsx`
+- Modify: `packages/admin/src/views/Settings.tsx`
+
+- [ ] **Step 1: `api.ts` workspace() 型別加 r2_configured**
+
+`packages/admin/src/api.ts` 第 45 行 `workspace: () => req("GET", "/workspace"),` 改為:
+```ts
+ workspace: () => req<{ workspace: any; r2_configured: boolean }>("GET", "/workspace"),
+```
+
+- [ ] **Step 2: `App.tsx` 加一次性 R2 提示**
+
+`packages/admin/src/App.tsx`:在 import 區把 `api` 與 `Modal` 引入(目前未引入):
+```ts
+import { api } from "./api";
+import { IconLogout } from "./ui";
+import { Modal } from "./ui";
+```
+(若 `./ui` 已在同一行 import,合併為 `import { IconLogout, Modal } from "./ui";`。)
+
+在 `export default function App()` 之外(檔案內)新增元件:
+```tsx
+function R2Notice() {
+ const [show, setShow] = useState(false);
+ useEffect(() => {
+ if (localStorage.getItem("chippot.r2NoticeSeen")) return;
+ api.workspace().then((w) => { if (w && w.r2_configured === false) setShow(true); }).catch(() => {});
+ }, []);
+ if (!show) return null;
+ const dismiss = () => { localStorage.setItem("chippot.r2NoticeSeen", "1"); setShow(false); };
+ return (
+
+ 偵測到尚未綁定 R2 儲存空間,以下功能將無法使用:
+
+ - 成員上傳繳費截圖
+ - 後台檢視繳費截圖
+ - 截圖自動保存清理
+
+ 不影響:宣告繳費、後台審核、對帳、Discord 通知。如需截圖功能,請在 wrangler.toml 註冊 R2 binding 後重新部署。
+
+
+ );
+}
+```
+在 App 的 `return ( ...
)` 內最上方插入 ``(例如緊接 `` 之後):
+```tsx
+ return (
+
+
+
+ )}
+```
+
+- [ ] **Step 4: typecheck + build**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+pnpm --filter @chippot/admin typecheck && pnpm --filter @chippot/admin build 2>&1 | tail -2
+```
+Expected: 無錯、`✓ built`。
+
+- [ ] **Step 5: Commit**
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+git add packages/admin/src/api.ts packages/admin/src/App.tsx packages/admin/src/views/Settings.tsx
+git commit -m "feat(admin): one-time R2 notice + persistent R2 status row
+
+When the worker reports r2_configured=false, show a one-time (localStorage) modal
+listing the disabled screenshot features, and a persistent status row in Settings."
+```
+
+---
+
+## Task 5: DEPLOY.md 標 R2 選填(subagent)
+
+**Files:**
+- Modify: `docs/DEPLOY.md`
+
+> ⚠️ **不要碰 `packages/worker/wrangler.toml`。** 該檔被 `git update-index --skip-worktree` 保護、本機是 owner 真實值;任何 `git add` 都可能把真實值 commit 進公開 repo(外洩)。`wrangler.toml` 的「R2 選填」註解由 **controller** 在收尾時用 skip-worktree dance 安全處理(見計畫末「Controller-only step」),**不在本任務範圍**。
+
+- [ ] **Step 1: `docs/DEPLOY.md` R2 標選填**
+
+在 `docs/DEPLOY.md` 第 2 節「建立 Cloudflare 資源(D1 / R2)」中,R2 建立步驟標為**選填**,並加一句說明。找到 R2 相關行(建立 R2 bucket 的指令/敘述),在其前後加註:
+```
+> R2 為**選填**:若不需要成員上傳繳費截圖,可跳過建立 R2、並移除 wrangler.toml 的 [[r2_buckets]] 區塊。
+> 未綁 R2 時,以下功能停用——成員上傳/後台檢視繳費截圖、截圖自動保存清理;其餘(宣告繳費、審核、對帳、通知)正常。
+> 後台首次進入會提示 R2 未設定,設定頁也會顯示「截圖儲存(R2):未啟用」。
+```
+(具體插入點:第 2 節 R2 段落;保持與該節既有 zh-TW 風格一致。)
+
+- [ ] **Step 2: 確認 DEPLOY.md 無 owner 值,且未動到 wrangler.toml**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+grep -n "panspace\|poterpan5466" docs/DEPLOY.md || echo "(DEPLOY.md clean)"
+git status --porcelain
+```
+Expected: `(DEPLOY.md clean)`;`git status` 只顯示 `docs/DEPLOY.md`(**不得**出現 `packages/worker/wrangler.toml`)。
+
+- [ ] **Step 3: Commit(只 DEPLOY.md)**
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+git add docs/DEPLOY.md
+git commit -m "docs(deploy): mark R2 as optional
+
+Document that skipping R2 (omit the [[r2_buckets]] binding) runs the system without
+screenshot upload/view/retention; everything else works. Note the admin one-time
+prompt + the persistent status row."
+```
+
+---
+
+## Controller-only step(收尾:wrangler.toml 加 R2 選填註解,安全處理 skip-worktree)
+
+> 由 controller(持有 owner 真實值)在所有 subagent task 完成後執行。`packages/worker/wrangler.toml` 被 skip-worktree、本機為真實值;必須避免把真實值 commit。安全程序:
+> 1. `cp packages/worker/wrangler.toml /tmp/wrangler.local.toml`(備份本機真實值)
+> 2. `git update-index --no-skip-worktree packages/worker/wrangler.toml`
+> 3. `git checkout HEAD -- packages/worker/wrangler.toml`(工作檔還原為**已提交的佔位值版**)
+> 4. 在佔位值版的 `[[r2_buckets]]` 區塊上方加註解「選填:不需要成員上傳截圖可移除此區塊…」
+> 5. `git add packages/worker/wrangler.toml && git commit`(提交=佔位值+註解,無真實值)
+> 6. `cp /tmp/wrangler.local.toml packages/worker/wrangler.toml`(還原本機真實值)
+> 7. `git update-index --skip-worktree packages/worker/wrangler.toml`(重設保護)
+> 8. 驗證:`git show HEAD:packages/worker/wrangler.toml | grep -E "選填|your-d1-database-id"`(提交版有註解且仍佔位);`git status --porcelain`(乾淨,skip-worktree 隱藏本機真實值)。
+
+---
+
+## Final verification(全部 Task 完成後)
+
+- [ ] **Step 1: 全 monorepo 綠燈(無 .dev.vars = CI 條件)**
+
+Run:
+```bash
+cd /Users/poterpan/Documents/Coding/Project/chippot
+mv packages/worker/.dev.vars packages/worker/.dev.vars.off 2>/dev/null; true
+pnpm -r typecheck && pnpm -r test 2>&1 | grep -E "Test Files|Tests " && VITE_API_BASE=https://example.workers.dev pnpm -r build 2>&1 | grep -cE "built in"
+mv packages/worker/.dev.vars.off packages/worker/.dev.vars 2>/dev/null; true
+```
+Expected: typecheck 全過;全綠無 failed(`Tests 168 passed (168)`);build 計數 2(web+admin)。
+
+---
+
+## Self-Review 對照(spec → task)
+
+- spec §3 + §4A(BUCKET 選填 + storage/images/retention/deleteProof guard) → Task 1 ✓
+- spec §4A(getWorkspace r2_configured、upload proof_enabled) → Task 1 ✓
+- spec §4B(web 隱藏截圖) → Task 3 ✓
+- spec §4B(Discord 忽略附件 + 提示) → Task 2 ✓
+- spec §4C(後台首次 localStorage 提示) → Task 4 ✓
+- spec §4D(設定頁常駐狀態) → Task 4 ✓
+- spec §4E(wrangler.toml 選填註解 + DEPLOY.md) → Task 5 ✓
+- spec §6(測試:settle/images/retention/getWorkspace/proof_enabled/deleteProof) → Task 1 測試 ✓;Discord → Task 2 ✓
+- 型別:`r2_configured`(getWorkspace/admin api)、`proof_enabled`(upload/web TokenInfo)名稱跨 task 一致 ✓
diff --git a/docs/superpowers/specs/2026-06-07-r2-optional-design.md b/docs/superpowers/specs/2026-06-07-r2-optional-design.md
new file mode 100644
index 0000000..60de861
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-07-r2-optional-design.md
@@ -0,0 +1,105 @@
+# R2 改為選填 — 設計
+
+> 日期:2026-06-07 狀態:設計定稿,待寫實作計畫
+> 功能 1(兩個體驗優化之一;功能 2「成員/訂閱刪除 CRUD」另立 spec)
+
+## 1. 背景與目標
+
+有些部署者不需要「成員上傳繳費截圖」功能,因此不想註冊 Cloudflare R2。目前 `env.BUCKET`
+(R2 binding)是**必填**,程式多處直接使用,未綁 R2 會壞。目標:
+
+1. **R2 變選填**:不註冊 R2 binding 時,整個系統照常運作,只有「截圖佐證」相關功能優雅停用。
+2. **後台首次提示**:未設定 R2 時,管理者第一次進後台彈一次提示,說明哪些功能不可用(只彈一次)。
+3. **常駐狀態**:設定頁顯示一個常駐的「截圖儲存:未啟用 / 已啟用」狀態,方便日後查看。
+
+**宣告繳費本身不受影響**:選渠道、填備註、後台審核(verify/reject/改金額)、對帳、Discord 開繳/催繳全部照常。
+
+## 2. 關鍵設計決策
+
+- **偵測方式=binding 是否存在**(`!!env.BUCKET`),不另設 workspace 開關。R2 是基礎設施設定,
+ forker 不在 `wrangler.toml` 註冊 `[[r2_buckets]]` 即視為未啟用;零額外狀態、不會「設定說有但實際沒綁」矛盾。
+- **無 R2 時截圖一律隱藏/忽略**(成員端),既有「宣告繳費」路徑全程可用。
+- **首次提示用 localStorage**(admin SPA 目前未用 localStorage);只彈一次。
+
+## 3. R2 依賴點與停用後行為
+
+`env.BUCKET` 目前 4 處使用 → 改 `BUCKET?: R2Bucket`(選填)並逐處 guard:
+
+| 位置 | 現行 | 無 R2 時 |
+|---|---|---|
+| `core/storage.ts` `settleUserPeriod` step 3(存 proof) | `putObject(env.BUCKET, …)` | 有 proof 也跳過儲存,付款宣告照常寫入(`has_proof=0`、`screenshot_key=NULL`),不報錯 |
+| `routes/images.ts`(看截圖) | `env.BUCKET.get(key)` | 回 404(截圖功能停用) |
+| `routes/admin.ts` `deleteProof` | `env.BUCKET.delete(key)` | 無 BUCKET 或無 proof → 安全 no-op(仍清 DB 欄位) |
+| `core/retention.ts`(cron) | `env.BUCKET.delete(...)` | 整段跳過、回 0 |
+
+> `storage.ts` 的 `deleteObject` 補償清理(:201、:219)也在 `if (key)` 內,key 為 null 時自然略過;
+> 但因 step 3 已 guard,無 R2 時 key 恆為 null。
+
+## 4. 元件設計
+
+### A. 後端偵測旗標
+- `env.ts`:`BUCKET?: R2Bucket`。
+- `routes/admin.ts` `getWorkspace`:回應由 `{ workspace }` 擴充為 `{ workspace, r2_configured: !!env.BUCKET }`。
+- `routes/upload.ts` `handleUploadInfo`(`GET /upload/:token`):回應加 `proof_enabled: !!env.BUCKET`。
+
+### B. 成員端截圖 UX
+- **web 上傳頁**(`packages/web`):`TokenInfo` 型別加 `proof_enabled?: boolean`;`App.tsx` 依此**隱藏截圖欄位與相關說明**,只留渠道+備註。`api.ts` 的 `submitPayment` 在停用時不附 `screenshot`。
+- **Discord `/繳費`**(`adapters/discord/handler.ts`):附了截圖但無 R2 → 不傳 proof 給 `settleUserPeriod`(或傳了也被 storage guard 跳過),ephemeral 回覆加一句「本站未開啟截圖功能,已記錄你的繳費宣告」。
+- **core 防線**:`settleUserPeriod` step 3 改為 `if (input.proof && env.BUCKET)` 才 `putObject`;這是無論前端如何都成立的單一真相 guard。
+
+### C. 後台首次提示(功能 1b/1c)
+- admin SPA 頂層(`App.tsx`)載入後:若 `r2_configured===false` 且 `localStorage.getItem("chippot.r2NoticeSeen")` 不存在 → 顯示一次 Modal。
+- Modal 內容:標題「Cloudflare R2 尚未設定」;說明停用的功能(成員上傳/檢視繳費截圖、截圖自動保存清理);一句「不影響:宣告繳費、後台審核、對帳、Discord 通知」;關閉鈕。
+- 關閉時 `localStorage.setItem("chippot.r2NoticeSeen", "1")`,不再彈。
+
+### D. 常駐狀態(功能採納項)
+- 設定頁(`views/Settings.tsx`)新增一行常駐狀態:**截圖儲存(R2):✅ 已啟用 / ⚠️ 未啟用**,由 `api.workspace()` 回的 `r2_configured` 驅動。未啟用時附一句「成員無法上傳截圖;其餘功能正常」。
+
+### E. 設定 / 文件
+- `wrangler.toml`:`[[r2_buckets]]` 區塊上方加註解「選填:不需要成員上傳截圖可移除此區塊(R2 未綁時截圖功能自動停用)」。
+- `docs/DEPLOY.md`:第 2 節 R2 標為**選填**;新增說明「移除 R2 binding 後,成員上傳/檢視截圖與保存清理停用,其餘照常」。
+
+## 5. 介面 / 檔案異動清單
+
+| 檔案 | 異動 |
+|---|---|
+| `packages/worker/src/env.ts` | `BUCKET?: R2Bucket`(選填) |
+| `packages/worker/src/core/storage.ts` | `settleUserPeriod` step 3 存 proof 加 `&& env.BUCKET` guard |
+| `packages/worker/src/routes/images.ts` | 無 BUCKET → 404 |
+| `packages/worker/src/routes/admin.ts` | `deleteProof` guard;`getWorkspace` 回 `r2_configured` |
+| `packages/worker/src/core/retention.ts` | 無 BUCKET → 跳過、回 0 |
+| `packages/worker/src/routes/upload.ts` | `handleUploadInfo` 回 `proof_enabled` |
+| `packages/worker/src/adapters/discord/handler.ts` | `/繳費` 無 R2 時忽略附件 + 提示 |
+| `packages/web/src/api.ts` | `TokenInfo.proof_enabled`;`submitPayment` 停用時不附截圖 |
+| `packages/web/src/App.tsx` | 依 `proof_enabled` 隱藏截圖欄位 |
+| `packages/admin/src/api.ts` | `workspace()` 回傳型別加 `r2_configured` |
+| `packages/admin/src/App.tsx` | 首次 R2 提示 Modal(localStorage 一次性) |
+| `packages/admin/src/views/Settings.tsx` | 常駐 R2 狀態列 |
+| `packages/worker/wrangler.toml` | `[[r2_buckets]]` 加「選填」註解 |
+| `docs/DEPLOY.md` | R2 標選填 + 停用功能說明 |
+
+## 6. 測試
+
+既有 R2 測試不受影響(vitest 測試環境仍提供 BUCKET binding)。新增(以 `(env as any).BUCKET = undefined` 模擬未綁):
+- `settleUserPeriod`:附 proof 但無 BUCKET → 仍成功結算、`has_proof=0`、`screenshot_key=NULL`、不丟例外。
+- `images` 路由:無 BUCKET → 404。
+- `retention`:無 BUCKET → 回 0、不呼叫刪除。
+- `GET /admin/workspace`:回 `r2_configured`(有 BUCKET=true);模擬無 BUCKET=false。
+- `GET /upload/:token`:回 `proof_enabled` 對應 BUCKET 有無。
+- `deleteProof`:無 BUCKET → 仍清 DB 欄位、不丟例外。
+
+> 注意:vitest pool 的 BUCKET 來自設定;逐測試以 save/restore `(env as any).BUCKET` 模擬未綁(沿用既有 token save/restore 範式),避免污染其他測試。
+
+## 7. 風險與權衡
+
+- **既有截圖資料**:本變更只影響「未綁 R2」的部署;已綁 R2 者行為完全不變。
+- **Discord slash option**:`/繳費` 的「截圖」option 是靜態註冊、無法依 R2 動態隱藏,故採「忽略附件 + 提示」而非隱藏(已於 §4B 說明)。
+- **localStorage 一次性**:清掉瀏覽器儲存會再彈一次;可接受(符合「首次提示」語意)。常駐狀態列提供長期可見性。
+
+## 8. 決議摘要(brainstorming)
+
+1. 偵測:**binding-absence**(`!!env.BUCKET`),不另設開關。
+2. 成員截圖:**隱藏/忽略**,宣告繳費照常。
+3. 首次提示:**localStorage 一次性 Modal**。
+4. **加常駐 R2 狀態列**於設定頁。
+5. 與「成員/訂閱刪除 CRUD」分為**兩份獨立 spec/PR**;本 spec 為功能 1。
diff --git a/packages/admin/src/App.tsx b/packages/admin/src/App.tsx
index 4c8c6b8..6d1e839 100644
--- a/packages/admin/src/App.tsx
+++ b/packages/admin/src/App.tsx
@@ -3,7 +3,8 @@ import { Dashboard } from "./views/Dashboard";
import { Payments } from "./views/Payments";
import { Users, Subscriptions, Plans, ChannelTags } from "./views/Manage";
import { Settings } from "./views/Settings";
-import { IconLogout } from "./ui";
+import { api } from "./api";
+import { IconLogout, Modal } from "./ui";
const VIEWS = [
{ id: "dashboard", label: "對帳看板", el:
},
@@ -15,6 +16,28 @@ const VIEWS = [
{ id: "settings", label: "設定", el:
},
];
+function R2Notice() {
+ const [show, setShow] = useState(false);
+ useEffect(() => {
+ if (localStorage.getItem("chippot.r2NoticeSeen")) return;
+ api.workspace().then((w) => { if (w && w.r2_configured === false) setShow(true); }).catch(() => {});
+ }, []);
+ if (!show) return null;
+ const dismiss = () => { localStorage.setItem("chippot.r2NoticeSeen", "1"); setShow(false); };
+ return (
+
+ 偵測到尚未綁定 R2 儲存空間,以下功能將無法使用:
+
+ - 成員上傳繳費截圖
+ - 後台檢視繳費截圖
+ - 截圖自動保存清理
+
+ 不影響:宣告繳費、後台審核、對帳、Discord 通知。如需截圖功能,請在 wrangler.toml 註冊 R2 binding 後重新部署。
+
+
+ );
+}
+
export default function App() {
const [view, setView] = useState(() => window.location.hash.slice(1) || "dashboard");
useEffect(() => {
@@ -26,6 +49,7 @@ export default function App() {
return (
+