feat(pay-v2): P8 Task4 续费入账 invoice.paid→renewal order + subscription.renewed

This commit is contained in:
wangjia
2026-07-10 18:06:38 +08:00
parent 4c242607a7
commit c677d64529
5 changed files with 308 additions and 14 deletions
+52 -3
View File
@@ -111,7 +111,7 @@ func (g *Gateway) HandleCallback(ctx context.Context, method string, in provider
}
switch ev.Kind {
case provider.EventSubscriptionRenewal:
return g.settleRenewal(ctx, ev) // Task 4
return g.settleRenewal(ctx, method, ev) // Task 4
case provider.EventSubscriptionPastDue:
return g.markSubscriptionPastDue(ctx, method, ev) // Task 5
case provider.EventSubscriptionCanceled:
@@ -132,8 +132,57 @@ func (g *Gateway) HandleCallback(ctx context.Context, method string, in provider
// --- Task 4/5/6 处理器占位(本 Task 只需 default 分支可用 + onSubscriptionActivated)。
// 保持本 Task 独立可编译;后续 Task 各自替换实现 + 补测试。
func (g *Gateway) settleRenewal(ctx context.Context, ev *provider.PaidEvent) (SettleResult, error) {
return SettleFailed, fmt.Errorf("not implemented: %s", ev.Kind)
// settleRenewal 处理续费 invoice.paid(设计 §5/§4 决策记录):每期铸独立 renewal OrderV2
// (out_trade_no = 首购单号 + "-r-" + invoice id),建即 paid(续费不经收银台,无 pending 中间态)。
// 幂等靠 renewal attempt 的 (channel,provider_ref=invoice.ID) 唯一索引 + renewal order 的
// out_trade_no 唯一索引双保险;出问题让 Stripe 重投,重投时 created=false 不重复入队,自愈。
func (g *Gateway) settleRenewal(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
// channel = 触发本次回调的 method(与 markSubscriptionPastDue/recordChargeback 同式);
// 订阅诞生时 Subscription.Channel 落的正是 att.Channel=method,查询须对齐,不能硬编码字面量
// "stripe"(测试固定用 "substripe" 注册子供应商,生产 Stripe 适配器 Method()="stripe")。
sub, err := g.subs.GetByProviderRef(method, ev.SubscriptionRef)
if err != nil {
if errors.Is(err, store.ErrSubNotFound) {
log.Printf("[renewal] 未知订阅 provider_ref=%s(未诞生/已清理),忽略", ev.SubscriptionRef)
return SettleIgnored, nil
}
return SettleFailed, err
}
paidAt := time.Now()
if ev.PaidAt != nil {
paidAt = *ev.PaidAt
}
renewalNo := sub.OutTradeNo + "-r-" + ev.InvoiceRef
created, err := g.orders.CreateRenewalPaid(
&model.OrderV2{
OutTradeNo: renewalNo, BizSystem: sub.BizSystem, BizRef: sub.BizRef, BizCode: sub.BizCode,
Subject: "续费", AmountMinor: ev.PaidAmountMinor, Currency: ev.PaidCurrency,
Status: model.OrderPaidV2, PaidAt: &paidAt,
},
&model.Attempt{
OutTradeNo: renewalNo, Channel: sub.Channel, Provider: sub.Channel, ProviderRef: ev.InvoiceRef,
AmountMinor: ev.PaidAmountMinor, Currency: ev.PaidCurrency, Status: model.AttemptPaid, PaidAt: &paidAt,
})
if err != nil {
return SettleFailed, err
}
// 续费成功即恢复/维持 active,刷新续费锚点(period_end 最小可行取 paidAt+30d;精确值后续从 invoice.period_end 下发)。
nextEnd := paidAt.Add(30 * 24 * time.Hour)
if _, err := g.subs.Activate(sub.SubID, &nextEnd); err != nil {
return SettleFailed, err
}
if !created || sub.BizSystem == "" {
return SettleDuplicate, nil // 重投 / 独立收款
}
if err := g.webhook.Enqueue(renewalNo, sub.BizSystem, EvtSubscriptionRenewed, "", map[string]any{
"event_type": EvtSubscriptionRenewed, "out_trade_no": renewalNo, "sub_id": sub.SubID,
"biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode,
"amount_minor": ev.PaidAmountMinor, "currency": ev.PaidCurrency, "channel": sub.Channel,
"paid_at": paidAt.Format(time.RFC3339),
}); err != nil {
return SettleFailed, err
}
return SettleProcessed, nil
}
func (g *Gateway) markSubscriptionPastDue(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {