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 {
|
switch ev.Kind {
|
||||||
case provider.EventSubscriptionRenewal:
|
case provider.EventSubscriptionRenewal:
|
||||||
return g.settleRenewal(ctx, ev) // Task 4
|
return g.settleRenewal(ctx, method, ev) // Task 4
|
||||||
case provider.EventSubscriptionPastDue:
|
case provider.EventSubscriptionPastDue:
|
||||||
return g.markSubscriptionPastDue(ctx, method, ev) // Task 5
|
return g.markSubscriptionPastDue(ctx, method, ev) // Task 5
|
||||||
case provider.EventSubscriptionCanceled:
|
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 4/5/6 处理器占位(本 Task 只需 default 分支可用 + onSubscriptionActivated)。
|
||||||
// 保持本 Task 独立可编译;后续 Task 各自替换实现 + 补测试。
|
// 保持本 Task 独立可编译;后续 Task 各自替换实现 + 补测试。
|
||||||
|
|
||||||
func (g *Gateway) settleRenewal(ctx context.Context, ev *provider.PaidEvent) (SettleResult, error) {
|
// settleRenewal 处理续费 invoice.paid(设计 §5/§4 决策记录):每期铸独立 renewal OrderV2
|
||||||
return SettleFailed, fmt.Errorf("not implemented: %s", ev.Kind)
|
// (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) {
|
func (g *Gateway) markSubscriptionPastDue(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/wangjia/pay/config"
|
"github.com/wangjia/pay/config"
|
||||||
@@ -187,3 +188,143 @@ func TestSubscriptionActivationOnFirstPayment(t *testing.T) {
|
|||||||
t.Fatalf("subscription duplicated on replay: %+v vs %+v", subAfter, sub)
|
t.Fatalf("subscription duplicated on replay: %+v vs %+v", subAfter, sub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSettleRenewal 先经首期激活诞生订阅(provider_sub_ref=sub_new),再喂
|
||||||
|
// EventSubscriptionRenewal:①新增 renewal OrderV2(paid,out_trade_no 含 -r-)
|
||||||
|
// ②webhook 收到 subscription.renewed ③重投幂等(不双铸/不双发) ④订阅刷新 current_period_end、
|
||||||
|
// 从 past_due 恢复 active。
|
||||||
|
func TestSettleRenewal(t *testing.T) {
|
||||||
|
g, fp, spy, orders, subs := newSubGateway(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
res, err := g.CreateSubscription(ctx, gateway.CreateSubscriptionInput{
|
||||||
|
SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSubscription: %v", err)
|
||||||
|
}
|
||||||
|
activateRaw, err := json.Marshal(provider.PaidEvent{
|
||||||
|
Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded,
|
||||||
|
PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal activate event: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: activateRaw}); err != nil {
|
||||||
|
t.Fatalf("activate: %v", err)
|
||||||
|
}
|
||||||
|
spy.calls = nil // 只看续费产生的 webhook
|
||||||
|
|
||||||
|
// 续费成功前先把订阅打成 past_due,验证续费成功后能恢复 active(路径复用 Activate)。
|
||||||
|
if ok, err := subs.MarkPastDue("substripe", "sub_new"); err != nil || !ok {
|
||||||
|
t.Fatalf("MarkPastDue: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
renewalRaw, err := json.Marshal(provider.PaidEvent{
|
||||||
|
Kind: provider.EventSubscriptionRenewal, SubscriptionRef: "sub_new", InvoiceRef: "in_123",
|
||||||
|
PaidAmountMinor: 2999, PaidCurrency: "USD", Status: provider.PaidSucceeded,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal renewal event: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw})
|
||||||
|
if err != nil || result != gateway.SettleProcessed {
|
||||||
|
t.Fatalf("HandleCallback renewal = %v, %v", result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allOrders, err := orders.ListOrders("pangolin", "u-1", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListOrders: %v", err)
|
||||||
|
}
|
||||||
|
var renewalOrder *model.OrderV2
|
||||||
|
for i := range allOrders {
|
||||||
|
if strings.Contains(allOrders[i].OutTradeNo, "-r-") {
|
||||||
|
renewalOrder = &allOrders[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if renewalOrder == nil {
|
||||||
|
t.Fatalf("renewal order not found: %+v", allOrders)
|
||||||
|
}
|
||||||
|
if renewalOrder.Status != model.OrderPaidV2 || renewalOrder.AmountMinor != 2999 || renewalOrder.Currency != "USD" {
|
||||||
|
t.Fatalf("renewal order = %+v", renewalOrder)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(renewalOrder.OutTradeNo, "-r-in_123") {
|
||||||
|
t.Fatalf("renewal out_trade_no = %s, want suffix -r-in_123", renewalOrder.OutTradeNo)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(spy.calls) != 1 {
|
||||||
|
t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls)
|
||||||
|
}
|
||||||
|
renewedCall := spy.calls[0]
|
||||||
|
if renewedCall["event_type"] != gateway.EvtSubscriptionRenewed {
|
||||||
|
t.Fatalf("event_type = %v, want %s", renewedCall["event_type"], gateway.EvtSubscriptionRenewed)
|
||||||
|
}
|
||||||
|
if renewedCall["sub_id"] != res.SubID {
|
||||||
|
t.Fatalf("sub_id = %v, want %s", renewedCall["sub_id"], res.SubID)
|
||||||
|
}
|
||||||
|
if renewedCall["out_trade_no"] != renewalOrder.OutTradeNo {
|
||||||
|
t.Fatalf("out_trade_no = %v, want %s", renewedCall["out_trade_no"], renewalOrder.OutTradeNo)
|
||||||
|
}
|
||||||
|
if renewedCall["amount_minor"] != int64(2999) {
|
||||||
|
t.Fatalf("amount_minor = %v, want 2999", renewedCall["amount_minor"])
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, err := subs.GetByProviderRef("substripe", "sub_new")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetByProviderRef: %v", err)
|
||||||
|
}
|
||||||
|
if sub.Status != model.SubActive {
|
||||||
|
t.Fatalf("subscription status after renewal = %v, want active(recovered from past_due)", sub.Status)
|
||||||
|
}
|
||||||
|
if sub.CurrentPeriodEnd == nil {
|
||||||
|
t.Fatalf("current_period_end not refreshed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重投同一 invoice → 不双铸 renewal order、不双发 webhook。
|
||||||
|
result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleCallback renewal replay: %v", err)
|
||||||
|
}
|
||||||
|
if result2 != gateway.SettleDuplicate {
|
||||||
|
t.Fatalf("replay result = %v, want duplicate", result2)
|
||||||
|
}
|
||||||
|
ordersAfterReplay, err := orders.ListOrders("pangolin", "u-1", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListOrders after replay: %v", err)
|
||||||
|
}
|
||||||
|
renewalCount := 0
|
||||||
|
for i := range ordersAfterReplay {
|
||||||
|
if strings.Contains(ordersAfterReplay[i].OutTradeNo, "-r-") {
|
||||||
|
renewalCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if renewalCount != 1 {
|
||||||
|
t.Fatalf("renewal order count after replay = %d, want 1", renewalCount)
|
||||||
|
}
|
||||||
|
if len(spy.calls) != 1 {
|
||||||
|
t.Fatalf("webhook calls after replay = %d, want still 1: %+v", len(spy.calls), spy.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettleRenewalUnknownSubscription 未知 provider_sub_ref(订阅未诞生/已清理)→ 忽略,不报错。
|
||||||
|
func TestSettleRenewalUnknownSubscription(t *testing.T) {
|
||||||
|
g, _, spy, _, _ := newSubGateway(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
raw, err := json.Marshal(provider.PaidEvent{
|
||||||
|
Kind: provider.EventSubscriptionRenewal, SubscriptionRef: "sub_ghost", InvoiceRef: "in_ghost",
|
||||||
|
PaidAmountMinor: 2999, PaidCurrency: "USD", Status: provider.PaidSucceeded,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleCallback: %v", err)
|
||||||
|
}
|
||||||
|
if result != gateway.SettleIgnored {
|
||||||
|
t.Fatalf("result = %v, want ignored", result)
|
||||||
|
}
|
||||||
|
if len(spy.calls) != 0 {
|
||||||
|
t.Fatalf("webhook calls = %d, want 0: %+v", len(spy.calls), spy.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -180,20 +180,44 @@ func (p *Provider) VerifyCallback(_ context.Context, in provider.CallbackInput)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("stripe: webhook 验签失败: %w", err)
|
return nil, fmt.Errorf("stripe: webhook 验签失败: %w", err)
|
||||||
}
|
}
|
||||||
if event.Type != "checkout.session.completed" {
|
switch event.Type {
|
||||||
|
case "checkout.session.completed":
|
||||||
|
var sess gostripe.CheckoutSession
|
||||||
|
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
|
||||||
|
return nil, fmt.Errorf("stripe: 解析 session 失败: %w", err)
|
||||||
|
}
|
||||||
|
ev := sessionToEvent(&sess, in.Raw)
|
||||||
|
if event.Created > 0 {
|
||||||
|
paidAt := unixToTime(event.Created)
|
||||||
|
ev.PaidAt = &paidAt
|
||||||
|
}
|
||||||
|
return ev, nil
|
||||||
|
case "invoice.paid":
|
||||||
|
var inv gostripe.Invoice
|
||||||
|
if err := json.Unmarshal(event.Data.Raw, &inv); err != nil {
|
||||||
|
return nil, fmt.Errorf("stripe: 解析 invoice 失败: %w", err)
|
||||||
|
}
|
||||||
|
if inv.BillingReason != gostripe.InvoiceBillingReasonSubscriptionCycle {
|
||||||
|
// 首期(subscription_create)由 checkout.session.completed 入账;其余非续费忽略。
|
||||||
|
return &provider.PaidEvent{Kind: provider.EventPayment, Status: provider.PaidPending, Raw: string(in.Raw)}, nil
|
||||||
|
}
|
||||||
|
ev := &provider.PaidEvent{
|
||||||
|
Kind: provider.EventSubscriptionRenewal, Status: provider.PaidSucceeded,
|
||||||
|
InvoiceRef: inv.ID, PaidAmountMinor: inv.Total, PaidCurrency: strings.ToUpper(string(inv.Currency)),
|
||||||
|
Raw: string(in.Raw),
|
||||||
|
}
|
||||||
|
if inv.Subscription != nil {
|
||||||
|
ev.SubscriptionRef = inv.Subscription.ID
|
||||||
|
}
|
||||||
|
if event.Created > 0 {
|
||||||
|
t := unixToTime(event.Created)
|
||||||
|
ev.PaidAt = &t
|
||||||
|
}
|
||||||
|
return ev, nil
|
||||||
|
default:
|
||||||
// 其它事件此阶段不处理:归一化 pending(管线 Settle 视为 ignored)。
|
// 其它事件此阶段不处理:归一化 pending(管线 Settle 视为 ignored)。
|
||||||
return &provider.PaidEvent{Status: provider.PaidPending, Raw: string(in.Raw)}, nil
|
return &provider.PaidEvent{Status: provider.PaidPending, Raw: string(in.Raw)}, nil
|
||||||
}
|
}
|
||||||
var sess gostripe.CheckoutSession
|
|
||||||
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
|
|
||||||
return nil, fmt.Errorf("stripe: 解析 session 失败: %w", err)
|
|
||||||
}
|
|
||||||
ev := sessionToEvent(&sess, in.Raw)
|
|
||||||
if event.Created > 0 {
|
|
||||||
paidAt := unixToTime(event.Created)
|
|
||||||
ev.PaidAt = &paidAt
|
|
||||||
}
|
|
||||||
return ev, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Provider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) {
|
func (p *Provider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) {
|
||||||
|
|||||||
@@ -167,6 +167,56 @@ func TestVerifyWebhookWrongSecretFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// invoice.paid + billing_reason=subscription_cycle → 归一化为 EventSubscriptionRenewal,
|
||||||
|
// 携带 invoice/subscription/金额,供 gateway.settleRenewal 铸 renewal order。
|
||||||
|
func TestVerifyInvoicePaidRenewal(t *testing.T) {
|
||||||
|
ts := fakeStripeAPI(t)
|
||||||
|
defer ts.Close()
|
||||||
|
p := newStripe(t, ts)
|
||||||
|
|
||||||
|
payload := `{"id":"evt_r","object":"event","type":"invoice.paid","created":1700000000,"data":{"object":{"id":"in_123","object":"invoice","billing_reason":"subscription_cycle","total":2999,"currency":"usd","subscription":{"id":"sub_new","object":"subscription"}}}}`
|
||||||
|
sig := signStripe(payload, whSecret, time.Now().Unix())
|
||||||
|
|
||||||
|
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
|
||||||
|
Raw: []byte(payload),
|
||||||
|
Headers: map[string]string{"Stripe-Signature": sig},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if ev.Kind != provider.EventSubscriptionRenewal {
|
||||||
|
t.Fatalf("kind = %v, want EventSubscriptionRenewal", ev.Kind)
|
||||||
|
}
|
||||||
|
if ev.InvoiceRef != "in_123" || ev.SubscriptionRef != "sub_new" || ev.PaidAmountMinor != 2999 || ev.Status != provider.PaidSucceeded {
|
||||||
|
t.Fatalf("event = %+v", ev)
|
||||||
|
}
|
||||||
|
if ev.PaidCurrency != "USD" {
|
||||||
|
t.Fatalf("currency = %s, want USD", ev.PaidCurrency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// invoice.paid 但 billing_reason=subscription_create 是首期发票,与 checkout.session.completed
|
||||||
|
// 是同一笔钱——由后者入账,这里必须跳过(归一化为 EventPayment,不触发续费铸单)。
|
||||||
|
func TestVerifyInvoicePaidFirstPeriodSkipped(t *testing.T) {
|
||||||
|
ts := fakeStripeAPI(t)
|
||||||
|
defer ts.Close()
|
||||||
|
p := newStripe(t, ts)
|
||||||
|
|
||||||
|
payload := `{"id":"evt_r2","object":"event","type":"invoice.paid","created":1700000000,"data":{"object":{"id":"in_first","object":"invoice","billing_reason":"subscription_create","total":2999,"currency":"usd","subscription":{"id":"sub_new","object":"subscription"}}}}`
|
||||||
|
sig := signStripe(payload, whSecret, time.Now().Unix())
|
||||||
|
|
||||||
|
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
|
||||||
|
Raw: []byte(payload),
|
||||||
|
Headers: map[string]string{"Stripe-Signature": sig},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if ev.Kind != provider.EventPayment {
|
||||||
|
t.Fatalf("kind = %v, want EventPayment(skip)", ev.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// signStripe 复刻 Stripe webhook 签名头: t=<ts>,v1=hex(HMAC-SHA256(secret, "<ts>.<payload>"))
|
// signStripe 复刻 Stripe webhook 签名头: t=<ts>,v1=hex(HMAC-SHA256(secret, "<ts>.<payload>"))
|
||||||
func signStripe(payload, secret string, ts int64) string {
|
func signStripe(payload, secret string, ts int64) string {
|
||||||
mac := hmac.New(sha256.New, []byte(secret))
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
"github.com/wangjia/pay/internal/model"
|
"github.com/wangjia/pay/internal/model"
|
||||||
)
|
)
|
||||||
@@ -65,6 +66,35 @@ func (s *OrderStore) MarkAttemptPaid(outTradeNo, channel, providerRef string, at
|
|||||||
return flipped, nil
|
return flipped, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateRenewalPaid 幂等建一张已付 renewal order + attempt(续费不经收银台,建即 paid)。
|
||||||
|
// 重复(invoice 重投)→ created=false。renewal order/attempt 均带唯一约束,ON CONFLICT DO NOTHING。
|
||||||
|
func (s *OrderStore) CreateRenewalPaid(order *model.OrderV2, att *model.Attempt) (bool, error) {
|
||||||
|
var created bool
|
||||||
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
ores := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "out_trade_no"}}, DoNothing: true,
|
||||||
|
}).Create(order)
|
||||||
|
if ores.Error != nil {
|
||||||
|
return ores.Error
|
||||||
|
}
|
||||||
|
if ores.RowsAffected == 0 {
|
||||||
|
return nil // 已建过 → 幂等 no-op
|
||||||
|
}
|
||||||
|
ares := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "channel"}, {Name: "provider_ref"}}, DoNothing: true,
|
||||||
|
}).Create(att)
|
||||||
|
if ares.Error != nil {
|
||||||
|
return ares.Error
|
||||||
|
}
|
||||||
|
created = ares.RowsAffected > 0
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("store.CreateRenewalPaid: %w", err)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *OrderStore) CancelOrder(outTradeNo string) (bool, error) {
|
func (s *OrderStore) CancelOrder(outTradeNo string) (bool, error) {
|
||||||
res := s.db.Model(&model.OrderV2{}).
|
res := s.db.Model(&model.OrderV2{}).
|
||||||
Where("out_trade_no = ? AND status = ?", outTradeNo, model.OrderPendingV2).
|
Where("out_trade_no = ? AND status = ?", outTradeNo, model.OrderPendingV2).
|
||||||
|
|||||||
Reference in New Issue
Block a user