feat(pay-v2): P8 Task5 订阅取消(API+入站)+ past_due 状态
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
"github.com/wangjia/pay/internal/provider"
|
||||
"github.com/wangjia/pay/internal/store"
|
||||
"github.com/wangjia/pay/internal/util"
|
||||
|
||||
"github.com/wangjia/pay/internal/accounts"
|
||||
@@ -133,3 +134,117 @@ func (g *Gateway) onSubscriptionActivated(ctx context.Context, ev *provider.Paid
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// SubscriptionView — GET /api/v2/subscriptions/:sub_id 返回视图(状态 + 续费锚点)。
|
||||
type SubscriptionView struct {
|
||||
SubID string `json:"sub_id"`
|
||||
Status string `json:"status"`
|
||||
CurrentPeriodEnd *time.Time `json:"current_period_end,omitempty"`
|
||||
CanceledAt *time.Time `json:"canceled_at,omitempty"`
|
||||
}
|
||||
|
||||
func (g *Gateway) GetSubscription(subID string) (*SubscriptionView, error) {
|
||||
sub, err := g.subs.GetBySubID(subID)
|
||||
if err != nil {
|
||||
return nil, err // store.ErrSubNotFound
|
||||
}
|
||||
return &SubscriptionView{
|
||||
SubID: sub.SubID, Status: string(sub.Status),
|
||||
CurrentPeriodEnd: sub.CurrentPeriodEnd, CanceledAt: sub.CanceledAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelSubscription 主动取消(POST /api/v2/subscriptions/:sub_id/cancel):查订阅 → 已终态则
|
||||
// 幂等 no-op(不再打渠道,避免重复取消命中渠道 400)→ 渠道侧取消 → 本地翻 canceled + 入队
|
||||
// subscription.canceled。Stripe 随后异步发 customer.subscription.deleted,入站处理器
|
||||
// (settleSubscriptionCanceled)再次调用同一 finalizeCanceled,两路收敛同一终态、天然幂等。
|
||||
func (g *Gateway) CancelSubscription(ctx context.Context, subID string) error {
|
||||
sub, err := g.subs.GetBySubID(subID)
|
||||
if err != nil {
|
||||
return err // store.ErrSubNotFound
|
||||
}
|
||||
if sub.Status == model.SubCanceled {
|
||||
return nil // 已取消(重复调用/webhook 已先到):幂等 no-op,不再打渠道
|
||||
}
|
||||
prov, err := g.providers.Get(sub.Channel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sp, ok := prov.(provider.SubscriptionProvider); ok {
|
||||
if err := sp.CancelSubscription(ctx, sub.ProviderSubRef); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return g.finalizeCanceled(sub)
|
||||
}
|
||||
|
||||
// settleSubscriptionCanceled 处理入站 customer.subscription.deleted:反查订阅 → finalizeCanceled。
|
||||
// 未知 provider_sub_ref(订阅未诞生/已清理)忽略,不报错(与 settleRenewal 同惯例)。
|
||||
func (g *Gateway) settleSubscriptionCanceled(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
|
||||
sub, err := g.subs.GetByProviderRef(method, ev.SubscriptionRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrSubNotFound) {
|
||||
return SettleIgnored, nil
|
||||
}
|
||||
return SettleFailed, err
|
||||
}
|
||||
if err := g.finalizeCanceled(sub); err != nil {
|
||||
return SettleFailed, err
|
||||
}
|
||||
return SettleProcessed, nil
|
||||
}
|
||||
|
||||
// finalizeCanceled 是取消的唯一落地点(主动取消 API + 入站 webhook 两路收敛于此):
|
||||
// MarkCanceled 幂等翻转 canceled;仅首次真正翻转(flipped)且有业务方(BizSystem 非空)
|
||||
// 才入队 subscription.canceled——重投/独立收款不重复发。
|
||||
//
|
||||
// outbox 键取真实首购单号 sub.OutTradeNo(不用合成后缀键):该单已 paid,Notifier 投递门禁
|
||||
// (orderPaid)天然放行;event_type=subscription.canceled 与该单已有的 payment.succeeded /
|
||||
// subscription.created 行不同 event_type,唯一键 (out_trade_no,event_type,refund_id) 不撞。
|
||||
// canceled 每订阅只发生一次,不存在"同订阅多次 canceled 被吞"的问题(不同于 past_due)。
|
||||
func (g *Gateway) finalizeCanceled(sub *model.Subscription) error {
|
||||
flipped, err := g.subs.MarkCanceled(sub.SubID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !flipped || sub.BizSystem == "" {
|
||||
return nil // 已取消(重投)/ 独立收款无业务方回调
|
||||
}
|
||||
return g.webhook.Enqueue(sub.OutTradeNo, sub.BizSystem, EvtSubscriptionCanceled, "", map[string]any{
|
||||
"event_type": EvtSubscriptionCanceled, "out_trade_no": sub.OutTradeNo, "sub_id": sub.SubID,
|
||||
"biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode,
|
||||
"canceled_at": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// markSubscriptionPastDue 处理入站 invoice.payment_failed:active→past_due(MarkPastDue 只在
|
||||
// 当前 active 时翻转,已 past_due/已 canceled 不动),入队 subscription.past_due 供业务方提醒
|
||||
// 用户换卡。下期 invoice.paid 成功由 settleRenewal 的 Activate 自动恢复 active。
|
||||
//
|
||||
// outbox 键取真实首购单号 sub.OutTradeNo(理由同 finalizeCanceled):Task5 最小可行接受
|
||||
// "同订阅多次 past_due 只报首次"(第二次失败的行撞 (out_trade_no,event_type) 唯一键、
|
||||
// EnqueueDelivery 幂等 no-op),Task 7 视需要再引入合成键 + Notifier 门禁旁路。
|
||||
func (g *Gateway) markSubscriptionPastDue(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
|
||||
sub, err := g.subs.GetByProviderRef(method, ev.SubscriptionRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrSubNotFound) {
|
||||
return SettleIgnored, nil
|
||||
}
|
||||
return SettleFailed, err
|
||||
}
|
||||
flipped, err := g.subs.MarkPastDue(sub.Channel, sub.ProviderSubRef)
|
||||
if err != nil {
|
||||
return SettleFailed, err
|
||||
}
|
||||
if !flipped || sub.BizSystem == "" {
|
||||
return SettleDuplicate, nil
|
||||
}
|
||||
if err := g.webhook.Enqueue(sub.OutTradeNo, sub.BizSystem, EvtSubscriptionPastDue, "", map[string]any{
|
||||
"event_type": EvtSubscriptionPastDue, "out_trade_no": sub.OutTradeNo, "sub_id": sub.SubID,
|
||||
"biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode,
|
||||
"failed_at": time.Now().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
return SettleFailed, err
|
||||
}
|
||||
return SettleProcessed, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user