package payment

import (
	"context"
	"database/sql"
	"fmt"
	"strings"
	"testing"
	"time"

	dbent "github.com/Wei-Shaw/sub2api/ent"
	"github.com/Wei-Shaw/sub2api/ent/enttest"
	"github.com/Wei-Shaw/sub2api/internal/config"
	infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
	"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
	"github.com/Wei-Shaw/sub2api/internal/service"
	"github.com/stretchr/testify/require"

	"entgo.io/ent/dialect"
	entsql "entgo.io/ent/dialect/sql"
	_ "modernc.org/sqlite"
)

func newPaymentSubscriptionEntClient(t *testing.T) *dbent.Client {
	t.Helper()

	dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared&_fk=1", strings.ReplaceAll(t.Name(), "/", "_"))
	db, err := sql.Open("sqlite", dsn)
	require.NoError(t, err)
	t.Cleanup(func() { _ = db.Close() })

	_, err = db.Exec("PRAGMA foreign_keys = ON")
	require.NoError(t, err)

	drv := entsql.OpenDB(dialect.SQLite, db)
	client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
	t.Cleanup(func() { _ = client.Close() })
	return client
}

func TestCreateWechatNativeOrder_AllowsSubscriptionPlanKeyWithoutGroupID(t *testing.T) {
	t.Parallel()

	repo := newPaymentOrderRepoStub()
	groupRepo := &groupRepoStub{
		activeGroups: []service.Group{
			{
				ID:                  11,
				Name:                "Trial3",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 3,
			},
		},
	}
	settings := settingGetterStub{
		bools: map[string]bool{
			service.SettingKeyZPayEnabled: true,
		},
		strings: map[string]string{
			service.SettingKeyZPayPID:       "pid_123",
			service.SettingKeyZPayKey:       "key_123",
			service.SettingKeyZPayGateway:   "https://zpayz.cn/submit.php",
			service.SettingKeyZPayNotifyURL: "https://sub-lb.tap365.org/api/v1/pay/notify/zpay",
			service.SettingKeyZPayReturnURL: "https://sub-lb.tap365.org/payment/result",
			service.SettingKeyZPaySitename:  "SubLab",
		},
	}
	svc := &Service{
		repo:                   repo,
		groupRepo:              groupRepo,
		settingGetter:          settings,
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
	}

	result, err := svc.CreateWechatNativeOrder(context.Background(), &CreateOrderInput{
		UserID:      1,
		GroupID:     0,
		PlanKey:     "3trial",
		BizType:     BizTypeSubscription,
		AmountFen:   700,
		Description: "3Trial",
	})
	require.NoError(t, err)
	require.NotNil(t, result)
	require.NotNil(t, repo.created)
	require.Equal(t, int64(11), repo.created.GroupID)
	require.Equal(t, "3trial", repo.created.PlanKey)
	require.Equal(t, "3trial", repo.created.PlanVariantKey)
	require.Equal(t, 1, repo.created.BillingMonths)
	require.Equal(t, 3, repo.created.ValidityDays)
	require.Equal(t, BizTypeSubscription, repo.created.BizType)
	require.Contains(t, result.CodeURL, "https://zpayz.cn/submit.php")
	require.Contains(t, result.CodeURL, "out_trade_no=")
}

func TestCreateWechatNativeOrder_RejectsSubscriptionWithoutPlanKey(t *testing.T) {
	t.Parallel()

	repo := newPaymentOrderRepoStub()
	groupRepo := &groupRepoStub{
		groupsByID: map[int64]*service.Group{
			37: &service.Group{
				ID:                  37,
				Name:                "Starter",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 30,
			},
		},
	}
	settings := settingGetterStub{}
	svc := &Service{
		repo:                   repo,
		groupRepo:              groupRepo,
		settingGetter:          settings,
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
	}

	result, err := svc.CreateWechatNativeOrder(context.Background(), &CreateOrderInput{
		UserID:      1,
		GroupID:     37,
		BizType:     BizTypeSubscription,
		AmountFen:   1,
		Description: "Starter",
	})
	require.Nil(t, result)
	require.Error(t, err)
	require.Equal(t, "PAYMENT_PLAN_KEY_REQUIRED", infraerrors.Reason(err))
	require.Nil(t, repo.created)
}

func TestCreateWechatNativeOrder_UsesZPayWhenGatewayConfiguredWithoutBoolToggle(t *testing.T) {
	t.Parallel()

	repo := newPaymentOrderRepoStub()
	groupRepo := &groupRepoStub{
		activeGroups: []service.Group{
			{
				ID:                  37,
				Name:                "Newstart",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 30,
			},
		},
	}
	settings := settingGetterStub{
		bools: map[string]bool{
			service.SettingKeyZPayEnabled: false,
		},
		strings: map[string]string{
			service.SettingKeyZPayPID:       "pid_123",
			service.SettingKeyZPayKey:       "key_123",
			service.SettingKeyZPayGateway:   "https://zpayz.cn/submit.php",
			service.SettingKeyZPayNotifyURL: "https://subjp2.tap365.org/api/v1/pay/notify/zpay",
			service.SettingKeyZPayReturnURL: "https://subjp2.tap365.org/purchase/result",
		},
	}
	svc := &Service{
		repo:                   repo,
		groupRepo:              groupRepo,
		settingGetter:          settings,
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
	}

	result, err := svc.CreateWechatNativeOrder(context.Background(), &CreateOrderInput{
		UserID:      1,
		PlanKey:     "newstart",
		BizType:     BizTypeSubscription,
		AmountFen:   10500,
		Description: "Newstart",
	})
	require.NoError(t, err)
	require.NotNil(t, result)
	require.Contains(t, result.CodeURL, "https://zpayz.cn/submit.php")
	require.NotNil(t, repo.created)
	require.Equal(t, int64(37), repo.created.GroupID)
}

func TestCreateWechatNativeOrder_AllowsQuarterlyDiscountedSubscription(t *testing.T) {
	t.Parallel()

	repo := newPaymentOrderRepoStub()
	groupRepo := &groupRepoStub{
		activeGroups: []service.Group{
			{
				ID:                  37,
				Name:                "Newstart",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 30,
			},
		},
	}
	settings := settingGetterStub{
		bools: map[string]bool{
			service.SettingKeyZPayEnabled: true,
		},
		strings: map[string]string{
			service.SettingKeyZPayPID:       "pid_123",
			service.SettingKeyZPayKey:       "key_123",
			service.SettingKeyZPayGateway:   "https://zpayz.cn/submit.php",
			service.SettingKeyZPayNotifyURL: "https://subjp2.tap365.org/api/v1/pay/notify/zpay",
			service.SettingKeyZPayReturnURL: "https://subjp2.tap365.org/purchase/result",
		},
	}
	svc := &Service{
		repo:                   repo,
		groupRepo:              groupRepo,
		settingGetter:          settings,
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
	}

	result, err := svc.CreateWechatNativeOrder(context.Background(), &CreateOrderInput{
		UserID:        1,
		PlanKey:       "newstart",
		BillingMonths: 3,
		BizType:       BizTypeSubscription,
		AmountFen:     45360,
		Description:   "Newstart · 3个月",
	})
	require.NoError(t, err)
	require.NotNil(t, result)
	require.NotNil(t, repo.created)
	require.Equal(t, int64(37), repo.created.GroupID)
	require.Equal(t, "newstart", repo.created.PlanKey)
	require.Equal(t, "newstart_3m", repo.created.PlanVariantKey)
	require.Equal(t, 3, repo.created.BillingMonths)
	require.Equal(t, 90, repo.created.ValidityDays)
	require.Equal(t, 45360, repo.created.AmountFen)
}

func TestCreateWechatNativeOrder_AllowsLegacyPlanKeyWhenItStillExistsInCurrentCatalog(t *testing.T) {
	t.Parallel()

	repo := newPaymentOrderRepoStub()
	svc := &Service{
		repo: repo,
		groupRepo: &groupRepoStub{
			activeGroups: []service.Group{
				{
					ID:                  41,
					Name:                "Pro",
					Status:              service.StatusActive,
					SubscriptionType:    service.SubscriptionTypeSubscription,
					DefaultValidityDays: 30,
				},
			},
		},
		settingGetter: settingGetterStub{
			bools: map[string]bool{
				service.SettingKeyZPayEnabled: true,
			},
			strings: map[string]string{
				service.SettingKeyZPayPID:       "pid_123",
				service.SettingKeyZPayKey:       "key_123",
				service.SettingKeyZPayGateway:   "https://zpayz.cn/submit.php",
				service.SettingKeyZPayNotifyURL: "https://sub-lb.tap365.org/api/v1/pay/notify/zpay",
				service.SettingKeyZPayReturnURL: "https://sub-lb.tap365.org/payment/result",
			},
		},
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
	}

	result, err := svc.CreateWechatNativeOrder(context.Background(), &CreateOrderInput{
		UserID:        1,
		PlanKey:       "pro",
		BillingMonths: 12,
		BizType:       BizTypeSubscription,
		AmountFen:     214200,
		Description:   "Pro · 1年",
	})
	require.NoError(t, err)
	require.NotNil(t, result)
	require.NotNil(t, repo.created)
	require.Equal(t, int64(41), repo.created.GroupID)
	require.Equal(t, "pro", repo.created.PlanKey)
	require.Equal(t, "pro_12m", repo.created.PlanVariantKey)
	require.Equal(t, 12, repo.created.BillingMonths)
	require.Equal(t, 365, repo.created.ValidityDays)
}

