feat(payment): add complete payment system with multi-provider support
Add a full payment and subscription system supporting EasyPay (Alipay/WeChat), Stripe, and direct Alipay/WeChat Pay providers with multi-instance load balancing.
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
stripe "github.com/stripe/stripe-go/v85"
|
||||
"github.com/stripe/stripe-go/v85/webhook"
|
||||
)
|
||||
|
||||
// Stripe constants.
|
||||
const (
|
||||
stripeCurrency = "cny"
|
||||
stripeEventPaymentSuccess = "payment_intent.succeeded"
|
||||
stripeEventPaymentFailed = "payment_intent.payment_failed"
|
||||
)
|
||||
|
||||
// Stripe implements the payment.CancelableProvider interface for Stripe payments.
|
||||
type Stripe struct {
|
||||
instanceID string
|
||||
config map[string]string
|
||||
|
||||
mu sync.Mutex
|
||||
initialized bool
|
||||
sc *stripe.Client
|
||||
}
|
||||
|
||||
// NewStripe creates a new Stripe provider instance.
|
||||
func NewStripe(instanceID string, config map[string]string) (*Stripe, error) {
|
||||
if config["secretKey"] == "" {
|
||||
return nil, fmt.Errorf("stripe config missing required key: secretKey")
|
||||
}
|
||||
return &Stripe{
|
||||
instanceID: instanceID,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Stripe) ensureInit() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.initialized {
|
||||
s.sc = stripe.NewClient(s.config["secretKey"])
|
||||
s.initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
// GetPublishableKey returns the publishable key for frontend use.
|
||||
func (s *Stripe) GetPublishableKey() string {
|
||||
return s.config["publishableKey"]
|
||||
}
|
||||
|
||||
func (s *Stripe) Name() string { return "Stripe" }
|
||||
func (s *Stripe) ProviderKey() string { return payment.TypeStripe }
|
||||
func (s *Stripe) SupportedTypes() []payment.PaymentType {
|
||||
return []payment.PaymentType{payment.TypeStripe}
|
||||
}
|
||||
|
||||
// stripePaymentMethodTypes maps our PaymentType to Stripe payment_method_types.
|
||||
var stripePaymentMethodTypes = map[string][]string{
|
||||
payment.TypeCard: {"card"},
|
||||
payment.TypeAlipay: {"alipay"},
|
||||
payment.TypeWxpay: {"wechat_pay"},
|
||||
payment.TypeLink: {"link"},
|
||||
}
|
||||
|
||||
// CreatePayment creates a Stripe PaymentIntent.
|
||||
func (s *Stripe) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
s.ensureInit()
|
||||
|
||||
amountInCents, err := payment.YuanToFen(req.Amount)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe create payment: %w", err)
|
||||
}
|
||||
|
||||
// Collect all Stripe payment_method_types from the instance's configured sub-methods
|
||||
methods := resolveStripeMethodTypes(req.InstanceSubMethods)
|
||||
|
||||
pmTypes := make([]*string, len(methods))
|
||||
for i, m := range methods {
|
||||
pmTypes[i] = stripe.String(m)
|
||||
}
|
||||
|
||||
params := &stripe.PaymentIntentCreateParams{
|
||||
Amount: stripe.Int64(amountInCents),
|
||||
Currency: stripe.String(stripeCurrency),
|
||||
PaymentMethodTypes: pmTypes,
|
||||
Description: stripe.String(req.Subject),
|
||||
Metadata: map[string]string{"orderId": req.OrderID},
|
||||
}
|
||||
|
||||
// WeChat Pay requires payment_method_options with client type
|
||||
if hasStripeMethod(methods, "wechat_pay") {
|
||||
params.PaymentMethodOptions = &stripe.PaymentIntentCreatePaymentMethodOptionsParams{
|
||||
WeChatPay: &stripe.PaymentIntentCreatePaymentMethodOptionsWeChatPayParams{
|
||||
Client: stripe.String("web"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
params.SetIdempotencyKey(fmt.Sprintf("pi-%s", req.OrderID))
|
||||
params.Context = ctx
|
||||
|
||||
pi, err := s.sc.V1PaymentIntents.Create(ctx, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe create payment: %w", err)
|
||||
}
|
||||
|
||||
return &payment.CreatePaymentResponse{
|
||||
TradeNo: pi.ID,
|
||||
ClientSecret: pi.ClientSecret,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryOrder retrieves a PaymentIntent by ID.
|
||||
func (s *Stripe) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
|
||||
s.ensureInit()
|
||||
|
||||
pi, err := s.sc.V1PaymentIntents.Retrieve(ctx, tradeNo, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe query order: %w", err)
|
||||
}
|
||||
|
||||
status := payment.ProviderStatusPending
|
||||
switch pi.Status {
|
||||
case stripe.PaymentIntentStatusSucceeded:
|
||||
status = payment.ProviderStatusPaid
|
||||
case stripe.PaymentIntentStatusCanceled:
|
||||
status = payment.ProviderStatusFailed
|
||||
}
|
||||
|
||||
return &payment.QueryOrderResponse{
|
||||
TradeNo: pi.ID,
|
||||
Status: status,
|
||||
Amount: payment.FenToYuan(pi.Amount),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyNotification verifies a Stripe webhook event.
|
||||
func (s *Stripe) VerifyNotification(_ context.Context, rawBody string, headers map[string]string) (*payment.PaymentNotification, error) {
|
||||
s.ensureInit()
|
||||
|
||||
webhookSecret := s.config["webhookSecret"]
|
||||
if webhookSecret == "" {
|
||||
return nil, fmt.Errorf("stripe webhookSecret not configured")
|
||||
}
|
||||
|
||||
sig := headers["stripe-signature"]
|
||||
if sig == "" {
|
||||
return nil, fmt.Errorf("stripe notification missing stripe-signature header")
|
||||
}
|
||||
|
||||
event, err := webhook.ConstructEvent([]byte(rawBody), sig, webhookSecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe verify notification: %w", err)
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case stripeEventPaymentSuccess:
|
||||
return parseStripePaymentIntent(&event, payment.ProviderStatusSuccess, rawBody)
|
||||
case stripeEventPaymentFailed:
|
||||
return parseStripePaymentIntent(&event, payment.ProviderStatusFailed, rawBody)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func parseStripePaymentIntent(event *stripe.Event, status string, rawBody string) (*payment.PaymentNotification, error) {
|
||||
var pi stripe.PaymentIntent
|
||||
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
|
||||
return nil, fmt.Errorf("stripe parse payment_intent: %w", err)
|
||||
}
|
||||
return &payment.PaymentNotification{
|
||||
TradeNo: pi.ID,
|
||||
OrderID: pi.Metadata["orderId"],
|
||||
Amount: payment.FenToYuan(pi.Amount),
|
||||
Status: status,
|
||||
RawData: rawBody,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Refund creates a Stripe refund.
|
||||
func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
|
||||
s.ensureInit()
|
||||
|
||||
amountInCents, err := payment.YuanToFen(req.Amount)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe refund: %w", err)
|
||||
}
|
||||
|
||||
params := &stripe.RefundCreateParams{
|
||||
PaymentIntent: stripe.String(req.TradeNo),
|
||||
Amount: stripe.Int64(amountInCents),
|
||||
Reason: stripe.String(string(stripe.RefundReasonRequestedByCustomer)),
|
||||
}
|
||||
params.Context = ctx
|
||||
|
||||
r, err := s.sc.V1Refunds.Create(ctx, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stripe refund: %w", err)
|
||||
}
|
||||
|
||||
refundStatus := payment.ProviderStatusPending
|
||||
if r.Status == stripe.RefundStatusSucceeded {
|
||||
refundStatus = payment.ProviderStatusSuccess
|
||||
}
|
||||
|
||||
return &payment.RefundResponse{
|
||||
RefundID: r.ID,
|
||||
Status: refundStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveStripeMethodTypes converts instance supported_types (comma-separated)
|
||||
// into Stripe API payment_method_types. Falls back to ["card"] if empty.
|
||||
func resolveStripeMethodTypes(instanceSubMethods string) []string {
|
||||
if instanceSubMethods == "" {
|
||||
return []string{"card"}
|
||||
}
|
||||
var methods []string
|
||||
for _, t := range strings.Split(instanceSubMethods, ",") {
|
||||
t = strings.TrimSpace(t)
|
||||
if mapped, ok := stripePaymentMethodTypes[t]; ok {
|
||||
methods = append(methods, mapped...)
|
||||
}
|
||||
}
|
||||
if len(methods) == 0 {
|
||||
return []string{"card"}
|
||||
}
|
||||
return methods
|
||||
}
|
||||
|
||||
// hasStripeMethod checks if the given Stripe method list contains the target method.
|
||||
func hasStripeMethod(methods []string, target string) bool {
|
||||
for _, m := range methods {
|
||||
if m == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CancelPayment cancels a pending PaymentIntent.
|
||||
func (s *Stripe) CancelPayment(ctx context.Context, tradeNo string) error {
|
||||
s.ensureInit()
|
||||
|
||||
_, err := s.sc.V1PaymentIntents.Cancel(ctx, tradeNo, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stripe cancel payment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure interface compliance.
|
||||
var (
|
||||
_ payment.Provider = (*Stripe)(nil)
|
||||
_ payment.CancelableProvider = (*Stripe)(nil)
|
||||
)
|
||||
Reference in New Issue
Block a user