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:
keyhan
2026-06-20 11:48:45 +03:30
parent b232129ee2
commit 49726f1dfd
21 changed files with 2013 additions and 20 deletions
@@ -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>