func TestCreateWechatNativeOrder_OverridesMismatchedDiscountedAmountWithServerPricing(t *testing.T) {
	t.Parallel()

	repo := newPaymentOrderRepoStub()
	svc := &Service{
		repo: repo,
		groupRepo: &groupRepoStub{
			activeGroups: []service.Group{
				{
					ID:                  29,
					Name:                "Newspark",
					Status:              service.StatusActive,
					SubscriptionType:    service.SubscriptionTypeSubscription,
					DefaultValidityDays: 30,
				},
			},
		},
		settingGetter: settingGetterStub{
			bools: map[string]bool{
				service.SettingKeyZPayEnabled: true,
			},
			strings: map[string]string{
				service.SettingKeyZPayPID:       "pid_123",
				service.SettingKeyZPayKey:       "key_123",
				service.SettingKeyZPayGateway:   "https://zpayz.cn/submit.php",
				service.SettingKeyZPayNotifyURL: "https://subjp2.tap365.org/api/v1/pay/notify/zpay",
				service.SettingKeyZPayReturnURL: "https://subjp2.tap365.org/purchase/result",
			},
		},
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
	}

	result, err := svc.CreateWechatNativeOrder(context.Background(), &CreateOrderInput{
		UserID:        1,
		PlanKey:       "newspark",
		BillingMonths: 3,
		BizType:       BizTypeSubscription,
		AmountFen:     1,
	})
	require.NoError(t, err)
	require.NotNil(t, result)
	require.NotNil(t, repo.created)
	require.Equal(t, 15120, repo.created.AmountFen)
}

func TestCreateWechatNativeOrder_RejectsUnsupportedPlanKeyAsBadRequest(t *testing.T) {
	t.Parallel()

	svc := &Service{
		repo:                   &paymentOrderRepoStub{},
		groupRepo:              &groupRepoStub{},
		settingGetter:          settingGetterStub{},
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
	}

	result, err := svc.CreateWechatNativeOrder(context.Background(), &CreateOrderInput{
		UserID:    1,
		PlanKey:   "not-a-real-plan",
		BizType:   BizTypeSubscription,
		AmountFen: 700,
	})
	require.Nil(t, result)
	require.Error(t, err)
	require.Equal(t, 400, infraerrors.Code(err))
	require.Equal(t, "PAYMENT_PLAN_KEY_UNSUPPORTED", infraerrors.Reason(err))
	require.Contains(t, infraerrors.Message(err), "not-a-real-plan")
}

func TestCreateWechatNativeOrder_ReturnsNotFoundForUnknownGroupID(t *testing.T) {
	t.Parallel()

	svc := &Service{
		repo:                   &paymentOrderRepoStub{},
		groupRepo:              &groupRepoStub{getByIDErr: service.ErrGroupNotFound},
		settingGetter:          settingGetterStub{},
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
	}

	result, err := svc.CreateWechatNativeOrder(context.Background(), &CreateOrderInput{
		UserID:    1,
		GroupID:   404,
		PlanKey:   "newstart",
		BizType:   BizTypeSubscription,
		AmountFen: 700,
	})
	require.Nil(t, result)
	require.Error(t, err)
	require.Equal(t, 404, infraerrors.Code(err))
	require.Equal(t, infraerrors.Reason(service.ErrGroupNotFound), infraerrors.Reason(err))
}

func TestCreateWechatNativeOrder_ReturnsAmbiguousWhenMultipleGroupsMatchPlanKey(t *testing.T) {
	t.Parallel()

	svc := &Service{
		repo: &paymentOrderRepoStub{},
		groupRepo: &groupRepoStub{
			activeGroups: []service.Group{
				{ID: 38, Name: "Newpro", Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeSubscription},
				{ID: 40, Name: "Newpro", Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeSubscription},
			},
		},
		settingGetter:          settingGetterStub{},
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
	}

	result, err := svc.CreateWechatNativeOrder(context.Background(), &CreateOrderInput{
		UserID:    1,
		PlanKey:   "newpro",
		BizType:   BizTypeSubscription,
		AmountFen: 28000,
	})
	require.Nil(t, result)
	require.Error(t, err)
	require.Equal(t, 500, infraerrors.Code(err))
	require.Equal(t, "PAYMENT_SUBSCRIPTION_GROUP_AMBIGUOUS", infraerrors.Reason(err))
	require.Contains(t, infraerrors.Message(err), "38,40")
}

func TestCreateWechatNativeOrder_SelectsPreferredGroupFromDuplicates(t *testing.T) {
	t.Parallel()

	now := time.Now()
	repo := newPaymentOrderRepoStub()
	settings := settingGetterStub{
		bools: map[string]bool{
			service.SettingKeyZPayEnabled: true,
		},
		strings: map[string]string{
			service.SettingKeyZPayPID:       "pid_123",
			service.SettingKeyZPayKey:       "key_123",
			service.SettingKeyZPayGateway:   "https://zpayz.cn/submit.php",
			service.SettingKeyZPayNotifyURL: "https://sub-lb.tap365.org/api/v1/pay/notify/zpay",
			service.SettingKeyZPayReturnURL: "https://sub-lb.tap365.org/purchase",
		},
	}
	svc := &Service{
		repo: repo,
		groupRepo: &groupRepoStub{
			activeGroups: []service.Group{
				{
					ID:               27,
					Name:             "Newspark",
					Description:      "old newspark",
					Status:           service.StatusActive,
					SubscriptionType: service.SubscriptionTypeSubscription,
					CreatedAt:        now.Add(-2 * time.Hour),
					UpdatedAt:        now.Add(-2 * time.Hour),
				},
				{
					ID:               29,
					Name:             "Newspark",
					Description:      "current newspark",
					Status:           service.StatusActive,
					SubscriptionType: service.SubscriptionTypeSubscription,
					CreatedAt:        now.Add(-1 * time.Hour),
					UpdatedAt:        now,
				},
			},
		},
		settingGetter:          settings,
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
	}

	result, err := svc.CreateWechatNativeOrder(context.Background(), &CreateOrderInput{
		UserID:      1,
		PlanKey:     "newspark",
		BizType:     BizTypeSubscription,
		AmountFen:   5600,
		Description: "Newspark",
	})
	require.NoError(t, err)
	require.NotNil(t, result)
	require.NotNil(t, repo.created)
	require.Equal(t, int64(29), repo.created.GroupID)
}

func TestHandleZPayNotify_AssignsSubscriptionWithGroupValidityAndMarksPaid(t *testing.T) {
	t.Parallel()

	repo := newPaymentOrderRepoStub()
	repo.put(&PaymentOrder{
		ID:         1001,
		UserID:     9,
		GroupID:    11,
		BizType:    BizTypeSubscription,
		OutTradeNo: "SUB-TRIAL-001",
		AmountFen:  700,
		Status:     StatusPending,
	})

	groupRepo := &groupRepoStub{
		groupsByID: map[int64]*service.Group{
			11: {
				ID:                  11,
				Name:                "3Trial",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 3,
			},
		},
		activeGroups: []service.Group{
			{
				ID:                  11,
				Name:                "3Trial",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 3,
			},
		},
	}
	userSubRepo := newUserSubscriptionRepoStub()
	subSvc := service.NewSubscriptionService(groupRepo, userSubRepo, nil, newPaymentSubscriptionEntClient(t), &config.Config{})
	svc := &Service{
		repo:                repo,
		groupRepo:           groupRepo,
		subscriptionService: subSvc,
		settingGetter: settingGetterStub{
			bools: map[string]bool{
				service.SettingKeyZPayEnabled: true,
			},
			strings: map[string]string{
				service.SettingKeyZPayPID:       "pid_123",
				service.SettingKeyZPayKey:       "key_123",
				service.SettingKeyZPayGateway:   "https://zpayz.cn/submit.php",
				service.SettingKeyZPayNotifyURL: "https://subjp2.tap365.org/api/v1/pay/notify/zpay",
				service.SettingKeyZPayReturnURL: "https://subjp2.tap365.org/purchase/result",
			},
		},
	}

	before := time.Now()
	params := signedZPayNotifyParams("pid_123", "key_123", "SUB-TRIAL-001", "70010001", "7")
	err := svc.HandleZPayNotify(context.Background(), params)
	after := time.Now()
	require.NoError(t, err)

	order, err := repo.GetByOutTradeNo(context.Background(), "SUB-TRIAL-001")
	require.NoError(t, err)
	require.Equal(t, StatusPaid, order.Status)
	require.Equal(t, "70010001", order.WechatTransactionID)
	require.NotNil(t, order.NotifyAt)

	sub, err := userSubRepo.GetByUserIDAndGroupID(context.Background(), 9, 11)
	require.NoError(t, err)
	require.Equal(t, service.SubscriptionStatusActive, sub.Status)
	require.WithinDuration(t, before.AddDate(0, 0, 3), sub.ExpiresAt, 5*time.Second)
	require.True(t, sub.ExpiresAt.After(after.AddDate(0, 0, 2)))
	require.Contains(t, sub.Notes, "out_trade_no=SUB-TRIAL-001")
}

