feat(events): manage tiered cancellation policies

This commit is contained in:
alisaza 2026-09-07 14:37:58 +03:30
parent 0421b4f6ee
commit 98e6482c0c
19 changed files with 262 additions and 58 deletions

View File

@ -5,44 +5,77 @@ import { texts } from '@/texts'
interface CancellationPolicyEditorProps { interface CancellationPolicyEditorProps {
disabled?: boolean disabled?: boolean
cancellationFeePercent: number
cancellationFeePercent12To24Hours: number
cancellationFeePercentMoreThan24Hours: number
onChange: (
field: 'cancellationFeePercent' | 'cancellationFeePercent12To24Hours' | 'cancellationFeePercentMoreThan24Hours',
value: number value: number
onChange: (value: number) => void ) => void
} }
/** /**
* Makes the cancellation fee explicit to hosts while keeping the persisted * Makes all three time-based cancellation fee tiers explicit to hosts.
* event contract to the existing `cancellationFeePercent` field.
*/ */
export default function CancellationPolicyEditor({ disabled = false, value, onChange }: CancellationPolicyEditorProps) { export default function CancellationPolicyEditor({
const normalizedValue = Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 0 disabled = false,
cancellationFeePercent,
cancellationFeePercent12To24Hours,
cancellationFeePercentMoreThan24Hours,
onChange,
}: CancellationPolicyEditorProps) {
const tiers = [
{
field: 'cancellationFeePercentMoreThan24Hours' as const,
label: texts.events.cancellationFeeMoreThan24HoursLabel,
value: cancellationFeePercentMoreThan24Hours,
},
{
field: 'cancellationFeePercent12To24Hours' as const,
label: texts.events.cancellationFee12To24HoursLabel,
value: cancellationFeePercent12To24Hours,
},
{
field: 'cancellationFeePercent' as const,
label: texts.events.cancellationFeeLabel,
value: cancellationFeePercent,
},
]
return ( return (
<div className="rounded-consumer-control bg-[#E2E2E280] p-3"> <div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-3"> {tiers.map((tier) => {
<div> const normalizedValue = Number.isFinite(tier.value) ? Math.min(100, Math.max(0, tier.value)) : 0
<p className="text-sm font-medium text-consumer-text">{texts.events.cancellationFeeLabel}</p>
<p className="mt-1 text-xs leading-5 text-secondary-20">{texts.events.cancellationSectionDescription}</p> return (
</div> <div
key={tier.field}
className="rounded-consumer-control bg-[#E2E2E280] p-3"
>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium text-consumer-text">{tier.label}</p>
<div className="relative shrink-0"> <div className="relative shrink-0">
<Input <Input
aria-label={texts.events.cancellationFeeLabel} aria-label={tier.label}
disabled={disabled} disabled={disabled}
generalType="numberInput" generalType="numberInput"
inputWrapper="h-10 w-16 rounded-xl border-0 bg-white px-2 text-center text-sm font-medium text-tertiary-900 shadow-none [&_input]:text-center disabled:bg-white/70" inputWrapper="h-10 w-16 rounded-xl border-0 bg-white px-2 text-center text-sm font-medium text-tertiary-900 shadow-none [&_input]:text-center disabled:bg-white/70"
maxValue={100} maxValue={100}
minValue={0} minValue={0}
name="cancellationFeePercent" name={tier.field}
value={normalizedValue} value={normalizedValue}
onValueChange={(nextValue) => { onValueChange={(nextValue) => {
const next = Number(nextValue) const next = Number(nextValue)
onChange(Number.isFinite(next) ? Math.min(100, Math.max(0, next)) : 0) onChange(tier.field, Number.isFinite(next) ? Math.min(100, Math.max(0, next)) : 0)
}} }}
/> />
{/* This marker belongs to the compact numeric field, not to its value. */}
<span className="pointer-events-none absolute inset-y-0 start-1 flex items-center text-xs text-secondary-20">٪</span> <span className="pointer-events-none absolute inset-y-0 start-1 flex items-center text-xs text-secondary-20">٪</span>
</div> </div>
</div> </div>
</div> </div>
) )
})}
</div>
)
} }

View File

