PAYATHON 2026

如何按付款周期生成 invoice 并避免重复数据

支付老李

明确结论

不能只通过 bit 字段判断某个客户是否已经生成 invoice。付款状态与 invoice 是否存在是两回事。

更可靠的处理方式如下:

  1. 为每张周期性 invoice 记录明确的账期,例如 period_start 和 period_end。
  2. 将 customer_id + period_start + period_end 作为业务唯一标识。
  3. 在数据库中添加唯一约束,避免并发任务插入重复数据。
  4. 生成 invoice 时直接尝试插入。如果发生唯一性冲突,则说明该 invoice 已经生成。
  5. 付款完成后更新 invoice 的付款状态,同时写入 tblpayment。这两项操作应放在同一个事务中。

采用这种设计后,即使定时任务重复执行、服务重试或多个进程同时运行,也不会生成重复的 invoice。

账期应该如何确定

假设客户的订阅起始日是 2026-01-01,不同的 payment_method 可以对应以下账期:

payment_methodperiod_startperiod_end
monthly2026-01-012026-02-01
quarterly2026-01-012026-04-01
annually2026-01-012027-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,并相应调整唯一索引。

备注:内容仅供参考。