func TestHandleZPayNotify_AssignsTrial3WithFallbackValidityWhenGroupDefaultZero(t *testing.T) {
	t.Parallel()

	repo := newPaymentOrderRepoStub()
	repo.put(&PaymentOrder{
		ID:         1002,
		UserID:     9,
		GroupID:    43,
		BizType:    BizTypeSubscription,
		OutTradeNo: "SUB-TRIAL-FALLBACK-001",
		AmountFen:  700,
		Status:     StatusPending,
	})

	groupRepo := &groupRepoStub{
		groupsByID: map[int64]*service.Group{
			43: {
				ID:                  43,
				Name:                "Trial3",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 0,
			},
		},
		activeGroups: []service.Group{
			{
				ID:                  43,
				Name:                "Trial3",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 0,
			},
		},
	}
	userSubRepo := newUserSubscriptionRepoStub()
	subSvc := service.NewSubscriptionService(groupRepo, userSubRepo, nil, newPaymentSubscriptionEntClient(t), &config.Config{})
	svc := &Service{
		repo:                repo,
		groupRepo:           groupRepo,
		subscriptionService: subSvc,
		settingGetter: settingGetterStub{
			bools: map[string]bool{
				service.SettingKeyZPayEnabled: true,
			},
			strings: map[string]string{
				service.SettingKeyZPayPID:       "pid_123",
				service.SettingKeyZPayKey:       "key_123",
				service.SettingKeyZPayGateway:   "https://zpayz.cn/submit.php",
				service.SettingKeyZPayNotifyURL: "https://subjp2.tap365.org/api/v1/pay/notify/zpay",
				service.SettingKeyZPayReturnURL: "https://subjp2.tap365.org/purchase/result",
			},
		},
	}

	before := time.Now()
	params := signedZPayNotifyParams("pid_123", "key_123", "SUB-TRIAL-FALLBACK-001", "70010002", "7")
	err := svc.HandleZPayNotify(context.Background(), params)
	require.NoError(t, err)

	sub, err := userSubRepo.GetByUserIDAndGroupID(context.Background(), 9, 43)
	require.NoError(t, err)
	require.WithinDuration(t, before.AddDate(0, 0, 3), sub.ExpiresAt, 5*time.Second)
}

func TestHandleZPayNotify_RepeatPurchaseExtendsSubscription(t *testing.T) {
	t.Parallel()

	groupRepo := &groupRepoStub{
		groupsByID: map[int64]*service.Group{
			11: {
				ID:                  11,
				Name:                "3Trial",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 3,
			},
		},
		activeGroups: []service.Group{
			{
				ID:                  11,
				Name:                "3Trial",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 3,
			},
		},
	}
	userSubRepo := newUserSubscriptionRepoStub()
	subSvc := service.NewSubscriptionService(groupRepo, userSubRepo, nil, newPaymentSubscriptionEntClient(t), &config.Config{})
	repo := newPaymentOrderRepoStub()
	repo.put(&PaymentOrder{
		ID:         1001,
		UserID:     9,
		GroupID:    11,
		BizType:    BizTypeSubscription,
		OutTradeNo: "SUB-TRIAL-001",
		AmountFen:  700,
		Status:     StatusPending,
	})
	repo.put(&PaymentOrder{
		ID:         1002,
		UserID:     9,
		GroupID:    11,
		BizType:    BizTypeSubscription,
		OutTradeNo: "SUB-TRIAL-002",
		AmountFen:  700,
		Status:     StatusPending,
	})
	svc := &Service{
		repo:                repo,
		groupRepo:           groupRepo,
		subscriptionService: subSvc,
		settingGetter: settingGetterStub{
			bools: map[string]bool{
				service.SettingKeyZPayEnabled: true,
			},
			strings: map[string]string{
				service.SettingKeyZPayPID:     "pid_123",
				service.SettingKeyZPayKey:     "key_123",
				service.SettingKeyZPayGateway: "https://zpayz.cn/submit.php",
			},
		},
	}

	require.NoError(t, svc.HandleZPayNotify(context.Background(), signedZPayNotifyParams("pid_123", "key_123", "SUB-TRIAL-001", "70010001", "7")))
	first, err := userSubRepo.GetByUserIDAndGroupID(context.Background(), 9, 11)
	require.NoError(t, err)
	firstExpiry := first.ExpiresAt

	require.NoError(t, svc.HandleZPayNotify(context.Background(), signedZPayNotifyParams("pid_123", "key_123", "SUB-TRIAL-002", "70010002", "7")))
	second, err := userSubRepo.GetByUserIDAndGroupID(context.Background(), 9, 11)
	require.NoError(t, err)
	require.Equal(t, first.ID, second.ID)
	require.WithinDuration(t, firstExpiry.AddDate(0, 0, 3), second.ExpiresAt, 5*time.Second)
	require.Contains(t, second.Notes, "out_trade_no=SUB-TRIAL-001")
	require.Contains(t, second.Notes, "out_trade_no=SUB-TRIAL-002")

	secondOrder, err := repo.GetByOutTradeNo(context.Background(), "SUB-TRIAL-002")
	require.NoError(t, err)
	require.Equal(t, StatusPaid, secondOrder.Status)
}

func TestHandleZPayNotify_AssignsQuarterlySubscriptionUsingFixed90Days(t *testing.T) {
	t.Parallel()

	repo := newPaymentOrderRepoStub()
	repo.put(&PaymentOrder{
		ID:             2001,
		UserID:         9,
		GroupID:        37,
		PlanKey:        "starter",
		PlanVariantKey: "starter_3m",
		BillingMonths:  3,
		ValidityDays:   90,
		BizType:        BizTypeSubscription,
		OutTradeNo:     "SUB-STARTER-3M-001",
		AmountFen:      28350,
		Status:         StatusPending,
	})

	groupRepo := &groupRepoStub{
		groupsByID: map[int64]*service.Group{
			37: {
				ID:                  37,
				Name:                "Starter",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 30,
			},
		},
		activeGroups: []service.Group{
			{
				ID:                  37,
				Name:                "Starter",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 30,
			},
		},
	}
	userSubRepo := newUserSubscriptionRepoStub()
	subSvc := service.NewSubscriptionService(groupRepo, userSubRepo, nil, newPaymentSubscriptionEntClient(t), &config.Config{})
	svc := &Service{
		repo:                repo,
		groupRepo:           groupRepo,
		subscriptionService: subSvc,
		settingGetter: settingGetterStub{
			bools: map[string]bool{
				service.SettingKeyZPayEnabled: true,
			},
			strings: map[string]string{
				service.SettingKeyZPayPID:       "pid_123",
				service.SettingKeyZPayKey:       "key_123",
				service.SettingKeyZPayGateway:   "https://zpayz.cn/submit.php",
				service.SettingKeyZPayNotifyURL: "https://subjp2.tap365.org/api/v1/pay/notify/zpay",
				service.SettingKeyZPayReturnURL: "https://subjp2.tap365.org/purchase/result",
			},
		},
	}

	before := time.Now()
	params := signedZPayNotifyParams("pid_123", "key_123", "SUB-STARTER-3M-001", "70010003", "283.50")
	err := svc.HandleZPayNotify(context.Background(), params)
	require.NoError(t, err)

	sub, err := userSubRepo.GetByUserIDAndGroupID(context.Background(), 9, 37)
	require.NoError(t, err)
	require.WithinDuration(t, before.AddDate(0, 0, 90), sub.ExpiresAt, 5*time.Second)
	require.Contains(t, sub.Notes, "plan_variant_key=starter_3m")
}

func TestHandleZPayNotify_AssignsAnnualSubscriptionUsingFixed365Days(t *testing.T) {
	t.Parallel()

	repo := newPaymentOrderRepoStub()
	repo.put(&PaymentOrder{
		ID:             2002,
		UserID:         9,
		GroupID:        41,
		PlanKey:        "pro",
		PlanVariantKey: "pro_12m",
		BillingMonths:  12,
		ValidityDays:   365,
		BizType:        BizTypeSubscription,
		OutTradeNo:     "SUB-PRO-12M-001",
		AmountFen:      214200,
		Status:         StatusPending,
	})

	groupRepo := &groupRepoStub{
		groupsByID: map[int64]*service.Group{
			41: {
				ID:                  41,
				Name:                "Pro",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 30,
			},
		},
		activeGroups: []service.Group{{
			ID:                  41,
			Name:                "Pro",
			Status:              service.StatusActive,
			SubscriptionType:    service.SubscriptionTypeSubscription,
			DefaultValidityDays: 30,
		}},
	}
	userSubRepo := newUserSubscriptionRepoStub()
	subSvc := service.NewSubscriptionService(groupRepo, userSubRepo, nil, newPaymentSubscriptionEntClient(t), &config.Config{})
	svc := &Service{
		repo:                repo,
		groupRepo:           groupRepo,
		subscriptionService: subSvc,
		settingGetter: settingGetterStub{
			bools: map[string]bool{service.SettingKeyZPayEnabled: true},
			strings: map[string]string{
				service.SettingKeyZPayPID:       "pid_123",
				service.SettingKeyZPayKey:       "key_123",
				service.SettingKeyZPayGateway:   "https://zpayz.cn/submit.php",
				service.SettingKeyZPayNotifyURL: "https://subjp2.tap365.org/api/v1/pay/notify/zpay",
				service.SettingKeyZPayReturnURL: "https://subjp2.tap365.org/purchase/result",
			},
		},
	}

	before := time.Now()
	err := svc.HandleZPayNotify(context.Background(), signedZPayNotifyParams("pid_123", "key_123", "SUB-PRO-12M-001", "70010004", "2142"))
	require.NoError(t, err)

	sub, err := userSubRepo.GetByUserIDAndGroupID(context.Background(), 9, 41)
	require.NoError(t, err)
	require.WithinDuration(t, before.AddDate(0, 0, 365), sub.ExpiresAt, 5*time.Second)
	require.Contains(t, sub.Notes, "plan_variant_key=pro_12m")
}

