Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
![Cloudflare Workers](https://img.shields.io/badge/Cloudflare-Workers-F38020?logo=cloudflare&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-18-61DAFB?logo=react&logoColor=black)
![Vitest](https://img.shields.io/badge/tests-349%20passing-0f6e63?logo=vitest&logoColor=white)
![Vitest](https://img.shields.io/badge/tests-355%20passing-0f6e63?logo=vitest&logoColor=white)
![Serverless](https://img.shields.io/badge/100%25-serverless-074340)

<br/>
Expand Down Expand Up @@ -66,11 +66,12 @@ pre-wired) and a multi-workspace-ready data model, so it generalizes well beyond
notice (Discord / Google Chat / Slack — body shape auto-detected by host) with a deep link
straight to that member's period review — one submit settles every subscription, so the link
opens all of them together with a 一鍵全部核准 button.
- ⏰ **Daily cron, idempotent** — opens billing, sends one batched overdue reminder per period, and
enforces screenshot retention — all deduped through `notification_logs`.
- ⏰ **Daily cron, idempotent** — opens billing, sends an overdue reminder **every day** until a
period has no unpaid bills left, and enforces screenshot retention — all deduped through
`notification_logs`.
- 🛡️ **Access-gated admin** — the whole admin host sits behind Cloudflare Access (email OTP); the
SPA and its API are same-origin so the Access JWT reaches the Worker.
- 🧪 **Real-runtime tests** — 349 Vitest cases run against actual Miniflare D1 + R2 (FK constraints
- 🧪 **Real-runtime tests** — 355 Vitest cases run against actual Miniflare D1 + R2 (FK constraints
enforced), not mocks.

## How a payment flows
Expand Down Expand Up @@ -141,7 +142,7 @@ packages/
src/core/ channel-agnostic domain logic
src/adapters/discord/ Ed25519 verify · commands · handler · notify
src/routes/ interactions · upload · admin · images
migrations/ D1 schema (0001…0005)
migrations/ D1 schema (0001…0006)
scripts/ register-commands.mjs
test/ Vitest (real Miniflare D1/R2)
web/ public token-gated upload page (Vite/React)
Expand Down Expand Up @@ -240,8 +241,9 @@ payment screenshots) and fill in `wrangler.toml` accordingly — `database_id`,
period for review (shared screenshot once, every row listed, 一鍵全部核准), phone-friendly. Both
are optional and best-effort (a slow or failing endpoint never blocks the payment).
- **Daily cron** (01:00 UTC = 09:00 Asia/Taipei) — idempotently opens each period's bills, posts the
billing-opened notice (tagging plan roles), sends **one batched overdue reminder per period**
listing all unpaid members, and runs screenshot retention. Everything dedups via `notification_logs`.
billing-opened notice (tagging plan roles), sends **an overdue reminder every day** listing the
still-unpaid members (stopping once everyone has paid), and runs screenshot retention. Everything
dedups via `notification_logs`.

## Roadmap

Expand Down
13 changes: 7 additions & 6 deletions README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
![Cloudflare Workers](https://img.shields.io/badge/Cloudflare-Workers-F38020?logo=cloudflare&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-18-61DAFB?logo=react&logoColor=black)
![Vitest](https://img.shields.io/badge/tests-349%20passing-0f6e63?logo=vitest&logoColor=white)
![Vitest](https://img.shields.io/badge/tests-355%20passing-0f6e63?logo=vitest&logoColor=white)
![Serverless](https://img.shields.io/badge/100%25-serverless-074340)

<br/>
Expand Down Expand Up @@ -60,11 +60,11 @@ ChipPot 解決的是一個很具體的痛點:社團大量採購 OpenAI / Anthr
- 📲 **繳費推播** — 成員送出繳費時,可推一則 Bark 和/或 webhook(Discord/Google Chat/Slack,body
格式依主機自動判斷)給擁有者,並帶上直接跳到該成員該期審核的深連結——一次送出會結清所有訂閱,
所以連結會把它們一起打開,可一鍵全部核准。
- ⏰ **每日 cron、冪等** — 自動開帳、每期發**一則**整批催繳、執行截圖保存期清理,全部經
`notification_logs` 去重。
- ⏰ **每日 cron、冪等** — 自動開帳、進入催繳後**每天發一則**整批催繳(直到全部繳完)、執行
截圖保存期清理,全部經 `notification_logs` 去重。
- 🛡️ **Access 保護的後台** — 整個後台主機在 Cloudflare Access 後(email OTP);SPA 與其 API 同源,
Access JWT 因此能到達 Worker。
- 🧪 **真環境測試** — 349 個 Vitest 案例跑在真正的 Miniflare D1 + R2(強制 FK 約束),不是 mock。
- 🧪 **真環境測試** — 355 個 Vitest 案例跑在真正的 Miniflare D1 + R2(強制 FK 約束),不是 mock。

## 一筆繳費怎麼跑

Expand Down Expand Up @@ -132,7 +132,7 @@ packages/
src/core/ 與管道無關的核心邏輯
src/adapters/discord/ Ed25519 驗章 · 指令 · handler · 通知
src/routes/ interactions · upload · admin · images
migrations/ D1 schema(0001…0005)
migrations/ D1 schema(0001…0006)
scripts/ register-commands.mjs
test/ Vitest(真 Miniflare D1/R2)
web/ 公開的 token-gated 上傳頁(Vite/React)
Expand Down Expand Up @@ -224,7 +224,8 @@ pnpm --filter @chippot/worker register
手機上也好操作。存檔前可先按**送出測試**確認打得通。兩者都選填、best-effort(端點慢或掛掉都不會
卡住成員繳費)。
- **每日 cron**(01:00 UTC = 台北 09:00)— 冪等地開出各期帳單、發開繳通知(tag 方案身分組)、
**每期發一則整批逾期催繳**(列出所有未繳者),並執行截圖保存期清理。全部經 `notification_logs` 去重。
**每天對仍有未繳者的期別發一則整批逾期催繳**(直到全部繳完),並執行截圖保存期清理。全部經
`notification_logs` 去重。

## 後續規劃

Expand Down
32 changes: 32 additions & 0 deletions packages/worker/migrations/0006_overdue_daily.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
-- #48 每日催繳: the overdue reminder's dedup slot is keyed on (workspace, period, DAY) so the
-- daily cron keeps reminding every day until the period has no unpaid bills left, instead of
-- exactly once per period. The 'event' column carries the Taipei business date for overdue rows;
-- it is '' for every other notification type, which is exactly the old whole-entity slot.
-- SQLite cannot ALTER a table-level UNIQUE, so the table is rebuilt. notification_logs has no
-- foreign keys in either direction, so a plain rebuild is safe.
CREATE TABLE notification_logs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL,
type TEXT NOT NULL CHECK (type IN ('billing_opened','overdue','receipt')),
period TEXT NOT NULL,
plan_id INTEGER NOT NULL DEFAULT 0,
user_id INTEGER NOT NULL DEFAULT 0,
subscription_id INTEGER NOT NULL DEFAULT 0,
-- '' = the whole-entity slot (billing_opened / receipt). Overdue rows carry the YYYY-MM-DD
-- business date the reminder was sent, so one message is allowed per day, per period.
event TEXT NOT NULL DEFAULT '',
external_channel_type TEXT,
external_message_id TEXT,
sent_at TEXT NOT NULL,
UNIQUE(workspace_id, type, period, plan_id, user_id, subscription_id, event)
);

INSERT INTO notification_logs_new
(id, workspace_id, type, period, plan_id, user_id, subscription_id, event,
external_channel_type, external_message_id, sent_at)
SELECT id, workspace_id, type, period, plan_id, user_id, subscription_id, '',
external_channel_type, external_message_id, sent_at
FROM notification_logs;

DROP TABLE notification_logs;
ALTER TABLE notification_logs_new RENAME TO notification_logs;
9 changes: 5 additions & 4 deletions packages/worker/src/core/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,10 +607,11 @@ export async function retractPeriodBilling(
// call is the one that closed the period (see marker_cleared).
env.DB.prepare("DELETE FROM notification_logs WHERE workspace_id = ? AND type = 'billing_opened' AND period = ?")
.bind(workspaceId, period),
// The overdue slot is claimed once per (workspace, period) and never expires, so leaving it
// behind would permanently mute overdue reminders if this period is ever re-opened —
// claimNotification would lose and sendOverdueForPeriod would just report already_sent, with
// no error anywhere. Kept as its own statement so it cannot inflate the marker's changes count.
// The cron's overdue slot is keyed per day (#48), so a re-opened period's stale slots would
// otherwise fire "already_sent" reminders that describe a period that no longer exists — and
// sendOverdueForPeriod reports already_sent with no error anywhere. Delete every overdue row
// (all days) so reminders restart from clean. Kept as its own statement so it cannot inflate
// the marker's changes count.
// ('receipt' is declared in the type union but never claimed, so there is no slot to release.)
env.DB.prepare("DELETE FROM notification_logs WHERE workspace_id = ? AND type = 'overdue' AND period = ?")
.bind(workspaceId, period),
Expand Down
38 changes: 32 additions & 6 deletions packages/worker/src/core/notify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,24 +38,50 @@ export interface NotificationKey {
planId?: number;
userId?: number;
subscriptionId?: number;
/** Distinguishes two messages that share one entity. '' = the whole-entity slot. */
event?: string;
}

/**
* Claim a notification slot to guarantee at-most-once sending. Inserts a notification_logs
* row; returns true if this caller won the slot (should send), false if already sent.
* Uses NOT NULL DEFAULT 0 sentinels so the UNIQUE actually dedupes (roadmap §4.1).
* Uses NOT NULL DEFAULT 0 / '' sentinels so the UNIQUE actually dedupes (roadmap §4.1).
*/
export async function claimNotification(db: D1Database, k: NotificationKey): Promise<boolean> {
return (await claimNotificationSlot(db, k)).won;
}

export interface NotificationSlot {
/** True if this caller won the slot (should send); false if already claimed. */
won: boolean;
/** The claimed row's id (D1 last_row_id); null when `won` is false. */
id: number | null;
}

/**
* Claim with ownership: on a win the caller owns `id`, and a later release must go through
* releaseSlot(id) — never a keyed DELETE — so a concurrent claim that replaced the row cannot have
* its slot deleted by someone else's failed send (see sendOverdueForPeriod, #48 / Codex finding 2).
*/
export async function claimNotificationSlot(db: D1Database, k: NotificationKey): Promise<NotificationSlot> {
const res = await db
.prepare(
`INSERT INTO notification_logs
(workspace_id, type, period, plan_id, user_id, subscription_id, external_channel_type, sent_at)
VALUES (?, ?, ?, ?, ?, ?, 'discord', ?)
ON CONFLICT(workspace_id, type, period, plan_id, user_id, subscription_id) DO NOTHING`
(workspace_id, type, period, plan_id, user_id, subscription_id, event, external_channel_type, sent_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'discord', ?)
ON CONFLICT(workspace_id, type, period, plan_id, user_id, subscription_id, event) DO NOTHING`
)
.bind(k.workspaceId, k.type, k.period, k.planId ?? 0, k.userId ?? 0, k.subscriptionId ?? 0, nowUtcIso())
.bind(k.workspaceId, k.type, k.period, k.planId ?? 0, k.userId ?? 0, k.subscriptionId ?? 0, k.event ?? "", nowUtcIso())
.run();
return (res.meta.changes ?? 0) > 0;
const won = (res.meta.changes ?? 0) > 0;
return { won, id: won ? Number(res.meta.last_row_id) : null };
}

/** Release a slot by the row id the claim returned. Deleting by id (not by key) is what makes the
* release ownership-safe: a stale id simply deletes nothing instead of a concurrent claim's row. */
export async function releaseSlot(db: D1Database, id: number): Promise<number> {
const res = await db.prepare("DELETE FROM notification_logs WHERE id = ?").bind(id).run();
return res.meta.changes ?? 0;
}

/**
Expand Down
38 changes: 24 additions & 14 deletions packages/worker/src/core/scheduled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { parseSettings } from "../env";
import { taipeiPeriod, taipeiDate, taipeiDayOfMonth, daysBetween } from "./time";
import { ensurePeriodPayment } from "./billing";
import { runRetention } from "./retention";
import { claimNotification, type Notifier, type PlanOpenLine, type OverduePerson } from "./notify";
import { claimNotification, claimNotificationSlot, releaseSlot, type Notifier, type PlanOpenLine, type OverduePerson } from "./notify";

export interface DailySummary {
paymentsEnsured: number;
Expand Down Expand Up @@ -109,9 +109,11 @@ export interface OverdueResult {
/**
* Send the overdue reminder for ONE period as a single batched public message listing every
* unpaid member — pending OR rejected (a rejected submission still owes) — tagged once with
* their plans + total, deduped per (ws, period).
* their plans + total, deduped per (ws, period, DAY): the daily cron keeps reminding every day
* until the period has no unpaid bills left (#48).
*
* force=false (cron): only fires when ≥1 member is past overdue_days; claim-then-send.
* force=false (cron): only fires when ≥1 member is past overdue_days; claim-then-send. The slot
* carries the Taipei business date, so a re-run the same day is deduped but the next day fires.
* force=true (admin "催繳未繳成員"): lists ALL unpaid members regardless of overdue_days and clears
* the dedup slot first so it always re-sends. The two lists genuinely differ, which is why the UI
* must not call both "立即重發" — see the copy in views/PushStatus.tsx.
Expand All @@ -124,7 +126,7 @@ export async function sendOverdueForPeriod(
workspaceId: number,
period: string,
notifier: Notifier,
opts: { force: boolean; dryRun?: boolean; now?: Date }
opts: { force: boolean; dryRun?: boolean; now?: Date; dayKey?: string }
): Promise<OverdueResult> {
const bare = (outcome: OverdueOutcome, overdueDays = 0): OverdueResult =>
({ notified: 0, outcome, overdue_days: overdueDays, people: [] });
Expand All @@ -136,6 +138,10 @@ export async function sendOverdueForPeriod(
if (!channelId) return bare("no_channel", settings.overdue_days);
if (!env.DISCORD_BOT_TOKEN) return bare("no_bot_token", settings.overdue_days);
const today = taipeiDate(opts.now ?? new Date());
// The cron dedupes per business day; the admin force-resend keeps the entity-wide slot so it can
// always fire (it deletes that whole-entity slot below). A caller-provided dayKey overrides the
// date (tests / replay).
const dayKey = opts.dayKey ?? today;

const rows = await env.DB
.prepare(
Expand Down Expand Up @@ -167,15 +173,20 @@ export async function sendOverdueForPeriod(
if (opts.dryRun) return { notified: 0, outcome: "preview", overdue_days: settings.overdue_days, people };

if (opts.force) {
// force = admin resend: clear the slot so the claim below always wins. This delete-then-claim
// isn't atomic, but force is an occasional single-admin dashboard action whose button is
// disabled while in flight; the only risk is a duplicate message from two truly-concurrent
// resends, which we accept (no DO/lock — YAGNI). Unlike the billing_opened slot, the overdue
// row carries no "period is open" meaning, so a momentary gap is harmless.
await env.DB.prepare("DELETE FROM notification_logs WHERE workspace_id = ? AND type = 'overdue' AND period = ?")
// force = admin resend: clear the entity-wide ('' slot) only, then claim it afresh below. The
// cron's per-day slots are left untouched, so a same-day cron retry still reads as already sent.
// The delete-then-claim isn't atomic, but force is an occasional single-admin dashboard action
// whose button is disabled while in flight; the only risk is a duplicate message from two
// truly-concurrent resends, which we accept (no DO/lock — YAGNI). Unlike the billing_opened
// slot, the overdue row carries no "period is open" meaning, so a momentary gap is harmless.
await env.DB.prepare("DELETE FROM notification_logs WHERE workspace_id = ? AND type = 'overdue' AND period = ? AND event = ''")
.bind(workspaceId, period).run();
}
if (!(await claimNotification(env.DB, { workspaceId, type: "overdue", period }))) {
// The cron's slot is per day (dayKey); the admin's force resend reuses the '' entity-wide slot,
// which is what the DELETE above just cleared.
const event = opts.force ? "" : dayKey;
const slot = await claimNotificationSlot(env.DB, { workspaceId, type: "overdue", period, event });
if (!slot.won) {
return { notified: 0, outcome: "already_sent", overdue_days: settings.overdue_days, people };
}
if (!(await notifier.sendOverdue(env, channelId, period, people, settings.overdue_template))) {
Expand All @@ -184,9 +195,8 @@ export async function sendOverdueForPeriod(
// 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.
// We can delete unconditionally: this call won the claim above, so the row is the one we wrote.
await env.DB.prepare("DELETE FROM notification_logs WHERE workspace_id = ? AND type = 'overdue' AND period = ?")
.bind(workspaceId, period).run();
// 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 };
}
return { notified: people.length, outcome: "sent", overdue_days: settings.overdue_days, people };
Expand Down
Loading