Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion model/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
15 changes: 15 additions & 0 deletions model/redemption.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}{
Expand Down
80 changes: 80 additions & 0 deletions model/redemption_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -63,6 +67,7 @@ export function RedemptionsMutateDrawer({
const isUpdate = Boolean(currentRow)
const { triggerRefresh } = useRedemptions()
const [isSubmitting, setIsSubmitting] = useState(false)
const [createdCodes, setCreatedCodes] = useState<string[]>([])

const { data: groupsData } = useQuery({
queryKey: ['groups'],
Expand All @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-xl'>
<DialogHeader>
<DialogTitle>
{isUpdate
? t('Update Redemption Code')
: t('Create Redemption Code')}
</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
{createdCodes.length ? (
<DialogDescription>
{t(
'Complete redemption codes are shown only this once. Save them before closing.'
)}
</DialogDescription>
) : null}
</DialogHeader>

{createdCodes.length ? (
<div className='space-y-3'>
<div className='bg-muted/40 rounded-xl border p-3'>
<Textarea
value={allCodes}
readOnly
className='min-h-64 resize-none border-0 bg-transparent font-mono text-xs shadow-none focus-visible:ring-0'
/>
</div>
<CopyButton
value={allCodes}
variant='outline'
size='default'
className='w-full gap-2'
>
{t('Copy all redemption codes')}
</CopyButton>
</div>
) : null}

<Form {...form}>
<form
id='redemption-form'
onSubmit={handleSubmit}
className='space-y-4'
className={cn('space-y-4', createdCodes.length && 'hidden')}
>
<FormField
control={form.control}
Expand Down Expand Up @@ -341,12 +394,24 @@ export function RedemptionsMutateDrawer({
</Form>

<DialogFooter className='border-0'>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button form='redemption-form' type='submit' disabled={isSubmitting}>
{isSubmitting ? t('Saving...') : t('Save changes')}
</Button>
{createdCodes.length ? (
<Button onClick={() => handleOpenChange(false)}>
{t('I have saved them')}
</Button>
) : (
<>
<Button variant='outline' onClick={() => handleOpenChange(false)}>
{t('Cancel')}
</Button>
<Button
form='redemption-form'
type='submit'
disabled={isSubmitting}
>
{isSubmitting ? t('Saving...') : t('Save changes')}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
Expand Down
3 changes: 3 additions & 0 deletions web/default/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,7 @@
"Compatible API routes for common AI application workflows": "Compatible API routes for common AI application workflows",
"Complete API documentation with multi-language SDK support": "Complete API documentation with multi-language SDK support",
"Complete Order": "Complete Order",
"Complete redemption codes are shown only this once. Save them before closing.": "Complete redemption codes are shown only this once. Save them before closing.",
"Complete these steps to finish the initial installation.": "Complete these steps to finish the initial installation.",
"Completed": "Completed",
"Completed top-up order for the user": "Completed top-up order for the user",
Expand Down Expand Up @@ -1177,6 +1178,7 @@
"Copy all backup codes": "Copy all backup codes",
"Copy All Codes": "Copy All Codes",
"Copy all invitation codes": "Copy all invitation codes",
"Copy all redemption codes": "Copy all redemption codes",
"Copy API key": "Copy API key",
"Copy callback URL": "Copy callback URL",
"Copy Channel": "Copy Channel",
Expand Down Expand Up @@ -4325,6 +4327,7 @@
"Save Preferences": "Save Preferences",
"Save preview": "Save preview",
"Save rate limits": "Save rate limits",
"Save redemption codes now": "Save redemption codes now",
"Save sensitive words": "Save sensitive words",
"Save Settings": "Save Settings",
"Save sidebar modules": "Save sidebar modules",
Expand Down
3 changes: 3 additions & 0 deletions web/default/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,7 @@
"Compatible API routes for common AI application workflows": "Routes API compatibles pour les workflows courants des applications d'IA",
"Complete API documentation with multi-language SDK support": "Documentation API complète avec support SDK multilingue",
"Complete Order": "Compléter la commande",
"Complete redemption codes are shown only this once. Save them before closing.": "Les codes d’échange complets ne sont affichés qu’une seule fois. Enregistrez-les avant de fermer.",
"Complete these steps to finish the initial installation.": "Suivez ces étapes pour terminer l'installation initiale.",
"Completed": "Terminé",
"Completed top-up order for the user": "Commande de recharge complétée pour l'utilisateur",
Expand Down Expand Up @@ -1177,6 +1178,7 @@
"Copy all backup codes": "Copier tous les codes de sauvegarde",
"Copy All Codes": "Copier tous les codes",
"Copy all invitation codes": "Copy all invitation codes",
"Copy all redemption codes": "Copier tous les codes d’échange",
"Copy API key": "Copier la clé API",
"Copy callback URL": "Copier l'URL de rappel",
"Copy Channel": "Copier le canal",
Expand Down Expand Up @@ -4325,6 +4327,7 @@
"Save Preferences": "Enregistrer les préférences",
"Save preview": "Aperçu de l’enregistrement",
"Save rate limits": "Enregistrer les limites de débit",
"Save redemption codes now": "Enregistrez les codes d’échange maintenant",
"Save sensitive words": "Enregistrer les mots sensibles",
"Save Settings": "Enregistrer les paramètres",
"Save sidebar modules": "Enregistrer les modules de la barre latérale",
Expand Down
3 changes: 3 additions & 0 deletions web/default/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,7 @@
"Compatible API routes for common AI application workflows": "一般的なAIアプリケーションワークフロー向けの互換APIルート",
"Complete API documentation with multi-language SDK support": "多言語SDKをサポートする完全なAPIドキュメント",
"Complete Order": "手動チャージ",
"Complete redemption codes are shown only this once. Save them before closing.": "完全な交換コードは今回のみ表示されます。閉じる前に保存してください。",
"Complete these steps to finish the initial installation.": "初期インストールを完了するには、これらの手順を完了してください。",
"Completed": "完了",
"Completed top-up order for the user": "ユーザーのチャージ注文を完了しました",
Expand Down Expand Up @@ -1177,6 +1178,7 @@
"Copy all backup codes": "すべてのバックアップコードをコピー",
"Copy All Codes": "すべてのコードをコピー",
"Copy all invitation codes": "招待コードをすべてコピー",
"Copy all redemption codes": "交換コードをすべてコピー",
"Copy API key": "APIキーをコピー",
"Copy callback URL": "コールバック URL をコピー",
"Copy Channel": "チャネルをコピー",
Expand Down Expand Up @@ -4325,6 +4327,7 @@
"Save Preferences": "設定を保存",
"Save preview": "保存プレビュー",
"Save rate limits": "レート制限を保存",
"Save redemption codes now": "交換コードを今すぐ保存",
"Save sensitive words": "敏感な言葉を保存",
"Save Settings": "設定を保存",
"Save sidebar modules": "サイドバーモジュールを保存",
Expand Down
3 changes: 3 additions & 0 deletions web/default/src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,7 @@
"Compatible API routes for common AI application workflows": "Совместимые API-маршруты для типовых сценариев ИИ-приложений",
"Complete API documentation with multi-language SDK support": "Полная документация API с поддержкой SDK на нескольких языках",
"Complete Order": "Вывод заказа",
"Complete redemption codes are shown only this once. Save them before closing.": "Полные коды погашения отображаются только один раз. Сохраните их перед закрытием.",
"Complete these steps to finish the initial installation.": "Выполните эти шаги, чтобы завершить начальную установку.",
"Completed": "Завершено",
"Completed top-up order for the user": "Заказ на пополнение для пользователя выполнен",
Expand Down Expand Up @@ -1177,6 +1178,7 @@
"Copy all backup codes": "Скопировать все резервные коды",
"Copy All Codes": "Скопировать все коды",
"Copy all invitation codes": "Копировать все коды приглашения",
"Copy all redemption codes": "Копировать все коды погашения",
"Copy API key": "Скопировать ключ API",
"Copy callback URL": "Скопировать URL обратного вызова",
"Copy Channel": "Скопировать канал",
Expand Down Expand Up @@ -4325,6 +4327,7 @@
"Save Preferences": "Сохранить настройки",
"Save preview": "Предпросмотр сохранения",
"Save rate limits": "Сохранить лимиты скорости",
"Save redemption codes now": "Сохраните коды погашения сейчас",
"Save sensitive words": "Сохранить чувствительные слова",
"Save Settings": "Сохранить настройки",
"Save sidebar modules": "Сохранить модули боковой панели",
Expand Down
Loading