diff --git a/backend/internal/repository/redeem_reduction_remainder_integration_test.go b/backend/internal/repository/redeem_reduction_remainder_integration_test.go new file mode 100644 index 0000000000..0bb110807a --- /dev/null +++ b/backend/internal/repository/redeem_reduction_remainder_integration_test.go @@ -0,0 +1,51 @@ +//go:build integration + +package repository + +import ( + "context" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestRedeemReductionPreservesPartialDay(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + user, err := client.User.Create().SetEmail("reduction-remainder@example.com").SetPasswordHash("test").Save(ctx) + require.NoError(t, err) + group, err := client.Group.Create().SetName("reduction-remainder").Save(ctx) + require.NoError(t, err) + // Redemption commits its own transaction; remove fixtures explicitly so + // repository list/count tests cannot observe this test's subscriptions. + t.Cleanup(func() { + _, err := integrationDB.ExecContext(context.Background(), `DELETE FROM redeem_codes WHERE group_id=$1`, group.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(context.Background(), `DELETE FROM user_subscriptions WHERE user_id=$1`, user.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(context.Background(), `DELETE FROM groups WHERE id=$1`, group.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(context.Background(), `DELETE FROM users WHERE id=$1`, user.ID) + require.NoError(t, err) + }) + now := time.Now().UTC().Truncate(time.Microsecond) + expiry := now.Add(36 * time.Hour) + sub, err := client.UserSubscription.Create().SetUserID(user.ID).SetGroupID(group.ID).SetStartsAt(now.Add(-time.Hour)).SetExpiresAt(expiry).SetStatus(service.SubscriptionStatusActive).Save(ctx) + require.NoError(t, err) + repo := NewUserSubscriptionRepository(client) + codes := NewRedeemCodeRepository(client) + code := &service.RedeemCode{Code: "REDUCE-ONE-DAY", Type: service.RedeemTypeSubscription, Status: service.StatusUnused, ValidityDays: -1, GroupID: &group.ID} + require.NoError(t, codes.Create(ctx, code)) + subscriptions := service.NewSubscriptionService(nil, repo, nil, client, nil) + redeem := service.NewRedeemService(codes, NewUserRepository(client, integrationDB), subscriptions, nil, nil, client, nil, nil) + _, err = redeem.RedeemForAdminFulfillment(ctx, user.ID, code.Code) + require.NoError(t, err) + got, err := repo.GetByID(ctx, sub.ID) + require.NoError(t, err) + require.Equal(t, service.SubscriptionStatusActive, got.Status) + require.True(t, expiry.AddDate(0, 0, -1).Equal(got.ExpiresAt)) + used, err := codes.GetByID(ctx, code.ID) + require.NoError(t, err) + require.Equal(t, service.StatusUsed, used.Status) +} diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index 30dd4f911f..4894355add 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -711,14 +711,15 @@ func (s *RedeemService) reduceOrCancelSubscription(ctx context.Context, userID, } now := time.Now() - remaining := int(sub.ExpiresAt.Sub(now).Hours() / 24) - if remaining < 0 { - remaining = 0 + if s.subscriptionService.now != nil { + now = s.subscriptionService.now() } + // Preserve calendar-day semantics without rounding away the remaining hours. + newExpiresAt := sub.ExpiresAt.AddDate(0, 0, -reduceDays) notes := fmt.Sprintf("通过兑换码 %s 退款扣减 %d 天", code, reduceDays) - if remaining <= reduceDays { + if !newExpiresAt.After(now) { // 剩余天数不足,直接取消订阅 if err := s.subscriptionService.userSubRepo.UpdateStatus(ctx, sub.ID, SubscriptionStatusExpired); err != nil { return fmt.Errorf("cancel subscription: %w", err) @@ -729,7 +730,6 @@ func (s *RedeemService) reduceOrCancelSubscription(ctx context.Context, userID, } } else { // 缩短天数 - newExpiresAt := sub.ExpiresAt.AddDate(0, 0, -reduceDays) if err := s.subscriptionService.userSubRepo.ExtendExpiry(ctx, sub.ID, newExpiresAt); err != nil { return fmt.Errorf("reduce subscription: %w", err) } diff --git a/backend/internal/service/redeem_subscription_reduction_remainder_test.go b/backend/internal/service/redeem_subscription_reduction_remainder_test.go new file mode 100644 index 0000000000..0e59f02fb6 --- /dev/null +++ b/backend/internal/service/redeem_subscription_reduction_remainder_test.go @@ -0,0 +1,44 @@ +package service + +import ( + "context" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestRedeemReductionPreservesRemainingTime(t *testing.T) { + now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC) + for _, tc := range []struct { + name string + remaining time.Duration + days int + expired bool + }{ + {"partial day", 36 * time.Hour, 1, false}, + {"one second left", 24*time.Hour + time.Second, 1, false}, + {"exact exhaustion", 24 * time.Hour, 1, true}, + {"excess deduction", 12 * time.Hour, 1, true}, + {"already expired", -time.Hour, 1, true}, + {"multiple days", 84 * time.Hour, 2, false}, + } { + t.Run(tc.name, func(t *testing.T) { + sub := UserSubscription{ID: 7, ExpiresAt: now.Add(tc.remaining), Status: SubscriptionStatusActive, Notes: "original"} + repo := &lockingRenewalRepo{stale: sub, current: sub} + subscriptions := NewSubscriptionService(nil, repo, nil, nil, nil) + subscriptions.now = func() time.Time { return now } + svc := &RedeemService{subscriptionService: subscriptions} + require.NoError(t, svc.reduceOrCancelSubscription(context.Background(), 11, 13, tc.days, "deduct")) + want := sub.ExpiresAt.AddDate(0, 0, -tc.days) + status := SubscriptionStatusActive + if tc.expired { + want = now + status = SubscriptionStatusExpired + } + require.Equal(t, want, repo.current.ExpiresAt) + require.Equal(t, status, repo.current.Status) + require.Contains(t, repo.current.Notes, "original") + require.Contains(t, repo.current.Notes, "deduct") + }) + } +}