Midday 循环发票系统全解:调度生成、幂等保障与时区一致的实现
【免费下载链接】middayInvoicing, Time tracking, File reconciliation, Storage, Financial Overview & your own Assistant made for Freelancers项目地址: https://gitcode.com/GitHub_Trending/mi/midday
本文以 Midday 仓库中的循环发票(Recurring Invoice)系统文档为主线,完整拆解该系统的架构、数据模型、状态机与生成流程,并结合apps/worker、apps/api、packages/db、packages/invoice中的真实源码,说明调度器如何批量生成发票、如何保证不重复开票、以及日期在 UTC 与用户时区之间如何保持一致,帮助读者理解一套可落地的周期账单系统的设计细节。
核心概念与系统架构
Midday 的循环发票系统用于按固定频率自动生成并发送发票。它与另外两类发票有本质区别:一次性发票是手动创建的;计划发票(scheduled invoice)只是在未来某个时间点发送一次;而循环发票代表一个持续性的系列(Recurring Series),系统按定义的频率不断生成新发票,直到满足结束条件为止。
三个核心概念:
- Recurring Series:定义发票"何时、如何"生成的配置记录,存储于
invoice_recurring表; - Generated Invoices:由系列生成的每一张具体发票,通过
invoiceRecurringId字段反向关联到系列; - Sequence Number:每张生成的发票在所属系列内拥有唯一序号(1, 2, 3...),这是幂等性的关键。
从源码结构看,该架构落在四个应用中:Dashboard(recurring-config.tsx 配置面板)发起 tRPC 请求,API 层(invoice-recurring 路由)完成校验与写库,Worker 层(generate-recurring.ts)定时扫描到期系列并生成发票,PDF 渲染与邮件发送由 BullMQ 队列中的generate-invoice、send-invoice-email任务接力完成。
数据模型
invoice_recurring 表
该表存储每个循环系列的配置与运行状态:
这些枚举值并非文档约定,而是由 packages/invoice/src/utils/recurring.ts 中的常量数组统一声明(RECURRING_FREQUENCIES、RECURRING_STATUSES、RECURRING_END_TYPES),并派生出 TypeScript 类型与 Zod schema,确保前后端共享同一套取值来源。
频率选项(Frequency Options)
| Frequency | 说明 | 使用字段 |
|---|---|---|
weekly | 每周同一天 | frequencyDay(0=周日, 6=周六) |
biweekly | 每 2 周 | frequencyDay |
monthly_date | 每月固定日期 | frequencyDay(1-31) |
monthly_weekday | 每月第 N 个星期几 | frequencyDay+frequencyWeek |
monthly_last_day | 每月最后一天 | - |
quarterly | 每 3 个月 | frequencyDay |
semi_annual | 每 6 个月 | frequencyDay |
annual | 每年一次 | frequencyDay |
custom | 每 X 天 | frequencyInterval |
各频率的取值范围由客户端校验函数validateRecurringConfig()(packages/invoice/src/utils/recurring.ts 第 576 行起)与 API 层 Zod schema 双重把关:周系频率要求frequencyDay在 0-6;月/季/半年/年频率要求frequencyDay在 1-31;monthly_weekday额外要求frequencyWeek在 1-5;custom要求frequencyInterval >= 1。
结束条件(End Conditions)
| End Type | 说明 | 使用字段 |
|---|---|---|
never | 无限期运行 | - |
on_date | 到达指定日期后停止 | endDate |
after_count | 生成 N 张发票后停止 | endCount |
状态机
循环系列的生命周期是一个四状态机:
状态转换表
| From | To | 触发条件 | 说明 |
|---|---|---|---|
| - | active | 创建系列 | 初始状态,计算nextScheduledAt |
active | active | 发票生成成功 | 计数器 +1,计算下次日期 |
active | paused | 用户操作 | 通过 API 手动暂停 |
active | paused | 连续 3 次失败 | 自动暂停 |
active | completed | 结束条件满足 | 日期已过或数量达到 |
active | canceled | 用户删除 | 软删除,保留已生成发票 |
paused | active | 用户恢复 | 下次日期从当前时间重新计算 |
paused | completed | 恢复时发现已结束 | 暂停期间结束条件已满足 |
paused | canceled | 用户删除 | 软删除 |
DB 层的实现与状态机一一对应,位于 packages/db/src/queries/invoice-recurring.ts:
- 暂停:
pauseInvoiceRecurring()仅更新status='paused',nextScheduledAt被保留; - 恢复:
resumeInvoiceRecurring()(第 665 行起)先从当前时间重新计算下次日期,再检查结束条件——若暂停期间结束条件已满足,直接置为completed且nextScheduledAt=null,否则恢复为active并将consecutiveFailures归零; - 删除:
deleteInvoiceRecurring()(第 741 行起)不做物理删除,而是设置status='canceled'、nextScheduledAt=null,已生成的发票全部保留。
生成流程
调度入口是 apps/worker/src/processors/invoices/generate-recurring.ts 中的InvoiceRecurringSchedulerProcessor。调度任务由 Worker 启动时以静态 cron 配置注册(见 invoices.config.ts),主生成任务为invoice-recurring-scheduler。需要注意的一处细节:文档原文描述调度周期为"每 2 小时",而当前仓库中的注册配置为cron: "0 * * * *"(每小时整点),配套提醒任务为cron: "30 * * * *"(每小时半点),处理器代码注释仍写着 "Runs every 2 hours"——从源码结构看,以 invoices.config.ts 的 cron 配置为准,周期可随部署调整。
到期查询与批量处理
getDueInvoiceRecurring()(queries/invoice-recurring.ts 第 447 行起)是调度器每次运行的第一步:
// 默认批大小,防止大量到期系列一次压垮系统 const DEFAULT_BATCH_SIZE = 50; // WHERE status = 'active' AND next_scheduled_at <= now // ORDER BY nextScheduledAt(最先到期的先处理,保证公平性) // LIMIT limit + 1(多取一条用于判断 hasMore)实现上有三个值得注意的设计:
- 按
nextScheduledAt升序排序——最先到期的系列优先处理,避免长期饥饿; limit + 1探测法——多取一条记录判断是否还有剩余(hasMore),任务返回hasMore: true表示未处理完的部分留到下一轮;- staging 干跑模式——在 staging 环境中处理器只查询并打日志("[STAGING MODE] ... logging only, no execution"),返回模拟结果而不写库,方便上线前验证待处理列表。
幂等性保障
系统通过三层机制防止重复生成发票:
- 调度层:BullMQ 的
upsertJobScheduler确保全局只有一个调度器任务实例在运行; - 发票层:创建前调用
checkInvoiceExists(recurringId, sequence)检查该序列号是否已有发票; - 事务层:发票创建与系列计数器更新在同一个数据库事务中完成,原子提交。
源码中的处理比"存在即跳过"更细致(generate-recurring.ts 第 176-271 行):
- 若已存在发票且状态为
draft或scheduled(例如用户创建的未来日期循环发票),调度器不新建发票,而是复用该发票并入队发送,同时调用markInvoiceGenerated()推进系列; - 若已存在发票且已
sent/paid(用户在计划日期前手动发过),只推进系列计数器(skipped++),不重发; - 否则走新建路径:生成发票 ID、取下一个发票号(
getNextInvoiceNumber)、按dueDateOffset计算到期日,然后在事务内依次执行draftInvoice()→updateInvoice(invoiceRecurringId, recurringSequence)→markInvoiceGenerated()。
markInvoiceGenerated()(queries/invoice-recurring.ts 第 517 行起)在事务内完成四件事:计数器 +1、consecutiveFailures归零、记录lastGeneratedAt、计算并写入新的nextScheduledAt(若结束条件满足则置null并将状态改为completed)。
事务提交之后,处理器才向 BullMQ 投递generate-invoice(deliveryType: "create_and_send")与invoice_recurring_generated通知任务。这里有一个刻意的容错设计:如果入队失败,代码不会调用recordInvoiceGenerationFailure()——因为发票已存在,重试时会被幂等检查接住,或者用户可以在 Dashboard 手动补发。此外,若系列因本次生成而完成(updatedRecurring.status === "completed"),还会额外投递recurring_series_completed通知。
失败处理与自动暂停
DB 层的recordInvoiceGenerationFailure()(queries/invoice-recurring.ts 第 599 行起)定义了阈值:
const MAX_CONSECUTIVE_FAILURES = 3; // 累计失败 >= 3 时,status 置为 "paused"(autoPaused = true)处理器捕获到失败后(失败原因被抽象为带错误码的RecurringInvoiceError,如客户被删除、客户无邮箱、模板数据不合法等),若返回autoPaused为真,则向通知队列投递recurring_series_paused事件,提醒团队修复后手动恢复。
Kill Switch
调度器支持通过环境变量紧急停用,无需重新部署:
DISABLE_RECURRING_INVOICES=true在 generate-recurring.ts 第 67-79 行,处理器入口第一时间检查该变量,命中即记录 warn 日志并直接返回空结果,不处理任何系列。
批量限制
为防止大量发票同时到期时压垮系统,处理按批次进行:
| 处理器 | 批大小 | 说明 |
|---|---|---|
| 循环发票生成 | 50 | 每次调度运行最多生成 50 张(DEFAULT_BATCH_SIZE,见 DB 查询层) |
| 到期提醒通知 | 100 | 每次调度运行最多发送 100 条 |
设计理由:
- 避免一次处理上千个系列造成的内存压力;
- 将负载分散到多次调度运行中;
- 最先到期的发票优先处理(按
nextScheduledAt排序); - 仍有剩余时任务返回
hasMore: true,下一轮继续。
通知机制
24 小时到期提醒
独立调度器invoice-upcoming-notification(处理器位于 upcoming-notification.ts)与主生成任务错峰运行(主任务整点、提醒任务半点),为即将到期的系列发送 24 小时提前提醒:
upcoming_notification_sent_at字段保证每个计费周期只提醒一次,且在生成新发票后重置。
站内活动通知
系统还创建 in-app 活动通知,覆盖关键事件:
| 事件 | 活动类型 | 优先级 |
|---|---|---|
| 系列创建 | recurring_series_started | 3 |
| 系列完成 | recurring_series_completed | 3 |
| 系列被自动暂停 | recurring_series_paused | 4(更高) |
| 发票即将到期 | recurring_invoice_upcoming | 3 |
其中recurring_series_completed与recurring_series_paused两个通知任务在 generate-recurring.ts 中可以直接看到对应的入队代码。
API 端点
invoice-recurring tRPC 路由 暴露以下过程(配合 Zod 校验 schema):
| Procedure | 类型 | 说明 |
|---|---|---|
create | Mutation | 创建新的循环系列 |
update | Mutation | 更新系列配置 |
delete | Mutation | 取消系列(软删除) |
getById | Query | 获取系列详情 |
getList | Query | 分页列出系列 |
pause | Mutation | 暂停一个 active 系列 |
resume | Mutation | 恢复一个 paused 系列 |
getUpcoming | Query | 预览即将生成的发票 |
getUpcoming背后的getUpcomingInvoices()查询会调用 DB 层的calculateUpcomingDates(),用与真实调度完全相同的时区感知算法推算未来若干期发票日期,因此预览与线上实际开票日期保持一致。
创建循环系列的流程
从一张草稿发票创建循环系列时(路由create过程 + submit-button.tsx 前端提交):
- 校验客户邮箱(必填,因为发票会自动邮件发送);
- 创建
invoice_recurring记录; - 将该草稿发票关联为系列的第 1 张(sequence #1);
- 基于发票开票日期计算
nextScheduledAt; - 向团队发送创建通知。
已有系列可通过 edit-recurring-sheet.tsx 编辑频率、结束条件与金额等配置。
日期处理与时区一致性
这是该系统最容易踩坑的部分,文档给出了完整的设计。
存储格式
所有"仅日期"字段(开票日期、到期日期、结束日期)都存储为UTC 午夜时间戳(TIMESTAMPTZ列)。例如 "2024 年 1 月 15 日" 存为2024-01-15T00:00:00.000Z。这是一种与时区无关的规范表示法,可直接沿用现有数据库 schema。
时区问题
若 UTC 时区以西的用户(如 EST = UTC-5)遇到以 UTC 午夜存储的日期:
存储值: 2024-01-15T00:00:00.000Z(UTC 时间 1 月 15 日午夜)如果简单地按本地时区换算:
- 在 EST(UTC-5)下变成
2024-01-14T19:00:00(1 月 14 日!) - 日历组件会显示错误的日期
方案一:显示用 TZDate
使用@date-fns/tz的TZDate按 UTC 解释存储值,保证日历正确显示:
import { TZDate } from "@date-fns/tz"; // 显示:将存储的 UTC 日期按 UTC 解释用于日历展示 const selectedDate = new TZDate(dueDate, "UTC"); // "2024-01-15T00:00:00.000Z" → 日历显示 1 月 15 日 ✓方案二:存储用 localDateToUTCMidnight
用户在日历选择器中选中日期时,浏览器返回的是本地午夜的Date对象。需要用本地日期分量转换为 UTC 午夜:
import { localDateToUTCMidnight } from "@midday/invoice/recurring"; // 存储:把本地选中的日期转换为 UTC 午夜 const handleSelect = (date: Date) => { setValue("dueDate", localDateToUTCMidnight(date)); };实现(packages/invoice/src/utils/recurring.ts 第 54-58 行):
export function localDateToUTCMidnight(date: Date): string { return new Date( Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()), ).toISOString(); }它提取的是本地年/月/日分量,再为这一天构造 UTC 午夜。
为什么不用 getStartOfDayUTC?
代码库中有两个看起来相似的函数,用途完全不同:
| 函数 | 用途 | 使用的分量方法 |
|---|---|---|
getStartOfDayUTC(date) | 将 UTC 日期归一到 UTC 午夜 | getUTCFullYear()/getUTCMonth()/getUTCDate() |
localDateToUTCMidnight(date) | 将本地选择转换为 UTC 午夜 | getFullYear()/getMonth()/getDate() |
差异示例:
UTC+14 用户选中 1 月 15 日 日历返回: new Date(2024, 0, 15) → 2024-01-14T10:00:00.000Z (UTC) getStartOfDayUTC(): 2024-01-14T00:00:00.000Z ✗(错误日期!) localDateToUTCMidnight(): 2024-01-15T00:00:00.000Z ✓(正确!)这个"客户端/服务器分工"在源码中有明确注释:generate-recurring.ts 第 331-338 行特意说明——服务器端生成发票时使用getStartOfDayUTC(因为nextScheduledAt本身就是 UTC 时间戳,需保留其 UTC 日期),而浏览器端表单存储时使用localDateToUTCMidnight。
服务端:时区感知的调度日期计算
前端getNextDate()(packages/invoice/src/utils/recurring.ts 第 372 行起)仅用于 UI 预览,是不带时区支持的简化算法(其中monthly_date对超出当月天数的日期做了钳制,如 31 号在 30 天的月份回落到 30 号)。真正权威的服务端实现是 packages/db/src/utils/invoice-recurring.ts 中的calculateNextScheduledDate()(第 114 行起):
const createTZDate = tz(timezone); // 来自 @date-fns/tz const tzCurrentDate = createTZDate(currentDate); // 对 weekly / biweekly / monthly_date / monthly_weekday / monthly_last_day / // quarterly / semi_annual / annual / custom 分别计算, // 且 monthly_date、quarterly 等均对月末天数做 Math.min 钳制该函数接收invoice_recurring.timezone中保存的用户 IANA 时区(如America/New_York),确保"每月 15 号""每两周"等语义在用户本地日历上成立。生成的发票日期最终仍存为 UTC 午夜,与手动创建的发票保持一致。
markInvoiceGenerated()中还有一处防"补票风暴"的细节:以原定nextScheduledAt为基准计算下一期(保持双周发票固定在同一个星期几),再经advanceToFutureDate()将日期推进到未来——即使调度器运行滞后,也不会连续补发多张"过期"发票。
到期状态比较
apps/dashboard/src/utils/format.ts 中的getDueDateStatus()按 UTC 天级比较到期日:
// 按 UTC 解析到期日(存储为 UTC 午夜) const due = new TZDate(dueDate, "UTC"); // 取当前 UTC 日期做一致比较 const now = new Date(); const nowUTC = new TZDate(now.toISOString(), "UTC"); // 在 UTC 天级比较 const diffDays = differenceInDays(startOfDay(due), startOfDay(nowUTC));相应地,从开票日期推导循环模式时也应使用 UTC 分量方法:
// 开票日期存储为 UTC 午夜,故使用 UTC 方法 const dayOfWeek = issueDate.getUTCDay(); const dayOfMonth = issueDate.getUTCDate();设计决策
为什么用软删除?
循环系列被"取消"(canceled)而非物理删除,以保留与已生成发票的关联,维护审计历史,并支持反向查询"某张发票来自哪个系列"。
为什么连续 3 次失败就自动暂停?
连续失败通常意味着系统性问题(客户邮箱失效、模板损坏等)。自动暂停避免:队列中堆积失败任务、错误通知轰炸、浪费处理资源。团队会收到通知,修复后可手动恢复。
为什么恢复时从当前时间重算下次日期?
paused 系列恢复时,下一张发票基于当前日期生成,而不是补发错过的那些期。这避免"补票洪水",同时保证后续计费周期可预期。
为什么强制要求客户邮箱?
循环发票会自动邮件发送。没有有效邮箱地址,发票虽然能生成但无法投递。提前校验能给用户即时反馈,而不是让系列在后台静默失败。
关键文件索引
| 文件 | 用途 |
|---|---|
| apps/dashboard/src/components/invoice/recurring-config.tsx | 频率、结束条件配置与预览的 UI 面板 |
| apps/dashboard/src/components/invoice/submit-button.tsx | 带循环选项的发票表单提交 |
| apps/dashboard/src/components/sheets/edit-recurring-sheet.tsx | 编辑已有循环系列的 Sheet |
| apps/api/src/trpc/routers/invoice-recurring.ts | 全部 API 端点的 tRPC 路由 |
| apps/api/src/schemas/invoice-recurring.ts | Zod 校验 schema |
| apps/worker/src/processors/invoices/generate-recurring.ts | 生成发票的定时任务处理器 |
| apps/worker/src/processors/invoices/upcoming-notification.ts | 24 小时到期提醒调度器 |
| apps/worker/src/schedulers/invoices.config.ts | 调度任务 cron 注册配置 |
| packages/db/src/queries/invoice-recurring.ts | 数据库查询(CRUD、状态转换、到期查询) |
| packages/db/src/utils/invoice-recurring.ts | 服务端时区感知的日期计算 |
| packages/invoice/src/utils/recurring.ts | 共享工具(枚举常量、标签、预览计算、UTC 日期转换) |
| packages/invoice/src/index.tsx | 发票包入口(含@midday/invoice/recurring导出) |
【免费下载链接】middayInvoicing, Time tracking, File reconciliation, Storage, Financial Overview & your own Assistant made for Freelancers项目地址: https://gitcode.com/GitHub_Trending/mi/midday
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考