func TestHandleZPayNotify_AlreadyPaidRetryDoesNotDoubleExtend(t *testing.T) {
	t.Parallel()

	groupRepo := &groupRepoStub{
		groupsByID: map[int64]*service.Group{
			11: {
				ID:                  11,
				Name:                "3Trial",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 3,
			},
		},
		activeGroups: []service.Group{
			{
				ID:                  11,
				Name:                "3Trial",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 3,
			},
		},
	}
	userSubRepo := newUserSubscriptionRepoStub()
	subSvc := service.NewSubscriptionService(groupRepo, userSubRepo, nil, newPaymentSubscriptionEntClient(t), &config.Config{})
	repo := newPaymentOrderRepoStub()
	repo.put(&PaymentOrder{
		ID:         1001,
		UserID:     9,
		GroupID:    11,
		BizType:    BizTypeSubscription,
		OutTradeNo: "SUB-TRIAL-001",
		AmountFen:  700,
		Status:     StatusPending,
	})
	svc := &Service{
		repo:                repo,
		groupRepo:           groupRepo,
		subscriptionService: subSvc,
		settingGetter: settingGetterStub{
			bools: map[string]bool{
				service.SettingKeyZPayEnabled: true,
			},
			strings: map[string]string{
				service.SettingKeyZPayPID:     "pid_123",
				service.SettingKeyZPayKey:     "key_123",
				service.SettingKeyZPayGateway: "https://zpayz.cn/submit.php",
			},
		},
	}

	params := signedZPayNotifyParams("pid_123", "key_123", "SUB-TRIAL-001", "70010001", "7")
	require.NoError(t, svc.HandleZPayNotify(context.Background(), params))
	first, err := userSubRepo.GetByUserIDAndGroupID(context.Background(), 9, 11)
	require.NoError(t, err)
	firstExpiry := first.ExpiresAt

	require.NoError(t, svc.HandleZPayNotify(context.Background(), params))
	second, err := userSubRepo.GetByUserIDAndGroupID(context.Background(), 9, 11)
	require.NoError(t, err)
	require.WithinDuration(t, firstExpiry, second.ExpiresAt, time.Second)
}

func TestHandleZPayNotify_StatusUpdateRetryDoesNotDoubleExtend(t *testing.T) {
	t.Parallel()

	groupRepo := &groupRepoStub{
		groupsByID: map[int64]*service.Group{
			11: {
				ID:                  11,
				Name:                "3Trial",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 3,
			},
		},
		activeGroups: []service.Group{
			{
				ID:                  11,
				Name:                "3Trial",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 3,
			},
		},
	}
	userSubRepo := newUserSubscriptionRepoStub()
	subSvc := service.NewSubscriptionService(groupRepo, userSubRepo, nil, newPaymentSubscriptionEntClient(t), &config.Config{})
	repo := newPaymentOrderRepoStub()
	repo.updateStatusFailures = 1
	repo.put(&PaymentOrder{
		ID:         1001,
		UserID:     9,
		GroupID:    11,
		BizType:    BizTypeSubscription,
		OutTradeNo: "SUB-TRIAL-001",
		AmountFen:  700,
		Status:     StatusPending,
	})
	svc := &Service{
		repo:                repo,
		groupRepo:           groupRepo,
		subscriptionService: subSvc,
		settingGetter: settingGetterStub{
			bools: map[string]bool{
				service.SettingKeyZPayEnabled: true,
			},
			strings: map[string]string{
				service.SettingKeyZPayPID:     "pid_123",
				service.SettingKeyZPayKey:     "key_123",
				service.SettingKeyZPayGateway: "https://zpayz.cn/submit.php",
			},
		},
	}

	params := signedZPayNotifyParams("pid_123", "key_123", "SUB-TRIAL-001", "70010001", "7")
	err := svc.HandleZPayNotify(context.Background(), params)
	require.Error(t, err)
	require.Contains(t, err.Error(), "update order status")

	first, err := userSubRepo.GetByUserIDAndGroupID(context.Background(), 9, 11)
	require.NoError(t, err)
	firstExpiry := first.ExpiresAt
	require.Contains(t, first.Notes, "out_trade_no=SUB-TRIAL-001")

	orderAfterFirst, err := repo.GetByOutTradeNo(context.Background(), "SUB-TRIAL-001")
	require.NoError(t, err)
	require.Equal(t, StatusPending, orderAfterFirst.Status)

	require.NoError(t, svc.HandleZPayNotify(context.Background(), params))

	second, err := userSubRepo.GetByUserIDAndGroupID(context.Background(), 9, 11)
	require.NoError(t, err)
	require.WithinDuration(t, firstExpiry, second.ExpiresAt, time.Second)
	require.Contains(t, second.Notes, "out_trade_no=SUB-TRIAL-001")

	orderAfterRetry, err := repo.GetByOutTradeNo(context.Background(), "SUB-TRIAL-001")
	require.NoError(t, err)
	require.Equal(t, StatusPaid, orderAfterRetry.Status)
}

func TestHandleZPayNotify_ReconcilesPaidOrderWithoutPriorFulfillment(t *testing.T) {
	t.Parallel()

	groupRepo := &groupRepoStub{
		groupsByID: map[int64]*service.Group{
			11: {
				ID:                  11,
				Name:                "3Trial",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 3,
			},
		},
		activeGroups: []service.Group{
			{
				ID:                  11,
				Name:                "3Trial",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 3,
			},
		},
	}
	userSubRepo := newUserSubscriptionRepoStub()
	subSvc := service.NewSubscriptionService(groupRepo, userSubRepo, nil, newPaymentSubscriptionEntClient(t), &config.Config{})
	repo := newPaymentOrderRepoStub()
	repo.put(&PaymentOrder{
		ID:                  1001,
		UserID:              9,
		GroupID:             11,
		BizType:             BizTypeSubscription,
		OutTradeNo:          "SUB-TRIAL-001",
		AmountFen:           700,
		Status:              StatusPaid,
		WechatTransactionID: "70010001",
	})
	svc := &Service{
		repo:                repo,
		groupRepo:           groupRepo,
		subscriptionService: subSvc,
		settingGetter: settingGetterStub{
			bools: map[string]bool{
				service.SettingKeyZPayEnabled: true,
			},
			strings: map[string]string{
				service.SettingKeyZPayPID:     "pid_123",
				service.SettingKeyZPayKey:     "key_123",
				service.SettingKeyZPayGateway: "https://zpayz.cn/submit.php",
			},
		},
	}

	require.NoError(t, svc.HandleZPayNotify(context.Background(), signedZPayNotifyParams("pid_123", "key_123", "SUB-TRIAL-001", "70010001", "7")))

	sub, err := userSubRepo.GetByUserIDAndGroupID(context.Background(), 9, 11)
	require.NoError(t, err)
	require.Contains(t, sub.Notes, "out_trade_no=SUB-TRIAL-001")
	require.Equal(t, service.SubscriptionStatusActive, sub.Status)
}

type paymentOrderRepoStub struct {
	created              *PaymentOrder
	byID                 map[int64]*PaymentOrder
	byTrade              map[string]*PaymentOrder
	updateStatusFailures int
}

func newPaymentOrderRepoStub() *paymentOrderRepoStub {
	return &paymentOrderRepoStub{
		byID:    make(map[int64]*PaymentOrder),
		byTrade: make(map[string]*PaymentOrder),
	}
}

func (s *paymentOrderRepoStub) put(order *PaymentOrder) {
	if order == nil {
		return
	}
	if s.byID == nil {
		s.byID = make(map[int64]*PaymentOrder)
	}
	if s.byTrade == nil {
		s.byTrade = make(map[string]*PaymentOrder)
	}
	copyOrder := *order
	s.created = &copyOrder
	s.byID[copyOrder.ID] = &copyOrder
	s.byTrade[copyOrder.OutTradeNo] = &copyOrder
}

func (s *paymentOrderRepoStub) Create(_ context.Context, order *PaymentOrder) (*PaymentOrder, error) {
	if order == nil {
		return nil, nil
	}
	copyOrder := *order
	if copyOrder.ID == 0 {
		copyOrder.ID = int64(len(s.byID) + 1)
	}
	s.put(&copyOrder)
	return s.byID[copyOrder.ID], nil
}

func (s *paymentOrderRepoStub) GetByID(_ context.Context, id int64) (*PaymentOrder, error) {
	if order, ok := s.byID[id]; ok {
		copyOrder := *order
		return &copyOrder, nil
	}
	return nil, nil
}

func (s *paymentOrderRepoStub) GetByOutTradeNo(_ context.Context, outTradeNo string) (*PaymentOrder, error) {
	if order, ok := s.byTrade[outTradeNo]; ok {
		copyOrder := *order
		return &copyOrder, nil
	}
	return nil, nil
}

func (s *paymentOrderRepoStub) ListByUserID(_ context.Context, userID int64, limit int) ([]PaymentOrder, error) {
	items := make([]PaymentOrder, 0)
	for _, order := range s.byID {
		if order == nil || order.UserID != userID {
			continue
		}
		items = append(items, *order)
	}
	if limit > 0 && len(items) > limit {
		items = items[:limit]
	}
	return items, nil
}

func (s *paymentOrderRepoStub) UpdateStatus(_ context.Context, id int64, status, wechatTransactionID string, notifyAt *time.Time) error {
	if s.updateStatusFailures > 0 {
		s.updateStatusFailures--
		return fmt.Errorf("forced update status failure")
	}
	if order, ok := s.byID[id]; ok {
		order.Status = status
		order.WechatTransactionID = wechatTransactionID
		order.NotifyAt = notifyAt
		s.byTrade[order.OutTradeNo] = order
		if s.created != nil && s.created.ID == id {
			s.created = order
		}
	}
	return nil
}

type groupRepoStub struct {
	activeGroups []service.Group
	groupsByID   map[int64]*service.Group
	getByIDErr   error
	listErr      error
}

func (s *groupRepoStub) Create(context.Context, *service.Group) error { return nil }

