// Package payment implements WeChat Pay integration for subscription purchases.
package payment

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"strings"
	"time"

	dbent "github.com/Wei-Shaw/sub2api/ent"
	dbuser "github.com/Wei-Shaw/sub2api/ent/user"
	"github.com/Wei-Shaw/sub2api/internal/config"
	"github.com/Wei-Shaw/sub2api/internal/payment/wechat"
	infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
	"github.com/Wei-Shaw/sub2api/internal/service"
	"github.com/shopspring/decimal"
	"go.uber.org/zap"

	"entgo.io/ent/dialect"
)

// Payment order status constants.
const (
	StatusPending = "pending"
	StatusPaid    = "paid"
	StatusFailed  = "failed"
)

// Payment order business type constants.
const (
	BizTypeSubscription    = "subscription"
	BizTypeBalanceRecharge = "balance_recharge"
)

const (
	PaymentMethodWechat  = "wechat"
	PaymentMethodBalance = "balance"
	PaymentMethodPoints  = "points"
)

var (
	ErrPaymentPointsInsufficient  = infraerrors.Conflict("PAYMENT_POINTS_INSUFFICIENT", "积分余额不足")
	ErrPaymentBalanceInsufficient = infraerrors.Conflict("PAYMENT_BALANCE_INSUFFICIENT", "余额不足")
)

// PaymentOrderRepository defines the database operations required by the payment service.
type PaymentOrderRepository interface {
	Create(ctx context.Context, order *PaymentOrder) (*PaymentOrder, error)
	GetByID(ctx context.Context, id int64) (*PaymentOrder, error)
	GetByOutTradeNo(ctx context.Context, outTradeNo string) (*PaymentOrder, error)
	ListByUserID(ctx context.Context, userID int64, limit int) ([]PaymentOrder, error)
	UpdateStatus(ctx context.Context, id int64, status, wechatTransactionID string, notifyAt *time.Time) error
}

// PaymentOrder represents a payment order record.
type PaymentOrder struct {
	ID                  int64
	UserID              int64
	GroupID             int64
	PlanKey             string
	PlanVariantKey      string
	BillingMonths       int
	ValidityDays        int
	BizType             string
	PaymentMethod       string
	OutTradeNo          string
	AmountFen           int
	Status              string
	WechatTransactionID string
	NotifyAt            *time.Time
	CreatedAt           time.Time
	GroupName           string
}

// CreateOrderInput holds the parameters for creating a new payment order.
type CreateOrderInput struct {
	UserID         int64
	GroupID        int64
	PlanKey        string
	PlanVariantKey string
	BillingMonths  int
	ValidityDays   int
	BizType        string
	AmountFen      int
	Description    string
	PaymentMethod  string
}

// CreateOrderResult holds the result of a successful order creation.
type CreateOrderResult struct {
	OrderID   int64
	CodeURL   string
	AmountFen int
	ExpiresAt time.Time
}

// PaymentOrderSummary is the user-facing order history projection.
type PaymentOrderSummary struct {
	ID              int64      `json:"id"`
	BizType         string     `json:"biz_type"`
	PaymentMethod   string     `json:"payment_method,omitempty"`
	GroupID         int64      `json:"group_id"`
	GroupName       string     `json:"group_name,omitempty"`
	Title           string     `json:"title"`
	AmountFen       int        `json:"amount_fen"`
	Status          string     `json:"status"`
	OutTradeNo      string     `json:"out_trade_no"`
	ProviderTradeNo string     `json:"provider_trade_no,omitempty"`
	CreatedAt       time.Time  `json:"created_at"`
	NotifyAt        *time.Time `json:"notify_at,omitempty"`
}

type CreateSubscriptionPointsOrderInput struct {
	UserID         int64
	GroupID        int64
	PlanKey        string
	BillingMonths  int
	IdempotencyKey string
}

type CreateSubscriptionPointsOrderResult struct {
	OrderID       int64  `json:"order_id"`
	Status        string `json:"status"`
	AmountFen     int    `json:"amount_fen"`
	PointsCost    string `json:"points_cost"`
	PaymentMethod string `json:"payment_method"`
}

type CreateSubscriptionBalanceOrderInput struct {
	UserID         int64
	GroupID        int64
	PlanKey        string
	BillingMonths  int
	IdempotencyKey string
}

type CreateSubscriptionBalanceOrderResult struct {
	OrderID       int64   `json:"order_id"`
	Status        string  `json:"status"`
	AmountFen     int     `json:"amount_fen"`
	BalanceCost   string  `json:"balance_cost"`
	BalanceAfter  float64 `json:"balance_after"`
	PaymentMethod string  `json:"payment_method"`
}

// SettingGetter is a read-only interface for retrieving system settings.
type SettingGetter interface {
	GetString(ctx context.Context, key string) (string, error)
	GetBool(ctx context.Context, key string) (bool, error)
}

type InviteCommissionSettler interface {
	SettleForPaidOrder(ctx context.Context, order *service.InviteCommissionOrder) error
}

// Service handles payment business logic including order creation and notification processing.
type Service struct {
	repo                         PaymentOrderRepository
	wechatClient                 *wechat.Client
	groupRepo                    service.GroupRepository
	subscriptionService          *service.SubscriptionService
	userAssetRepo                service.UserAssetRepository
	redeemService                *service.RedeemService
	entClient                    *dbent.Client
	settingGetter                SettingGetter
	subscriptionTestPriceDivisor int
	subscriptionPlanPrices       map[string]int
	inviteCommissionSvc          InviteCommissionSettler
	inviteRetryWorker            *InviteCommissionRetryWorker
}

// NewService creates a new payment Service instance.
func NewService(
	repo PaymentOrderRepository,
	groupRepo service.GroupRepository,
	subscriptionService *service.SubscriptionService,
	userAssetRepo service.UserAssetRepository,
	redeemService *service.RedeemService,
	entClient *dbent.Client,
	settingGetter SettingGetter,
	subscriptionTestPriceDivisor int,
	subscriptionPlanPrices map[string]int,
) *Service {
	return &Service{
		repo:                         repo,
		groupRepo:                    groupRepo,
		subscriptionService:          subscriptionService,
		userAssetRepo:                userAssetRepo,
		redeemService:                redeemService,
		entClient:                    entClient,
		settingGetter:                settingGetter,
		subscriptionTestPriceDivisor: normalizeSubscriptionTestPriceDivisor(subscriptionTestPriceDivisor),
		subscriptionPlanPrices:       normalizeSubscriptionPlanPrices(subscriptionPlanPrices),
	}
}

func (s *Service) SetInviteCommissionService(inviteCommissionSvc InviteCommissionSettler) {
	if s == nil {
		return
	}
	s.inviteCommissionSvc = inviteCommissionSvc
}

func (s *Service) SetInviteCommissionRetryWorker(worker *InviteCommissionRetryWorker) {
	if s == nil {
		return
	}
	s.inviteRetryWorker = worker
	if s.inviteRetryWorker != nil {
		s.inviteRetryWorker.Start()
	}
}

func (s *Service) Stop() {
	if s == nil || s.inviteRetryWorker == nil {
		return
	}
	s.inviteRetryWorker.Stop()
}

// getWechatClient returns a lazily-initialized WeChat Pay client.
// Returns an error if WeChat Pay is not enabled or not fully configured.
func (s *Service) getWechatClient(ctx context.Context) (*wechat.Client, error) {
	if s.wechatClient != nil {
		return s.wechatClient, nil
	}

	enabled, err := s.settingGetter.GetBool(ctx, "wechat_pay_enabled")
	if err != nil || !enabled {
		return nil, errors.New("wechat pay not enabled")
	}

	mchID, _ := s.settingGetter.GetString(ctx, "wechat_pay_mch_id")
	appID, _ := s.settingGetter.GetString(ctx, "wechat_pay_app_id")
	apiV3Key, _ := s.settingGetter.GetString(ctx, "wechat_pay_api_v3_key")
	serialNo, _ := s.settingGetter.GetString(ctx, "wechat_pay_serial_no")
	privateKey, _ := s.settingGetter.GetString(ctx, "wechat_pay_private_key")

	client, err := wechat.NewClient(mchID, appID, serialNo, privateKey, apiV3Key)
	if err != nil {
		return nil, err
	}
	s.wechatClient = client
	return client, nil
}

