feat(pay-v2): P8 Task4 续费入账 invoice.paid→renewal order + subscription.renewed
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user