func (s *groupRepoStub) GetByID(_ context.Context, id int64) (*service.Group, error) {
	if s.getByIDErr != nil {
		return nil, s.getByIDErr
	}
	if s.groupsByID != nil {
		if group, ok := s.groupsByID[id]; ok {
			return cloneGroup(group), nil
		}
	}
	for i := range s.activeGroups {
		group := &s.activeGroups[i]
		if group.ID == id {
			return cloneGroup(group), nil
		}
	}
	return nil, service.ErrGroupNotFound
}

func (s *groupRepoStub) GetByIDLite(ctx context.Context, id int64) (*service.Group, error) {
	return s.GetByID(ctx, id)
}

func (s *groupRepoStub) Update(context.Context, *service.Group) error { return nil }

func (s *groupRepoStub) Delete(context.Context, int64) error { return nil }

func (s *groupRepoStub) DeleteCascade(context.Context, int64) ([]int64, error) { return nil, nil }

func (s *groupRepoStub) List(context.Context, pagination.PaginationParams) ([]service.Group, *pagination.PaginationResult, error) {
	return nil, nil, nil
}

func (s *groupRepoStub) ListWithFilters(context.Context, pagination.PaginationParams, string, string, string, *bool) ([]service.Group, *pagination.PaginationResult, error) {
	return nil, nil, nil
}

func (s *groupRepoStub) ListActive(context.Context) ([]service.Group, error) {
	if s.listErr != nil {
		return nil, s.listErr
	}
	cloned := make([]service.Group, 0, len(s.activeGroups))
	for i := range s.activeGroups {
		cloned = append(cloned, *cloneGroup(&s.activeGroups[i]))
	}
	return cloned, nil
}

func (s *groupRepoStub) ListActiveByPlatform(ctx context.Context, _ string) ([]service.Group, error) {
	return s.ListActive(ctx)
}

func (s *groupRepoStub) ExistsByName(context.Context, string) (bool, error) { return false, nil }

func (s *groupRepoStub) GetAccountCount(context.Context, int64) (int64, int64, error) {
	return 0, 0, nil
}

func (s *groupRepoStub) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
	return 0, nil
}

func (s *groupRepoStub) GetAccountIDsByGroupIDs(context.Context, []int64) ([]int64, error) {
	return nil, nil
}

func (s *groupRepoStub) BindAccountsToGroup(context.Context, int64, []int64) error { return nil }

func (s *groupRepoStub) UpdateSortOrders(context.Context, []service.GroupSortOrderUpdate) error {
	return nil
}

type settingGetterStub struct {
	bools   map[string]bool
	strings map[string]string
}

func (s settingGetterStub) GetString(_ context.Context, key string) (string, error) {
	if s.strings == nil {
		return "", nil
	}
	return s.strings[key], nil
}

func (s settingGetterStub) GetBool(_ context.Context, key string) (bool, error) {
	if s.bools == nil {
		return false, nil
	}
	return s.bools[key], nil
}

type userAssetRepoStub struct {
	asset           *service.UserAsset
	changes         []*service.UserAssetChange
	ensureErr       error
	updateErr       error
	createChangeErr error
}

func newUserAssetRepoStub(userID int64, availableAmount string) *userAssetRepoStub {
	return &userAssetRepoStub{
		asset: &service.UserAsset{
			ID:                   "asset_points_1",
			UserID:               userID,
			AssetCategory:        service.AssetCategoryInternalCredit,
			AssetCode:            service.AssetCodePoints,
			AssetChainType:       service.AssetChainTypeInternal,
			AssetNetwork:         service.AssetNetworkInternal,
			AssetChainRef:        service.AssetChainRefInternal,
			AssetContractAddress: "",
			AssetDecimals:        8,
			CustodyType:          service.CustodyTypeInternal,
			AccountRef:           "",
			AvailableAmount:      availableAmount,
			FrozenAmount:         "0.00000000",
			PendingAmount:        "0.00000000",
			TotalEarned:          "0.00000000",
			TotalSpent:           "0.00000000",
			Version:              1,
			Status:               service.AssetStatusActive,
			Extra:                map[string]any{},
		},
		changes: make([]*service.UserAssetChange, 0),
	}
}

func (s *userAssetRepoStub) cloneAsset() *service.UserAsset {
	if s == nil || s.asset == nil {
		return nil
	}
	cloned := *s.asset
	if s.asset.Extra != nil {
		cloned.Extra = make(map[string]any, len(s.asset.Extra))
		for k, v := range s.asset.Extra {
			cloned.Extra[k] = v
		}
	}
	return &cloned
}

func (s *userAssetRepoStub) GetByID(context.Context, string) (*service.UserAsset, error) {
	return s.cloneAsset(), nil
}

func (s *userAssetRepoStub) GetByLogicalIdentity(context.Context, int64, string, string, string, string) (*service.UserAsset, error) {
	return s.cloneAsset(), nil
}

func (s *userAssetRepoStub) EnsureAsset(context.Context, *service.EnsureUserAssetInput) (*service.UserAsset, error) {
	return s.cloneAsset(), nil
}

func (s *userAssetRepoStub) EnsurePointsAssetForUpdate(context.Context, int64) (*service.UserAsset, error) {
	if s.ensureErr != nil {
		return nil, s.ensureErr
	}
	return s.cloneAsset(), nil
}

func (s *userAssetRepoStub) UpdateAmountsOptimistic(_ context.Context, input *service.UpdateUserAssetAmountsInput) (*service.UserAsset, error) {
	if s.updateErr != nil {
		return nil, s.updateErr
	}
	s.asset.AvailableAmount = input.AvailableAmount
	s.asset.FrozenAmount = input.FrozenAmount
	s.asset.PendingAmount = input.PendingAmount
	s.asset.TotalEarned = input.TotalEarned
	s.asset.TotalSpent = input.TotalSpent
	s.asset.Version++
	return s.cloneAsset(), nil
}

func (s *userAssetRepoStub) CreateChange(_ context.Context, input *service.CreateUserAssetChangeInput) (*service.UserAssetChange, error) {
	if s.createChangeErr != nil {
		return nil, s.createChangeErr
	}
	change := &service.UserAssetChange{
		ID:              input.ID,
		UserAssetID:     input.UserAssetID,
		UserID:          input.UserID,
		AssetCode:       input.AssetCode,
		Direction:       input.Direction,
		AvailableDelta:  input.AvailableDelta,
		AvailableBefore: input.AvailableBefore,
		AvailableAfter:  input.AvailableAfter,
		FrozenDelta:     input.FrozenDelta,
		FrozenBefore:    input.FrozenBefore,
		FrozenAfter:     input.FrozenAfter,
		BizType:         input.BizType,
		BizRefType:      input.BizRefType,
		BizRefID:        input.BizRefID,
		IdempotencyKey:  input.IdempotencyKey,
		Remark:          input.Remark,
		Extra:           input.Extra,
		OccurredAt:      input.OccurredAt,
		CreatedAt:       time.Now(),
	}
	s.changes = append(s.changes, change)
	return change, nil
}

func (s *userAssetRepoStub) ListChangesByUser(context.Context, int64, pagination.PaginationParams) ([]service.UserAssetChange, *pagination.PaginationResult, error) {
	return nil, &pagination.PaginationResult{}, nil
}

func (s *userAssetRepoStub) ListPointChangesByUser(context.Context, int64, pagination.PaginationParams) ([]service.UserAssetChange, *pagination.PaginationResult, error) {
	return nil, &pagination.PaginationResult{}, nil
}

func cloneGroup(group *service.Group) *service.Group {
	if group == nil {
		return nil
	}
	cloned := *group
	if group.ModelRouting != nil {
		cloned.ModelRouting = make(map[string][]int64, len(group.ModelRouting))
		for k, ids := range group.ModelRouting {
			cloned.ModelRouting[k] = append([]int64(nil), ids...)
		}
	}
	if group.SupportedModelScopes != nil {
		cloned.SupportedModelScopes = append([]string(nil), group.SupportedModelScopes...)
	}
	return &cloned
}

type userSubscriptionRepoStub struct {
	nextID int64
	byID   map[int64]*service.UserSubscription
	byKey  map[string]*service.UserSubscription
}

func newUserSubscriptionRepoStub() *userSubscriptionRepoStub {
	return &userSubscriptionRepoStub{
		nextID: 1,
		byID:   make(map[int64]*service.UserSubscription),
		byKey:  make(map[string]*service.UserSubscription),
	}
}

func subscriptionKey(userID, groupID int64) string {
	return fmt.Sprintf("%d:%d", userID, groupID)
}

func cloneSubscription(sub *service.UserSubscription) *service.UserSubscription {
	if sub == nil {
		return nil
	}
	cloned := *sub
	if sub.AssignedBy != nil {
		assignedBy := *sub.AssignedBy
		cloned.AssignedBy = &assignedBy
	}
	if sub.User != nil {
		user := *sub.User
		cloned.User = &user
	}
	if sub.Group != nil {
		cloned.Group = cloneGroup(sub.Group)
	}
	if sub.AssignedByUser != nil {
		user := *sub.AssignedByUser
		cloned.AssignedByUser = &user
	}
	return &cloned
}

func (s *userSubscriptionRepoStub) store(sub *service.UserSubscription) *service.UserSubscription {
	if sub.ID == 0 {
		sub.ID = s.nextID
		s.nextID++
	}
	cloned := cloneSubscription(sub)
	s.byID[cloned.ID] = cloned
	s.byKey[subscriptionKey(cloned.UserID, cloned.GroupID)] = cloned
	return cloneSubscription(cloned)
}

func (s *userSubscriptionRepoStub) Create(_ context.Context, sub *service.UserSubscription) error {
	s.store(sub)
	return nil
}

func (s *userSubscriptionRepoStub) GetByID(_ context.Context, id int64) (*service.UserSubscription, error) {
	if sub, ok := s.byID[id]; ok {
		return cloneSubscription(sub), nil
	}
	return nil, service.ErrSubscriptionNotFound
}