// CreateWechatNativeOrder creates a WeChat Native (QR code) payment order.
// It persists the order to the database first, then calls the WeChat Pay API.
func (s *Service) CreateWechatNativeOrder(ctx context.Context, input *CreateOrderInput) (*CreateOrderResult, error) {
	log := billingLog(ctx, "payment.order.create")
	if input == nil {
		err := infraerrors.BadRequest("PAYMENT_CREATE_INPUT_REQUIRED", "create order input is required")
		log.Error("payment.order.create.invalid_input", zap.Error(err))
		return nil, err
	}

	input.BizType = normalizeBizType(input.BizType)
	if input.BizType == "" {
		err := infraerrors.BadRequest("PAYMENT_BIZ_TYPE_UNSUPPORTED", "unsupported payment biz type")
		log.Error("payment.order.create.invalid_biz_type", zap.String("biz_type", input.BizType), zap.Error(err))
		return nil, err
	}
	if strings.TrimSpace(input.Description) == "" {
		input.Description = defaultDescriptionForBizType(input.BizType)
	}

	log.Info("payment.order.create.requested",
		zap.Int64("user_id", input.UserID),
		zap.Int64("group_id", input.GroupID),
		zap.String("plan_key", normalizeSubscriptionPlanKey(input.PlanKey)),
		zap.Int("billing_months", normalizeSubscriptionBillingMonths(input.BillingMonths)),
		zap.String("biz_type", input.BizType),
		zap.Int("amount_fen", input.AmountFen),
		zap.String("description", input.Description),
	)

	if input.BizType == BizTypeSubscription {
		rawPlanKey := strings.TrimSpace(input.PlanKey)
		if rawPlanKey == "" {
			err := infraerrors.BadRequest(
				"PAYMENT_PLAN_KEY_REQUIRED",
				"plan_key is required for subscription orders",
			)
			log.Error("payment.order.create.subscription_plan_key_required",
				zap.Int64("user_id", input.UserID),
				zap.Int64("group_id", input.GroupID),
				zap.Error(err),
			)
			return nil, err
		}
		normalizedPlanKey := normalizeSubscriptionPlanKey(rawPlanKey)
		if !isPurchasableSubscriptionPlanKey(normalizedPlanKey) {
			err := infraerrors.BadRequest(
				"PAYMENT_PLAN_KEY_DEPRECATED",
				fmt.Sprintf("subscription plan_key is no longer available for purchase: %s", normalizedPlanKey),
			)
			log.Error("payment.order.create.subscription_plan_key_deprecated",
				zap.Int64("user_id", input.UserID),
				zap.Int64("group_id", input.GroupID),
				zap.String("plan_key", normalizedPlanKey),
				zap.Error(err),
			)
			return nil, err
		}

		group, err := s.resolveSubscriptionGroup(ctx, input.GroupID, input.PlanKey)
		if err != nil {
			log.Error("payment.order.create.resolve_subscription_group_failed",
				zap.Int64("user_id", input.UserID),
				zap.Int64("group_id", input.GroupID),
				zap.String("plan_key", normalizedPlanKey),
				zap.Error(err),
			)
			return nil, err
		}

		if !groupMatchesSubscriptionPlanKey(group, normalizedPlanKey) {
			err := infraerrors.BadRequest(
				"PAYMENT_PLAN_GROUP_MISMATCH",
				fmt.Sprintf("group_id=%d does not match plan_key=%s", group.ID, normalizedPlanKey),
			)
			log.Error("payment.order.create.subscription_group_plan_mismatch",
				zap.Int64("user_id", input.UserID),
				zap.Int64("group_id", group.ID),
				zap.String("group_name", group.Name),
				zap.String("plan_key", normalizedPlanKey),
				zap.Error(err),
			)
			return nil, err
		}

		spec, err := s.buildSubscriptionPurchaseSpec(normalizedPlanKey, input.BillingMonths)
		if err != nil {
			log.Error("payment.order.create.subscription_purchase_spec_failed",
				zap.Int64("user_id", input.UserID),
				zap.Int64("group_id", group.ID),
				zap.String("plan_key", normalizedPlanKey),
				zap.Int("billing_months", normalizeSubscriptionBillingMonths(input.BillingMonths)),
				zap.Error(err),
			)
			return nil, err
		}
		requestedAmountFen := input.AmountFen
		input.PlanKey = spec.PlanKey
		input.PlanVariantKey = spec.PlanVariantKey
		input.BillingMonths = spec.BillingMonths
		input.ValidityDays = spec.ValidityDays
		input.AmountFen = spec.ExpectedAmount
		log.Info("payment.order.create.subscription_amount_overridden_by_server",
			zap.Int64("user_id", input.UserID),
			zap.Int64("group_id", group.ID),
			zap.String("plan_key", spec.PlanKey),
			zap.String("plan_variant_key", spec.PlanVariantKey),
			zap.Int("billing_months", spec.BillingMonths),
			zap.Int("base_amount_fen", spec.BaseAmountFen),
			zap.Int("requested_amount_fen", requestedAmountFen),
			zap.Int("resolved_amount_fen", spec.ExpectedAmount),
			zap.Int("validity_days", spec.ValidityDays),
			zap.Int("subscription_test_price_divisor", s.subscriptionTestPriceDivisor),
		)

		input.GroupID = group.ID
		log.Info("payment.order.create.subscription_group_resolved",
			zap.Int64("user_id", input.UserID),
			zap.Int64("group_id", group.ID),
			zap.String("group_name", group.Name),
			zap.Int("default_validity_days", group.DefaultValidityDays),
			zap.String("plan_key", normalizeSubscriptionPlanKey(input.PlanKey)),
			zap.String("plan_variant_key", strings.TrimSpace(input.PlanVariantKey)),
			zap.Int("billing_months", input.BillingMonths),
			zap.Int("validity_days", input.ValidityDays),
		)
	}
	if input.BizType == BizTypeBalanceRecharge && input.AmountFen <= 0 {
		err := infraerrors.BadRequest("PAYMENT_AMOUNT_INVALID", "amount_fen must be greater than 0")
		log.Error("payment.order.create.invalid_amount",
			zap.String("biz_type", input.BizType),
			zap.Int("amount_fen", input.AmountFen),
			zap.Error(err),
		)
		return nil, err
	}

	// Prefer Z-Pay aggregation gateway when enabled. This keeps the
	// frontend contract unchanged while allowing the operator to switch
	// payment providers via configuration.
	if zcfg, err := s.getZPayConfig(ctx); err == nil && zcfg != nil && zcfg.Enabled {
		log.Info("payment.order.create.provider_selected",
			zap.String("provider", "zpay"),
			zap.String("biz_type", input.BizType),
			zap.String("gateway", zcfg.Gateway),
			zap.String("notify_url", zcfg.NotifyURL),
			zap.String("return_url", zcfg.ReturnURL),
		)
		return s.createZPayOrder(ctx, input)
	}
	log.Info("payment.order.create.provider_selected",
		zap.String("provider", "wechat"),
		zap.String("biz_type", input.BizType),
	)

	client, err := s.getWechatClient(ctx)
	if err != nil {
		log.Error("payment.order.create.wechat_client_failed", zap.Error(err))
		return nil, err
	}

	notifyURL, _ := s.settingGetter.GetString(ctx, service.SettingKeyWechatPayNotifyURL)

	outTradeNo := generateTradeNo()
	dbOrder, err := s.repo.Create(ctx, &PaymentOrder{
		UserID:         input.UserID,
		GroupID:        input.GroupID,
		PlanKey:        normalizeSubscriptionPlanKey(input.PlanKey),
		PlanVariantKey: strings.TrimSpace(input.PlanVariantKey),
		BillingMonths:  input.BillingMonths,
		ValidityDays:   input.ValidityDays,
		BizType:        input.BizType,
		PaymentMethod:  PaymentMethodWechat,
		OutTradeNo:     outTradeNo,
		AmountFen:      input.AmountFen,
		Status:         StatusPending,
	})
	if err != nil {
		log.Error("payment.order.create.persist_failed",
			zap.String("provider", "wechat"),
			zap.String("biz_type", input.BizType),
			zap.Error(err),
		)
		return nil, fmt.Errorf("create payment order: %w", err)
	}
	log.Info("payment.order.create.persisted",
		append(paymentOrderLogFields(dbOrder), zap.String("provider", "wechat"))...,
	)

	resp, err := client.CreateNativeOrder(ctx, &wechat.CreateNativeOrderRequest{
		Description: input.Description,
		OutTradeNo:  outTradeNo,
		NotifyURL:   notifyURL,
		Amount: wechat.Amount{
			Total:    input.AmountFen,
			Currency: "CNY",
		},
	})
	if err != nil {
		// Mark order as failed so it is not left in a dangling pending state.
		_ = s.repo.UpdateStatus(ctx, dbOrder.ID, StatusFailed, "", nil)
		log.Error("payment.order.create.wechat_upstream_failed",
			append(paymentOrderLogFields(dbOrder), zap.Error(err))...,
		)
		return nil, fmt.Errorf("wechat native order: %w", err)
	}

	expiresAt := time.Now().Add(5 * time.Minute)
	log.Info("payment.order.create.ready",
		append(
			append(paymentOrderLogFields(dbOrder), zap.String("provider", "wechat")),
			zap.Time("expires_at", expiresAt),
		)...,
	)

	return &CreateOrderResult{
		OrderID:   dbOrder.ID,
		CodeURL:   resp.CodeURL,
		AmountFen: input.AmountFen,
		ExpiresAt: expiresAt,
	}, nil
}