@ -239,6 +239,8 @@ export default function EventCreateWizard({ finishRoute = APP_ROUTES.MANAGE_EVEN
genderRestriction: data.genderRestriction, genderRestriction: data.genderRestriction,
ageRestriction: data.ageRestriction, ageRestriction: data.ageRestriction,
cancellationFeePercent: data.cancellationFeePercent, cancellationFeePercent: data.cancellationFeePercent,
cancellationFeePercent12To24Hours: data.cancellationFeePercent12To24Hours,
cancellationFeePercentMoreThan24Hours: data.cancellationFeePercentMoreThan24Hours,
settings: { settings: {
isDiscoverable: data.isDiscoverable, isDiscoverable: data.isDiscoverable,
autoCreateGroup: data.autoCreateGroup, autoCreateGroup: data.autoCreateGroup,

View File

@ -30,6 +30,8 @@ const baseEvent = {
reservedCapacity: 3, reservedCapacity: 3,
bookedCount: 5, bookedCount: 5,
cancellationFeePercent: 10, cancellationFeePercent: 10,
cancellationFeePercent12To24Hours: 10,
cancellationFeePercentMoreThan24Hours: 10,
commissionPercent: null, commissionPercent: null,
effectiveCommissionPercent: 7, effectiveCommissionPercent: 7,
status: 'published', status: 'published',

View File

@ -55,6 +55,8 @@ export function mapEventToWizardCloneData(event: CreatedEvent, media: EventMedia
genderRestriction: event.genderRestriction ?? 'open', genderRestriction: event.genderRestriction ?? 'open',
ageRestriction: event.ageRestriction ?? 'open', ageRestriction: event.ageRestriction ?? 'open',
cancellationFeePercent: event.cancellationFeePercent, cancellationFeePercent: event.cancellationFeePercent,
cancellationFeePercent12To24Hours: event.cancellationFeePercent12To24Hours,
cancellationFeePercentMoreThan24Hours: event.cancellationFeePercentMoreThan24Hours,
isDiscoverable: event.settings.isDiscoverable, isDiscoverable: event.settings.isDiscoverable,
autoCreateGroup: event.settings.autoCreateGroup, autoCreateGroup: event.settings.autoCreateGroup,
addressVisibility: event.settings.addressVisibility, addressVisibility: event.settings.addressVisibility,

View File

@ -32,6 +32,8 @@ export default function Step3PricingCapacity({ data, onBack, onChange, onNext }:
genderRestriction: data.genderRestriction, genderRestriction: data.genderRestriction,
ageRestriction: data.ageRestriction, ageRestriction: data.ageRestriction,
cancellationFeePercent: data.cancellationFeePercent, cancellationFeePercent: data.cancellationFeePercent,
cancellationFeePercent12To24Hours: data.cancellationFeePercent12To24Hours,
cancellationFeePercentMoreThan24Hours: data.cancellationFeePercentMoreThan24Hours,
isDiscoverable: data.isDiscoverable, isDiscoverable: data.isDiscoverable,
autoCreateGroup: data.autoCreateGroup, autoCreateGroup: data.autoCreateGroup,
waitlistAutoOffer: data.waitlistAutoOffer, waitlistAutoOffer: data.waitlistAutoOffer,
@ -51,6 +53,8 @@ export default function Step3PricingCapacity({ data, onBack, onChange, onNext }:
genderRestriction: values.genderRestriction ?? 'open', genderRestriction: values.genderRestriction ?? 'open',
ageRestriction: values.ageRestriction ?? 'open', ageRestriction: values.ageRestriction ?? 'open',
cancellationFeePercent: Number(values.cancellationFeePercent ?? 0), cancellationFeePercent: Number(values.cancellationFeePercent ?? 0),
cancellationFeePercent12To24Hours: Number(values.cancellationFeePercent12To24Hours ?? 0),
cancellationFeePercentMoreThan24Hours: Number(values.cancellationFeePercentMoreThan24Hours ?? 0),
isDiscoverable: values.isDiscoverable ?? false, isDiscoverable: values.isDiscoverable ?? false,
autoCreateGroup: values.autoCreateGroup ?? true, autoCreateGroup: values.autoCreateGroup ?? true,
waitlistAutoOffer: values.waitlistAutoOffer ?? true, waitlistAutoOffer: values.waitlistAutoOffer ?? true,
@ -72,6 +76,8 @@ export default function Step3PricingCapacity({ data, onBack, onChange, onNext }:
genderRestriction: values.genderRestriction, genderRestriction: values.genderRestriction,
ageRestriction: values.ageRestriction, ageRestriction: values.ageRestriction,
cancellationFeePercent: values.cancellationFeePercent, cancellationFeePercent: values.cancellationFeePercent,
cancellationFeePercent12To24Hours: values.cancellationFeePercent12To24Hours,
cancellationFeePercentMoreThan24Hours: values.cancellationFeePercentMoreThan24Hours,
isDiscoverable: values.isDiscoverable, isDiscoverable: values.isDiscoverable,
autoCreateGroup: values.autoCreateGroup, autoCreateGroup: values.autoCreateGroup,
waitlistAutoOffer: values.waitlistAutoOffer, waitlistAutoOffer: values.waitlistAutoOffer,
@ -145,9 +151,11 @@ export default function Step3PricingCapacity({ data, onBack, onChange, onNext }:
<WizardFormFullWidth> <WizardFormFullWidth>
<CancellationPolicyEditor <CancellationPolicyEditor
value={form.watch('cancellationFeePercent')} cancellationFeePercent={form.watch('cancellationFeePercent')}
onChange={(cancellationFeePercent) => { cancellationFeePercent12To24Hours={form.watch('cancellationFeePercent12To24Hours')}
form.setValue('cancellationFeePercent', cancellationFeePercent, { shouldDirty: true, shouldValidate: true }) cancellationFeePercentMoreThan24Hours={form.watch('cancellationFeePercentMoreThan24Hours')}
onChange={(field, value) => {
form.setValue(field, value, { shouldDirty: true, shouldValidate: true })
}} }}
/> />
</WizardFormFullWidth> </WizardFormFullWidth>

View File

@ -44,6 +44,8 @@ export interface EventWizardFormData {
genderRestriction: EventGenderRestriction genderRestriction: EventGenderRestriction
ageRestriction: EventAgeRestriction ageRestriction: EventAgeRestriction
cancellationFeePercent: number cancellationFeePercent: number
cancellationFeePercent12To24Hours: number
cancellationFeePercentMoreThan24Hours: number
isDiscoverable: boolean isDiscoverable: boolean
autoCreateGroup: boolean autoCreateGroup: boolean
addressVisibility: 'public' | 'attendees_only' addressVisibility: 'public' | 'attendees_only'
@ -75,7 +77,9 @@ export const INITIAL_WIZARD_DATA: EventWizardFormData = {
reservedCapacity: 0, reservedCapacity: 0,
genderRestriction: 'open', genderRestriction: 'open',
ageRestriction: 'open', ageRestriction: 'open',
cancellationFeePercent: 0, cancellationFeePercent: 30,
cancellationFeePercent12To24Hours: 20,
cancellationFeePercentMoreThan24Hours: 10,
// Defaults to true here in the wizard, unlike the backend's own // Defaults to true here in the wizard, unlike the backend's own
// default of false -- since this is the only place that currently // default of false -- since this is the only place that currently
// sets it, and an organizer/admin creating an event almost always // sets it, and an organizer/admin creating an event almost always

View File

@ -70,6 +70,8 @@ export const createHostEvent = <T extends Record<string, unknown>>(overrides: T)
cityId: 2, cityId: 2,
provinceId: 1, provinceId: 1,
cancellationFeePercent: 0, cancellationFeePercent: 0,
cancellationFeePercent12To24Hours: 0,
cancellationFeePercentMoreThan24Hours: 0,
discoveryPlacements: [], discoveryPlacements: [],
createdAt: '2029-12-01T10:00:00.000Z', createdAt: '2029-12-01T10:00:00.000Z',
publishedAt: null, publishedAt: null,

View File

@ -18,6 +18,7 @@ import { formatNumber, formatPersonName } from '@/helpers'
import { getPublicMapApiKey } from '@/helpers/mapir' import { getPublicMapApiKey } from '@/helpers/mapir'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters' import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import { ageRestrictionLabel, genderRestrictionLabel } from '@/lib/eventAudience' import { ageRestrictionLabel, genderRestrictionLabel } from '@/lib/eventAudience'
import { texts } from '@/texts'
import { DetailItem } from './AdminEventDetailUi' import { DetailItem } from './AdminEventDetailUi'
@ -141,7 +142,11 @@ const AdminEventDetailsSection = ({
</Button> </Button>
</div> </div>
</DetailItem> </DetailItem>
<DetailItem label="جریمه لغو">{number(event.cancellationFeePercent)}٪</DetailItem> <DetailItem label={texts.events.cancellationFeeMoreThan24HoursLabel}>
{number(event.cancellationFeePercentMoreThan24Hours)}٪
</DetailItem>
<DetailItem label={texts.events.cancellationFee12To24HoursLabel}>{number(event.cancellationFeePercent12To24Hours)}٪</DetailItem>
<DetailItem label={texts.events.cancellationFeeLabel}>{number(event.cancellationFeePercent)}٪</DetailItem>
<DetailItem label="محدودیت جنسیتی"> <DetailItem label="محدودیت جنسیتی">
{restrictionStatus(event.genderRestriction, genderRestrictionLabel(event.genderRestriction))} {restrictionStatus(event.genderRestriction, genderRestrictionLabel(event.genderRestriction))}
</DetailItem> </DetailItem>

View File

@ -152,6 +152,8 @@ const baseEvent = {
ageRestriction: 'open' as const, ageRestriction: 'open' as const,
bookedCount: 3, bookedCount: 3,
cancellationFeePercent: 10, cancellationFeePercent: 10,
cancellationFeePercent12To24Hours: 10,
cancellationFeePercentMoreThan24Hours: 10,
settings: { settings: {
isDiscoverable: true, isDiscoverable: true,
autoCreateGroup: true, autoCreateGroup: true,
@ -215,7 +217,9 @@ describe('EventEditForm', () => {
expect(screen.getByLabelText('تاریخ شروع')).toBeDisabled() expect(screen.getByLabelText('تاریخ شروع')).toBeDisabled()
expect(screen.getByLabelText('آدرس')).toBeDisabled() expect(screen.getByLabelText('آدرس')).toBeDisabled()
expect(screen.getByLabelText('قیمت (تومان)')).toBeDisabled() expect(screen.getByLabelText('قیمت (تومان)')).toBeDisabled()
expect(screen.getByLabelText('درصد جریمه لغو (۰۱۰۰)')).toBeDisabled() expect(screen.getByLabelText('لغو زودهنگام — بیش از ۲۴ ساعت مانده')).toBeDisabled()
expect(screen.getByLabelText('لغو در آستانه برگزاری — ۱۲ تا ۲۴ ساعت مانده')).toBeDisabled()
expect(screen.getByLabelText('لغو لحظه آخری — ۱ دقیقه تا ۱۲ ساعت مانده')).toBeDisabled()
expect(screen.getByLabelText('محدودیت جنسی')).toBeDisabled() expect(screen.getByLabelText('محدودیت جنسی')).toBeDisabled()
expect(screen.getByLabelText('محدودیت سنی')).toBeDisabled() expect(screen.getByLabelText('محدودیت سنی')).toBeDisabled()
expect(screen.getByLabelText('عنوان')).not.toBeDisabled() expect(screen.getByLabelText('عنوان')).not.toBeDisabled()
@ -268,6 +272,8 @@ describe('EventEditForm', () => {
expect(payload).not.toHaveProperty('price') expect(payload).not.toHaveProperty('price')
expect(payload).not.toHaveProperty('isFree') expect(payload).not.toHaveProperty('isFree')
expect(payload).not.toHaveProperty('cancellationFeePercent') expect(payload).not.toHaveProperty('cancellationFeePercent')
expect(payload).not.toHaveProperty('cancellationFeePercent12To24Hours')
expect(payload).not.toHaveProperty('cancellationFeePercentMoreThan24Hours')
expect(payload).not.toHaveProperty('genderRestriction') expect(payload).not.toHaveProperty('genderRestriction')
expect(payload).not.toHaveProperty('ageRestriction') expect(payload).not.toHaveProperty('ageRestriction')
expect(updateEventAsAdmin).not.toHaveBeenCalled() expect(updateEventAsAdmin).not.toHaveBeenCalled()

View File

@ -63,6 +63,8 @@ interface EventEditFormValues {
genderRestriction: 'open' | 'female_only' | 'male_only' genderRestriction: 'open' | 'female_only' | 'male_only'
ageRestriction: 'open' | 'age_15_19' | 'age_20_24' | 'age_25_plus' ageRestriction: 'open' | 'age_15_19' | 'age_20_24' | 'age_25_plus'
cancellationFeePercent: number cancellationFeePercent: number
cancellationFeePercent12To24Hours: number
cancellationFeePercentMoreThan24Hours: number
isDiscoverable: boolean isDiscoverable: boolean
autoCreateGroup: boolean autoCreateGroup: boolean
addressVisibility: 'public' | 'attendees_only' addressVisibility: 'public' | 'attendees_only'
@ -122,6 +124,8 @@ function eventToFormValues(event: CreatedEvent): EventEditFormValues {
genderRestriction: event.genderRestriction ?? 'open', genderRestriction: event.genderRestriction ?? 'open',
ageRestriction: event.ageRestriction ?? 'open', ageRestriction: event.ageRestriction ?? 'open',
cancellationFeePercent: event.cancellationFeePercent, cancellationFeePercent: event.cancellationFeePercent,
cancellationFeePercent12To24Hours: event.cancellationFeePercent12To24Hours,
cancellationFeePercentMoreThan24Hours: event.cancellationFeePercentMoreThan24Hours,
isDiscoverable: event.settings.isDiscoverable, isDiscoverable: event.settings.isDiscoverable,
autoCreateGroup: event.settings.autoCreateGroup, autoCreateGroup: event.settings.autoCreateGroup,
addressVisibility: event.settings.addressVisibility, addressVisibility: event.settings.addressVisibility,
@ -268,6 +272,15 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
return return
} }
if (
values.cancellationFeePercent < values.cancellationFeePercent12To24Hours ||
values.cancellationFeePercent12To24Hours < values.cancellationFeePercentMoreThan24Hours
) {
addToast({ title: texts.validation.eventWizard.cancelPenaltyOrder, color: 'danger' })
return
}
const price = values.isFree ? 0 : Number(values.price) const price = values.isFree ? 0 : Number(values.price)
const payload: Partial<CreateEventPayload> = { const payload: Partial<CreateEventPayload> = {
title: values.title.trim(), title: values.title.trim(),
@ -315,6 +328,8 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
if (!cancellationFeeLocked) { if (!cancellationFeeLocked) {
payload.cancellationFeePercent = Number(values.cancellationFeePercent ?? 0) payload.cancellationFeePercent = Number(values.cancellationFeePercent ?? 0)
payload.cancellationFeePercent12To24Hours = Number(values.cancellationFeePercent12To24Hours ?? 0)
payload.cancellationFeePercentMoreThan24Hours = Number(values.cancellationFeePercentMoreThan24Hours ?? 0)
} }
if (!audienceLocked) { if (!audienceLocked) {
@ -599,6 +614,22 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
selectKey="selectKey" selectKey="selectKey"
/> />
</WizardFormFullWidth> </WizardFormFullWidth>
<Input
disabled={cancellationFeeLocked}
generalType="numberInput"
label={texts.events.cancellationFeeMoreThan24HoursLabel}
maxValue={100}
minValue={0}
name="cancellationFeePercentMoreThan24Hours"
/>
<Input
disabled={cancellationFeeLocked}
generalType="numberInput"
label={texts.events.cancellationFee12To24HoursLabel}
maxValue={100}
minValue={0}
name="cancellationFeePercent12To24Hours"
/>
<Input <Input
disabled={cancellationFeeLocked} disabled={cancellationFeeLocked}
generalType="numberInput" generalType="numberInput"

View File

@ -14,6 +14,8 @@ export const EVENT_SENSITIVE_FIELDS = [
'isFree', 'isFree',
'price', 'price',
'cancellationFeePercent', 'cancellationFeePercent',
'cancellationFeePercent12To24Hours',
'cancellationFeePercentMoreThan24Hours',
'genderRestriction', 'genderRestriction',
'ageRestriction', 'ageRestriction',
] as const ] as const

View File

@ -3,38 +3,50 @@ import { describe, expect, it } from 'vitest'
import { calculateCancellationPreview, resolveCancellationTicketPrice } from '@/services/bookings' import { calculateCancellationPreview, resolveCancellationTicketPrice } from '@/services/bookings'
describe('booking cancellation preview', () => { describe('booking cancellation preview', () => {
const startsAt = '2026-09-08T12:00:00.000Z'
const policy = {
cancellationFeePercent: 10,
cancellationFeePercent12To24Hours: 10,
cancellationFeePercentMoreThan24Hours: 10,
}
it('matches the backend floor-based commission formula', () => { it('matches the backend floor-based commission formula', () => {
// 7% mirrors the platform default (backend/src/common/constants/business.constants.ts#COMMISSION_PERCENT); // 7% mirrors the platform default (backend/src/common/constants/business.constants.ts#COMMISSION_PERCENT);
// commissionPercent is always passed in from the booking's resolved rate, never hardcoded here. // commissionPercent is always passed in from the booking's resolved rate, never hardcoded here.
expect(calculateCancellationPreview(101_000, 10, 7)).toEqual({ expect(calculateCancellationPreview(101_000, policy, 7, startsAt, new Date('2026-09-08T11:00:00Z'))).toEqual({
ticketPrice: 101_000, ticketPrice: 101_000,
platformCommission: 7_070, cancellationFeePercent: 10,
platformCommission: 707,
commissionPercent: 7, commissionPercent: 7,
hostCancellationFee: 10_100, hostCancellationFee: 9_393,
refundToGuest: 83_830, refundToGuest: 90_900,
}) })
}) })
it('does not produce fractional Toman values', () => { it('does not produce fractional Toman values', () => {
// A different rate here (5%) than the test above (7%) is deliberate — it // A different rate here (5%) than the test above (7%) is deliberate — it
// exercises an event-level commission override, not just the platform default. // exercises an event-level commission override, not just the platform default.
expect(calculateCancellationPreview(999, 7, 5)).toEqual({ expect(
calculateCancellationPreview(999, { ...policy, cancellationFeePercent: 7 }, 5, startsAt, new Date('2026-09-08T11:00:00Z'))
).toEqual({
ticketPrice: 999, ticketPrice: 999,
platformCommission: 49, cancellationFeePercent: 7,
platformCommission: 3,
commissionPercent: 5, commissionPercent: 5,
hostCancellationFee: 69, hostCancellationFee: 66,
refundToGuest: 881, refundToGuest: 930,
}) })
}) })
it('uses post-discount paid amount so fees match refund.service gross', () => { it('uses post-discount paid amount so fees match refund.service gross', () => {
// List 10_000, discount 2_000 → paid 8_000; 7% + 10% → refund 6_640 // List 10_000, discount 2_000 → paid 8_000; 10% penalty → refund 7_200.
expect(calculateCancellationPreview(8_000, 10, 7)).toEqual({ expect(calculateCancellationPreview(8_000, policy, 7, startsAt, new Date('2026-09-08T11:00:00Z'))).toEqual({
ticketPrice: 8_000, ticketPrice: 8_000,
platformCommission: 560, cancellationFeePercent: 10,
platformCommission: 56,
commissionPercent: 7, commissionPercent: 7,
hostCancellationFee: 800, hostCancellationFee: 744,
refundToGuest: 6_640, refundToGuest: 7_200,
}) })
}) })

View File

@ -299,7 +299,9 @@ export const CANCEL_BOOKING = async (
/** /**
* Client-side guest-cancel refund preview booking-cancellation-refund.md * Client-side guest-cancel refund preview booking-cancellation-refund.md
* formula: refund = paidGross × (1 - platform commission % - cancellationFeePercent). * formula: refund = paidGross - selected cancellation fee. The platform
* commission is taken only from that fee and the remainder belongs to the
* host after the event completes.
* Mirrors refund.service.ts#refundGuestCancellation exactly (same * Mirrors refund.service.ts#refundGuestCancellation exactly (same
* floor() rounding on each component) so this preview never shows a * floor() rounding on each component) so this preview never shows a
* different number than what's actually credited. * different number than what's actually credited.
@ -324,20 +326,46 @@ export interface CancellationPreview {
platformCommission: number platformCommission: number
/** The rate actually applied, echoed back so callers can label the commission line without re-touching the booking. */ /** The rate actually applied, echoed back so callers can label the commission line without re-touching the booking. */
commissionPercent: number commissionPercent: number
cancellationFeePercent: number
hostCancellationFee: number hostCancellationFee: number
refundToGuest: number refundToGuest: number
} }
export interface CancellationFeePolicy {
cancellationFeePercent: number
cancellationFeePercent12To24Hours: number
cancellationFeePercentMoreThan24Hours: number
}
const HOUR_MS = 60 * 60 * 1000
export const resolveCancellationFeePercent = (
policy: CancellationFeePolicy,
eventStartsAt: string | Date,
cancelledAt: Date = new Date()
): number => {
const remainingMs = new Date(eventStartsAt).getTime() - cancelledAt.getTime()
if (remainingMs <= 12 * HOUR_MS) return policy.cancellationFeePercent
if (remainingMs <= 24 * HOUR_MS) return policy.cancellationFeePercent12To24Hours
return policy.cancellationFeePercentMoreThan24Hours
}
export const calculateCancellationPreview = ( export const calculateCancellationPreview = (
ticketPrice: number, ticketPrice: number,
cancellationFeePercent: number, policy: CancellationFeePolicy,
commissionPercent: number commissionPercent: number,
eventStartsAt: string | Date,
cancelledAt: Date = new Date()
): CancellationPreview => { ): CancellationPreview => {
const platformCommission = Math.floor(ticketPrice * (commissionPercent / 100)) const cancellationFeePercent = resolveCancellationFeePercent(policy, eventStartsAt, cancelledAt)
const hostCancellationFee = Math.floor(ticketPrice * (cancellationFeePercent / 100)) const grossCancellationFee = Math.floor(ticketPrice * (cancellationFeePercent / 100))
const refundToGuest = ticketPrice - platformCommission - hostCancellationFee const platformCommission = Math.floor(grossCancellationFee * (commissionPercent / 100))
const hostCancellationFee = grossCancellationFee - platformCommission
const refundToGuest = ticketPrice - grossCancellationFee
return { ticketPrice, platformCommission, commissionPercent, hostCancellationFee, refundToGuest } return { ticketPrice, platformCommission, commissionPercent, cancellationFeePercent, hostCancellationFee, refundToGuest }
} }
/** Gross base for guest-cancel preview — paid amount after discount, else list price. */ /** Gross base for guest-cancel preview — paid amount after discount, else list price. */

View File

@ -52,6 +52,8 @@ export interface DiscoveryEvent {
bookedCount: number bookedCount: number
reservedCapacity: number reservedCapacity: number
cancellationFeePercent: number cancellationFeePercent: number
cancellationFeePercent12To24Hours: number
cancellationFeePercentMoreThan24Hours: number
commissionPercent: number | null commissionPercent: number | null
effectiveCommissionPercent: number effectiveCommissionPercent: number
status: EventStatus status: EventStatus

View File

@ -243,6 +243,8 @@ export interface EventRevisionPayload {
genderRestriction?: string genderRestriction?: string
ageRestriction?: string ageRestriction?: string
cancellationFeePercent?: number cancellationFeePercent?: number
cancellationFeePercent12To24Hours?: number
cancellationFeePercentMoreThan24Hours?: number
settings?: CreateEventPayload['settings'] settings?: CreateEventPayload['settings']
media: CreateEventMediaPayload[] media: CreateEventMediaPayload[]
faqs?: CreateEventFaqPayload[] faqs?: CreateEventFaqPayload[]

View File

@ -276,7 +276,9 @@ export const events = {
peopleRestrictionLabel: 'محدودیت‌افراد', peopleRestrictionLabel: 'محدودیت‌افراد',
ageRestrictionLabel: 'محدودیت سنی', ageRestrictionLabel: 'محدودیت سنی',
cancellationSectionDescription: 'شرایط لغو را پیش از رزرو به مهمان نشان می‌دهیم تا تصمیم شفافی بگیرد.', cancellationSectionDescription: 'شرایط لغو را پیش از رزرو به مهمان نشان می‌دهیم تا تصمیم شفافی بگیرد.',
cancellationFeeLabel: 'درصد جریمه لغو (۰۱۰۰)', cancellationFeeLabel: 'لغو لحظه آخری — ۱ دقیقه تا ۱۲ ساعت مانده',
cancellationFee12To24HoursLabel: 'لغو در آستانه برگزاری — ۱۲ تا ۲۴ ساعت مانده',
cancellationFeeMoreThan24HoursLabel: 'لغو زودهنگام — بیش از ۲۴ ساعت مانده',
advancedSettingsHint: 'پیشنهاد می‌کنیم مقادیر پیش‌فرض را نگه داری', advancedSettingsHint: 'پیشنهاد می‌کنیم مقادیر پیش‌فرض را نگه داری',
discoverableLabel: 'نمایش در جستجوی عمومی', discoverableLabel: 'نمایش در جستجوی عمومی',
autoCreateGroupLabel: 'ساخت خودکار گروه چت پس از رویداد', autoCreateGroupLabel: 'ساخت خودکار گروه چت پس از رویداد',

View File

@ -65,6 +65,7 @@ export const validation = {
cancelPenaltyRequired: 'درصد جریمه را وارد کنید', cancelPenaltyRequired: 'درصد جریمه را وارد کنید',
cancelPenaltyNonNegative: 'درصد جریمه نمی‌تواند منفی باشد', cancelPenaltyNonNegative: 'درصد جریمه نمی‌تواند منفی باشد',
cancelPenaltyMax: 'درصد جریمه حداکثر ۱۰۰ است', cancelPenaltyMax: 'درصد جریمه حداکثر ۱۰۰ است',
cancelPenaltyOrder: 'درصد جریمه باید با نزدیک‌شدن به زمان رویداد بیشتر یا مساوی شود',
paidPricePositive: 'برای رویداد پولی، قیمت باید بیشتر از صفر باشد', paidPricePositive: 'برای رویداد پولی، قیمت باید بیشتر از صفر باشد',
reservedExceedsCapacity: 'ظرفیت رزرو شده نمی‌تواند از ظرفیت کل بیشتر باشد', reservedExceedsCapacity: 'ظرفیت رزرو شده نمی‌تواند از ظرفیت کل بیشتر باشد',
faqQuestionRequired: 'سوال را وارد کنید', faqQuestionRequired: 'سوال را وارد کنید',

View File

@ -119,6 +119,8 @@ describe('event wizard validation', () => {
genderRestriction: 'open', genderRestriction: 'open',
ageRestriction: 'open', ageRestriction: 'open',
cancellationFeePercent: 0, cancellationFeePercent: 0,
cancellationFeePercent12To24Hours: 0,
cancellationFeePercentMoreThan24Hours: 0,
isDiscoverable: true, isDiscoverable: true,
autoCreateGroup: true, autoCreateGroup: true,
waitlistAutoOffer: true, waitlistAutoOffer: true,
@ -135,6 +137,8 @@ describe('event wizard validation', () => {
genderRestriction: 'open', genderRestriction: 'open',
ageRestriction: 'open', ageRestriction: 'open',
cancellationFeePercent: 0, cancellationFeePercent: 0,
cancellationFeePercent12To24Hours: 0,
cancellationFeePercentMoreThan24Hours: 0,
isDiscoverable: true, isDiscoverable: true,
autoCreateGroup: true, autoCreateGroup: true,
waitlistAutoOffer: true, waitlistAutoOffer: true,
@ -151,6 +155,8 @@ describe('event wizard validation', () => {
genderRestriction: 'open', genderRestriction: 'open',
ageRestriction: 'open', ageRestriction: 'open',
cancellationFeePercent: 0, cancellationFeePercent: 0,
cancellationFeePercent12To24Hours: 0,
cancellationFeePercentMoreThan24Hours: 0,
isDiscoverable: true, isDiscoverable: true,
autoCreateGroup: true, autoCreateGroup: true,
waitlistAutoOffer: true, waitlistAutoOffer: true,
@ -168,6 +174,8 @@ describe('event wizard validation', () => {
genderRestriction: 'open', genderRestriction: 'open',
ageRestriction: 'open', ageRestriction: 'open',
cancellationFeePercent: 0, cancellationFeePercent: 0,
cancellationFeePercent12To24Hours: 0,
cancellationFeePercentMoreThan24Hours: 0,
isDiscoverable: true, isDiscoverable: true,
autoCreateGroup: true, autoCreateGroup: true,
waitlistAutoOffer: true, waitlistAutoOffer: true,
@ -179,6 +187,40 @@ describe('event wizard validation', () => {
expect(EventWizardStep3Validation.safeParse({ ...base, ageRestriction: 'age_15_19' }).success).toBe(true) expect(EventWizardStep3Validation.safeParse({ ...base, ageRestriction: 'age_15_19' }).success).toBe(true)
}) })
it('requires cancellation penalties to decrease as cancellation gets earlier', () => {
const base = {
isFree: false,
price: 100_000,
capacity: 20,
reservedCapacity: 0,
genderRestriction: 'open',
ageRestriction: 'open',
cancellationFeePercent: 30,
cancellationFeePercent12To24Hours: 20,
cancellationFeePercentMoreThan24Hours: 10,
isDiscoverable: true,
autoCreateGroup: true,
waitlistAutoOffer: true,
sendReviewRequestSms: false,
}
expect(EventWizardStep3Validation.safeParse(base).success).toBe(true)
expect(
EventWizardStep3Validation.safeParse({
...base,
cancellationFeePercentMoreThan24Hours: 25,
}).success
).toBe(false)
expect(
EventWizardStep3Validation.safeParse({
...base,
cancellationFeePercent: 0,
cancellationFeePercent12To24Hours: 0,
cancellationFeePercentMoreThan24Hours: 0,
}).success
).toBe(true)
})
it('requires both question and answer for FAQ rows', () => { it('requires both question and answer for FAQ rows', () => {
expect(EventWizardFaqFormValidation.safeParse({ question: '', answer: 'پاسخ' }).success).toBe(false) expect(EventWizardFaqFormValidation.safeParse({ question: '', answer: 'پاسخ' }).success).toBe(false)
expect(EventWizardFaqFormValidation.parse({ question: 'سوال؟', answer: 'پاسخ' })).toEqual({ expect(EventWizardFaqFormValidation.parse({ question: 'سوال؟', answer: 'پاسخ' })).toEqual({

View File

@ -108,6 +108,14 @@ export const EventWizardStep3Validation = z
genderRestriction: z.enum(['open', 'female_only', 'male_only']), genderRestriction: z.enum(['open', 'female_only', 'male_only']),
ageRestriction: z.enum(['open', 'age_15_19', 'age_20_24', 'age_25_plus']), ageRestriction: z.enum(['open', 'age_15_19', 'age_20_24', 'age_25_plus']),
cancellationFeePercent: z.number({ error: v.cancelPenaltyRequired }).min(0, v.cancelPenaltyNonNegative).max(100, v.cancelPenaltyMax), cancellationFeePercent: z.number({ error: v.cancelPenaltyRequired }).min(0, v.cancelPenaltyNonNegative).max(100, v.cancelPenaltyMax),
cancellationFeePercent12To24Hours: z
.number({ error: v.cancelPenaltyRequired })
.min(0, v.cancelPenaltyNonNegative)
.max(100, v.cancelPenaltyMax),
cancellationFeePercentMoreThan24Hours: z
.number({ error: v.cancelPenaltyRequired })
.min(0, v.cancelPenaltyNonNegative)
.max(100, v.cancelPenaltyMax),
isDiscoverable: z.boolean(), isDiscoverable: z.boolean(),
autoCreateGroup: z.boolean(), autoCreateGroup: z.boolean(),
waitlistAutoOffer: z.boolean(), waitlistAutoOffer: z.boolean(),
@ -128,6 +136,16 @@ export const EventWizardStep3Validation = z
path: ['reservedCapacity'], path: ['reservedCapacity'],
}) })
} }
if (
values.cancellationFeePercent < values.cancellationFeePercent12To24Hours ||
values.cancellationFeePercent12To24Hours < values.cancellationFeePercentMoreThan24Hours
) {
ctx.addIssue({
code: 'custom',
message: v.cancelPenaltyOrder,
path: ['cancellationFeePercent12To24Hours'],
})
}
}) })
export type EventWizardStep3Values = z.infer<typeof EventWizardStep3Validation> export type EventWizardStep3Values = z.infer<typeof EventWizardStep3Validation>