feat(events): manage tiered cancellation policies
This commit is contained in:
parent
0421b4f6ee
commit
98e6482c0c
@ -5,44 +5,77 @@ import { texts } from '@/texts'
|
||||
|
||||
interface CancellationPolicyEditorProps {
|
||||
disabled?: boolean
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
cancellationFeePercent: number
|
||||
cancellationFeePercent12To24Hours: number
|
||||
cancellationFeePercentMoreThan24Hours: number
|
||||
onChange: (
|
||||
field: 'cancellationFeePercent' | 'cancellationFeePercent12To24Hours' | 'cancellationFeePercentMoreThan24Hours',
|
||||
value: number
|
||||
) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the cancellation fee explicit to hosts while keeping the persisted
|
||||
* event contract to the existing `cancellationFeePercent` field.
|
||||
* Makes all three time-based cancellation fee tiers explicit to hosts.
|
||||
*/
|
||||
export default function CancellationPolicyEditor({ disabled = false, value, onChange }: CancellationPolicyEditorProps) {
|
||||
const normalizedValue = Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 0
|
||||
export default function CancellationPolicyEditor({
|
||||
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 (
|
||||
<div className="rounded-consumer-control bg-[#E2E2E280] p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="relative shrink-0">
|
||||
<Input
|
||||
aria-label={texts.events.cancellationFeeLabel}
|
||||
disabled={disabled}
|
||||
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"
|
||||
maxValue={100}
|
||||
minValue={0}
|
||||
name="cancellationFeePercent"
|
||||
value={normalizedValue}
|
||||
onValueChange={(nextValue) => {
|
||||
const next = Number(nextValue)
|
||||
<div className="flex flex-col gap-3">
|
||||
{tiers.map((tier) => {
|
||||
const normalizedValue = Number.isFinite(tier.value) ? Math.min(100, Math.max(0, tier.value)) : 0
|
||||
|
||||
onChange(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>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<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">
|
||||
<Input
|
||||
aria-label={tier.label}
|
||||
disabled={disabled}
|
||||
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"
|
||||
maxValue={100}
|
||||
minValue={0}
|
||||
name={tier.field}
|
||||
value={normalizedValue}
|
||||
onValueChange={(nextValue) => {
|
||||
const next = Number(nextValue)
|
||||
|
||||
onChange(tier.field, Number.isFinite(next) ? Math.min(100, Math.max(0, next)) : 0)
|
||||
}}
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 start-1 flex items-center text-xs text-secondary-20">٪</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -239,6 +239,8 @@ export default function EventCreateWizard({ finishRoute = APP_ROUTES.MANAGE_EVEN
|
||||
genderRestriction: data.genderRestriction,
|
||||
ageRestriction: data.ageRestriction,
|
||||
cancellationFeePercent: data.cancellationFeePercent,
|
||||
cancellationFeePercent12To24Hours: data.cancellationFeePercent12To24Hours,
|
||||
cancellationFeePercentMoreThan24Hours: data.cancellationFeePercentMoreThan24Hours,
|
||||
settings: {
|
||||
isDiscoverable: data.isDiscoverable,
|
||||
autoCreateGroup: data.autoCreateGroup,
|
||||
|
||||
@ -30,6 +30,8 @@ const baseEvent = {
|
||||
reservedCapacity: 3,
|
||||
bookedCount: 5,
|
||||
cancellationFeePercent: 10,
|
||||
cancellationFeePercent12To24Hours: 10,
|
||||
cancellationFeePercentMoreThan24Hours: 10,
|
||||
commissionPercent: null,
|
||||
effectiveCommissionPercent: 7,
|
||||
status: 'published',
|
||||
|
||||
@ -55,6 +55,8 @@ export function mapEventToWizardCloneData(event: CreatedEvent, media: EventMedia
|
||||
genderRestriction: event.genderRestriction ?? 'open',
|
||||
ageRestriction: event.ageRestriction ?? 'open',
|
||||
cancellationFeePercent: event.cancellationFeePercent,
|
||||
cancellationFeePercent12To24Hours: event.cancellationFeePercent12To24Hours,
|
||||
cancellationFeePercentMoreThan24Hours: event.cancellationFeePercentMoreThan24Hours,
|
||||
isDiscoverable: event.settings.isDiscoverable,
|
||||
autoCreateGroup: event.settings.autoCreateGroup,
|
||||
addressVisibility: event.settings.addressVisibility,
|
||||
|
||||
@ -32,6 +32,8 @@ export default function Step3PricingCapacity({ data, onBack, onChange, onNext }:
|
||||
genderRestriction: data.genderRestriction,
|
||||
ageRestriction: data.ageRestriction,
|
||||
cancellationFeePercent: data.cancellationFeePercent,
|
||||
cancellationFeePercent12To24Hours: data.cancellationFeePercent12To24Hours,
|
||||
cancellationFeePercentMoreThan24Hours: data.cancellationFeePercentMoreThan24Hours,
|
||||
isDiscoverable: data.isDiscoverable,
|
||||
autoCreateGroup: data.autoCreateGroup,
|
||||
waitlistAutoOffer: data.waitlistAutoOffer,
|
||||
@ -51,6 +53,8 @@ export default function Step3PricingCapacity({ data, onBack, onChange, onNext }:
|
||||
genderRestriction: values.genderRestriction ?? 'open',
|
||||
ageRestriction: values.ageRestriction ?? 'open',
|
||||
cancellationFeePercent: Number(values.cancellationFeePercent ?? 0),
|
||||
cancellationFeePercent12To24Hours: Number(values.cancellationFeePercent12To24Hours ?? 0),
|
||||
cancellationFeePercentMoreThan24Hours: Number(values.cancellationFeePercentMoreThan24Hours ?? 0),
|
||||
isDiscoverable: values.isDiscoverable ?? false,
|
||||
autoCreateGroup: values.autoCreateGroup ?? true,
|
||||
waitlistAutoOffer: values.waitlistAutoOffer ?? true,
|
||||
@ -72,6 +76,8 @@ export default function Step3PricingCapacity({ data, onBack, onChange, onNext }:
|
||||
genderRestriction: values.genderRestriction,
|
||||
ageRestriction: values.ageRestriction,
|
||||
cancellationFeePercent: values.cancellationFeePercent,
|
||||
cancellationFeePercent12To24Hours: values.cancellationFeePercent12To24Hours,
|
||||
cancellationFeePercentMoreThan24Hours: values.cancellationFeePercentMoreThan24Hours,
|
||||
isDiscoverable: values.isDiscoverable,
|
||||
autoCreateGroup: values.autoCreateGroup,
|
||||
waitlistAutoOffer: values.waitlistAutoOffer,
|
||||
@ -145,9 +151,11 @@ export default function Step3PricingCapacity({ data, onBack, onChange, onNext }:
|
||||
|
||||
<WizardFormFullWidth>
|
||||
<CancellationPolicyEditor
|
||||
value={form.watch('cancellationFeePercent')}
|
||||
onChange={(cancellationFeePercent) => {
|
||||
form.setValue('cancellationFeePercent', cancellationFeePercent, { shouldDirty: true, shouldValidate: true })
|
||||
cancellationFeePercent={form.watch('cancellationFeePercent')}
|
||||
cancellationFeePercent12To24Hours={form.watch('cancellationFeePercent12To24Hours')}
|
||||
cancellationFeePercentMoreThan24Hours={form.watch('cancellationFeePercentMoreThan24Hours')}
|
||||
onChange={(field, value) => {
|
||||
form.setValue(field, value, { shouldDirty: true, shouldValidate: true })
|
||||
}}
|
||||
/>
|
||||
</WizardFormFullWidth>
|
||||
|
||||
@ -44,6 +44,8 @@ export interface EventWizardFormData {
|
||||
genderRestriction: EventGenderRestriction
|
||||
ageRestriction: EventAgeRestriction
|
||||
cancellationFeePercent: number
|
||||
cancellationFeePercent12To24Hours: number
|
||||
cancellationFeePercentMoreThan24Hours: number
|
||||
isDiscoverable: boolean
|
||||
autoCreateGroup: boolean
|
||||
addressVisibility: 'public' | 'attendees_only'
|
||||
@ -75,7 +77,9 @@ export const INITIAL_WIZARD_DATA: EventWizardFormData = {
|
||||
reservedCapacity: 0,
|
||||
genderRestriction: 'open',
|
||||
ageRestriction: 'open',
|
||||
cancellationFeePercent: 0,
|
||||
cancellationFeePercent: 30,
|
||||
cancellationFeePercent12To24Hours: 20,
|
||||
cancellationFeePercentMoreThan24Hours: 10,
|
||||
// Defaults to true here in the wizard, unlike the backend's own
|
||||
// default of false -- since this is the only place that currently
|
||||
// sets it, and an organizer/admin creating an event almost always
|
||||
|
||||
@ -70,6 +70,8 @@ export const createHostEvent = <T extends Record<string, unknown>>(overrides: T)
|
||||
cityId: 2,
|
||||
provinceId: 1,
|
||||
cancellationFeePercent: 0,
|
||||
cancellationFeePercent12To24Hours: 0,
|
||||
cancellationFeePercentMoreThan24Hours: 0,
|
||||
discoveryPlacements: [],
|
||||
createdAt: '2029-12-01T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
|
||||
@ -18,6 +18,7 @@ import { formatNumber, formatPersonName } from '@/helpers'
|
||||
import { getPublicMapApiKey } from '@/helpers/mapir'
|
||||
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
|
||||
import { ageRestrictionLabel, genderRestrictionLabel } from '@/lib/eventAudience'
|
||||
import { texts } from '@/texts'
|
||||
|
||||
import { DetailItem } from './AdminEventDetailUi'
|
||||
|
||||
@ -141,7 +142,11 @@ const AdminEventDetailsSection = ({
|
||||
</Button>
|
||||
</div>
|
||||
</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="محدودیت جنسیتی">
|
||||
{restrictionStatus(event.genderRestriction, genderRestrictionLabel(event.genderRestriction))}
|
||||
</DetailItem>
|
||||
|
||||
@ -152,6 +152,8 @@ const baseEvent = {
|
||||
ageRestriction: 'open' as const,
|
||||
bookedCount: 3,
|
||||
cancellationFeePercent: 10,
|
||||
cancellationFeePercent12To24Hours: 10,
|
||||
cancellationFeePercentMoreThan24Hours: 10,
|
||||
settings: {
|
||||
isDiscoverable: 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('عنوان')).not.toBeDisabled()
|
||||
@ -268,6 +272,8 @@ describe('EventEditForm', () => {
|
||||
expect(payload).not.toHaveProperty('price')
|
||||
expect(payload).not.toHaveProperty('isFree')
|
||||
expect(payload).not.toHaveProperty('cancellationFeePercent')
|
||||
expect(payload).not.toHaveProperty('cancellationFeePercent12To24Hours')
|
||||
expect(payload).not.toHaveProperty('cancellationFeePercentMoreThan24Hours')
|
||||
expect(payload).not.toHaveProperty('genderRestriction')
|
||||
expect(payload).not.toHaveProperty('ageRestriction')
|
||||
expect(updateEventAsAdmin).not.toHaveBeenCalled()
|
||||
|
||||
@ -63,6 +63,8 @@ interface EventEditFormValues {
|
||||
genderRestriction: 'open' | 'female_only' | 'male_only'
|
||||
ageRestriction: 'open' | 'age_15_19' | 'age_20_24' | 'age_25_plus'
|
||||
cancellationFeePercent: number
|
||||
cancellationFeePercent12To24Hours: number
|
||||
cancellationFeePercentMoreThan24Hours: number
|
||||
isDiscoverable: boolean
|
||||
autoCreateGroup: boolean
|
||||
addressVisibility: 'public' | 'attendees_only'
|
||||
@ -122,6 +124,8 @@ function eventToFormValues(event: CreatedEvent): EventEditFormValues {
|
||||
genderRestriction: event.genderRestriction ?? 'open',
|
||||
ageRestriction: event.ageRestriction ?? 'open',
|
||||
cancellationFeePercent: event.cancellationFeePercent,
|
||||
cancellationFeePercent12To24Hours: event.cancellationFeePercent12To24Hours,
|
||||
cancellationFeePercentMoreThan24Hours: event.cancellationFeePercentMoreThan24Hours,
|
||||
isDiscoverable: event.settings.isDiscoverable,
|
||||
autoCreateGroup: event.settings.autoCreateGroup,
|
||||
addressVisibility: event.settings.addressVisibility,
|
||||
@ -268,6 +272,15 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
||||
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 payload: Partial<CreateEventPayload> = {
|
||||
title: values.title.trim(),
|
||||
@ -315,6 +328,8 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
||||
|
||||
if (!cancellationFeeLocked) {
|
||||
payload.cancellationFeePercent = Number(values.cancellationFeePercent ?? 0)
|
||||
payload.cancellationFeePercent12To24Hours = Number(values.cancellationFeePercent12To24Hours ?? 0)
|
||||
payload.cancellationFeePercentMoreThan24Hours = Number(values.cancellationFeePercentMoreThan24Hours ?? 0)
|
||||
}
|
||||
|
||||
if (!audienceLocked) {
|
||||
@ -599,6 +614,22 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
||||
selectKey="selectKey"
|
||||
/>
|
||||
</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
|
||||
disabled={cancellationFeeLocked}
|
||||
generalType="numberInput"
|
||||
|
||||
@ -14,6 +14,8 @@ export const EVENT_SENSITIVE_FIELDS = [
|
||||
'isFree',
|
||||
'price',
|
||||
'cancellationFeePercent',
|
||||
'cancellationFeePercent12To24Hours',
|
||||
'cancellationFeePercentMoreThan24Hours',
|
||||
'genderRestriction',
|
||||
'ageRestriction',
|
||||
] as const
|
||||
|
||||
@ -3,38 +3,50 @@ import { describe, expect, it } from 'vitest'
|
||||
import { calculateCancellationPreview, resolveCancellationTicketPrice } from '@/services/bookings'
|
||||
|
||||
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', () => {
|
||||
// 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.
|
||||
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,
|
||||
platformCommission: 7_070,
|
||||
cancellationFeePercent: 10,
|
||||
platformCommission: 707,
|
||||
commissionPercent: 7,
|
||||
hostCancellationFee: 10_100,
|
||||
refundToGuest: 83_830,
|
||||
hostCancellationFee: 9_393,
|
||||
refundToGuest: 90_900,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not produce fractional Toman values', () => {
|
||||
// A different rate here (5%) than the test above (7%) is deliberate — it
|
||||
// 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,
|
||||
platformCommission: 49,
|
||||
cancellationFeePercent: 7,
|
||||
platformCommission: 3,
|
||||
commissionPercent: 5,
|
||||
hostCancellationFee: 69,
|
||||
refundToGuest: 881,
|
||||
hostCancellationFee: 66,
|
||||
refundToGuest: 930,
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
expect(calculateCancellationPreview(8_000, 10, 7)).toEqual({
|
||||
// List 10_000, discount 2_000 → paid 8_000; 10% penalty → refund 7_200.
|
||||
expect(calculateCancellationPreview(8_000, policy, 7, startsAt, new Date('2026-09-08T11:00:00Z'))).toEqual({
|
||||
ticketPrice: 8_000,
|
||||
platformCommission: 560,
|
||||
cancellationFeePercent: 10,
|
||||
platformCommission: 56,
|
||||
commissionPercent: 7,
|
||||
hostCancellationFee: 800,
|
||||
refundToGuest: 6_640,
|
||||
hostCancellationFee: 744,
|
||||
refundToGuest: 7_200,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -299,7 +299,9 @@ export const CANCEL_BOOKING = async (
|
||||
|
||||
/**
|
||||
* 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
|
||||
* floor() rounding on each component) so this preview never shows a
|
||||
* different number than what's actually credited.
|
||||
@ -324,20 +326,46 @@ export interface CancellationPreview {
|
||||
platformCommission: number
|
||||
/** The rate actually applied, echoed back so callers can label the commission line without re-touching the booking. */
|
||||
commissionPercent: number
|
||||
cancellationFeePercent: number
|
||||
hostCancellationFee: 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 = (
|
||||
ticketPrice: number,
|
||||
cancellationFeePercent: number,
|
||||
commissionPercent: number
|
||||
policy: CancellationFeePolicy,
|
||||
commissionPercent: number,
|
||||
eventStartsAt: string | Date,
|
||||
cancelledAt: Date = new Date()
|
||||
): CancellationPreview => {
|
||||
const platformCommission = Math.floor(ticketPrice * (commissionPercent / 100))
|
||||
const hostCancellationFee = Math.floor(ticketPrice * (cancellationFeePercent / 100))
|
||||
const refundToGuest = ticketPrice - platformCommission - hostCancellationFee
|
||||
const cancellationFeePercent = resolveCancellationFeePercent(policy, eventStartsAt, cancelledAt)
|
||||
const grossCancellationFee = Math.floor(ticketPrice * (cancellationFeePercent / 100))
|
||||
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. */
|
||||
|
||||
@ -52,6 +52,8 @@ export interface DiscoveryEvent {
|
||||
bookedCount: number
|
||||
reservedCapacity: number
|
||||
cancellationFeePercent: number
|
||||
cancellationFeePercent12To24Hours: number
|
||||
cancellationFeePercentMoreThan24Hours: number
|
||||
commissionPercent: number | null
|
||||
effectiveCommissionPercent: number
|
||||
status: EventStatus
|
||||
|
||||
@ -243,6 +243,8 @@ export interface EventRevisionPayload {
|
||||
genderRestriction?: string
|
||||
ageRestriction?: string
|
||||
cancellationFeePercent?: number
|
||||
cancellationFeePercent12To24Hours?: number
|
||||
cancellationFeePercentMoreThan24Hours?: number
|
||||
settings?: CreateEventPayload['settings']
|
||||
media: CreateEventMediaPayload[]
|
||||
faqs?: CreateEventFaqPayload[]
|
||||
|
||||
@ -276,7 +276,9 @@ export const events = {
|
||||
peopleRestrictionLabel: 'محدودیتافراد',
|
||||
ageRestrictionLabel: 'محدودیت سنی',
|
||||
cancellationSectionDescription: 'شرایط لغو را پیش از رزرو به مهمان نشان میدهیم تا تصمیم شفافی بگیرد.',
|
||||
cancellationFeeLabel: 'درصد جریمه لغو (۰–۱۰۰)',
|
||||
cancellationFeeLabel: 'لغو لحظه آخری — ۱ دقیقه تا ۱۲ ساعت مانده',
|
||||
cancellationFee12To24HoursLabel: 'لغو در آستانه برگزاری — ۱۲ تا ۲۴ ساعت مانده',
|
||||
cancellationFeeMoreThan24HoursLabel: 'لغو زودهنگام — بیش از ۲۴ ساعت مانده',
|
||||
advancedSettingsHint: 'پیشنهاد میکنیم مقادیر پیشفرض را نگه داری',
|
||||
discoverableLabel: 'نمایش در جستجوی عمومی',
|
||||
autoCreateGroupLabel: 'ساخت خودکار گروه چت پس از رویداد',
|
||||
|
||||
@ -65,6 +65,7 @@ export const validation = {
|
||||
cancelPenaltyRequired: 'درصد جریمه را وارد کنید',
|
||||
cancelPenaltyNonNegative: 'درصد جریمه نمیتواند منفی باشد',
|
||||
cancelPenaltyMax: 'درصد جریمه حداکثر ۱۰۰ است',
|
||||
cancelPenaltyOrder: 'درصد جریمه باید با نزدیکشدن به زمان رویداد بیشتر یا مساوی شود',
|
||||
paidPricePositive: 'برای رویداد پولی، قیمت باید بیشتر از صفر باشد',
|
||||
reservedExceedsCapacity: 'ظرفیت رزرو شده نمیتواند از ظرفیت کل بیشتر باشد',
|
||||
faqQuestionRequired: 'سوال را وارد کنید',
|
||||
|
||||
@ -119,6 +119,8 @@ describe('event wizard validation', () => {
|
||||
genderRestriction: 'open',
|
||||
ageRestriction: 'open',
|
||||
cancellationFeePercent: 0,
|
||||
cancellationFeePercent12To24Hours: 0,
|
||||
cancellationFeePercentMoreThan24Hours: 0,
|
||||
isDiscoverable: true,
|
||||
autoCreateGroup: true,
|
||||
waitlistAutoOffer: true,
|
||||
@ -135,6 +137,8 @@ describe('event wizard validation', () => {
|
||||
genderRestriction: 'open',
|
||||
ageRestriction: 'open',
|
||||
cancellationFeePercent: 0,
|
||||
cancellationFeePercent12To24Hours: 0,
|
||||
cancellationFeePercentMoreThan24Hours: 0,
|
||||
isDiscoverable: true,
|
||||
autoCreateGroup: true,
|
||||
waitlistAutoOffer: true,
|
||||
@ -151,6 +155,8 @@ describe('event wizard validation', () => {
|
||||
genderRestriction: 'open',
|
||||
ageRestriction: 'open',
|
||||
cancellationFeePercent: 0,
|
||||
cancellationFeePercent12To24Hours: 0,
|
||||
cancellationFeePercentMoreThan24Hours: 0,
|
||||
isDiscoverable: true,
|
||||
autoCreateGroup: true,
|
||||
waitlistAutoOffer: true,
|
||||
@ -168,6 +174,8 @@ describe('event wizard validation', () => {
|
||||
genderRestriction: 'open',
|
||||
ageRestriction: 'open',
|
||||
cancellationFeePercent: 0,
|
||||
cancellationFeePercent12To24Hours: 0,
|
||||
cancellationFeePercentMoreThan24Hours: 0,
|
||||
isDiscoverable: true,
|
||||
autoCreateGroup: true,
|
||||
waitlistAutoOffer: true,
|
||||
@ -179,6 +187,40 @@ describe('event wizard validation', () => {
|
||||
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', () => {
|
||||
expect(EventWizardFaqFormValidation.safeParse({ question: '', answer: 'پاسخ' }).success).toBe(false)
|
||||
expect(EventWizardFaqFormValidation.parse({ question: 'سوال؟', answer: 'پاسخ' })).toEqual({
|
||||
|
||||
@ -108,6 +108,14 @@ export const EventWizardStep3Validation = z
|
||||
genderRestriction: z.enum(['open', 'female_only', 'male_only']),
|
||||
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),
|
||||
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(),
|
||||
autoCreateGroup: z.boolean(),
|
||||
waitlistAutoOffer: z.boolean(),
|
||||
@ -128,6 +136,16 @@ export const EventWizardStep3Validation = z
|
||||
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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user