diff --git a/model/errors.go b/model/errors.go index 1b3568a..94823dd 100644 --- a/model/errors.go +++ b/model/errors.go @@ -23,4 +23,7 @@ var ( ) // Redemption errors -var ErrRedeemFailed = errors.New("redeem.failed") +var ( + ErrRedeemFailed = errors.New("redeem.failed") + ErrActiveSubscriptionRedemptionDenied = errors.New("an active subscription cannot be replaced by a redemption code") +) diff --git a/model/redemption.go b/model/redemption.go index 69176fe..e2014c4 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -116,6 +116,21 @@ func Redeem(key string, userID int) (result RedemptionResult, err error) { if redemptionType != RedemptionTypeQuota && redemptionType != RedemptionTypeGroup { return errors.New("unsupported redemption type") } + if redemptionType == RedemptionTypeGroup { + var activeSubscription UserSubscription + err := lockForUpdate(tx). + Select("id"). + Where("user_id = ? AND status = ? AND end_time > ?", userID, "active", common.GetTimestamp()). + Order("id asc"). + First(&activeSubscription).Error + switch { + case err == nil: + return ErrActiveSubscriptionRedemptionDenied + case errors.Is(err, gorm.ErrRecordNotFound): + default: + return err + } + } update := tx.Model(&Redemption{}). Where("id = ? AND status = ?", redemption.Id, common.RedemptionCodeStatusEnabled). Updates(map[string]interface{}{ diff --git a/model/redemption_test.go b/model/redemption_test.go index dd182c7..61d73bc 100644 --- a/model/redemption_test.go +++ b/model/redemption_test.go @@ -208,6 +208,86 @@ func TestRedeemGrantsTemporaryGroupEntitlement(t *testing.T) { require.Error(t, err) } +func TestRedeemGroupEntitlementDeniedWithActiveSubscription(t *testing.T) { + for _, source := range []string{"redemption", PaymentMethodBalance} { + t.Run(source, func(t *testing.T) { + userID, key := setupRedeemFixture(t, 0) + plan := &SubscriptionPlan{ + Title: "Light", + Enabled: true, + DurationUnit: SubscriptionDurationMonth, + DurationValue: 1, + UpgradeGroup: "Light", + TotalAmount: 5000, + FiveHourQuota: 1000, + } + plan.NormalizeDefaults() + require.NoError(t, DB.Create(plan).Error) + require.NoError(t, DB.Create(&UserSubscription{ + UserId: userID, + PlanId: plan.Id, + AmountTotal: 5000, + StartTime: common.GetTimestamp() - 60, + EndTime: common.GetTimestamp() + 3600, + Status: "active", + Source: source, + }).Error) + require.NoError(t, DB.Model(&Redemption{}).Where(commonKeyCol+" = ?", key).Updates(map[string]interface{}{ + "type": RedemptionTypeGroup, + "group_name": "Moderate", + "group_duration_minutes": 60, + }).Error) + + _, err := Redeem(key, userID) + require.ErrorIs(t, err, ErrRedeemFailed) + + var redemption Redemption + require.NoError(t, DB.First(&redemption, commonKeyCol+" = ?", key).Error) + assert.Equal(t, common.RedemptionCodeStatusEnabled, redemption.Status) + assert.Zero(t, redemption.UsedUserId) + + var activeCount int64 + require.NoError(t, DB.Model(&UserSubscription{}). + Where("user_id = ? AND status = ? AND end_time > ?", userID, "active", common.GetTimestamp()). + Count(&activeCount).Error) + assert.Equal(t, int64(1), activeCount) + }) + } +} + +func TestRedeemQuotaStillAllowedWithActiveSubscription(t *testing.T) { + userID, key := setupRedeemFixture(t, 500) + plan := &SubscriptionPlan{ + Title: "Light", + Enabled: true, + DurationUnit: SubscriptionDurationMonth, + DurationValue: 1, + UpgradeGroup: "Light", + TotalAmount: 5000, + FiveHourQuota: 1000, + } + plan.NormalizeDefaults() + require.NoError(t, DB.Create(plan).Error) + require.NoError(t, DB.Create(&UserSubscription{ + UserId: userID, + PlanId: plan.Id, + AmountTotal: 5000, + StartTime: common.GetTimestamp() - 60, + EndTime: common.GetTimestamp() + 3600, + Status: "active", + Source: PaymentMethodBalance, + }).Error) + + result, err := Redeem(key, userID) + require.NoError(t, err) + assert.Equal(t, RedemptionTypeQuota, result.Type) + assert.Equal(t, 500, result.Quota) + + var user User + require.NoError(t, DB.First(&user, "id = ?", userID).Error) + assert.Equal(t, 500, user.Quota) +} + func TestRedeemGrantsPermanentGroupEntitlement(t *testing.T) { userID, key := setupRedeemFixture(t, 0) require.NoError(t, DB.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{ diff --git a/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx b/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx index 8fb02cb..7f62f41 100644 --- a/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx +++ b/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx @@ -5,11 +5,13 @@ import { useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { CopyButton } from '@/components/copy-button' import { DateTimePicker } from '@/components/datetime-picker' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogTitle, @@ -32,9 +34,11 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' import { getGroups } from '@/features/users/api' import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency' import { formatQuota, parseQuotaFromDollars } from '@/lib/format' +import { cn } from '@/lib/utils' import { createRedemption, getRedemption, updateRedemption } from '../api' import { SUCCESS_MESSAGES } from '../constants' @@ -63,6 +67,7 @@ export function RedemptionsMutateDrawer({ const isUpdate = Boolean(currentRow) const { triggerRefresh } = useRedemptions() const [isSubmitting, setIsSubmitting] = useState(false) + const [createdCodes, setCreatedCodes] = useState([]) const { data: groupsData } = useQuery({ queryKey: ['groups'], @@ -85,9 +90,17 @@ export function RedemptionsMutateDrawer({ }) } else if (open) { form.reset(REDEMPTION_FORM_DEFAULT_VALUES) + setCreatedCodes([]) } }, [currentRow, form, open]) + const handleOpenChange = (nextOpen: boolean) => { + onOpenChange(nextOpen) + if (!nextOpen) { + setCreatedCodes([]) + } + } + const onSubmit = async (data: RedemptionFormValues) => { setIsSubmitting(true) try { @@ -99,13 +112,22 @@ export function RedemptionsMutateDrawer({ toast.error(result.message || t('Failed to save redemption code')) return } + const generatedCodes = Array.isArray(result.data) ? result.data : [] + if (!currentRow && !generatedCodes.length) { + toast.error(t('Failed to save redemption code')) + return + } toast.success( currentRow ? t(SUCCESS_MESSAGES.REDEMPTION_UPDATED) : t(SUCCESS_MESSAGES.REDEMPTION_CREATED) ) - onOpenChange(false) triggerRefresh() + if (currentRow) { + handleOpenChange(false) + return + } + setCreatedCodes(generatedCodes) } finally { setIsSubmitting(false) } @@ -126,23 +148,54 @@ export function RedemptionsMutateDrawer({ const currencyLabel = getCurrencyLabel() const tokensOnly = currencyMeta.kind === 'tokens' const benefitType = form.watch('type') + const allCodes = createdCodes.join('\n') + let dialogTitle = t('Create Redemption Code') + if (isUpdate) { + dialogTitle = t('Update Redemption Code') + } + if (createdCodes.length) { + dialogTitle = t('Save redemption codes now') + } return ( - + - - {isUpdate - ? t('Update Redemption Code') - : t('Create Redemption Code')} - + {dialogTitle} + {createdCodes.length ? ( + + {t( + 'Complete redemption codes are shown only this once. Save them before closing.' + )} + + ) : null} + {createdCodes.length ? ( +
+
+