// CreateSubscriptionPointsOrder 使用积分直接兑换订阅。
//
// 整条链路必须在同一个事务里完成：
// 订单创建 -> 锁定积分资产 -> 校验余额 -> 扣减积分 -> 写审计 -> 发放订阅 -> 订单置 paid。
func (s *Service) CreateSubscriptionPointsOrder(ctx context.Context, input *CreateSubscriptionPointsOrderInput) (*CreateSubscriptionPointsOrderResult, error) {
	log := billingLog(ctx, "payment.order.points")
	if input == nil {
		err := infraerrors.BadRequest("PAYMENT_POINTS_INPUT_REQUIRED", "points payment input is required")
		log.Error("payment.order.points.invalid_input", zap.Error(err))
		return nil, err
	}
	if input.UserID <= 0 {
		err := infraerrors.BadRequest("PAYMENT_USER_ID_INVALID", "user_id must be greater than 0")
		log.Error("payment.order.points.invalid_user_id", zap.Int64("user_id", input.UserID), zap.Error(err))
		return nil, err
	}
	if s.entClient == nil {
		err := infraerrors.InternalServer("PAYMENT_ENT_CLIENT_NOT_CONFIGURED", "payment ent client not configured")
		log.Error("payment.order.points.ent_client_missing", zap.Error(err))
		return nil, err
	}
	if s.userAssetRepo == nil {
		err := infraerrors.InternalServer("PAYMENT_USER_ASSET_REPO_NOT_CONFIGURED", "payment user asset repository not configured")
		log.Error("payment.order.points.asset_repo_missing", zap.Error(err))
		return nil, err
	}

	normalizedPlanKey := normalizeSubscriptionPlanKey(input.PlanKey)
	if normalizedPlanKey == "" {
		err := infraerrors.BadRequest("PAYMENT_PLAN_KEY_REQUIRED", "plan_key is required for subscription points order")
		log.Error("payment.order.points.plan_key_missing", zap.Int64("user_id", input.UserID), zap.Error(err))
		return nil, err
	}
	if !isPurchasableSubscriptionPlanKey(normalizedPlanKey) {
		err := infraerrors.BadRequest("PAYMENT_PLAN_KEY_DEPRECATED", fmt.Sprintf("subscription plan_key is no longer available for purchase: %s", normalizedPlanKey))
		log.Error("payment.order.points.plan_key_invalid",
			zap.Int64("user_id", input.UserID),
			zap.String("plan_key", normalizedPlanKey),
			zap.Error(err),
		)
		return nil, err
	}

	group, err := s.resolveSubscriptionGroup(ctx, input.GroupID, normalizedPlanKey)
	if err != nil {
		log.Error("payment.order.points.resolve_group_failed",
			zap.Int64("user_id", input.UserID),
			zap.Int64("group_id", input.GroupID),
			zap.String("plan_key", normalizedPlanKey),
			zap.Error(err),
		)
		return nil, err
	}
	log.Info("payment.order.balance.resolve_group_succeeded",
		zap.Int64("user_id", input.UserID),
		zap.Int64("group_id", group.ID),
		zap.String("group_name", group.Name),
		zap.String("plan_key", normalizedPlanKey),
	)
	if !groupMatchesSubscriptionPlanKey(group, normalizedPlanKey) {
		err := infraerrors.BadRequest("PAYMENT_PLAN_GROUP_MISMATCH", fmt.Sprintf("group_id=%d does not match plan_key=%s", group.ID, normalizedPlanKey))
		log.Error("payment.order.points.group_plan_mismatch",
			zap.Int64("user_id", input.UserID),
			zap.Int64("group_id", group.ID),
			zap.String("group_name", group.Name),
			zap.String("plan_key", normalizedPlanKey),
			zap.Error(err),
		)
		return nil, err
	}

	spec, err := s.buildSubscriptionPurchaseSpec(normalizedPlanKey, input.BillingMonths)
	if err != nil {
		log.Error("payment.order.points.build_spec_failed",
			zap.Int64("user_id", input.UserID),
			zap.Int64("group_id", group.ID),
			zap.String("plan_key", normalizedPlanKey),
			zap.Int("billing_months", input.BillingMonths),
			zap.Error(err),
		)
		return nil, err
	}
	pointsCost := pointsCostFromAmountFen(spec.ExpectedAmount)
	outTradeNo := generateTradeNo()

	tx, err := s.entClient.Tx(ctx)
	if err != nil {
		log.Error("payment.order.points.begin_tx_failed", zap.Error(err))
		return nil, fmt.Errorf("begin payment points transaction: %w", err)
	}
	committed := false
	defer func() {
		if !committed {
			_ = tx.Rollback()
		}
	}()
	txCtx := dbent.NewTxContext(ctx, tx)

	asset, err := s.userAssetRepo.EnsurePointsAssetForUpdate(txCtx, input.UserID)
	if err != nil {
		log.Error("payment.order.points.lock_asset_failed",
			zap.Int64("user_id", input.UserID),
			zap.Error(err),
		)
		return nil, err
	}

	availableBefore, err := decimal.NewFromString(strings.TrimSpace(asset.AvailableAmount))
	if err != nil {
		return nil, fmt.Errorf("parse points available amount: %w", err)
	}
	totalSpentBefore, err := decimal.NewFromString(strings.TrimSpace(asset.TotalSpent))
	if err != nil {
		return nil, fmt.Errorf("parse points total_spent: %w", err)
	}
	if availableBefore.LessThan(pointsCost) {
		log.Warn("payment.order.points.balance_insufficient",
			zap.Int64("user_id", input.UserID),
			zap.String("plan_key", spec.PlanKey),
			zap.String("available_points", availableBefore.StringFixed(8)),
			zap.String("required_points", pointsCost.StringFixed(8)),
		)
		return nil, ErrPaymentPointsInsufficient
	}

	order, err := s.repo.Create(txCtx, &PaymentOrder{
		UserID:         input.UserID,
		GroupID:        group.ID,
		PlanKey:        spec.PlanKey,
		PlanVariantKey: strings.TrimSpace(spec.PlanVariantKey),
		BillingMonths:  spec.BillingMonths,
		ValidityDays:   spec.ValidityDays,
		BizType:        BizTypeSubscription,
		PaymentMethod:  PaymentMethodPoints,
		OutTradeNo:     outTradeNo,
		AmountFen:      spec.ExpectedAmount,
		Status:         StatusPending,
	})
	if err != nil {
		log.Error("payment.order.points.create_order_failed",
			zap.Int64("user_id", input.UserID),
			zap.String("plan_key", spec.PlanKey),
			zap.Error(err),
		)
		return nil, fmt.Errorf("create points payment order: %w", err)
	}

	availableAfter := availableBefore.Sub(pointsCost)
	totalSpentAfter := totalSpentBefore.Add(pointsCost)
	updatedAsset, err := s.userAssetRepo.UpdateAmountsOptimistic(txCtx, &service.UpdateUserAssetAmountsInput{
		UserAssetID:     asset.ID,
		ExpectedVersion: asset.Version,
		AvailableAmount: pointsToStorageString(availableAfter),
		FrozenAmount:    asset.FrozenAmount,
		PendingAmount:   asset.PendingAmount,
		TotalEarned:     asset.TotalEarned,
		TotalSpent:      pointsToStorageString(totalSpentAfter),
	})
	if err != nil {
		log.Error("payment.order.points.update_asset_failed",
			zap.Int64("user_id", input.UserID),
			zap.String("plan_key", spec.PlanKey),
			zap.Error(err),
		)
		return nil, err
	}

	changeID := service.NewStringID()
	changeKey := buildSubscriptionPointsAssetChangeKey(input.UserID, input.IdempotencyKey, order.OutTradeNo)
	if _, err := s.userAssetRepo.CreateChange(txCtx, &service.CreateUserAssetChangeInput{
		ID:                   changeID,
		UserAssetID:          updatedAsset.ID,
		UserID:               input.UserID,
		AssetCategory:        updatedAsset.AssetCategory,
		AssetCode:            updatedAsset.AssetCode,
		AssetChainType:       updatedAsset.AssetChainType,
		AssetNetwork:         updatedAsset.AssetNetwork,
		AssetChainRef:        updatedAsset.AssetChainRef,
		AssetContractAddress: updatedAsset.AssetContractAddress,
		AssetDecimals:        updatedAsset.AssetDecimals,
		Direction:            service.AssetDirectionDebit,
		AvailableDelta:       pointsToStorageString(pointsCost.Neg()),
		AvailableBefore:      pointsToStorageString(availableBefore),
		AvailableAfter:       pointsToStorageString(availableAfter),
		FrozenDelta:          pointsToStorageString(decimal.Zero),
		FrozenBefore:         updatedAsset.FrozenAmount,
		FrozenAfter:          updatedAsset.FrozenAmount,
		BizType:              "subscription_points_purchase",
		BizRefType:           "payment_order",
		BizRefID:             fmt.Sprintf("%d", order.ID),
		IdempotencyKey:       changeKey,
		Remark:               fmt.Sprintf("积分兑换订阅：%s", strings.TrimSpace(group.Name)),
		Extra: map[string]any{
			"payment_order_id": order.ID,
			"out_trade_no":     order.OutTradeNo,
			"plan_key":         spec.PlanKey,
			"plan_variant_key": spec.PlanVariantKey,
			"group_id":         group.ID,
			"group_name":       group.Name,
			"billing_months":   spec.BillingMonths,
			"validity_days":    spec.ValidityDays,
			"payment_method":   PaymentMethodPoints,
		},
		OccurredAt: pointerTime(time.Now()),
	}); err != nil {
		log.Error("payment.order.points.create_asset_change_failed",
			zap.Int64("user_id", input.UserID),
			zap.Int64("order_id", order.ID),
			zap.Error(err),
		)
		return nil, err
	}

	note := fmt.Sprintf("积分兑换订阅 order_id=%d out_trade_no=%s points=%s", order.ID, order.OutTradeNo, pointsCost.StringFixed(8))
	if _, _, err := s.subscriptionService.AssignOrExtendSubscription(txCtx, &service.AssignSubscriptionInput{
		UserID:       input.UserID,
		GroupID:      group.ID,
		ValidityDays: spec.ValidityDays,
		Notes:        note,
	}); err != nil {
		log.Error("payment.order.points.assign_subscription_failed",
			zap.Int64("user_id", input.UserID),
			zap.Int64("order_id", order.ID),
			zap.Error(err),
		)
		return nil, err
	}

	now := time.Now()
	if err := s.repo.UpdateStatus(txCtx, order.ID, StatusPaid, "", &now); err != nil {
		log.Error("payment.order.points.update_order_status_failed",
			zap.Int64("user_id", input.UserID),
			zap.Int64("order_id", order.ID),
			zap.Error(err),
		)
		return nil, err
	}

	if err := tx.Commit(); err != nil {
		log.Error("payment.order.points.commit_failed",
			zap.Int64("user_id", input.UserID),
			zap.Int64("order_id", order.ID),
			zap.Error(err),
		)
		return nil, fmt.Errorf("commit points payment transaction: %w", err)
	}
	committed = true
	order.Status = StatusPaid
	order.NotifyAt = &now

	s.settleInviteCommissionAfterFulfillment(ctx, order, "points_purchase")

	log.Info("payment.order.points.succeeded",
		zap.Int64("user_id", input.UserID),
		zap.Int64("order_id", order.ID),
		zap.Int64("group_id", group.ID),
		zap.String("group_name", group.Name),
		zap.String("plan_key", spec.PlanKey),
		zap.String("points_cost", pointsCost.StringFixed(8)),
		zap.Int("amount_fen", spec.ExpectedAmount),
	)

	return &CreateSubscriptionPointsOrderResult{
		OrderID:       order.ID,
		Status:        StatusPaid,
		AmountFen:     spec.ExpectedAmount,
		PointsCost:    pointsCost.StringFixed(8),
		PaymentMethod: PaymentMethodPoints,
	}, nil
}

