feat(billing): add percentage discount coupons
Admins can create coupon codes that discount specific services (app runtimes, optional services, managed products, custom-domain addon, or all) and restrict them to specific users or make them public, with total and per-user usage caps and an active date window. Coupons apply in deploy, renewal, and upgrade flows: cost-breakdown lines are tagged with a service key, the eligible portion is discounted and capped to the payable amount, the invoice records discountAmount/ discountCode, and the redemption is recorded once when the invoice is fully paid (covering wallet, gateway, and mixed payments). - Discount + DiscountRedemption entities; invoice discount columns - DiscountService (CRUD, validation, redemption) + admin/validate API - Idempotent schema bootstrap on init so production (synchronize off) provisions the tables/columns without a migration runner - Admin discounts UI, coupon entry in deploy/renewal, invoice discount line - fa/en strings; discount.service unit spec Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import type { Discount, DiscountServiceOption, PricingCatalog, User } from '@/types';
|
||||
import { Tag, Plus, Edit2, Trash2, X, Check } from 'lucide-react';
|
||||
|
||||
interface DraftDiscount {
|
||||
id?: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
percentOff: number;
|
||||
scopeAll: boolean;
|
||||
services: string[];
|
||||
isPublic: boolean;
|
||||
allowedUsers: { id: string; label: string }[];
|
||||
maxUses: string;
|
||||
maxUsesPerUser: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function userLabel(u: User): string {
|
||||
const name = `${u.firstName ?? ''} ${u.lastName ?? ''}`.trim();
|
||||
return name ? `${name}${u.email ? ` · ${u.email}` : ''}` : u.email || u.phone || u.id;
|
||||
}
|
||||
|
||||
function emptyDraft(): DraftDiscount {
|
||||
return {
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
percentOff: 10,
|
||||
scopeAll: true,
|
||||
services: [],
|
||||
isPublic: true,
|
||||
allowedUsers: [],
|
||||
maxUses: '',
|
||||
maxUsesPerUser: '',
|
||||
startsAt: '',
|
||||
endsAt: '',
|
||||
isActive: true,
|
||||
};
|
||||
}
|
||||
|
||||
function toDraft(d: Discount): DraftDiscount {
|
||||
return {
|
||||
id: d.id,
|
||||
code: d.code,
|
||||
name: d.name,
|
||||
description: d.description ?? '',
|
||||
percentOff: d.percentOff,
|
||||
scopeAll: !d.services || d.services.length === 0,
|
||||
services: d.services ?? [],
|
||||
isPublic: d.isPublic,
|
||||
allowedUsers: (d.allowedUserIds ?? []).map((id) => ({ id, label: id })),
|
||||
maxUses: d.maxUses != null ? String(d.maxUses) : '',
|
||||
maxUsesPerUser: d.maxUsesPerUser != null ? String(d.maxUsesPerUser) : '',
|
||||
startsAt: d.startsAt ? d.startsAt.slice(0, 10) : '',
|
||||
endsAt: d.endsAt ? d.endsAt.slice(0, 10) : '',
|
||||
isActive: d.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DiscountsSection() {
|
||||
const t = useT();
|
||||
const d = t.dashboard.billing.discounts;
|
||||
const queryClient = useQueryClient();
|
||||
const [draft, setDraft] = useState<DraftDiscount | null>(null);
|
||||
|
||||
const { data: discounts } = useQuery<Discount[]>({
|
||||
queryKey: ['discounts'],
|
||||
queryFn: () => api.get('/billing/discounts').then((r) => r.data),
|
||||
});
|
||||
const { data: catalog } = useQuery<PricingCatalog>({
|
||||
queryKey: ['pricing-catalog'],
|
||||
queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data),
|
||||
});
|
||||
|
||||
const serviceOptions = catalog?.discountServiceOptions ?? [];
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, DiscountServiceOption[]>();
|
||||
for (const opt of serviceOptions) {
|
||||
if (!map.has(opt.group)) map.set(opt.group, []);
|
||||
map.get(opt.group)!.push(opt);
|
||||
}
|
||||
return Array.from(map.entries());
|
||||
}, [serviceOptions]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (body: DraftDiscount) => {
|
||||
const payload = {
|
||||
code: body.code.trim(),
|
||||
name: body.name.trim(),
|
||||
description: body.description.trim() || undefined,
|
||||
percentOff: body.percentOff,
|
||||
services: body.scopeAll ? [] : body.services,
|
||||
isPublic: body.isPublic,
|
||||
allowedUserIds: body.isPublic ? [] : body.allowedUsers.map((u) => u.id),
|
||||
maxUses: body.maxUses ? Number(body.maxUses) : null,
|
||||
maxUsesPerUser: body.maxUsesPerUser ? Number(body.maxUsesPerUser) : null,
|
||||
startsAt: body.startsAt ? new Date(body.startsAt).toISOString() : null,
|
||||
endsAt: body.endsAt ? new Date(body.endsAt).toISOString() : null,
|
||||
isActive: body.isActive,
|
||||
};
|
||||
return body.id
|
||||
? api.patch(`/billing/discounts/${body.id}`, payload)
|
||||
: api.post('/billing/discounts', payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['discounts'] });
|
||||
notify.success(d.saved);
|
||||
setDraft(null);
|
||||
},
|
||||
onError: (err: unknown) => notify.error(err, d.saveFailed),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/billing/discounts/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['discounts'] });
|
||||
notify.success(d.deleted);
|
||||
},
|
||||
onError: (err: unknown) => notify.error(err, d.saveFailed),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card space-y-4 mt-8">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-start gap-3">
|
||||
<Tag className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{d.title}</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{d.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!draft && (
|
||||
<button onClick={() => setDraft(emptyDraft())} className="btn-primary text-sm flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" /> {d.add}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{draft && (
|
||||
<DiscountForm
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
groups={groups}
|
||||
onSave={() => saveMutation.mutate(draft)}
|
||||
saving={saveMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!discounts?.length && !draft ? (
|
||||
<p className="text-center py-8 text-gray-400 text-sm">{d.empty}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{discounts?.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-3 border border-gray-200 rounded-lg p-3 flex-wrap"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="font-mono font-semibold text-primary-700 bg-primary-50 px-2 py-0.5 rounded">
|
||||
{item.code}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">
|
||||
{item.name} · {item.percentOff}%
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{item.services.length === 0 ? d.allServices : item.services.join('، ')}
|
||||
{' · '}
|
||||
{item.isPublic ? d.public : d.restricted}
|
||||
{' · '}
|
||||
{d.used}: {item.usedCount}
|
||||
{item.maxUses != null ? `/${item.maxUses}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
item.isActive ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{item.isActive ? d.active : d.inactive}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setDraft(toDraft(item))}
|
||||
className="p-1.5 text-gray-500 hover:text-primary-600"
|
||||
aria-label={d.edit}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(d.deleteConfirm)) deleteMutation.mutate(item.id);
|
||||
}}
|
||||
className="p-1.5 text-gray-500 hover:text-red-600"
|
||||
aria-label={d.delete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiscountForm({
|
||||
draft,
|
||||
setDraft,
|
||||
groups,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
draft: DraftDiscount;
|
||||
setDraft: (d: DraftDiscount | null) => void;
|
||||
groups: [string, DiscountServiceOption[]][];
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const d = t.dashboard.billing.discounts;
|
||||
const patch = (p: Partial<DraftDiscount>) => setDraft({ ...draft, ...p });
|
||||
|
||||
const toggleService = (value: string) => {
|
||||
patch({
|
||||
services: draft.services.includes(value)
|
||||
? draft.services.filter((s) => s !== value)
|
||||
: [...draft.services, value],
|
||||
});
|
||||
};
|
||||
|
||||
const canSave = draft.code.trim() && draft.name.trim() && draft.percentOff > 0;
|
||||
|
||||
return (
|
||||
<div className="border border-primary-200 bg-primary-50/30 rounded-xl p-4 space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">{d.code}</label>
|
||||
<input
|
||||
className="input-field w-full font-mono mt-0.5"
|
||||
placeholder={d.codePlaceholder}
|
||||
value={draft.code}
|
||||
onChange={(e) => patch({ code: e.target.value.toUpperCase() })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">{d.name}</label>
|
||||
<input
|
||||
className="input-field w-full mt-0.5"
|
||||
placeholder={d.namePlaceholder}
|
||||
value={draft.name}
|
||||
onChange={(e) => patch({ name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">{d.percentOff}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
className="input-field w-full mt-0.5"
|
||||
value={draft.percentOff}
|
||||
onChange={(e) => patch({ percentOff: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">{d.description}</label>
|
||||
<input
|
||||
className="input-field w-full mt-0.5"
|
||||
value={draft.description}
|
||||
onChange={(e) => patch({ description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scope */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-gray-600">{d.scope}</span>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" checked={draft.scopeAll} onChange={() => patch({ scopeAll: true })} />
|
||||
{d.allServices}
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
checked={!draft.scopeAll}
|
||||
onChange={() => patch({ scopeAll: false })}
|
||||
/>
|
||||
{d.selectServices}
|
||||
</label>
|
||||
</div>
|
||||
{!draft.scopeAll && (
|
||||
<div className="space-y-3 rounded-lg border border-gray-200 bg-white p-3">
|
||||
{groups.map(([group, options]) => (
|
||||
<div key={group}>
|
||||
<p className="text-xs font-semibold text-gray-500 mb-1">
|
||||
{(d.groups as Record<string, string>)[group] ?? group}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map((opt) => {
|
||||
const active = draft.services.includes(opt.value);
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => toggleService(opt.value)}
|
||||
className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
|
||||
active
|
||||
? 'bg-primary-600 text-white border-primary-600'
|
||||
: 'bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{active && <Check className="w-3 h-3 inline -mt-0.5 mr-1 rtl:mr-0 rtl:ml-1" />}
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Audience */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-gray-600">{d.audience}</span>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" checked={draft.isPublic} onChange={() => patch({ isPublic: true })} />
|
||||
{d.public}
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
checked={!draft.isPublic}
|
||||
onChange={() => patch({ isPublic: false })}
|
||||
/>
|
||||
{d.restricted}
|
||||
</label>
|
||||
</div>
|
||||
{!draft.isPublic && (
|
||||
<UserPicker
|
||||
selected={draft.allowedUsers}
|
||||
onChange={(allowedUsers) => patch({ allowedUsers })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Limits & dates */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">{d.maxUses}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="input-field w-full mt-0.5"
|
||||
placeholder={d.unlimited}
|
||||
value={draft.maxUses}
|
||||
onChange={(e) => patch({ maxUses: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">{d.maxUsesPerUser}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="input-field w-full mt-0.5"
|
||||
placeholder={d.unlimited}
|
||||
value={draft.maxUsesPerUser}
|
||||
onChange={(e) => patch({ maxUsesPerUser: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">{d.startsAt}</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input-field w-full mt-0.5"
|
||||
value={draft.startsAt}
|
||||
onChange={(e) => patch({ startsAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">{d.endsAt}</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input-field w-full mt-0.5"
|
||||
value={draft.endsAt}
|
||||
onChange={(e) => patch({ endsAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.isActive}
|
||||
onChange={(e) => patch({ isActive: e.target.checked })}
|
||||
/>
|
||||
{d.active}
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button onClick={() => setDraft(null)} className="btn-secondary text-sm">
|
||||
{d.cancel}
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!canSave || saving}
|
||||
className="btn-primary text-sm disabled:opacity-50"
|
||||
>
|
||||
{saving ? d.saving : d.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserPicker({
|
||||
selected,
|
||||
onChange,
|
||||
}: {
|
||||
selected: { id: string; label: string }[];
|
||||
onChange: (users: { id: string; label: string }[]) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const d = t.dashboard.billing.discounts;
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const { data: results } = useQuery<User[]>({
|
||||
queryKey: ['discount-user-search', search],
|
||||
queryFn: () => api.get(`/users?search=${encodeURIComponent(search)}`).then((r) => r.data),
|
||||
enabled: search.trim().length >= 2,
|
||||
});
|
||||
|
||||
const add = (u: User) => {
|
||||
if (selected.some((s) => s.id === u.id)) return;
|
||||
onChange([...selected, { id: u.id, label: userLabel(u) }]);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-3 space-y-2">
|
||||
<input
|
||||
className="input-field w-full text-sm"
|
||||
placeholder={d.searchUsers}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{search.trim().length >= 2 && results && results.length > 0 && (
|
||||
<div className="max-h-40 overflow-y-auto border border-gray-100 rounded-lg divide-y">
|
||||
{results.slice(0, 8).map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
onClick={() => add(u)}
|
||||
className="w-full text-left rtl:text-right px-3 py-1.5 text-sm hover:bg-gray-50"
|
||||
>
|
||||
{userLabel(u)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selected.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">{d.noUsersSelected}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selected.map((u) => (
|
||||
<span
|
||||
key={u.id}
|
||||
className="inline-flex items-center gap-1 text-xs bg-gray-100 rounded-full pl-2.5 pr-1 py-0.5"
|
||||
>
|
||||
{u.label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(selected.filter((s) => s.id !== u.id))}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
} from '@/types';
|
||||
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import DiscountsSection from './DiscountsSection';
|
||||
|
||||
const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
|
||||
|
||||
@@ -711,6 +712,8 @@ export default function AdminBillingPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<DiscountsSection />
|
||||
|
||||
<LifecycleSettingsSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -123,6 +123,7 @@ export default function AppDetailPage() {
|
||||
const [snapshotTab, setSnapshotTab] = useState<'revisions' | 'snapshots'>('revisions');
|
||||
const [showRenewalModal, setShowRenewalModal] = useState(false);
|
||||
const [selectedCycle, setSelectedCycle] = useState<'hourly' | 'monthly' | 'yearly'>('monthly');
|
||||
const [renewCoupon, setRenewCoupon] = useState('');
|
||||
const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false);
|
||||
const [upgradeCostData, setUpgradeCostData] = useState<{
|
||||
proratedAmount: number;
|
||||
@@ -262,12 +263,17 @@ export default function AppDetailPage() {
|
||||
});
|
||||
|
||||
const renewMutation = useMutation({
|
||||
mutationFn: (cycle: string) => api.post(`/billing/applications/${appId}/renew`, { cycle }),
|
||||
mutationFn: (cycle: string) =>
|
||||
api.post(`/billing/applications/${appId}/renew`, {
|
||||
cycle,
|
||||
couponCode: renewCoupon.trim() || undefined,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
notify.success(res.data.message || 'Application renewed successfully!');
|
||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
||||
setShowRenewalModal(false);
|
||||
setRenewCoupon('');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
notify.error(err, 'Failed to renew application');
|
||||
@@ -276,7 +282,12 @@ export default function AppDetailPage() {
|
||||
|
||||
const createRenewalInvoiceMutation = useMutation({
|
||||
mutationFn: (cycle: string) =>
|
||||
api.post<Invoice>(`/billing/applications/${appId}/renew/invoice`, { cycle }).then((r) => r.data),
|
||||
api
|
||||
.post<Invoice>(`/billing/applications/${appId}/renew/invoice`, {
|
||||
cycle,
|
||||
couponCode: renewCoupon.trim() || undefined,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
onSuccess: (invoice) => {
|
||||
notify.success(ad.invoiceCreated);
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
@@ -1299,10 +1310,23 @@ export default function AppDetailPage() {
|
||||
})()
|
||||
)}
|
||||
|
||||
{/* Coupon */}
|
||||
<div className="mb-4">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{t.dashboard.billing.discounts.coupon.label}
|
||||
</label>
|
||||
<input
|
||||
className="input-field w-full font-mono mt-1.5"
|
||||
placeholder={t.dashboard.billing.discounts.coupon.placeholder}
|
||||
value={renewCoupon}
|
||||
onChange={(e) => setRenewCoupon(e.target.value.toUpperCase())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowRenewalModal(false)}
|
||||
onClick={() => { setShowRenewalModal(false); setRenewCoupon(''); }}
|
||||
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl font-medium text-gray-700 hover:bg-gray-50 transition-all"
|
||||
>{t.common.cancel}</button>
|
||||
<button
|
||||
|
||||
@@ -297,6 +297,8 @@ export default function DeployPage() {
|
||||
const [isWpDragging, setIsWpDragging] = useState(false);
|
||||
const wpFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedCycle, setSelectedCycle] = useState<BillingCycle>('monthly');
|
||||
const [couponCode, setCouponCode] = useState('');
|
||||
const [appliedCoupon, setAppliedCoupon] = useState('');
|
||||
const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet');
|
||||
const [isPaid, setIsPaid] = useState(false);
|
||||
|
||||
@@ -388,6 +390,7 @@ export default function DeployPage() {
|
||||
form.runtime !== 'wordpress' && form.enableRabbitmq ? form.optionalServiceResources?.rabbitmq : undefined,
|
||||
enableCustomDomain,
|
||||
cycle: selectedCycle,
|
||||
couponCode: appliedCoupon || undefined,
|
||||
};
|
||||
|
||||
// Cost calculation for the review step (includes prepaid resource credits)
|
||||
@@ -404,7 +407,10 @@ export default function DeployPage() {
|
||||
enabled: step >= 2,
|
||||
});
|
||||
|
||||
const payAmount = costData?.amountDue ?? 0;
|
||||
const couponDiscount = costData?.couponDiscount ?? null;
|
||||
const couponDiscountAmount = couponDiscount?.valid ? couponDiscount.discountAmount ?? 0 : 0;
|
||||
const beforeDiscount = costData?.amountDue ?? 0;
|
||||
const payAmount = couponDiscountAmount > 0 ? (costData?.amountDueAfterDiscount ?? beforeDiscount) : beforeDiscount;
|
||||
const fullPrice = costData?.fullAmount ?? 0;
|
||||
const coveredAmount = costData?.coveredAmount ?? 0;
|
||||
const extrasBreakdown = costData?.extrasBreakdown ?? [];
|
||||
@@ -455,7 +461,10 @@ export default function DeployPage() {
|
||||
|
||||
// Deduct from wallet
|
||||
setDeployStage('paying');
|
||||
await api.post(`/billing/wallet/pay/${appId}`, { cycle: selectedCycle });
|
||||
await api.post(`/billing/wallet/pay/${appId}`, {
|
||||
cycle: selectedCycle,
|
||||
couponCode: appliedCoupon || undefined,
|
||||
});
|
||||
|
||||
return res;
|
||||
},
|
||||
@@ -534,7 +543,10 @@ export default function DeployPage() {
|
||||
|
||||
// Deduct from the wallet (which was just charged by gateway)
|
||||
setDeployStage('paying');
|
||||
await api.post(`/billing/wallet/pay/${appId}`, { cycle: selectedCycle });
|
||||
await api.post(`/billing/wallet/pay/${appId}`, {
|
||||
cycle: selectedCycle,
|
||||
couponCode: appliedCoupon || undefined,
|
||||
});
|
||||
|
||||
return res;
|
||||
},
|
||||
@@ -2596,6 +2608,52 @@ export default function DeployPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Coupon */}
|
||||
{costData && costData.monthly > 0 && (
|
||||
<div className="bg-white rounded-xl p-4 border border-gray-200">
|
||||
<label className="text-sm font-semibold text-gray-700">{t.dashboard.billing.discounts.coupon.label}</label>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<input
|
||||
className="input-field flex-1 font-mono"
|
||||
placeholder={t.dashboard.billing.discounts.coupon.placeholder}
|
||||
value={couponCode}
|
||||
onChange={(e) => setCouponCode(e.target.value.toUpperCase())}
|
||||
disabled={!!appliedCoupon}
|
||||
/>
|
||||
{appliedCoupon ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAppliedCoupon(''); setCouponCode(''); }}
|
||||
className="btn-secondary text-sm shrink-0"
|
||||
>
|
||||
{t.dashboard.billing.discounts.coupon.remove}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAppliedCoupon(couponCode.trim())}
|
||||
disabled={!couponCode.trim()}
|
||||
className="btn-primary text-sm shrink-0 disabled:opacity-50"
|
||||
>
|
||||
{t.dashboard.billing.discounts.coupon.apply}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{appliedCoupon && couponDiscount && (
|
||||
couponDiscount.valid ? (
|
||||
<p className="text-xs text-emerald-600 mt-2 flex items-center justify-between">
|
||||
<span>{t.dashboard.billing.discounts.coupon.applied} · {couponDiscount.percentOff}%</span>
|
||||
<span className="font-semibold">− {Number(couponDiscountAmount).toLocaleString('en-US')} Toman</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-red-500 mt-2">
|
||||
{(t.dashboard.billing.discounts.reasons as Record<string, string>)[couponDiscount.reason ?? 'not_found'] ?? couponDiscount.reason}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Method */}
|
||||
{costData && costData.monthly > 0 && !requiresPayment && (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{dw.noPaymentCredit}</div>
|
||||
|
||||
@@ -233,6 +233,18 @@ export default function InvoicesPage() {
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-2 text-sm">
|
||||
{Number(selectedInvoice.discountAmount || 0) > 0 && (
|
||||
<>
|
||||
<div className="flex justify-between"><span className="text-gray-500">{inv.subtotal}</span><span>{formatPrice(selectedInvoice.subtotal)} {inv.toman}</span></div>
|
||||
<div className="flex justify-between text-emerald-600">
|
||||
<span>
|
||||
{t.dashboard.billing.discounts.coupon.discountLine}
|
||||
{selectedInvoice.discountCode ? ` (${selectedInvoice.discountCode})` : ''}
|
||||
</span>
|
||||
<span>− {formatPrice(Number(selectedInvoice.discountAmount))} {inv.toman}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-between"><span className="text-gray-500">{inv.total}</span><span className="font-semibold">{formatPrice(selectedInvoice.total)} {inv.toman}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">{inv.paidLabel}</span><span className="font-semibold text-green-600">{formatPrice(selectedInvoice.paidAmount)} {inv.toman}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">{inv.due}</span><span className="font-semibold text-primary-700">{formatPrice(selectedInvoice.dueAmount)} {inv.toman}</span></div>
|
||||
|
||||
@@ -655,6 +655,7 @@ const en: Dictionary = {
|
||||
},
|
||||
title: 'Invoices',
|
||||
subtitle: 'Review what each payment was for and pay open invoices.',
|
||||
subtotal: 'Subtotal',
|
||||
walletBalance: 'Wallet balance',
|
||||
toman: 'Toman',
|
||||
filterAll: 'All',
|
||||
@@ -1102,6 +1103,69 @@ const en: Dictionary = {
|
||||
saveFailedShort: 'Failed to save',
|
||||
hours: 'hours',
|
||||
days: 'days',
|
||||
discounts: {
|
||||
title: 'Discount codes',
|
||||
subtitle: 'Percentage discounts on different services — public or for specific users.',
|
||||
add: 'New discount',
|
||||
empty: 'No discount codes yet',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
deleteConfirm: 'Delete this discount code?',
|
||||
code: 'Code',
|
||||
codePlaceholder: 'NOWRUZ1403',
|
||||
name: 'Label',
|
||||
namePlaceholder: 'Nowruz discount',
|
||||
description: 'Description',
|
||||
percentOff: 'Percent off',
|
||||
scope: 'Eligible services',
|
||||
allServices: 'All services',
|
||||
selectServices: 'Select specific services',
|
||||
audience: 'Eligible users',
|
||||
public: 'Public (all users)',
|
||||
restricted: 'Specific users',
|
||||
searchUsers: 'Search users by name or email…',
|
||||
noUsersSelected: 'No users selected yet',
|
||||
limits: 'Limits',
|
||||
maxUses: 'Total usage cap',
|
||||
maxUsesPerUser: 'Per-user cap',
|
||||
unlimited: 'Unlimited',
|
||||
startsAt: 'Start date',
|
||||
endsAt: 'End date',
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
used: 'Used',
|
||||
save: 'Save',
|
||||
saving: 'Saving…',
|
||||
cancel: 'Cancel',
|
||||
saved: 'Discount code saved',
|
||||
deleted: 'Discount code deleted',
|
||||
saveFailed: 'Failed to save discount code',
|
||||
groups: {
|
||||
runtime: 'App runtimes',
|
||||
optional: 'Optional services',
|
||||
managed: 'Managed services',
|
||||
addon: 'Add-ons',
|
||||
},
|
||||
coupon: {
|
||||
label: 'Discount code',
|
||||
placeholder: 'Enter discount code',
|
||||
apply: 'Apply',
|
||||
checking: 'Checking…',
|
||||
applied: 'Discount applied',
|
||||
remove: 'Remove',
|
||||
discountLine: 'Discount',
|
||||
},
|
||||
reasons: {
|
||||
not_found: 'Invalid discount code',
|
||||
inactive: 'This discount code is inactive',
|
||||
not_started: 'This code is not active yet',
|
||||
expired: 'This code has expired',
|
||||
max_uses_reached: 'This code has reached its usage limit',
|
||||
max_uses_per_user_reached: 'You have reached your usage limit for this code',
|
||||
not_eligible_user: 'This code is not available for your account',
|
||||
no_eligible_services: 'This code does not discount your selected services',
|
||||
},
|
||||
},
|
||||
},
|
||||
servicesNew: {
|
||||
steps: ['Service type', 'Configuration', 'Review & pay'],
|
||||
|
||||
@@ -654,6 +654,7 @@ const fa = {
|
||||
},
|
||||
title: 'فاکتورها',
|
||||
subtitle: 'ببین هر پرداخت بابت چه بوده و فاکتورهای باز را پرداخت کن.',
|
||||
subtotal: 'جمع جزء',
|
||||
walletBalance: 'موجودی کیفپول',
|
||||
toman: 'تومان',
|
||||
filterAll: 'همه',
|
||||
@@ -1101,6 +1102,69 @@ const fa = {
|
||||
saveFailedShort: 'ذخیره ناموفق بود',
|
||||
hours: 'ساعت',
|
||||
days: 'روز',
|
||||
discounts: {
|
||||
title: 'کدهای تخفیف',
|
||||
subtitle: 'تخفیف درصدی روی سرویسهای مختلف؛ عمومی یا مخصوص کاربران خاص.',
|
||||
add: 'کد تخفیف جدید',
|
||||
empty: 'هنوز کد تخفیفی تعریف نشده',
|
||||
edit: 'ویرایش',
|
||||
delete: 'حذف',
|
||||
deleteConfirm: 'این کد تخفیف حذف شود؟',
|
||||
code: 'کد',
|
||||
codePlaceholder: 'NOWRUZ1403',
|
||||
name: 'عنوان',
|
||||
namePlaceholder: 'تخفیف نوروزی',
|
||||
description: 'توضیحات',
|
||||
percentOff: 'درصد تخفیف',
|
||||
scope: 'سرویسهای مشمول',
|
||||
allServices: 'همهٔ سرویسها',
|
||||
selectServices: 'انتخاب سرویسهای خاص',
|
||||
audience: 'کاربران مشمول',
|
||||
public: 'عمومی (همهٔ کاربران)',
|
||||
restricted: 'کاربران مشخص',
|
||||
searchUsers: 'جستوجوی کاربر بر اساس نام یا ایمیل…',
|
||||
noUsersSelected: 'هنوز کاربری انتخاب نشده',
|
||||
limits: 'محدودیتها',
|
||||
maxUses: 'سقف کل استفاده',
|
||||
maxUsesPerUser: 'سقف هر کاربر',
|
||||
unlimited: 'نامحدود',
|
||||
startsAt: 'تاریخ شروع',
|
||||
endsAt: 'تاریخ پایان',
|
||||
active: 'فعال',
|
||||
inactive: 'غیرفعال',
|
||||
used: 'استفادهشده',
|
||||
save: 'ذخیره',
|
||||
saving: 'در حال ذخیره…',
|
||||
cancel: 'انصراف',
|
||||
saved: 'کد تخفیف ذخیره شد',
|
||||
deleted: 'کد تخفیف حذف شد',
|
||||
saveFailed: 'ذخیرهٔ کد تخفیف ناموفق بود',
|
||||
groups: {
|
||||
runtime: 'رانتایم اپلیکیشن',
|
||||
optional: 'سرویسهای جانبی',
|
||||
managed: 'سرویسهای مدیریتشده',
|
||||
addon: 'افزونهها',
|
||||
},
|
||||
coupon: {
|
||||
label: 'کد تخفیف',
|
||||
placeholder: 'کد تخفیف را وارد کنید',
|
||||
apply: 'اعمال',
|
||||
checking: 'در حال بررسی…',
|
||||
applied: 'کد تخفیف اعمال شد',
|
||||
remove: 'حذف کد',
|
||||
discountLine: 'تخفیف',
|
||||
},
|
||||
reasons: {
|
||||
not_found: 'کد تخفیف نامعتبر است',
|
||||
inactive: 'این کد تخفیف غیرفعال است',
|
||||
not_started: 'این کد هنوز فعال نشده است',
|
||||
expired: 'این کد منقضی شده است',
|
||||
max_uses_reached: 'ظرفیت استفاده از این کد تمام شده است',
|
||||
max_uses_per_user_reached: 'سقف استفادهٔ شما از این کد پر شده است',
|
||||
not_eligible_user: 'این کد برای حساب شما قابل استفاده نیست',
|
||||
no_eligible_services: 'این کد روی سرویسهای انتخابی شما تخفیف ندارد',
|
||||
},
|
||||
},
|
||||
},
|
||||
servicesNew: {
|
||||
steps: ['نوع سرویس', 'پیکربندی', 'بررسی و پرداخت'],
|
||||
|
||||
@@ -704,12 +704,49 @@ export interface CatalogOptionalServiceOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface DiscountServiceOption {
|
||||
value: string;
|
||||
label: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
export interface PricingCatalog {
|
||||
runtimes: Record<string, PricingRateRow[]>;
|
||||
optionalServices: Record<string, OptionalServiceCatalogEntry>;
|
||||
customDomain: CustomDomainCatalogRow;
|
||||
runtimeOptions: CatalogRuntimeOption[];
|
||||
optionalServiceOptions: CatalogOptionalServiceOption[];
|
||||
discountServiceOptions: DiscountServiceOption[];
|
||||
}
|
||||
|
||||
export interface Discount {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
percentOff: number;
|
||||
services: string[];
|
||||
isPublic: boolean;
|
||||
allowedUserIds: string[];
|
||||
maxUses: number | null;
|
||||
maxUsesPerUser: number | null;
|
||||
usedCount: number;
|
||||
startsAt: string | null;
|
||||
endsAt: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Result of POST /billing/discounts/validate */
|
||||
export interface DiscountValidation {
|
||||
valid: boolean;
|
||||
reason?: string;
|
||||
code?: string;
|
||||
name?: string;
|
||||
percentOff?: number;
|
||||
eligibleAmount?: number;
|
||||
discountAmount?: number;
|
||||
}
|
||||
|
||||
export interface WalletBalance {
|
||||
@@ -753,6 +790,8 @@ export interface Invoice {
|
||||
status: InvoiceStatus;
|
||||
paymentMethod?: PaymentMethod;
|
||||
subtotal: number;
|
||||
discountAmount?: number;
|
||||
discountCode?: string;
|
||||
total: number;
|
||||
paidAmount: number;
|
||||
dueAmount: number;
|
||||
@@ -794,6 +833,15 @@ export interface DeployExtraChargeLine {
|
||||
fullPeriodAmount?: number;
|
||||
}
|
||||
|
||||
export interface CouponDiscountPreview {
|
||||
valid: boolean;
|
||||
reason?: string;
|
||||
code?: string;
|
||||
name?: string;
|
||||
percentOff?: number;
|
||||
discountAmount?: number;
|
||||
}
|
||||
|
||||
export interface DeployCostPreview extends CostBreakdown {
|
||||
cycle: BillingCycle;
|
||||
fullAmount: number;
|
||||
@@ -805,6 +853,8 @@ export interface DeployCostPreview extends CostBreakdown {
|
||||
prepaidCreditUsed: boolean;
|
||||
prorateRemainingDays?: number;
|
||||
proratePeriodDays?: number;
|
||||
couponDiscount?: CouponDiscountPreview | null;
|
||||
amountDueAfterDiscount?: number;
|
||||
}
|
||||
|
||||
// ─── Snapshot / Rollback types ──────────────────────
|
||||
|
||||
Reference in New Issue
Block a user