func (s *userSubscriptionRepoStub) GetByUserIDAndGroupID(_ context.Context, userID, groupID int64) (*service.UserSubscription, error) {
	if sub, ok := s.byKey[subscriptionKey(userID, groupID)]; ok {
		return cloneSubscription(sub), nil
	}
	return nil, service.ErrSubscriptionNotFound
}

func (s *userSubscriptionRepoStub) GetActiveByUserIDAndGroupID(_ context.Context, userID, groupID int64) (*service.UserSubscription, error) {
	sub, err := s.GetByUserIDAndGroupID(context.Background(), userID, groupID)
	if err != nil {
		return nil, err
	}
	if sub.Status != service.SubscriptionStatusActive || !sub.ExpiresAt.After(time.Now()) {
		return nil, service.ErrSubscriptionNotFound
	}
	return sub, nil
}

func (s *userSubscriptionRepoStub) Update(_ context.Context, sub *service.UserSubscription) error {
	s.store(sub)
	return nil
}

func (s *userSubscriptionRepoStub) Delete(_ context.Context, id int64) error {
	if sub, ok := s.byID[id]; ok {
		delete(s.byKey, subscriptionKey(sub.UserID, sub.GroupID))
		delete(s.byID, id)
	}
	return nil
}

func (s *userSubscriptionRepoStub) ListByUserID(_ context.Context, userID int64) ([]service.UserSubscription, error) {
	var out []service.UserSubscription
	for _, sub := range s.byID {
		if sub.UserID == userID {
			out = append(out, *cloneSubscription(sub))
		}
	}
	return out, nil
}

func (s *userSubscriptionRepoStub) ListActiveByUserID(ctx context.Context, userID int64) ([]service.UserSubscription, error) {
	subs, err := s.ListByUserID(ctx, userID)
	if err != nil {
		return nil, err
	}
	var out []service.UserSubscription
	for i := range subs {
		if subs[i].Status == service.SubscriptionStatusActive && subs[i].ExpiresAt.After(time.Now()) {
			out = append(out, subs[i])
		}
	}
	return out, nil
}

func (s *userSubscriptionRepoStub) ListByGroupID(_ context.Context, groupID int64, _ pagination.PaginationParams) ([]service.UserSubscription, *pagination.PaginationResult, error) {
	var out []service.UserSubscription
	for _, sub := range s.byID {
		if sub.GroupID == groupID {
			out = append(out, *cloneSubscription(sub))
		}
	}
	return out, &pagination.PaginationResult{Total: int64(len(out)), Page: 1, PageSize: len(out), Pages: 1}, nil
}

func (s *userSubscriptionRepoStub) List(_ context.Context, _ pagination.PaginationParams, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) {
	var out []service.UserSubscription
	for _, sub := range s.byID {
		if userID != nil && sub.UserID != *userID {
			continue
		}
		if groupID != nil && sub.GroupID != *groupID {
			continue
		}
		if status != "" && sub.Status != status {
			continue
		}
		_ = platform
		_ = sortBy
		_ = sortOrder
		out = append(out, *cloneSubscription(sub))
	}
	return out, &pagination.PaginationResult{Total: int64(len(out)), Page: 1, PageSize: len(out), Pages: 1}, nil
}

func (s *userSubscriptionRepoStub) ExistsByUserIDAndGroupID(_ context.Context, userID, groupID int64) (bool, error) {
	_, ok := s.byKey[subscriptionKey(userID, groupID)]
	return ok, nil
}

func (s *userSubscriptionRepoStub) ExtendExpiry(_ context.Context, subscriptionID int64, newExpiresAt time.Time) error {
	sub, ok := s.byID[subscriptionID]
	if !ok {
		return service.ErrSubscriptionNotFound
	}
	sub.ExpiresAt = newExpiresAt
	sub.UpdatedAt = time.Now()
	return nil
}

func (s *userSubscriptionRepoStub) UpdateStatus(_ context.Context, subscriptionID int64, status string) error {
	sub, ok := s.byID[subscriptionID]
	if !ok {
		return service.ErrSubscriptionNotFound
	}
	sub.Status = status
	sub.UpdatedAt = time.Now()
	return nil
}

func (s *userSubscriptionRepoStub) UpdateNotes(_ context.Context, subscriptionID int64, notes string) error {
	sub, ok := s.byID[subscriptionID]
	if !ok {
		return service.ErrSubscriptionNotFound
	}
	sub.Notes = notes
	sub.UpdatedAt = time.Now()
	return nil
}

func (s *userSubscriptionRepoStub) ActivateWindows(context.Context, int64, time.Time) error {
	return nil
}

func (s *userSubscriptionRepoStub) ResetDailyUsage(context.Context, int64, time.Time) error {
	return nil
}

func (s *userSubscriptionRepoStub) ResetWeeklyUsage(context.Context, int64, time.Time) error {
	return nil
}

func (s *userSubscriptionRepoStub) ResetMonthlyUsage(context.Context, int64, time.Time) error {
	return nil
}

func (s *userSubscriptionRepoStub) IncrementUsage(context.Context, int64, float64) error { return nil }

func (s *userSubscriptionRepoStub) BatchUpdateExpiredStatus(context.Context) (int64, error) {
	return 0, nil
}

func signedZPayNotifyParams(pid, key, outTradeNo, tradeNo, money string) map[string]string {
	params := map[string]string{
		"pid":          pid,
		"out_trade_no": outTradeNo,
		"trade_no":     tradeNo,
		"money":        money,
		"name":         "3Trial",
		"trade_status": "TRADE_SUCCESS",
		"type":         "wxpay",
	}
	params["sign"] = generateZPaySign(params, key)
	params["sign_type"] = "MD5"
	return params
}

func TestCreateWechatNativeOrder_UsesConfiguredSubscriptionPricingProfile(t *testing.T) {
	t.Parallel()

	repo := newPaymentOrderRepoStub()
	groupRepo := &groupRepoStub{
		activeGroups: []service.Group{{
			ID:                  11,
			Name:                "Trial3",
			Status:              service.StatusActive,
			SubscriptionType:    service.SubscriptionTypeSubscription,
			DefaultValidityDays: 3,
		}},
	}
	settings := settingGetterStub{
		bools: map[string]bool{service.SettingKeyZPayEnabled: true},
		strings: map[string]string{
			service.SettingKeyZPayPID:       "pid_123",
			service.SettingKeyZPayKey:       "key_123",
			service.SettingKeyZPayGateway:   "https://zpayz.cn/submit.php",
			service.SettingKeyZPayNotifyURL: "https://subjp2.tap365.org/api/v1/pay/notify/zpay",
			service.SettingKeyZPayReturnURL: "https://subjp2.tap365.org/purchase/result",
		},
	}
	svc := NewService(repo, groupRepo, nil, nil, nil, nil, settings, 1, map[string]int{
		"3trial": 700,
	})

	result, err := svc.CreateWechatNativeOrder(context.Background(), &CreateOrderInput{
		UserID:      1,
		PlanKey:     "3trial",
		BizType:     BizTypeSubscription,
		AmountFen:   700,
		Description: "Trial3 test mode",
	})
	require.NoError(t, err)
	require.NotNil(t, result)
	require.NotNil(t, repo.created)
	require.Equal(t, 700, repo.created.AmountFen)
}

func TestResolveSubscriptionPlanPriceMap_UsesDefaultsWhenConfigEmpty(t *testing.T) {
	resolved := config.ResolveSubscriptionPlanPriceMap(config.SubscriptionPlanPriceCatalogConfig{})
	require.Equal(t, config.DefaultSubscriptionPlanPriceMap(), resolved)
}

func TestCreateSubscriptionPointsOrder_SucceedsAndSettlesInviteCommission(t *testing.T) {
	t.Parallel()

	groupRepo := &groupRepoStub{
		activeGroups: []service.Group{{
			ID:                  11,
			Name:                "Trial3",
			Status:              service.StatusActive,
			SubscriptionType:    service.SubscriptionTypeSubscription,
			DefaultValidityDays: 3,
		}},
		groupsByID: map[int64]*service.Group{
			11: {
				ID:                  11,
				Name:                "Trial3",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 3,
			},
		},
	}
	userSubRepo := newUserSubscriptionRepoStub()
	entClient := newPaymentSubscriptionEntClient(t)
	subSvc := service.NewSubscriptionService(groupRepo, userSubRepo, nil, entClient, &config.Config{})
	orderRepo := newPaymentOrderRepoStub()
	assetRepo := newUserAssetRepoStub(9, "20.00000000")
	inviteSpy := &inviteCommissionSettlerSpy{}
	svc := &Service{
		repo:                   orderRepo,
		groupRepo:              groupRepo,
		subscriptionService:    subSvc,
		userAssetRepo:          assetRepo,
		entClient:              entClient,
		settingGetter:          settingGetterStub{},
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
		inviteCommissionSvc:    inviteSpy,
	}

	result, err := svc.CreateSubscriptionPointsOrder(context.Background(), &CreateSubscriptionPointsOrderInput{
		UserID:         9,
		PlanKey:        "3trial",
		BillingMonths:  1,
		IdempotencyKey: "points-order-1",
	})
	require.NoError(t, err)
	require.NotNil(t, result)
	require.Equal(t, StatusPaid, result.Status)
	require.Equal(t, 700, result.AmountFen)
	require.Equal(t, "7.00000000", result.PointsCost)
	require.Equal(t, PaymentMethodPoints, result.PaymentMethod)

	require.NotNil(t, orderRepo.created)
	require.Equal(t, PaymentMethodPoints, orderRepo.created.PaymentMethod)
	require.Equal(t, StatusPaid, orderRepo.created.Status)
	require.Equal(t, int64(11), orderRepo.created.GroupID)

	require.Equal(t, "13.00000000", assetRepo.asset.AvailableAmount)
	require.Equal(t, "7.00000000", assetRepo.asset.TotalSpent)
	require.Len(t, assetRepo.changes, 1)
	require.Equal(t, "subscription_points_purchase", assetRepo.changes[0].BizType)

	sub, err := userSubRepo.GetByUserIDAndGroupID(context.Background(), 9, 11)
	require.NoError(t, err)
	require.Equal(t, service.SubscriptionStatusActive, sub.Status)
	require.Contains(t, sub.Notes, "积分兑换订阅")

	require.Len(t, inviteSpy.orders, 1)
	require.Equal(t, result.OrderID, inviteSpy.orders[0].ID)
	require.Equal(t, int64(9), inviteSpy.orders[0].BuyerUserID)
	require.Equal(t, int64(700), inviteSpy.orders[0].OrderAmountFen)
}