// CreateSubscriptionBalanceOrder 使用余额直接购买订阅。
//
// 整条链路必须在同一个事务里完成：
// 锁定用户余额 -> 校验余额 -> 创建订单 -> 扣减余额 -> 写余额审计 -> 发放订阅 -> 订单置 paid。
func (s *Service) CreateSubscriptionBalanceOrder(ctx context.Context, input *CreateSubscriptionBalanceOrderInput) (*CreateSubscriptionBalanceOrderResult, error) {
	log := billingLog(ctx, "payment.order.balance")
	if input == nil {
		err := infraerrors.BadRequest("PAYMENT_BALANCE_INPUT_REQUIRED", "balance payment input is required")
		log.Error("payment.order.balance.invalid_input", zap.Error(err))
		return nil, err
	}
	if input.UserID <= 0 {
		err := infraerrors.BadRequest("PAYMENT_USER_ID_INVALID", "user_id must be greater than 0")
		log.Error("payment.order.balance.invalid_user_id", zap.Int64("user_id", input.UserID), zap.Error(err))
		return nil, err
	}
	if s.entClient == nil {
		err := infraerrors.InternalServer("PAYMENT_ENT_CLIENT_NOT_CONFIGURED", "payment ent client not configured")
		log.Error("payment.order.balance.ent_client_missing", zap.Error(err))
		return nil, err
	}
	if s.subscriptionService == nil {
		err := infraerrors.InternalServer("PAYMENT_SUBSCRIPTION_SERVICE_NOT_CONFIGURED", "subscription service not configured")
		log.Error("payment.order.balance.subscription_service_missing", zap.Error(err))
		return nil, err
	}

	normalizedPlanKey := normalizeSubscriptionPlanKey(input.PlanKey)
	if normalizedPlanKey == "" {
		err := infraerrors.BadRequest("PAYMENT_PLAN_KEY_REQUIRED", "plan_key is required for subscription balance order")
		log.Error("payment.order.balance.plan_key_missing", zap.Int64("user_id", input.UserID), zap.Error(err))
		return nil, err
	}
	if !isPurchasableSubscriptionPlanKey(normalizedPlanKey) {
		err := infraerrors.BadRequest("PAYMENT_PLAN_KEY_DEPRECATED", fmt.Sprintf("subscription plan_key is no longer available for purchase: %s", normalizedPlanKey))
		log.Error("payment.order.balance.plan_key_invalid",
			zap.Int64("user_id", input.UserID),
			zap.String("plan_key", normalizedPlanKey),
			zap.Error(err),
		)
		return nil, err
	}

	group, err := s.resolveSubscriptionGroup(ctx, input.GroupID, normalizedPlanKey)
	if err != nil {
		log.Error("payment.order.balance.resolve_group_failed",
			zap.Int64("user_id", input.UserID),
			zap.Int64("group_id", input.GroupID),
			zap.String("plan_key", normalizedPlanKey),
			zap.Error(err),
		)
		return nil, err
	}
	if !groupMatchesSubscriptionPlanKey(group, normalizedPlanKey) {
		err := infraerrors.BadRequest("PAYMENT_PLAN_GROUP_MISMATCH", fmt.Sprintf("group_id=%d does not match plan_key=%s", group.ID, normalizedPlanKey))
		log.Error("payment.order.balance.group_plan_mismatch",
			zap.Int64("user_id", input.UserID),
			zap.Int64("group_id", group.ID),
			zap.String("group_name", group.Name),
			zap.String("plan_key", normalizedPlanKey),
			zap.Error(err),
		)
		return nil, err
	}

	spec, err := s.buildSubscriptionPurchaseSpec(normalizedPlanKey, input.BillingMonths)
	if err != nil {
		log.Error("payment.order.balance.build_spec_failed",
			zap.Int64("user_id", input.UserID),
			zap.Int64("group_id", group.ID),
			zap.String("plan_key", normalizedPlanKey),
			zap.Int("billing_months", input.BillingMonths),
			zap.Error(err),
		)
		return nil, err
	}
	log.Info("payment.order.balance.build_spec_succeeded",
		zap.Int64("user_id", input.UserID),
		zap.Int64("group_id", group.ID),
		zap.String("plan_key", spec.PlanKey),
		zap.String("plan_variant_key", strings.TrimSpace(spec.PlanVariantKey)),
		zap.Int("billing_months", spec.BillingMonths),
		zap.Int("validity_days", spec.ValidityDays),
		zap.Int("amount_fen", spec.ExpectedAmount),
	)
	balanceCost := balanceCostFromAmountFen(spec.ExpectedAmount)
	outTradeNo := generateTradeNo()
	log.Info("payment.order.balance.trade_no_generated",
		zap.Int64("user_id", input.UserID),
		zap.String("out_trade_no", outTradeNo),
		zap.String("plan_key", spec.PlanKey),
	)

	tx, err := s.entClient.Tx(ctx)
	if err != nil {
		log.Error("payment.order.balance.begin_tx_failed", zap.Error(err))
		return nil, fmt.Errorf("begin payment balance transaction: %w", err)
	}
	log.Info("payment.order.balance.tx_started",
		zap.Int64("user_id", input.UserID),
		zap.String("plan_key", spec.PlanKey),
		zap.Int64("group_id", group.ID),
	)
	committed := false
	defer func() {
		if !committed {
			log.Warn("payment.order.balance.rollback_started",
				zap.Int64("user_id", input.UserID),
				zap.String("out_trade_no", outTradeNo),
			)
			if rollbackErr := tx.Rollback(); rollbackErr != nil {
				log.Error("payment.order.balance.rollback_failed",
					zap.Int64("user_id", input.UserID),
					zap.String("out_trade_no", outTradeNo),
					zap.Error(rollbackErr),
				)
				return
			}
			log.Warn("payment.order.balance.rollback_succeeded",
				zap.Int64("user_id", input.UserID),
				zap.String("out_trade_no", outTradeNo),
			)
		}
	}()
	txCtx := dbent.NewTxContext(ctx, tx)

	log.Info("payment.order.balance.lock_user_started",
		zap.Int64("user_id", input.UserID),
	)
	lockedUser, err := s.lockUserForUpdate(txCtx, tx, input.UserID)
	if err != nil {
		log.Error("payment.order.balance.lock_user_failed",
			zap.Int64("user_id", input.UserID),
			zap.Error(err),
		)
		return nil, err
	}
	log.Info("payment.order.balance.lock_user_succeeded",
		zap.Int64("user_id", input.UserID),
		zap.Float64("balance_before", lockedUser.Balance),
	)

	balanceBefore := decimal.NewFromFloat(lockedUser.Balance)
	if balanceBefore.LessThan(balanceCost) {
		log.Warn("payment.order.balance.balance_insufficient",
			zap.Int64("user_id", input.UserID),
			zap.String("plan_key", spec.PlanKey),
			zap.String("available_balance", balanceBefore.StringFixed(8)),
			zap.String("required_balance", balanceCost.StringFixed(8)),
		)
		return nil, ErrPaymentBalanceInsufficient
	}

	log.Info("payment.order.balance.create_order_started",
		zap.Int64("user_id", input.UserID),
		zap.Int64("group_id", group.ID),
		zap.String("plan_key", spec.PlanKey),
		zap.Int("amount_fen", spec.ExpectedAmount),
	)
	order, err := s.repo.Create(txCtx, &PaymentOrder{
		UserID:         input.UserID,
		GroupID:        group.ID,
		PlanKey:        spec.PlanKey,
		PlanVariantKey: strings.TrimSpace(spec.PlanVariantKey),
		BillingMonths:  spec.BillingMonths,
		ValidityDays:   spec.ValidityDays,
		BizType:        BizTypeSubscription,
		PaymentMethod:  PaymentMethodBalance,
		OutTradeNo:     outTradeNo,
		AmountFen:      spec.ExpectedAmount,
		Status:         StatusPending,
	})
	if err != nil {
		log.Error("payment.order.balance.create_order_failed",
			zap.Int64("user_id", input.UserID),
			zap.String("plan_key", spec.PlanKey),
			zap.Error(err),
		)
		return nil, fmt.Errorf("create balance payment order: %w", err)
	}
	log.Info("payment.order.balance.create_order_succeeded",
		zap.Int64("user_id", input.UserID),
		zap.Int64("order_id", order.ID),
		zap.String("out_trade_no", order.OutTradeNo),
		zap.String("status", order.Status),
		zap.String("payment_method", order.PaymentMethod),
	)

	balanceAfter := balanceBefore.Sub(balanceCost)
	log.Info("payment.order.balance.update_user_balance_started",
		zap.Int64("user_id", input.UserID),
		zap.Int64("order_id", order.ID),
		zap.String("balance_cost", balanceCost.StringFixed(8)),
		zap.String("balance_before", balanceBefore.StringFixed(8)),
		zap.String("balance_after", balanceAfter.StringFixed(8)),
	)
	if _, err := tx.Client().User.Update().
		Where(dbuser.IDEQ(input.UserID)).
		SetBalance(balanceAfter.InexactFloat64()).
		Save(txCtx); err != nil {
		log.Error("payment.order.balance.update_user_balance_failed",
			zap.Int64("user_id", input.UserID),
			zap.Int64("order_id", order.ID),
			zap.Error(err),
		)
		if dbent.IsNotFound(err) {
			return nil, service.ErrUserNotFound
		}
		return nil, err
	}
	log.Info("payment.order.balance.update_user_balance_succeeded",
		zap.Int64("user_id", input.UserID),
		zap.Int64("order_id", order.ID),
		zap.String("balance_after", balanceAfter.StringFixed(8)),
	)

	now := time.Now()
	log.Info("payment.order.balance.create_balance_audit_started",
		zap.Int64("user_id", input.UserID),
		zap.Int64("order_id", order.ID),
		zap.String("audit_code", balanceAuditCode(order.OutTradeNo, "BAL")),
	)
	if _, err := tx.Client().RedeemCode.Create().
		SetCode(balanceAuditCode(order.OutTradeNo, "BAL")).
		SetType(service.RedeemTypeBalance).
		SetValue(balanceCost.Neg().InexactFloat64()).
		SetStatus(service.StatusUsed).
		SetUsedBy(input.UserID).
		SetUsedAt(now).
		SetNotes(fmt.Sprintf(
			"余额购买订阅 order_id=%d out_trade_no=%s plan_key=%s balance_before=%s balance_after=%s",
			order.ID,
			order.OutTradeNo,
			spec.PlanKey,
			balanceBefore.StringFixed(8),
			balanceAfter.StringFixed(8),
		)).
		Save(txCtx); err != nil {
		log.Error("payment.order.balance.create_balance_audit_failed",
			zap.Int64("user_id", input.UserID),
			zap.Int64("order_id", order.ID),
			zap.Error(err),
		)
		return nil, fmt.Errorf("create balance audit: %w", err)
	}
	log.Info("payment.order.balance.create_balance_audit_succeeded",
		zap.Int64("user_id", input.UserID),
		zap.Int64("order_id", order.ID),
	)

	note := fmt.Sprintf("余额购买订阅 order_id=%d out_trade_no=%s balance=%s", order.ID, order.OutTradeNo, balanceCost.StringFixed(8))
	log.Info("payment.order.balance.assign_subscription_started",
		zap.Int64("user_id", input.UserID),
		zap.Int64("order_id", order.ID),
		zap.Int64("group_id", group.ID),
		zap.Int("validity_days", spec.ValidityDays),
	)
	if _, _, err := s.subscriptionService.AssignOrExtendSubscription(txCtx, &service.AssignSubscriptionInput{
		UserID:       input.UserID,
		GroupID:      group.ID,
		ValidityDays: spec.ValidityDays,
		Notes:        note,
	}); err != nil {
		log.Error("payment.order.balance.assign_subscription_failed",
			zap.Int64("user_id", input.UserID),
			zap.Int64("order_id", order.ID),
			zap.Error(err),
		)
		return nil, err
	}
	log.Info("payment.order.balance.assign_subscription_succeeded",
		zap.Int64("user_id", input.UserID),
		zap.Int64("order_id", order.ID),
		zap.Int64("group_id", group.ID),
		zap.Int("validity_days", spec.ValidityDays),
	)

	log.Info("payment.order.balance.update_order_status_started",
		zap.Int64("user_id", input.UserID),
		zap.Int64("order_id", order.ID),
	)
	if err := s.repo.UpdateStatus(txCtx, order.ID, StatusPaid, "", &now); err != nil {
		log.Error("payment.order.balance.update_order_status_failed",
			zap.Int64("user_id", input.UserID),
			zap.Int64("order_id", order.ID),
			zap.Error(err),
		)
		return nil, err
	}
	log.Info("payment.order.balance.update_order_status_succeeded",
		zap.Int64("user_id", input.UserID),
		zap.Int64("order_id", order.ID),
		zap.String("status", StatusPaid),
	)

	log.Info("payment.order.balance.commit_started",
		zap.Int64("user_id", input.UserID),
		zap.Int64("order_id", order.ID),
	)
	if err := tx.Commit(); err != nil {
		log.Error("payment.order.balance.commit_failed",
			zap.Int64("user_id", input.UserID),
			zap.Int64("order_id", order.ID),
			zap.Error(err),
		)
		return nil, fmt.Errorf("commit balance payment transaction: %w", err)
	}
	committed = true
	order.Status = StatusPaid
	order.NotifyAt = &now
	log.Info("payment.order.balance.commit_succeeded",
		zap.Int64("user_id", input.UserID),
		zap.Int64("order_id", order.ID),
		zap.Time("notify_at", now),
		zap.String("order_status", order.Status),
	)

	log.Info("payment.order.balance.invite_settlement_started",
		zap.Int64("user_id", input.UserID),
		zap.Int64("order_id", order.ID),
	)
	s.settleInviteCommissionAfterFulfillment(ctx, order, "balance_purchase")
	log.Info("payment.order.balance.invite_settlement_finished",
		zap.Int64("user_id", input.UserID),
		zap.Int64("order_id", order.ID),
	)

	log.Info("payment.order.balance.succeeded",
		zap.Int64("user_id", input.UserID),
		zap.Int64("order_id", order.ID),
		zap.Int64("group_id", group.ID),
		zap.String("group_name", group.Name),
		zap.String("plan_key", spec.PlanKey),
		zap.String("balance_cost", balanceCost.StringFixed(8)),
		zap.Int("amount_fen", spec.ExpectedAmount),
	)

	return &CreateSubscriptionBalanceOrderResult{
		OrderID:       order.ID,
		Status:        StatusPaid,
		AmountFen:     spec.ExpectedAmount,
		BalanceCost:   balanceCost.StringFixed(8),
		BalanceAfter:  balanceAfter.InexactFloat64(),
		PaymentMethod: PaymentMethodBalance,
	}, nil
}

