diff --git a/README.md b/README.md
index 0c02e0c..0eaeb53 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@



-
+

@@ -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
@@ -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)
@@ -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
diff --git a/README.zh-TW.md b/README.zh-TW.md
index 8c9b771..a7a3a92 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -9,7 +9,7 @@



-
+

@@ -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。
## 一筆繳費怎麼跑
@@ -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)
@@ -224,7 +224,8 @@ pnpm --filter @chippot/worker register
手機上也好操作。存檔前可先按**送出測試**確認打得通。兩者都選填、best-effort(端點慢或掛掉都不會
卡住成員繳費)。
- **每日 cron**(01:00 UTC = 台北 09:00)— 冪等地開出各期帳單、發開繳通知(tag 方案身分組)、
- **每期發一則整批逾期催繳**(列出所有未繳者),並執行截圖保存期清理。全部經 `notification_logs` 去重。
+ **每天對仍有未繳者的期別發一則整批逾期催繳**(直到全部繳完),並執行截圖保存期清理。全部經
+ `notification_logs` 去重。
## 後續規劃
diff --git a/packages/worker/migrations/0006_overdue_daily.sql b/packages/worker/migrations/0006_overdue_daily.sql
new file mode 100644
index 0000000..702b6ce
--- /dev/null
+++ b/packages/worker/migrations/0006_overdue_daily.sql
@@ -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;
diff --git a/packages/worker/src/core/billing.ts b/packages/worker/src/core/billing.ts
index 25b5ca4..4b4cc46 100644
--- a/packages/worker/src/core/billing.ts
+++ b/packages/worker/src/core/billing.ts
@@ -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),
diff --git a/packages/worker/src/core/notify.ts b/packages/worker/src/core/notify.ts
index c2eb109..87ec5ed 100644
--- a/packages/worker/src/core/notify.ts
+++ b/packages/worker/src/core/notify.ts
@@ -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 {
+ 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 {
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 {
+ const res = await db.prepare("DELETE FROM notification_logs WHERE id = ?").bind(id).run();
+ return res.meta.changes ?? 0;
}
/**
diff --git a/packages/worker/src/core/scheduled.ts b/packages/worker/src/core/scheduled.ts
index 21ea91f..2c793b8 100644
--- a/packages/worker/src/core/scheduled.ts
+++ b/packages/worker/src/core/scheduled.ts
@@ -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;
@@ -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.
@@ -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 {
const bare = (outcome: OverdueOutcome, overdueDays = 0): OverdueResult =>
({ notified: 0, outcome, overdue_days: overdueDays, people: [] });
@@ -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(
@@ -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))) {
@@ -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 };
diff --git a/packages/worker/test/core/billing-retract.test.ts b/packages/worker/test/core/billing-retract.test.ts
index e266498..e0a71ed 100644
--- a/packages/worker/test/core/billing-retract.test.ts
+++ b/packages/worker/test/core/billing-retract.test.ts
@@ -45,6 +45,9 @@ beforeAll(async () => {
// an overdue reminder already fired for the mis-opened period; its dedup slot must be released
// on retract, or a later re-open could never remind anyone again
env.DB.prepare(`INSERT INTO notification_logs (workspace_id,type,period,plan_id,user_id,subscription_id,sent_at) VALUES (?,?,?,?,?,?,?)`).bind(WS,"overdue",P,0,0,0,TS),
+ // #48: the cron also leaves per-day slots (event = business date); retract must clear those too,
+ // not just the '' slot — a re-open must restart reminders from clean.
+ env.DB.prepare(`INSERT INTO notification_logs (workspace_id,type,period,plan_id,user_id,subscription_id,event,sent_at) VALUES (?,?,?,?,?,?,?,?)`).bind(WS,"overdue",P,0,0,0,"2027-11-05",TS),
// scoping pins: another period of the same workspace, and the same period of another workspace
env.DB.prepare(`INSERT INTO notification_logs (workspace_id,type,period,plan_id,user_id,subscription_id,sent_at) VALUES (?,?,?,?,?,?,?)`).bind(WS,"overdue",P_NEXT,0,0,0,TS),
env.DB.prepare(`INSERT INTO notification_logs (workspace_id,type,period,plan_id,user_id,subscription_id,sent_at) VALUES (?,?,?,?,?,?,?)`).bind(WS_OTHER,"overdue",P,0,0,0,TS),
@@ -80,7 +83,7 @@ describe("retractPeriodBilling", () => {
const tokens = (await env.DB.prepare("SELECT COUNT(*) c FROM upload_tokens WHERE workspace_id=? AND period=?").bind(WS, P).first<{ c: number }>())!.c;
expect(tokens).toBe(3);
expect(await env.BUCKET.get("proof-9700-orphan")).not.toBeNull();
- expect(await countOverdueLogs(WS, P)).toBe(1);
+ expect(await countOverdueLogs(WS, P)).toBe(2); // '' slot + one dated (per-day) slot, both untouched on dry run
});
it("apply deletes pending/rejected + the orphaned proof, keeps paid/verified whole, clears the marker", async () => {
@@ -118,11 +121,11 @@ describe("retractPeriodBilling", () => {
expect(await countOpenedLogs(P_NEXT)).toBe(1);
});
- // The overdue slot is claimed once per (workspace, period) and never expires. If a mis-opened
- // period had already sent a reminder, leaving that row behind would silently mute overdue
- // reminders forever after a re-open — sendOverdueForPeriod just reports already_sent, no error.
- it("releases the overdue dedup slot, scoped to this workspace+period", async () => {
- expect(await countOverdueLogs(WS, P)).toBe(0);
+ // The overdue slot is claimed once per (workspace, period, day) after #48. If a mis-opened period
+ // had already sent a reminder (one or more days), leaving those rows behind would silently mute
+ // overdue reminders forever after a re-open — sendOverdueForPeriod just reports already_sent, no error.
+ it("releases the overdue dedup slots (all days), scoped to this workspace+period", async () => {
+ expect(await countOverdueLogs(WS, P)).toBe(0); // both the '' slot and the dated slot are gone
expect(await countOverdueLogs(WS, P_NEXT)).toBe(1); // another period of the same workspace
expect(await countOverdueLogs(WS_OTHER, P)).toBe(1); // the same period of another workspace
diff --git a/packages/worker/test/core/overdue-daily.test.ts b/packages/worker/test/core/overdue-daily.test.ts
new file mode 100644
index 0000000..aab1c07
--- /dev/null
+++ b/packages/worker/test/core/overdue-daily.test.ts
@@ -0,0 +1,115 @@
+import { env } from "cloudflare:test";
+import { beforeAll, describe, expect, it } from "vitest";
+import { runDailyTasks, sendOverdueForPeriod } from "../../src/core/scheduled";
+import { claimNotification, type Notifier, type OverduePerson } from "../../src/core/notify";
+
+/**
+ * #48 每日催繳: the overdue reminder must fire once per DAY (not once per period) until
+ * everyone has paid. The dedup slot for 'overdue' is keyed on (workspace, period, day), so
+ * consecutive cron runs keep reminding while any bill is still pending/rejected, and stop
+ * the moment the period has no unpaid bills left.
+ */
+
+const TS = "2026-05-01T00:00:00.000Z";
+const CHAN = "chan-9890";
+
+const sent = { overdue: [] as { period: string; people: OverduePerson[] }[] };
+const notifier: Notifier = {
+ async sendBillingOpened() { return true; },
+ async sendOverdue(_e, _ch, period, people, _t) { sent.overdue.push({ period, people }); return true; },
+ async sendPaymentNudge() { return true; },
+};
+
+/** Seed a workspace + one member + one unpaid bill in `period`, due on day 5 of that month. */
+async function seedUnpaid(wsId: number, period: string) {
+ await env.DB.batch([
+ env.DB.prepare(`INSERT INTO workspaces (id,name,owner_id,channel_type,billing_day,settings,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)`)
+ .bind(wsId, "W", "o", "discord", 5, JSON.stringify({ discord_billing_channel_id: CHAN, overdue_days: 3, proof_retention_months: 24 }), TS, TS),
+ env.DB.prepare(`INSERT INTO users (id,workspace_id,discord_id,display_name,created_at,updated_at) VALUES (?,?,?,?,?,?)`)
+ .bind(wsId, wsId, `d-${wsId}`, "M", TS, TS),
+ env.DB.prepare(`INSERT INTO plans (id,workspace_id,name,provider,monthly_amount,created_at,updated_at) VALUES (?,?,?,?,?,?,?)`)
+ .bind(wsId, wsId, "ChatGPT", "openai", 315, TS, TS),
+ env.DB.prepare(`INSERT INTO subscriptions (id,workspace_id,user_id,plan_id,start_date,billing_day,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)`)
+ .bind(wsId, wsId, wsId, wsId, `${period}-01`, 5, TS, TS),
+ env.DB.prepare(`INSERT INTO payments (workspace_id,subscription_id,period,period_start,period_end,due_date,amount,status,source,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
+ .bind(wsId, wsId, period, `${period}-01`, `${period}-31`, `${period}-05`, 315, "pending", "cron", TS, TS),
+ ]);
+}
+
+beforeAll(async () => {
+ (env as any).DISCORD_BOT_TOKEN = "test-bot-token";
+ // Three independent periods so each test can mutate its own without touching the others.
+ await seedUnpaid(9890, "2028-05");
+ await seedUnpaid(9891, "2028-06");
+ await seedUnpaid(9892, "2028-07");
+ await seedUnpaid(9893, "2028-11"); // cron integration workspace
+});
+
+describe("#48 每日催繳", () => {
+ it("逾期成員未繳時,隔天會再催一次(每日一則,而非每期一則)", async () => {
+ // due 2028-05-05, overdue_days 3 → overdue from 05-08.
+ const r1 = await sendOverdueForPeriod(env, 9890, "2028-05", notifier, { force: false, now: new Date("2028-05-10T00:00:00Z") });
+ expect(r1).toMatchObject({ outcome: "sent", notified: 1 });
+
+ const r2 = await sendOverdueForPeriod(env, 9890, "2028-05", notifier, { force: false, now: new Date("2028-05-11T00:00:00Z") });
+ expect(r2).toMatchObject({ outcome: "sent", notified: 1 });
+ });
+
+ it("同一天只發一則(同日重複觸發去重)", async () => {
+ const day = new Date("2028-06-12T00:00:00Z");
+ const first = await sendOverdueForPeriod(env, 9891, "2028-06", notifier, { force: false, now: day });
+ expect(first).toMatchObject({ outcome: "sent", notified: 1 });
+
+ const second = await sendOverdueForPeriod(env, 9891, "2028-06", notifier, { force: false, now: day });
+ expect(second).toMatchObject({ outcome: "already_sent", notified: 0 });
+ expect(second.people.length).toBe(1); // 名單仍帶回,UI 才講得出「已經催過」
+ });
+
+ it("全部繳完後不再催(無 pending/rejected)", async () => {
+ await env.DB.prepare("UPDATE payments SET status = 'paid' WHERE workspace_id = ? AND period = ?").bind(9892, "2028-07").run();
+ const r = await sendOverdueForPeriod(env, 9892, "2028-07", notifier, { force: false, now: new Date("2028-07-13T00:00:00Z") });
+ expect(r).toMatchObject({ outcome: "none_due", notified: 0 });
+ });
+
+ it("手動催繳不會抹掉當天 cron 已送的 slot:同日 cron 重試仍回 already_sent", async () => {
+ const day = new Date("2028-05-15T00:00:00Z");
+ // cron 先送一次(event = 2028-05-15)
+ await sendOverdueForPeriod(env, 9890, "2028-05", notifier, { force: false, now: day });
+
+ // admin 手動催繳:只清 '' slot,不動每日 slot
+ await sendOverdueForPeriod(env, 9890, "2028-05", notifier, { force: true, now: day });
+
+ // 同日 cron 重試不得再送
+ const r = await sendOverdueForPeriod(env, 9890, "2028-05", notifier, { force: false, now: day });
+ expect(r).toMatchObject({ outcome: "already_sent", notified: 0 });
+ });
+
+ it("送失敗只釋放自己贏得的 slot,不刪掉他人同日的 slot", async () => {
+ // 用會失敗的 notifier 走一次 force('' slot):送失敗必須釋放名額,讓下一次能再送。
+ const failing: Notifier = {
+ async sendBillingOpened() { return true; },
+ async sendOverdue() { return false; },
+ async sendPaymentNudge() { return true; },
+ };
+ const r = await sendOverdueForPeriod(env, 9891, "2028-06", failing, { force: true, now: new Date("2028-06-12T00:00:00Z") });
+ expect(r).toMatchObject({ outcome: "send_failed", notified: 0 });
+
+ // '' slot 真的還回去了:再 claim 一次要成功。
+ expect(await claimNotification(env.DB, { workspaceId: 9891, type: "overdue", period: "2028-06", event: "" })).toBe(true);
+ });
+
+ it("每日 cron 對同一期別連續兩天都計入 overdueSent", async () => {
+ // period 2028-11, due 2028-11-05. Both runs land past overdue_days and on distinct days.
+ // runDailyTasks sweeps every workspace in the DB, so count only the 2028-11 messages.
+ const forNov = () => sent.overdue.filter((m) => m.period === "2028-11").length;
+
+ const before = forNov();
+ const s1 = await runDailyTasks(env, new Date("2028-11-09T16:30:00.000Z"), notifier); // Taipei 11-10
+ expect(forNov()).toBe(before + 1);
+ expect(s1.overdueSent).toBeGreaterThanOrEqual(1);
+
+ const s2 = await runDailyTasks(env, new Date("2028-11-10T16:30:00.000Z"), notifier); // Taipei 11-11
+ expect(forNov()).toBe(before + 2);
+ expect(s2.overdueSent).toBeGreaterThanOrEqual(1);
+ });
+});
diff --git a/packages/worker/test/core/overdue-preview.test.ts b/packages/worker/test/core/overdue-preview.test.ts
index 34a62f4..b9d20a4 100644
--- a/packages/worker/test/core/overdue-preview.test.ts
+++ b/packages/worker/test/core/overdue-preview.test.ts
@@ -76,15 +76,18 @@ describe("sendOverdueForPeriod 結果物件", () => {
expect(sent.length).toBe(before + 1);
});
- it("非 force 的第二次送出回 already_sent 而不是假的 sent", async () => {
- // 上一個 it 已經佔用 (ws, overdue, period) 的 slot;cron 再跑一次不能重送。
+ it("非 force 的第二次送出回 already_sent 而不是假的 sent(同日去重)", async () => {
+ // #48: the cron slot is per DAY. This second force=false send uses the same `now` as the
+ // previous it block's send above would have — but that send was force=true (the '' slot), so
+ // pin the day here to exercise the cron's per-day slot directly: two sends on one day.
const before = sent.length;
- const r = await sendOverdueForPeriod(env, WS, PERIOD, notifier, {
- force: false, dryRun: false, now: new Date("2098-06-30T00:00:00Z"),
- });
+ const day = new Date("2098-06-30T00:00:00Z");
+ const first = await sendOverdueForPeriod(env, WS, PERIOD, notifier, { force: false, dryRun: false, now: day });
+ expect(first).toMatchObject({ outcome: "sent", notified: 2 });
+ const r = await sendOverdueForPeriod(env, WS, PERIOD, notifier, { force: false, dryRun: false, now: day });
expect(r).toMatchObject({ outcome: "already_sent", notified: 0 });
expect(r.people.length).toBe(2); // 名單還是要帶回來,UI 才講得出「這 2 位已經催過了」
- expect(sent.length).toBe(before);
+ expect(sent.length).toBe(before + 1);
});
it("沒有 bot token 時回 no_bot_token 而不是假成功", async () => {