func TestCreateSubscriptionPointsOrder_InsufficientPoints(t *testing.T) {
	t.Parallel()

	groupRepo := &groupRepoStub{
		activeGroups: []service.Group{{
			ID:                  11,
			Name:                "Trial3",
			Status:              service.StatusActive,
			SubscriptionType:    service.SubscriptionTypeSubscription,
			DefaultValidityDays: 3,
		}},
	}
	entClient := newPaymentSubscriptionEntClient(t)
	subSvc := service.NewSubscriptionService(groupRepo, newUserSubscriptionRepoStub(), nil, entClient, &config.Config{})
	orderRepo := newPaymentOrderRepoStub()
	assetRepo := newUserAssetRepoStub(9, "3.00000000")
	inviteSpy := &inviteCommissionSettlerSpy{}
	svc := &Service{
		repo:                   orderRepo,
		groupRepo:              groupRepo,
		subscriptionService:    subSvc,
		userAssetRepo:          assetRepo,
		entClient:              entClient,
		settingGetter:          settingGetterStub{},
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
		inviteCommissionSvc:    inviteSpy,
	}

	result, err := svc.CreateSubscriptionPointsOrder(context.Background(), &CreateSubscriptionPointsOrderInput{
		UserID:         9,
		PlanKey:        "3trial",
		BillingMonths:  1,
		IdempotencyKey: "points-order-2",
	})
	require.Nil(t, result)
	require.ErrorIs(t, err, ErrPaymentPointsInsufficient)
	require.Nil(t, orderRepo.created)
	require.Equal(t, "3.00000000", assetRepo.asset.AvailableAmount)
	require.Empty(t, assetRepo.changes)
	require.Empty(t, inviteSpy.orders)
}

func TestCreateSubscriptionPointsOrder_InviteSettlementFailureDoesNotRollbackPurchase(t *testing.T) {
	t.Parallel()

	groupRepo := &groupRepoStub{
		activeGroups: []service.Group{{
			ID:                  11,
			Name:                "Trial3",
			Status:              service.StatusActive,
			SubscriptionType:    service.SubscriptionTypeSubscription,
			DefaultValidityDays: 3,
		}},
	}
	userSubRepo := newUserSubscriptionRepoStub()
	entClient := newPaymentSubscriptionEntClient(t)
	subSvc := service.NewSubscriptionService(groupRepo, userSubRepo, nil, entClient, &config.Config{})
	orderRepo := newPaymentOrderRepoStub()
	assetRepo := newUserAssetRepoStub(9, "20.00000000")
	inviteSpy := &inviteCommissionSettlerSpy{err: fmt.Errorf("settle failed")}
	retryWorker := &InviteCommissionRetryWorker{dispatchCh: make(chan int64, 1)}
	svc := &Service{
		repo:                   orderRepo,
		groupRepo:              groupRepo,
		subscriptionService:    subSvc,
		userAssetRepo:          assetRepo,
		entClient:              entClient,
		settingGetter:          settingGetterStub{},
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
		inviteCommissionSvc:    inviteSpy,
		inviteRetryWorker:      retryWorker,
	}

	result, err := svc.CreateSubscriptionPointsOrder(context.Background(), &CreateSubscriptionPointsOrderInput{
		UserID:         9,
		PlanKey:        "3trial",
		BillingMonths:  1,
		IdempotencyKey: "points-order-3",
	})
	require.NoError(t, err)
	require.NotNil(t, result)
	require.NotNil(t, orderRepo.created)
	require.Equal(t, StatusPaid, orderRepo.created.Status)
	require.Equal(t, "13.00000000", assetRepo.asset.AvailableAmount)

	sub, subErr := userSubRepo.GetByUserIDAndGroupID(context.Background(), 9, 11)
	require.NoError(t, subErr)
	require.Equal(t, service.SubscriptionStatusActive, sub.Status)
	require.Len(t, inviteSpy.orders, 1)

	select {
	case retryOrderID := <-retryWorker.dispatchCh:
		require.Equal(t, result.OrderID, retryOrderID)
	default:
		t.Fatal("expected invite retry worker to receive dispatched order id")
	}
}

func createPaymentTestUser(t *testing.T, ctx context.Context, client *dbent.Client, email string, balance float64) *dbent.User {
	t.Helper()

	user, err := client.User.Create().
		SetEmail(email).
		SetPasswordHash("test-password-hash").
		SetInviteCode(fmt.Sprintf("invite_%d", time.Now().UnixNano())).
		SetBalance(balance).
		Save(ctx)
	require.NoError(t, err)
	return user
}

func TestCreateSubscriptionBalanceOrder_SucceedsAndSettlesInviteCommission(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	groupRepo := &groupRepoStub{
		activeGroups: []service.Group{{
			ID:                  11,
			Name:                "Trial3",
			Status:              service.StatusActive,
			SubscriptionType:    service.SubscriptionTypeSubscription,
			DefaultValidityDays: 3,
		}},
		groupsByID: map[int64]*service.Group{
			11: {
				ID:                  11,
				Name:                "Trial3",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 3,
			},
		},
	}
	userSubRepo := newUserSubscriptionRepoStub()
	entClient := newPaymentSubscriptionEntClient(t)
	user := createPaymentTestUser(t, ctx, entClient, "balance-success@example.com", 20)
	subSvc := service.NewSubscriptionService(groupRepo, userSubRepo, nil, entClient, &config.Config{})
	orderRepo := newPaymentOrderRepoStub()
	inviteSpy := &inviteCommissionSettlerSpy{}
	svc := &Service{
		repo:                   orderRepo,
		groupRepo:              groupRepo,
		subscriptionService:    subSvc,
		entClient:              entClient,
		settingGetter:          settingGetterStub{},
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
		inviteCommissionSvc:    inviteSpy,
	}

	result, err := svc.CreateSubscriptionBalanceOrder(ctx, &CreateSubscriptionBalanceOrderInput{
		UserID:         user.ID,
		PlanKey:        "3trial",
		BillingMonths:  1,
		IdempotencyKey: "balance-order-1",
	})
	require.NoError(t, err)
	require.NotNil(t, result)
	require.Equal(t, StatusPaid, result.Status)
	require.Equal(t, 700, result.AmountFen)
	require.Equal(t, "7.00000000", result.BalanceCost)
	require.Equal(t, 13.0, result.BalanceAfter)
	require.Equal(t, PaymentMethodBalance, result.PaymentMethod)

	require.NotNil(t, orderRepo.created)
	require.Equal(t, PaymentMethodBalance, orderRepo.created.PaymentMethod)
	require.Equal(t, StatusPaid, orderRepo.created.Status)
	require.Equal(t, int64(11), orderRepo.created.GroupID)

	reloadedUser, err := entClient.User.Get(ctx, user.ID)
	require.NoError(t, err)
	require.Equal(t, 13.0, reloadedUser.Balance)

	audits, err := entClient.RedeemCode.Query().All(ctx)
	require.NoError(t, err)
	require.Len(t, audits, 1)
	require.Equal(t, service.RedeemTypeBalance, audits[0].Type)
	require.Equal(t, -7.0, audits[0].Value)
	require.Equal(t, service.StatusUsed, audits[0].Status)
	require.NotNil(t, audits[0].UsedBy)
	require.Equal(t, user.ID, *audits[0].UsedBy)
	require.NotNil(t, audits[0].Notes)
	require.Contains(t, *audits[0].Notes, "余额购买订阅")

	sub, err := userSubRepo.GetByUserIDAndGroupID(ctx, user.ID, 11)
	require.NoError(t, err)
	require.Equal(t, service.SubscriptionStatusActive, sub.Status)
	require.Contains(t, sub.Notes, "余额购买订阅")

	require.Len(t, inviteSpy.orders, 1)
	require.Equal(t, result.OrderID, inviteSpy.orders[0].ID)
	require.Equal(t, user.ID, inviteSpy.orders[0].BuyerUserID)
	require.Equal(t, int64(700), inviteSpy.orders[0].OrderAmountFen)
}