// HandleWechatNotify processes an inbound WeChat Pay payment notification.
// The handler is idempotent: already-paid orders are silently skipped.
func (s *Service) HandleWechatNotify(ctx context.Context, notify *wechat.NotifyRequest) error {
	log := billingLog(ctx, "payment.wechat.notify")
	log.Info("payment.wechat.notify.received",
		zap.String("wechat_notify_id", strings.TrimSpace(notify.ID)),
		zap.String("event_type", strings.TrimSpace(notify.EventType)),
		zap.String("resource_type", strings.TrimSpace(notify.ResourceType)),
		zap.String("resource_algorithm", strings.TrimSpace(notify.Resource.Algorithm)),
		zap.String("resource_original_type", strings.TrimSpace(notify.Resource.OriginalType)),
	)

	client, err := s.getWechatClient(ctx)
	if err != nil {
		log.Warn("payment.wechat.notify.skipped_client_not_ready", zap.Error(err))
		return nil
	}

	tx, err := client.DecryptNotify(notify)
	if err != nil {
		log.Error("payment.wechat.notify.decrypt_failed", zap.Error(err))
		return fmt.Errorf("decrypt notify: %w", err)
	}
	log.Info("payment.wechat.notify.decrypted",
		zap.String("out_trade_no", tx.OutTradeNo),
		zap.String("transaction_id", tx.TransactionID),
		zap.String("trade_state", tx.TradeState),
	)

	if tx.TradeState != "SUCCESS" {
		log.Info("payment.wechat.notify.ignored_non_success",
			zap.String("out_trade_no", tx.OutTradeNo),
			zap.String("transaction_id", tx.TransactionID),
			zap.String("trade_state", tx.TradeState),
		)
		return nil
	}

	order, err := s.repo.GetByOutTradeNo(ctx, tx.OutTradeNo)
	if err != nil {
		log.Error("payment.wechat.notify.order_lookup_failed",
			zap.String("out_trade_no", tx.OutTradeNo),
			zap.String("transaction_id", tx.TransactionID),
			zap.Error(err),
		)
		return fmt.Errorf("get order by out_trade_no: %w", err)
	}
	log.Info("payment.wechat.notify.order_loaded", paymentOrderLogFields(order)...)

	if order.Status == StatusPaid {
		if err := s.fulfillPaidOrder(ctx, order, "wechat_notify_reconcile"); err != nil {
			log.Error("payment.wechat.notify.reconcile_failed",
				append(paymentOrderLogFields(order), zap.String("transaction_id", tx.TransactionID), zap.Error(err))...,
			)
			return err
		}
		log.Info("payment.wechat.notify.already_paid_reconciled",
			append(paymentOrderLogFields(order), zap.String("transaction_id", tx.TransactionID))...,
		)
		return nil
	}

	notifyAt := time.Now()
	order.WechatTransactionID = tx.TransactionID
	order.NotifyAt = &notifyAt

	log.Info("payment.wechat.notify.fulfill_started",
		append(paymentOrderLogFields(order), zap.String("transaction_id", tx.TransactionID))...,
	)
	if err := s.fulfillPaidOrder(ctx, order, "wechat_notify"); err != nil {
		return err
	}

	if err := s.repo.UpdateStatus(ctx, order.ID, StatusPaid, tx.TransactionID, &notifyAt); err != nil {
		log.Error("payment.wechat.notify.status_update_failed",
			append(paymentOrderLogFields(order), zap.Error(err))...,
		)
		return fmt.Errorf("update order status: %w", err)
	}
	order.Status = StatusPaid
	log.Info("payment.wechat.notify.status_updated",
		append(paymentOrderLogFields(order), zap.String("transaction_id", tx.TransactionID))...,
	)

	log.Info("payment.wechat.notify.completed", paymentOrderLogFields(order)...)

	return nil
}

