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>
+64
View File
@@ -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'],
+64
View File
@@ -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: ['نوع سرویس', 'پیکربندی', 'بررسی و پرداخت'],
+50
View File
@@ -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 ──────────────────────