如何按付款周期生成 invoice 并避免重复数据
明确结论
不能只通过 bit 字段判断某个客户是否已经生成 invoice。付款状态与 invoice 是否存在是两回事。
更可靠的处理方式如下:
- 为每张周期性 invoice 记录明确的账期,例如
period_start和period_end。 - 将
customer_id + period_start + period_end作为业务唯一标识。 - 在数据库中添加唯一约束,避免并发任务插入重复数据。
- 生成 invoice 时直接尝试插入。如果发生唯一性冲突,则说明该 invoice 已经生成。
- 付款完成后更新 invoice 的付款状态,同时写入
tblpayment。这两项操作应放在同一个事务中。
采用这种设计后,即使定时任务重复执行、服务重试或多个进程同时运行,也不会生成重复的 invoice。
账期应该如何确定
假设客户的订阅起始日是 2026-01-01,不同的 payment_method 可以对应以下账期:
| payment_method | period_start | period_end |
|---|---|---|
monthly | 2026-01-01 | 2026-02-01 |
quarterly | 2026-01-01 | 2026-04-01 |
annually | 2026-01-01 | 2027-01-01 |
账期建议使用左闭右开区间:
[period_start, period_end)
也就是包含 period_start,不包含 period_end。这样相邻账期不会发生日期重叠,查询和计算也更简单。
如果业务允许客户在账期中途修改付款周期,还需要保存订阅版本或合同编号,并将其纳入 invoice 的唯一标识。
建议的表结构
下面的示例以 SQL Server 为前提,因为问题中使用了 bit 类型。具体字段可以根据现有系统调整。
CREATE TABLE dbo.tblinvoice
(
invoice_id bigint IDENTITY(1, 1) PRIMARY KEY,
customer_id bigint NOT NULL,
subscription_id bigint NOT NULL,
payment_method varchar(20) NOT NULL,
period_start date NOT NULL,
period_end date NOT NULL,
amount decimal(18, 2) NOT NULL,
payment_status varchar(20) NOT NULL
CONSTRAINT DF_tblinvoice_payment_status DEFAULT ('UNPAID'),
created_at datetime2(0) NOT NULL
CONSTRAINT DF_tblinvoice_created_at DEFAULT (SYSUTCDATETIME()),
paid_at datetime2(0) NULL,
CONSTRAINT CK_tblinvoice_period
CHECK (period_end > period_start),
CONSTRAINT CK_tblinvoice_payment_method
CHECK (payment_method IN ('monthly', 'quarterly', 'annually')),
CONSTRAINT CK_tblinvoice_payment_status
CHECK (payment_status IN ('UNPAID', 'PAID', 'VOID'))
);
其中最重要的是唯一索引:
CREATE UNIQUE INDEX UX_tblinvoice_billing_period
ON dbo.tblinvoice
(
customer_id,
subscription_id,
period_start,
period_end
);
如果一个客户只会有一个订阅,可以省略 subscription_id。不过,多数计费系统允许同一客户购买多个产品或服务,因此通常不能只使用 customer_id。
是否将 payment_method 加入唯一索引,要看具体的业务规则。一般不建议用它来区分账单,因为客户可能会变更付款周期,但同一订阅在同一账期内仍然只能有一张有效 invoice。
为什么不能只先查询再插入
下面的写法看起来可以防止重复,实际仍有并发问题:
IF NOT EXISTS
(
SELECT 1
FROM dbo.tblinvoice
WHERE customer_id = @customer_id
AND subscription_id = @subscription_id
AND period_start = @period_start
AND period_end = @period_end
)
BEGIN
INSERT INTO dbo.tblinvoice (...)
VALUES (...);
END;
如果两个任务同时执行,它们可能都会在插入前查到记录“不存在”,然后各自插入一条数据。应用层检查只能减少重复,无法代替数据库的唯一约束。
因此,唯一索引才是防止重复数据的最后一道可靠防线。
幂等地生成 invoice
可以直接执行 INSERT,然后捕获 SQL Server 返回的唯一键冲突。错误号 2601 和 2627 都可能表示唯一性冲突。
CREATE OR ALTER PROCEDURE dbo.GenerateInvoice
@customer_id bigint,
@subscription_id bigint,
@payment_method varchar(20),
@period_start date,
@period_end date,
@amount decimal(18, 2)
AS
BEGIN
SET NOCOUNT ON;
BEGIN TRY
INSERT INTO dbo.tblinvoice
(
customer_id,
subscription_id,
payment_method,
period_start,
period_end,
amount,
payment_status
)
VALUES
(
@customer_id,
@subscription_id,
@payment_method,
@period_start,
@period_end,
@amount,
'UNPAID'
);
SELECT
invoice_id,
CAST(1 AS bit) AS was_created
FROM dbo.tblinvoice
WHERE invoice_id = SCOPE_IDENTITY();
END TRY
BEGIN CATCH
IF ERROR_NUMBER() IN (2601, 2627)
BEGIN
SELECT
invoice_id,
CAST(0 AS bit) AS was_created
FROM dbo.tblinvoice
WHERE customer_id = @customer_id
AND subscription_id = @subscription_id
AND period_start = @period_start
AND period_end = @period_end;
RETURN;
END;
THROW;
END CATCH;
END;
调用方可以通过 was_created 判断执行结果:
1:本次创建了 invoice。0:该账期的 invoice 已经存在,返回原有记录。- 其他数据库错误:继续向上抛出,不能误判为重复数据。
这样设计后,生成任务可以安全重试。
根据付款周期计算账期
不要用“当前日期减去若干天”的方式模拟月份或年份。每个月的天数不同,闰年也会影响年度账期。应使用数据库提供的日期运算函数。
DECLARE @period_end date;
SET @period_end =
CASE @payment_method
WHEN 'monthly' THEN DATEADD(month, 1, @period_start)
WHEN 'quarterly' THEN DATEADD(month, 3, @period_start)
WHEN 'annually' THEN DATEADD(year, 1, @period_start)
ELSE NULL
END;
IF @period_end IS NULL
BEGIN
THROW 50001, 'Unsupported payment method.', 1;
END;
如果订阅起始日是每月的 29、30 或 31 日,需要提前确定月末处理规则。例如,SQL Server 对 2026-01-31 执行 DATEADD(month, 1, ...) 时,会返回下个月实际存在的月末日期。业务是否接受这种行为,应在计费规则中写清楚。
更稳妥的方式是保存 next_invoice_date。每次成功生成 invoice 后,按照合同约定更新下一个账期,不要每次都从当前日期反推。
付款记录与 invoice 状态
付款表应通过 invoice_id 关联 invoice,不要再次复制客户和账期作为主要关联条件。
CREATE TABLE dbo.tblpayment
(
payment_id bigint IDENTITY(1, 1) PRIMARY KEY,
invoice_id bigint NOT NULL,
amount decimal(18, 2) NOT NULL,
payment_reference varchar(100) NOT NULL,
paid_at datetime2(0) NOT NULL,
created_at datetime2(0) NOT NULL
CONSTRAINT DF_tblpayment_created_at DEFAULT (SYSUTCDATETIME()),
CONSTRAINT FK_tblpayment_invoice
FOREIGN KEY (invoice_id)
REFERENCES dbo.tblinvoice(invoice_id),
CONSTRAINT UQ_tblpayment_reference
UNIQUE (payment_reference)
);
payment_reference 应使用支付平台返回的唯一交易号,或者由系统生成的幂等键,以免同一个支付回调被重复处理。
如果业务规定一张 invoice 只能对应一笔付款,还可以添加以下索引:
CREATE UNIQUE INDEX UX_tblpayment_invoice
ON dbo.tblpayment(invoice_id);
如果系统支持分期付款、组合付款,或者允许支付失败后重试,就不应为 invoice_id 建立唯一索引。此时应根据付款流水号防重,并单独计算已付总额。
在同一事务中完成付款
插入付款记录与更新 invoice 状态必须保持一致,否则可能出现付款记录已经存在,但 invoice 仍显示未付款的情况。
CREATE OR ALTER PROCEDURE dbo.RecordInvoicePayment
@invoice_id bigint,
@amount decimal(18, 2),
@payment_reference varchar(100),
@paid_at datetime2(0)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
BEGIN TRY
INSERT INTO dbo.tblpayment
(
invoice_id,
amount,
payment_reference,
paid_at
)
VALUES
(
@invoice_id,
@amount,
@payment_reference,
@paid_at
);
UPDATE dbo.tblinvoice
SET payment_status = 'PAID',
paid_at = @paid_at
WHERE invoice_id = @invoice_id
AND payment_status = 'UNPAID';
IF @@ROWCOUNT = 0
BEGIN
THROW 50002, 'Invoice does not exist or is not unpaid.', 1;
END;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0
ROLLBACK TRANSACTION;
THROW;
END CATCH;
END;
生产系统还应在更新前核对 invoice 金额、币种和付款金额。上面的示例假设一笔付款就能结清整张 invoice。
bit 字段应该表达什么
如果现有字段是 is_paid bit,可以将它定义为:
0 = 未付款
1 = 已付款
但 bit 无法表示以下常见状态:
- invoice 已创建但尚未付款;
- 部分付款;
- 付款处理中;
- 付款失败;
- invoice 已取消;
- invoice 已退款。
如果业务中只有“未付款”和“已付款”两种状态,使用 bit 就够了。业务稍微复杂一些时,建议改用 payment_status。不要让同一个 bit 同时表示“invoice 是否已生成”和“是否完成付款”。
invoice 是否已经生成,应根据 tblinvoice 中是否存在对应的业务唯一键来判断:
SELECT invoice_id
FROM dbo.tblinvoice
WHERE customer_id = @customer_id
AND subscription_id = @subscription_id
AND period_start = @period_start
AND period_end = @period_end;
这个查询可以用于页面展示或插入前的预检查,但防止重复插入最终仍要依靠唯一索引。
需要特别注意的情况
- 不要只按
customer_id防重,否则同一客户只能生成一张 invoice。 - 不要只按月份名称或年份防重,应保存完整的账期边界。
- 金额应使用
decimal,不要使用float。 - 定时任务应允许重复运行,生成操作本身必须具备幂等性。
- 支付回调可能重复发送,因此
tblpayment也需要唯一的交易标识。 - invoice 编号可以单独生成,但不能代替账期唯一约束。
- 已开具的 invoice 通常不应直接覆盖。金额或合同发生变化时,应按照业务规则作废、冲销或生成调整记录。
- 如果同一账期允许存在不同类型的 invoice,例如正常账单和补差账单,应增加
invoice_type或adjustment_no,并相应调整唯一索引。
备注:内容仅供参考。