// GetOrderStatus returns the current status string for the given order ID.
// userID is required to prevent IDOR — callers may only query their own orders.
func (s *Service) GetOrderStatus(ctx context.Context, orderID, userID int64) (string, error) {
	log := billingLog(ctx, "payment.order.status")
	log.Info("payment.order.status.requested",
		zap.Int64("order_id", orderID),
		zap.Int64("user_id", userID),
	)

	order, err := s.repo.GetByID(ctx, orderID)
	if err != nil {
		log.Error("payment.order.status.lookup_failed",
			zap.Int64("order_id", orderID),
			zap.Int64("user_id", userID),
			zap.Error(err),
		)
		return "", err
	}
	if order == nil || order.UserID != userID {
		log.Warn("payment.order.status.not_found_for_user",
			zap.Int64("order_id", orderID),
			zap.Int64("user_id", userID),
		)
		return "", fmt.Errorf("order not found")
	}
	log.Info("payment.order.status.loaded", paymentOrderLogFields(order)...)
	return order.Status, nil
}

// ListUserOrders returns the latest payment orders for a user, newest first.
func (s *Service) ListUserOrders(ctx context.Context, userID int64, limit int) ([]PaymentOrderSummary, error) {
	log := billingLog(ctx, "payment.order.list")
	if userID <= 0 {
		err := infraerrors.BadRequest("PAYMENT_USER_ID_INVALID", "user_id must be greater than 0")
		log.Error("payment.order.list.invalid_user_id", zap.Int64("user_id", userID), zap.Error(err))
		return nil, err
	}
	if limit <= 0 {
		limit = 20
	}
	if limit > 100 {
		limit = 100
	}

	log.Info("payment.order.list.requested", zap.Int64("user_id", userID), zap.Int("limit", limit))

	orders, err := s.repo.ListByUserID(ctx, userID, limit)
	if err != nil {
		log.Error("payment.order.list.repo_failed",
			zap.Int64("user_id", userID),
			zap.Int("limit", limit),
			zap.Error(err),
		)
		return nil, fmt.Errorf("list payment orders: %w", err)
	}

	items := make([]PaymentOrderSummary, 0, len(orders))
	for i := range orders {
		order := orders[i]
		items = append(items, PaymentOrderSummary{
			ID:              order.ID,
			BizType:         normalizeBizType(order.BizType),
			PaymentMethod:   normalizePaymentMethod(order.PaymentMethod),
			GroupID:         order.GroupID,
			GroupName:       strings.TrimSpace(order.GroupName),
			Title:           paymentOrderTitle(&order),
			AmountFen:       order.AmountFen,
			Status:          order.Status,
			OutTradeNo:      order.OutTradeNo,
			ProviderTradeNo: order.WechatTransactionID,
			CreatedAt:       order.CreatedAt,
			NotifyAt:        order.NotifyAt,
		})
	}

	log.Info("payment.order.list.succeeded",
		zap.Int64("user_id", userID),
		zap.Int("limit", limit),
		zap.Int("count", len(items)),
	)
	return items, nil
}

// generateTradeNo generates a unique merchant trade number.
func generateTradeNo() string {
	b := make([]byte, 16)
	_, _ = rand.Read(b)
	return fmt.Sprintf("SUB%d%s", time.Now().UnixMilli(), hex.EncodeToString(b)[:8])
}

func normalizePaymentMethod(raw string) string {
	switch strings.TrimSpace(raw) {
	case PaymentMethodWechat:
		return PaymentMethodWechat
	case PaymentMethodBalance:
		return PaymentMethodBalance
	case PaymentMethodPoints:
		return PaymentMethodPoints
	default:
		return ""
	}
}

func pointsCostFromAmountFen(amountFen int) decimal.Decimal {
	if amountFen <= 0 {
		return decimal.Zero
	}
	return decimal.NewFromInt(int64(amountFen)).Div(decimal.NewFromInt(100))
}

func balanceCostFromAmountFen(amountFen int) decimal.Decimal {
	if amountFen <= 0 {
		return decimal.Zero
	}
	return decimal.NewFromInt(int64(amountFen)).Div(decimal.NewFromInt(100))
}

func pointsToStorageString(value decimal.Decimal) string {
	return value.StringFixed(8)
}

func buildSubscriptionPointsAssetChangeKey(userID int64, requestKey, outTradeNo string) string {
	key := strings.TrimSpace(requestKey)
	if key == "" {
		key = strings.TrimSpace(outTradeNo)
	}
	return fmt.Sprintf("subscription_points_purchase:%d:%s", userID, key)
}

func balanceAuditCode(outTradeNo, prefix string) string {
	code := strings.ToUpper(strings.TrimSpace(prefix)) + strings.ToUpper(strings.TrimSpace(outTradeNo))
	if len(code) > 32 {
		return code[:32]
	}
	return code
}

func pointerTime(value time.Time) *time.Time {
	return &value
}

func (s *Service) lockUserForUpdate(ctx context.Context, tx *dbent.Tx, userID int64) (*dbent.User, error) {
	query := tx.Client().User.Query().Where(dbuser.IDEQ(userID))
	if tx.Client().Driver().Dialect() != dialect.SQLite {
		query = query.ForUpdate()
	}
	user, err := query.Only(ctx)
	if err != nil {
		if dbent.IsNotFound(err) {
			return nil, service.ErrUserNotFound
		}
		return nil, err
	}
	return user, nil
}

func normalizeBizType(raw string) string {
	switch strings.TrimSpace(raw) {
	case "", BizTypeSubscription:
		return BizTypeSubscription
	case BizTypeBalanceRecharge:
		return BizTypeBalanceRecharge
	default:
		return ""
	}
}

func defaultDescriptionForBizType(bizType string) string {
	switch normalizeBizType(bizType) {
	case BizTypeBalanceRecharge:
		return "Sub2API 余额充值"
	default:
		return "Sub2API 订阅购买"
	}
}

func paymentOrderTitle(order *PaymentOrder) string {
	if order == nil {
		return "支付订单"
	}
	switch normalizeBizType(order.BizType) {
	case BizTypeBalanceRecharge:
		return "余额充值"
	case BizTypeSubscription:
		if name := strings.TrimSpace(order.GroupName); name != "" {
			return name
		}
		return "订阅购买"
	default:
		return "支付订单"
	}
}

var subscriptionPlanGroupNameAliases = map[string][]string{
	"3trial":   {"3Trial", "Trial3"},
	"spark":    {"Spark"},
	"starter":  {"Starter"},
	"pro":      {"Pro"},
	"super":    {"Super"},
	"ultra":    {"Ultra"},
	"newspark": {"Newspark"},
	"newstart": {"Newstart"},
	"newpro":   {"Newpro"},
	"newsuper": {"Newsuper"},
	"newultra": {"Newultra"},
	"basic":    {"basic"},
	"heavy":    {"heavy"},
}

var subscriptionPlanValidityFallbackDays = map[string]int{
	"3trial":   3,
	"spark":    30,
	"starter":  30,
	"pro":      30,
	"super":    30,
	"ultra":    30,
	"newspark": 30,
	"newstart": 30,
	"newpro":   30,
	"newsuper": 30,
	"newultra": 30,
	"basic":    30,
	"heavy":    30,
}

var subscriptionPlanSupportedBillingMonths = map[string][]int{
	"3trial":   {1},
	"spark":    {1, 3, 6, 12},
	"starter":  {1, 3, 6, 12},
	"pro":      {1, 3, 6, 12},
	"super":    {1, 3, 6, 12},
	"ultra":    {1, 3, 6, 12},
	"newspark": {1, 3},
	"newstart": {1, 3},
	"newpro":   {1, 3},
	"newsuper": {1, 3},
	"newultra": {1, 3},
	"basic":    {1, 3},
	"heavy":    {1, 3},
}

var subscriptionPlanDiscountBasisPoints = map[int]int{
	1:  10000,
	3:  9000,
	6:  9000,
	12: 8500,
}

type subscriptionPurchaseSpec struct {
	PlanKey        string
	PlanVariantKey string
	BillingMonths  int
	ValidityDays   int
	BaseAmountFen  int
	ExpectedAmount int
}

func normalizeSubscriptionPlanKey(raw string) string {
	switch strings.ToLower(strings.TrimSpace(raw)) {
	case "3trial", "3-trial", "trial", "trial3":
		return "3trial"
	case "spark":
		return "spark"
	case "starter":
		return "starter"
	case "pro":
		return "pro"
	case "super":
		return "super"
	case "ultra":
		return "ultra"
	case "newspark", "new-spark":
		return "newspark"
	case "newstart", "new-start":
		return "newstart"
	case "newpro", "new-pro":
		return "newpro"
	case "newsuper", "new-super":
		return "newsuper"
	case "newultra", "new-ultra":
		return "newultra"
	case "basic":
		return "basic"
	case "heavy":
		return "heavy"
	default:
		return ""
	}
}

func normalizeSubscriptionBillingMonths(raw int) int {
	if raw <= 0 {
		return 1
	}
	return raw
}

func isPurchasableSubscriptionPlanKey(planKey string) bool {
	switch normalizeSubscriptionPlanKey(planKey) {
	case "", "spark", "starter", "pro", "super", "ultra":
		return false
	default:
		return true
	}
}