func TestCreateSubscriptionBalanceOrder_RejectsDeprecatedUltraAsMainBalancePurchasePath(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	groupRepo := &groupRepoStub{
		activeGroups: []service.Group{{
			ID:                  51,
			Name:                "Ultra",
			Status:              service.StatusActive,
			SubscriptionType:    service.SubscriptionTypeSubscription,
			DefaultValidityDays: 30,
		}},
		groupsByID: map[int64]*service.Group{
			51: {
				ID:                  51,
				Name:                "Ultra",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 30,
			},
		},
	}
	entClient := newPaymentSubscriptionEntClient(t)
	user := createPaymentTestUser(t, ctx, entClient, "balance-ultra-deprecated-main-path@example.com", 1000)
	subSvc := service.NewSubscriptionService(groupRepo, newUserSubscriptionRepoStub(), nil, entClient, &config.Config{})
	orderRepo := newPaymentOrderRepoStub()
	svc := &Service{
		repo:                   orderRepo,
		groupRepo:              groupRepo,
		subscriptionService:    subSvc,
		entClient:              entClient,
		settingGetter:          settingGetterStub{},
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
	}

	result, err := svc.CreateSubscriptionBalanceOrder(ctx, &CreateSubscriptionBalanceOrderInput{
		UserID:         user.ID,
		PlanKey:        "ultra",
		BillingMonths:  1,
		IdempotencyKey: "balance-order-ultra-1",
	})
	require.Nil(t, result)
	require.Error(t, err)
	require.Equal(t, 400, infraerrors.Code(err))
	require.Equal(t, "PAYMENT_PLAN_KEY_DEPRECATED", infraerrors.Reason(err))
	require.Nil(t, orderRepo.created)
}

func TestCreateSubscriptionBalanceOrder_RejectsDeprecatedLegacyPlans(t *testing.T) {
	t.Parallel()

	testCases := []struct {
		name      string
		planKey   string
		groupID   int64
		groupName string
	}{
		{name: "spark", planKey: "spark", groupID: 52, groupName: "Spark"},
		{name: "starter", planKey: "starter", groupID: 53, groupName: "Starter"},
		{name: "pro", planKey: "pro", groupID: 54, groupName: "Pro"},
		{name: "super", planKey: "super", groupID: 55, groupName: "Super"},
		{name: "ultra", planKey: "ultra", groupID: 56, groupName: "Ultra"},
	}

	for _, tc := range testCases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			ctx := context.Background()
			groupRepo := &groupRepoStub{
				activeGroups: []service.Group{{
					ID:                  tc.groupID,
					Name:                tc.groupName,
					Status:              service.StatusActive,
					SubscriptionType:    service.SubscriptionTypeSubscription,
					DefaultValidityDays: 30,
				}},
			}
			entClient := newPaymentSubscriptionEntClient(t)
			user := createPaymentTestUser(t, ctx, entClient, fmt.Sprintf("balance-%s-deprecated@example.com", tc.planKey), 100)
			subSvc := service.NewSubscriptionService(groupRepo, newUserSubscriptionRepoStub(), nil, entClient, &config.Config{})
			orderRepo := newPaymentOrderRepoStub()
			svc := &Service{
				repo:                   orderRepo,
				groupRepo:              groupRepo,
				subscriptionService:    subSvc,
				entClient:              entClient,
				settingGetter:          settingGetterStub{},
				subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
			}

			result, err := svc.CreateSubscriptionBalanceOrder(ctx, &CreateSubscriptionBalanceOrderInput{
				UserID:         user.ID,
				PlanKey:        tc.planKey,
				BillingMonths:  1,
				IdempotencyKey: fmt.Sprintf("balance-order-%s-deprecated", tc.planKey),
			})
			require.Nil(t, result)
			require.Error(t, err)
			require.Equal(t, 400, infraerrors.Code(err))
			require.Equal(t, "PAYMENT_PLAN_KEY_DEPRECATED", infraerrors.Reason(err))
			require.Nil(t, orderRepo.created)
		})
	}
}

func TestCreateSubscriptionBalanceOrder_AllowsNewsparkAsLowestBalancePurchasePath(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	groupRepo := &groupRepoStub{
		activeGroups: []service.Group{{
			ID:                  61,
			Name:                "Newspark",
			Status:              service.StatusActive,
			SubscriptionType:    service.SubscriptionTypeSubscription,
			DefaultValidityDays: 30,
		}},
		groupsByID: map[int64]*service.Group{
			61: {
				ID:                  61,
				Name:                "Newspark",
				Status:              service.StatusActive,
				SubscriptionType:    service.SubscriptionTypeSubscription,
				DefaultValidityDays: 30,
			},
		},
	}
	userSubRepo := newUserSubscriptionRepoStub()
	entClient := newPaymentSubscriptionEntClient(t)
	user := createPaymentTestUser(t, ctx, entClient, "balance-newspark@example.com", 100)
	subSvc := service.NewSubscriptionService(groupRepo, userSubRepo, nil, entClient, &config.Config{})
	orderRepo := newPaymentOrderRepoStub()
	svc := &Service{
		repo:                   orderRepo,
		groupRepo:              groupRepo,
		subscriptionService:    subSvc,
		entClient:              entClient,
		settingGetter:          settingGetterStub{},
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
	}

	result, err := svc.CreateSubscriptionBalanceOrder(ctx, &CreateSubscriptionBalanceOrderInput{
		UserID:         user.ID,
		PlanKey:        "newspark",
		BillingMonths:  1,
		IdempotencyKey: "balance-order-newspark-1",
	})
	require.NoError(t, err)
	require.NotNil(t, result)
	require.Equal(t, StatusPaid, result.Status)
	require.Equal(t, 5600, result.AmountFen)
	require.Equal(t, "56.00000000", result.BalanceCost)
	require.Equal(t, 44.0, result.BalanceAfter)
	require.NotNil(t, orderRepo.created)
	require.Equal(t, "newspark", orderRepo.created.PlanKey)
	require.Equal(t, "newspark", orderRepo.created.PlanVariantKey)
	require.Equal(t, PaymentMethodBalance, orderRepo.created.PaymentMethod)
}

func TestCreateSubscriptionBalanceOrder_InsufficientBalance(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	groupRepo := &groupRepoStub{
		activeGroups: []service.Group{{
			ID:                  11,
			Name:                "Trial3",
			Status:              service.StatusActive,
			SubscriptionType:    service.SubscriptionTypeSubscription,
			DefaultValidityDays: 3,
		}},
	}
	entClient := newPaymentSubscriptionEntClient(t)
	user := createPaymentTestUser(t, ctx, entClient, "balance-insufficient@example.com", 3)
	subSvc := service.NewSubscriptionService(groupRepo, newUserSubscriptionRepoStub(), nil, entClient, &config.Config{})
	orderRepo := newPaymentOrderRepoStub()
	inviteSpy := &inviteCommissionSettlerSpy{}
	svc := &Service{
		repo:                   orderRepo,
		groupRepo:              groupRepo,
		subscriptionService:    subSvc,
		entClient:              entClient,
		settingGetter:          settingGetterStub{},
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
		inviteCommissionSvc:    inviteSpy,
	}

	result, err := svc.CreateSubscriptionBalanceOrder(ctx, &CreateSubscriptionBalanceOrderInput{
		UserID:         user.ID,
		PlanKey:        "3trial",
		BillingMonths:  1,
		IdempotencyKey: "balance-order-2",
	})
	require.Nil(t, result)
	require.ErrorIs(t, err, ErrPaymentBalanceInsufficient)
	require.Nil(t, orderRepo.created)
	require.Empty(t, inviteSpy.orders)

	reloadedUser, reloadErr := entClient.User.Get(ctx, user.ID)
	require.NoError(t, reloadErr)
	require.Equal(t, 3.0, reloadedUser.Balance)

	audits, auditErr := entClient.RedeemCode.Query().All(ctx)
	require.NoError(t, auditErr)
	require.Empty(t, audits)
}

func TestCreateSubscriptionBalanceOrder_InviteSettlementFailureDoesNotRollbackPurchase(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	groupRepo := &groupRepoStub{
		activeGroups: []service.Group{{
			ID:                  11,
			Name:                "Trial3",
			Status:              service.StatusActive,
			SubscriptionType:    service.SubscriptionTypeSubscription,
			DefaultValidityDays: 3,
		}},
	}
	userSubRepo := newUserSubscriptionRepoStub()
	entClient := newPaymentSubscriptionEntClient(t)
	user := createPaymentTestUser(t, ctx, entClient, "balance-invite-failure@example.com", 20)
	subSvc := service.NewSubscriptionService(groupRepo, userSubRepo, nil, entClient, &config.Config{})
	orderRepo := newPaymentOrderRepoStub()
	inviteSpy := &inviteCommissionSettlerSpy{err: fmt.Errorf("settle failed")}
	retryWorker := &InviteCommissionRetryWorker{dispatchCh: make(chan int64, 1)}
	svc := &Service{
		repo:                   orderRepo,
		groupRepo:              groupRepo,
		subscriptionService:    subSvc,
		entClient:              entClient,
		settingGetter:          settingGetterStub{},
		subscriptionPlanPrices: config.DefaultSubscriptionPlanPriceMap(),
		inviteCommissionSvc:    inviteSpy,
		inviteRetryWorker:      retryWorker,
	}

	result, err := svc.CreateSubscriptionBalanceOrder(ctx, &CreateSubscriptionBalanceOrderInput{
		UserID:         user.ID,
		PlanKey:        "3trial",
		BillingMonths:  1,
		IdempotencyKey: "balance-order-3",
	})
	require.NoError(t, err)
	require.NotNil(t, result)
	require.NotNil(t, orderRepo.created)
	require.Equal(t, StatusPaid, orderRepo.created.Status)

	reloadedUser, reloadErr := entClient.User.Get(ctx, user.ID)
	require.NoError(t, reloadErr)
	require.Equal(t, 13.0, reloadedUser.Balance)

	sub, subErr := userSubRepo.GetByUserIDAndGroupID(ctx, user.ID, 11)
	require.NoError(t, subErr)
	require.Equal(t, service.SubscriptionStatusActive, sub.Status)
	require.Len(t, inviteSpy.orders, 1)

	select {
	case retryOrderID := <-retryWorker.dispatchCh:
		require.Equal(t, result.OrderID, retryOrderID)
	default:
		t.Fatal("expected invite retry worker to receive dispatched order id")
	}
}