func subscriptionPlanVariantKey(planKey string, billingMonths int) string {
	normalizedPlanKey := normalizeSubscriptionPlanKey(planKey)
	if normalizedPlanKey == "" {
		return ""
	}
	months := normalizeSubscriptionBillingMonths(billingMonths)
	if months <= 1 {
		return normalizedPlanKey
	}
	return fmt.Sprintf("%s_%dm", normalizedPlanKey, months)
}

func isSupportedSubscriptionBillingMonths(planKey string, billingMonths int) bool {
	supported := subscriptionPlanSupportedBillingMonths[normalizeSubscriptionPlanKey(planKey)]
	if len(supported) == 0 {
		return false
	}
	for _, item := range supported {
		if item == billingMonths {
			return true
		}
	}
	return false
}

func normalizeSubscriptionPlanPrices(raw map[string]int) map[string]int {
	resolved := config.DefaultSubscriptionPlanPriceMap()
	for planKey, amountFen := range raw {
		normalizedPlanKey := normalizeSubscriptionPlanKey(planKey)
		if normalizedPlanKey == "" || amountFen <= 0 {
			continue
		}
		resolved[normalizedPlanKey] = amountFen
	}
	return resolved
}

func normalizeSubscriptionTestPriceDivisor(raw int) int {
	if raw < 1 {
		return 1
	}
	return raw
}

func discountedSubscriptionAmountFen(baseAmountFen, billingMonths int) int {
	months := normalizeSubscriptionBillingMonths(billingMonths)
	basisPoints, ok := subscriptionPlanDiscountBasisPoints[months]
	if !ok {
		basisPoints = 10000
	}
	total := baseAmountFen * months
	return (total*basisPoints + 5000) / 10000
}

func (s *Service) buildSubscriptionPurchaseSpec(planKey string, billingMonths int) (*subscriptionPurchaseSpec, error) {
	normalizedPlanKey := normalizeSubscriptionPlanKey(planKey)
	if normalizedPlanKey == "" {
		return nil, infraerrors.BadRequest(
			"PAYMENT_PLAN_KEY_UNSUPPORTED",
			fmt.Sprintf("unsupported subscription plan_key: %s", strings.TrimSpace(planKey)),
		)
	}
	if !isPurchasableSubscriptionPlanKey(normalizedPlanKey) {
		return nil, infraerrors.BadRequest(
			"PAYMENT_PLAN_KEY_DEPRECATED",
			fmt.Sprintf("subscription plan_key is no longer available for purchase: %s", normalizedPlanKey),
		)
	}

	months := normalizeSubscriptionBillingMonths(billingMonths)
	if !isSupportedSubscriptionBillingMonths(normalizedPlanKey, months) {
		return nil, infraerrors.BadRequest(
			"PAYMENT_BILLING_MONTHS_UNSUPPORTED",
			fmt.Sprintf("unsupported billing_months=%d for plan_key=%s", months, normalizedPlanKey),
		)
	}

	baseAmountFen, ok := s.subscriptionPlanPrices[normalizedPlanKey]
	if !ok || baseAmountFen <= 0 {
		return nil, infraerrors.InternalServer(
			"PAYMENT_PLAN_PRICE_MISSING",
			fmt.Sprintf("subscription price mapping missing: %s", normalizedPlanKey),
		)
	}

	baseValidityDays, ok := subscriptionPlanValidityFallbackDays[normalizedPlanKey]
	if !ok || baseValidityDays <= 0 {
		baseValidityDays = 30
	}

	validityDays := baseValidityDays
	if normalizedPlanKey != "3trial" {
		if months == 12 {
			validityDays = 365
		} else {
			validityDays = baseValidityDays * months
		}
	}

	return &subscriptionPurchaseSpec{
		PlanKey:        normalizedPlanKey,
		PlanVariantKey: subscriptionPlanVariantKey(normalizedPlanKey, months),
		BillingMonths:  months,
		ValidityDays:   validityDays,
		BaseAmountFen:  baseAmountFen,
		ExpectedAmount: discountedSubscriptionAmountFen(baseAmountFen, months),
	}, nil
}

func groupMatchesSubscriptionPlanKey(group *service.Group, planKey string) bool {
	if group == nil {
		return false
	}
	aliases, ok := subscriptionPlanGroupNameAliases[normalizeSubscriptionPlanKey(planKey)]
	if !ok {
		return false
	}
	groupName := strings.TrimSpace(group.Name)
	for _, alias := range aliases {
		if strings.EqualFold(groupName, alias) {
			return true
		}
	}
	return false
}

func (s *Service) resolveSubscriptionGroup(ctx context.Context, groupID int64, planKey string) (*service.Group, error) {
	if groupID > 0 {
		return s.validateSubscriptionGroup(ctx, groupID)
	}

	rawPlanKey := strings.TrimSpace(planKey)
	normalizedPlanKey := normalizeSubscriptionPlanKey(planKey)
	if rawPlanKey == "" {
		return nil, infraerrors.BadRequest(
			"PAYMENT_SUBSCRIPTION_TARGET_REQUIRED",
			"group_id or plan_key is required for subscription orders",
		)
	}
	if normalizedPlanKey == "" {
		return nil, infraerrors.BadRequest(
			"PAYMENT_PLAN_KEY_UNSUPPORTED",
			fmt.Sprintf("unsupported subscription plan_key: %s", rawPlanKey),
		)
	}
	if s.groupRepo == nil {
		return nil, infraerrors.InternalServer(
			"PAYMENT_GROUP_REPOSITORY_NOT_CONFIGURED",
			"group repository not configured",
		)
	}

	targetNames, ok := subscriptionPlanGroupNameAliases[normalizedPlanKey]
	if !ok {
		return nil, infraerrors.InternalServer(
			"PAYMENT_PLAN_KEY_MAPPING_MISSING",
			fmt.Sprintf("subscription plan_key mapping missing: %s", normalizedPlanKey),
		)
	}

	groups, err := s.groupRepo.ListActive(ctx)
	if err != nil {
		return nil, fmt.Errorf("list active groups: %w", err)
	}

	for _, targetName := range targetNames {
		matches := make([]*service.Group, 0, 1)
		for i := range groups {
			group := &groups[i]
			if !group.IsSubscriptionType() {
				continue
			}
			if strings.EqualFold(strings.TrimSpace(group.Name), targetName) {
				matches = append(matches, group)
			}
		}
		switch len(matches) {
		case 0:
			continue
		case 1:
			return matches[0], nil
		default:
			selected, resolved, candidateIDs := selectPreferredSubscriptionGroup(matches)
			if resolved {
				billingLog(ctx, "payment.order.create").Warn(
					"payment.order.create.subscription_group_duplicate_auto_selected",
					zap.String("plan_key", normalizedPlanKey),
					zap.String("group_name", targetName),
					zap.Strings("candidate_ids", candidateIDs),
					zap.Int64("selected_group_id", selected.ID),
					zap.String("selected_group_name", selected.Name),
					zap.Int("selected_default_validity_days", selected.DefaultValidityDays),
				)
				return selected, nil
			}
			return nil, infraerrors.InternalServer(
				"PAYMENT_SUBSCRIPTION_GROUP_AMBIGUOUS",
				fmt.Sprintf(
					"multiple subscription groups matched plan_key=%s group_name=%s ids=%s",
					normalizedPlanKey,
					targetName,
					strings.Join(candidateIDs, ","),
				),
			)
		}
	}

	return nil, infraerrors.InternalServer(
		"PAYMENT_SUBSCRIPTION_GROUP_NOT_FOUND",
		fmt.Sprintf(
			"subscription group not found for plan_key=%s aliases=%s",
			normalizedPlanKey,
			strings.Join(targetNames, ","),
		),
	)
}

func selectPreferredSubscriptionGroup(matches []*service.Group) (*service.Group, bool, []string) {
	if len(matches) == 0 {
		return nil, false, nil
	}
	if len(matches) == 1 {
		return matches[0], true, []string{fmt.Sprintf("%d", matches[0].ID)}
	}

	type scoredGroup struct {
		group *service.Group
		score int
	}

	scored := make([]scoredGroup, 0, len(matches))
	candidateIDs := make([]string, 0, len(matches))
	for _, match := range matches {
		if match == nil {
			continue
		}
		candidateIDs = append(candidateIDs, fmt.Sprintf("%d", match.ID))

		score := 0
		if match.DefaultValidityDays > 0 {
			score += 100
		}
		if strings.TrimSpace(match.Description) != "" {
			score += 20
		}
		if match.HasDailyLimit() || match.HasWeeklyLimit() || match.HasMonthlyLimit() {
			score += 10
		}
		if !match.UpdatedAt.IsZero() {
			score += int(match.UpdatedAt.Unix())
		} else {
			score += int(match.CreatedAt.Unix())
		}

		scored = append(scored, scoredGroup{group: match, score: score})
	}
	if len(scored) == 0 {
		return nil, false, candidateIDs
	}

	best := scored[0]
	duplicateTopScore := false
	for _, item := range scored[1:] {
		if item.score > best.score {
			best = item
			duplicateTopScore = false
			continue
		}
		if item.score == best.score {
			duplicateTopScore = true
		}
	}
	if duplicateTopScore || best.group == nil {
		return nil, false, candidateIDs
	}
	return best.group, true, candidateIDs
}

func subscriptionValidityDays(group *service.Group) int {
	if group == nil {
		return 30
	}
	if group.DefaultValidityDays > 0 {
		return group.DefaultValidityDays
	}

	switch normalizeSubscriptionPlanKey(group.Name) {
	case "3trial":
		return 3
	case "spark", "starter", "pro", "super", "ultra", "newspark", "newstart", "newpro", "newsuper", "newultra", "basic", "heavy":
		return 30
	default:
		return 30
	}
}

func (s *Service) validateSubscriptionGroup(ctx context.Context, groupID int64) (*service.Group, error) {
	if s.groupRepo == nil {
		return nil, infraerrors.InternalServer(
			"PAYMENT_GROUP_REPOSITORY_NOT_CONFIGURED",
			"group repository not configured",
		)
	}

	group, err := s.groupRepo.GetByID(ctx, groupID)
	if err != nil {
		if errors.Is(err, service.ErrGroupNotFound) {
			return nil, service.ErrGroupNotFound
		}
		return nil, fmt.Errorf("get subscription group: %w", err)
	}
	if !group.IsSubscriptionType() {
		return nil, service.ErrGroupNotSubscriptionType
	}
	if !group.IsActive() {
		return nil, infraerrors.BadRequest(
			"PAYMENT_SUBSCRIPTION_GROUP_INACTIVE",
			fmt.Sprintf("subscription group is not active: %d", groupID),
		)
	}
	return group, nil
}

func rechargeRedeemCode(outTradeNo string) string {
	code := "RCHG" + strings.ToUpper(strings.TrimSpace(outTradeNo))
	if len(code) > 32 {
		return code[:32]
	}
	return code
}

func (s *Service) fulfillPaidOrder(ctx context.Context, order *PaymentOrder, source string) error {
	log := billingLog(ctx, "payment.fulfill")
	if order == nil {
		err := errors.New("payment order is required")
		log.Error("payment.fulfill.invalid_order",
			zap.String("source", source),
			zap.Error(err),
		)
		return err
	}

	fields := append(paymentOrderLogFields(order), zap.String("source", source))
	log.Info("payment.fulfill.started", fields...)

	var err error
	switch normalizeBizType(order.BizType) {
	case BizTypeBalanceRecharge:
		err = s.grantRechargeBalance(ctx, order, source)
	case BizTypeSubscription:
		_, err = s.assignSubscriptionForPaidOrder(ctx, order, source)
		if err == nil {
			s.settleInviteCommissionAfterFulfillment(ctx, order, source)
		}
	default:
		err = fmt.Errorf("unsupported payment biz type: %s", order.BizType)
		log.Error("payment.fulfill.unsupported_biz_type",
			append(fields, zap.Error(err))...,
		)
	}

	if err != nil {
		log.Error("payment.fulfill.failed",
			append(fields, zap.Error(err))...,
		)
		return err
	}

	log.Info("payment.fulfill.completed", fields...)
	return nil
}

func (s *Service) settleInviteCommissionAfterFulfillment(ctx context.Context, order *PaymentOrder, source string) {
	if s == nil || s.inviteCommissionSvc == nil || order == nil {
		return
	}
	if normalizeBizType(order.BizType) != BizTypeSubscription {
		return
	}

	log := billingLog(ctx, "payment.invite_commission")
	if err := s.inviteCommissionSvc.SettleForPaidOrder(ctx, &service.InviteCommissionOrder{
		ID:             order.ID,
		BuyerUserID:    order.UserID,
		OutTradeNo:     order.OutTradeNo,
		OrderAmountFen: int64(order.AmountFen),
	}); err != nil {
		log.Error("payment.invite_commission.settle_failed",
			append(
				paymentOrderLogFields(order),
				zap.String("source", source),
				zap.Error(err),
			)...,
		)
		if s.inviteRetryWorker != nil {
			if dispatchErr := s.inviteRetryWorker.DispatchRetry(ctx, order.ID, "settle_failed:"+strings.TrimSpace(source)); dispatchErr != nil {
				log.Error("payment.invite_commission.retry_dispatch_failed",
					append(
						paymentOrderLogFields(order),
						zap.String("source", source),
						zap.Error(dispatchErr),
					)...,
				)
			}
		}
		return
	}

	log.Info("payment.invite_commission.settled",
		append(paymentOrderLogFields(order), zap.String("source", source))...,
	)
}

func (s *Service) grantRechargeBalance(ctx context.Context, order *PaymentOrder, source string) error {
	log := billingLog(ctx, "payment.recharge.fulfill")
	if order == nil {
		err := errors.New("payment order is required")
		log.Error("payment.recharge.fulfill.invalid_order",
			zap.String("source", source),
			zap.Error(err),
		)
		return err
	}
	if s.redeemService == nil {
		err := errors.New("redeem service not configured")
		log.Error("payment.recharge.fulfill.missing_redeem_service",
			append(paymentOrderLogFields(order), zap.String("source", source), zap.Error(err))...,
		)
		return err
	}

	fields := append(paymentOrderLogFields(order), zap.String("source", source))
	rechargeAmount := float64(order.AmountFen) / 100
	redeemCode := rechargeRedeemCode(order.OutTradeNo)

	log.Info("payment.recharge.fulfill.started",
		append(fields,
			zap.String("recharge_code", redeemCode),
			zap.Float64("credit_balance", rechargeAmount),
		)...,
	)

	existing, err := s.redeemService.GetByCode(ctx, redeemCode)
	if err == nil && existing != nil {
		if existing.UsedBy != nil && *existing.UsedBy == order.UserID && existing.Status == service.StatusUsed {
			log.Info("payment.recharge.fulfill.already_completed",
				append(fields,
					zap.String("recharge_code", redeemCode),
					zap.Float64("credit_balance", rechargeAmount),
				)...,
			)
			return nil
		}

		if existing.CanUse() {
			if _, redeemErr := s.redeemService.Redeem(ctx, order.UserID, redeemCode); redeemErr != nil {
				log.Error("payment.recharge.fulfill.redeem_existing_failed",
					append(fields,
						zap.String("recharge_code", redeemCode),
						zap.Float64("credit_balance", rechargeAmount),
						zap.Error(redeemErr),
					)...,
				)
				return fmt.Errorf("redeem existing recharge code: %w", redeemErr)
			}
			log.Info("payment.recharge.fulfill.redeemed_existing_code",
				append(fields,
					zap.String("recharge_code", redeemCode),
					zap.Float64("credit_balance", rechargeAmount),
				)...,
			)
			return nil
		}

		err = fmt.Errorf("recharge code already used by another user")
		log.Error("payment.recharge.fulfill.code_conflict",
			append(fields,
				zap.String("recharge_code", redeemCode),
				zap.Error(err),
			)...,
		)
		return err
	}
	if err != nil && !errors.Is(err, service.ErrRedeemCodeNotFound) {
		log.Error("payment.recharge.fulfill.lookup_failed",
			append(fields,
				zap.String("recharge_code", redeemCode),
				zap.Error(err),
			)...,
		)
		return fmt.Errorf("get recharge code: %w", err)
	}

	createInput := &service.RedeemCode{
		Code:   redeemCode,
		Type:   service.RedeemTypeBalance,
		Value:  rechargeAmount,
		Status: service.StatusUnused,
		Notes: fmt.Sprintf(
			"支付充值订单 %s 已支付，按 1 人民币 = 1 美元到账；biz_type=%s source=%s trade_no=%s",
			order.OutTradeNo,
			normalizeBizType(order.BizType),
			source,
			order.WechatTransactionID,
		),
	}

	if createErr := s.redeemService.CreateCode(ctx, createInput); createErr != nil {
		latest, getErr := s.redeemService.GetByCode(ctx, redeemCode)
		if getErr == nil && latest != nil {
			if latest.UsedBy != nil && *latest.UsedBy == order.UserID && latest.Status == service.StatusUsed {
				log.Info("payment.recharge.fulfill.concurrent_create_resolved",
					append(fields,
						zap.String("recharge_code", redeemCode),
						zap.Float64("credit_balance", rechargeAmount),
					)...,
				)
				return nil
			}
			if latest.CanUse() {
				if _, redeemErr := s.redeemService.Redeem(ctx, order.UserID, redeemCode); redeemErr != nil {
					log.Error("payment.recharge.fulfill.redeem_after_create_conflict_failed",
						append(fields,
							zap.String("recharge_code", redeemCode),
							zap.Float64("credit_balance", rechargeAmount),
							zap.Error(redeemErr),
						)...,
					)
					return fmt.Errorf("redeem recharge code after create conflict: %w", redeemErr)
				}
				log.Info("payment.recharge.fulfill.redeemed_after_create_conflict",
					append(fields,
						zap.String("recharge_code", redeemCode),
						zap.Float64("credit_balance", rechargeAmount),
					)...,
				)
				return nil
			}
		}

		log.Error("payment.recharge.fulfill.create_code_failed",
			append(fields,
				zap.String("recharge_code", redeemCode),
				zap.Float64("credit_balance", rechargeAmount),
				zap.Error(createErr),
			)...,
		)
		return fmt.Errorf("create recharge code: %w", createErr)
	}

	if _, redeemErr := s.redeemService.Redeem(ctx, order.UserID, redeemCode); redeemErr != nil {
		log.Error("payment.recharge.fulfill.redeem_failed",
			append(fields,
				zap.String("recharge_code", redeemCode),
				zap.Float64("credit_balance", rechargeAmount),
				zap.Error(redeemErr),
			)...,
		)
		return fmt.Errorf("redeem recharge code: %w", redeemErr)
	}

	log.Info("payment.recharge.fulfill.succeeded",
		append(fields,
			zap.String("recharge_code", redeemCode),
			zap.Float64("credit_balance", rechargeAmount),
		)...,
	)
	return nil
}
