diff --git a/frontend/src/hooks/useServices.ts b/frontend/src/hooks/useServices.ts index c932513..cd91e2c 100644 --- a/frontend/src/hooks/useServices.ts +++ b/frontend/src/hooks/useServices.ts @@ -24,10 +24,10 @@ export const useServices = () => { description: s.description || '', displayOrder: s.display_order ?? 0, photos: s.photos || [], - // Variable pricing fields + // Pricing fields variable_pricing: s.variable_pricing ?? false, - deposit_amount: s.deposit_amount ? parseFloat(s.deposit_amount) : null, - deposit_percent: s.deposit_percent ? parseFloat(s.deposit_percent) : null, + deposit_amount: s.deposit_amount ? parseFloat(s.deposit_amount) : 0, + requires_deposit: s.requires_deposit ?? false, requires_saved_payment_method: s.requires_saved_payment_method ?? false, deposit_display: s.deposit_display || null, })); @@ -68,8 +68,7 @@ interface ServiceInput { description?: string; photos?: string[]; variable_pricing?: boolean; - deposit_amount?: number | null; - deposit_percent?: number | null; + deposit_amount?: number; // 0 = no deposit } /** @@ -88,16 +87,13 @@ export const useCreateService = () => { photos: serviceData.photos || [], }; - // Add variable pricing fields if enabled + // Add pricing fields if (serviceData.variable_pricing !== undefined) { backendData.variable_pricing = serviceData.variable_pricing; } if (serviceData.deposit_amount !== undefined) { backendData.deposit_amount = serviceData.deposit_amount; } - if (serviceData.deposit_percent !== undefined) { - backendData.deposit_percent = serviceData.deposit_percent; - } const { data } = await apiClient.post('/services/', backendData); return data; @@ -122,10 +118,9 @@ export const useUpdateService = () => { if (updates.price !== undefined) backendData.price = updates.price.toString(); if (updates.description !== undefined) backendData.description = updates.description; if (updates.photos !== undefined) backendData.photos = updates.photos; - // Variable pricing fields + // Pricing fields if (updates.variable_pricing !== undefined) backendData.variable_pricing = updates.variable_pricing; if (updates.deposit_amount !== undefined) backendData.deposit_amount = updates.deposit_amount; - if (updates.deposit_percent !== undefined) backendData.deposit_percent = updates.deposit_percent; const { data } = await apiClient.patch(`/services/${id}/`, backendData); return data; diff --git a/frontend/src/pages/Services.tsx b/frontend/src/pages/Services.tsx index 619918c..55d9cfd 100644 --- a/frontend/src/pages/Services.tsx +++ b/frontend/src/pages/Services.tsx @@ -12,11 +12,9 @@ interface ServiceFormData { price: number; description: string; photos: string[]; - // Variable pricing fields + // Pricing fields variable_pricing: boolean; - deposit_type: 'amount' | 'percent' | null; - deposit_amount: number | null; - deposit_percent: number | null; + deposit_amount: number; // 0 = no deposit } const Services: React.FC = () => { @@ -43,9 +41,7 @@ const Services: React.FC = () => { description: '', photos: [], variable_pricing: false, - deposit_type: null, - deposit_amount: null, - deposit_percent: null, + deposit_amount: 0, }); // Photo gallery state @@ -213,20 +209,13 @@ const Services: React.FC = () => { description: '', photos: [], variable_pricing: false, - deposit_type: null, - deposit_amount: null, - deposit_percent: null, + deposit_amount: 0, }); setIsModalOpen(true); }; const openEditModal = (service: Service) => { setEditingService(service); - // Determine deposit type from existing data - let depositType: 'amount' | 'percent' | null = null; - if (service.deposit_amount) depositType = 'amount'; - else if (service.deposit_percent) depositType = 'percent'; - setFormData({ name: service.name, durationMinutes: service.durationMinutes, @@ -234,9 +223,7 @@ const Services: React.FC = () => { description: service.description || '', photos: service.photos || [], variable_pricing: service.variable_pricing || false, - deposit_type: depositType, - deposit_amount: service.deposit_amount || null, - deposit_percent: service.deposit_percent || null, + deposit_amount: service.deposit_amount || 0, }); setIsModalOpen(true); }; @@ -249,7 +236,6 @@ const Services: React.FC = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - // Prepare data for API - convert deposit_type to actual deposit values const apiData = { name: formData.name, durationMinutes: formData.durationMinutes, @@ -257,8 +243,7 @@ const Services: React.FC = () => { description: formData.description, photos: formData.photos, variable_pricing: formData.variable_pricing, - deposit_amount: formData.variable_pricing && formData.deposit_type === 'amount' ? formData.deposit_amount : null, - deposit_percent: formData.variable_pricing && formData.deposit_type === 'percent' ? formData.deposit_percent : null, + deposit_amount: formData.deposit_amount, }; try { @@ -426,6 +411,11 @@ const Services: React.FC = () => { {t('services.variablePricingBadge', 'Variable')} )} + {service.requires_deposit && ( + + ${service.deposit_amount} {t('services.depositBadge', 'deposit')} + + )} {service.photos && service.photos.length > 0 && ( @@ -578,6 +568,25 @@ const Services: React.FC = () => { + {/* Deposit Amount */} +
+ + setFormData({ ...formData, deposit_amount: parseFloat(e.target.value) || 0 })} + min={0} + step={0.01} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" + placeholder="0.00" + /> +

+ {t('services.depositHint', 'Set to 0 for no deposit. When set, customers must save a payment method to book.')} +

+
+ {/* Variable Pricing Toggle */}
@@ -594,9 +603,6 @@ const Services: React.FC = () => { onClick={() => setFormData({ ...formData, variable_pricing: !formData.variable_pricing, - deposit_type: !formData.variable_pricing ? 'amount' : null, - deposit_amount: !formData.variable_pricing ? 50 : null, - deposit_percent: null, })} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ formData.variable_pricing ? 'bg-brand-600' : 'bg-gray-300 dark:bg-gray-600' @@ -609,88 +615,10 @@ const Services: React.FC = () => { />
- - {/* Deposit Configuration */} {formData.variable_pricing && ( -
-
- -
- - -
-
- - {formData.deposit_type === 'amount' && ( -
- - setFormData({ ...formData, deposit_amount: parseFloat(e.target.value) || null })} - required - min={1} - step={0.01} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" - placeholder="50.00" - /> -
- )} - - {formData.deposit_type === 'percent' && ( -
- - setFormData({ ...formData, deposit_percent: parseFloat(e.target.value) || null })} - required - min={1} - max={100} - step={1} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" - placeholder="25" - /> - {formData.deposit_percent && formData.price > 0 && ( -

- {t('services.depositEstimate', 'Estimated deposit: ${amount}', { amount: ((formData.price * formData.deposit_percent) / 100).toFixed(2) })} -

- )} -
- )} - -

- {t('services.depositNote', 'Customers must save a payment method to book this service. A deposit will be charged at booking, and the remaining balance will be charged after service completion.')} -

-
+

+ {t('services.variablePricingNote', 'The price above is shown as an estimate. The final price will be determined and charged after service completion.')} +

)}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9ab24e8..0216252 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -209,11 +209,11 @@ export interface Service { photos?: string[]; created_at?: string; // Used for quota overage calculation (oldest archived first) is_archived_by_quota?: boolean; // True if archived due to quota overage - // Variable pricing fields + // Pricing fields variable_pricing?: boolean; // If true, final price is determined after service completion - deposit_amount?: number | null; // Fixed deposit amount - deposit_percent?: number | null; // Deposit as percentage (0-100) - requires_saved_payment_method?: boolean; // If true, customer must have saved card + deposit_amount?: number; // Deposit amount (0 = no deposit required) + requires_deposit?: boolean; // True if deposit_amount > 0 (computed) + requires_saved_payment_method?: boolean; // True if deposit > 0 or variable pricing (computed) deposit_display?: string | null; // Human-readable deposit description } diff --git a/smoothschedule/schedule/migrations/0026_simplify_deposit_fields.py b/smoothschedule/schedule/migrations/0026_simplify_deposit_fields.py new file mode 100644 index 0000000..75d2289 --- /dev/null +++ b/smoothschedule/schedule/migrations/0026_simplify_deposit_fields.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.8 on 2025-12-04 18:40 + +import django.core.validators +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('schedule', '0025_add_variable_pricing'), + ] + + operations = [ + migrations.RemoveField( + model_name='service', + name='deposit_percent', + ), + migrations.RemoveField( + model_name='service', + name='requires_saved_payment_method', + ), + migrations.AlterField( + model_name='service', + name='deposit_amount', + field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Deposit amount to collect at booking (0 = no deposit)', max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0'))]), + ), + ] diff --git a/smoothschedule/schedule/models.py b/smoothschedule/schedule/models.py index f551cd9..d4815bb 100644 --- a/smoothschedule/schedule/models.py +++ b/smoothschedule/schedule/models.py @@ -39,24 +39,14 @@ class Service(models.Model): default=False, help_text="If true, final price is determined after service completion" ) + # Deposit - applies to both variable and fixed pricing + # 0 = no deposit required, > 0 = deposit amount to collect at booking deposit_amount = models.DecimalField( max_digits=10, decimal_places=2, - null=True, - blank=True, - help_text="Fixed deposit amount to collect at booking (used if deposit_percent is not set)" - ) - deposit_percent = models.DecimalField( - max_digits=5, - decimal_places=2, - null=True, - blank=True, - validators=[MinValueValidator(Decimal('0')), ], - help_text="Deposit as percentage of estimated price (0-100)" - ) - requires_saved_payment_method = models.BooleanField( - default=False, - help_text="If true, customer must have a saved payment method to book (auto-set for variable pricing)" + default=Decimal('0.00'), + validators=[MinValueValidator(Decimal('0'))], + help_text="Deposit amount to collect at booking (0 = no deposit)" ) # Quota overage archiving @@ -80,29 +70,15 @@ class Service(models.Model): def __str__(self): return f"{self.name} ({self.duration} min - ${self.price})" - def get_deposit_amount(self, estimated_price=None): - """ - Calculate the deposit amount for this service. + @property + def requires_deposit(self): + """Check if this service requires a deposit""" + return self.deposit_amount and self.deposit_amount > 0 - Args: - estimated_price: Optional estimated price for percentage calculation. - Falls back to self.price if not provided. - - Returns: - Decimal: The deposit amount, or None if no deposit required. - """ - if self.deposit_amount: - return self.deposit_amount - if self.deposit_percent: - base_price = estimated_price or self.price - return (base_price * self.deposit_percent / 100).quantize(Decimal('0.01')) - return None - - def clean(self): - """Validate deposit configuration""" - super().clean() - if self.deposit_percent and self.deposit_percent > 100: - raise ValidationError({'deposit_percent': 'Deposit percentage cannot exceed 100%'}) + @property + def requires_saved_payment_method(self): + """Customer must have a saved payment method if deposit > 0 or variable pricing""" + return self.requires_deposit or self.variable_pricing class ResourceType(models.Model): diff --git a/smoothschedule/schedule/serializers.py b/smoothschedule/schedule/serializers.py index 4b50934..6defbc9 100644 --- a/smoothschedule/schedule/serializers.py +++ b/smoothschedule/schedule/serializers.py @@ -146,6 +146,8 @@ class ServiceSerializer(serializers.ModelSerializer): """Serializer for Service model""" duration_minutes = serializers.IntegerField(source='duration', read_only=True) deposit_display = serializers.SerializerMethodField() + requires_deposit = serializers.BooleanField(read_only=True) + requires_saved_payment_method = serializers.BooleanField(read_only=True) class Meta: model = Service @@ -153,44 +155,19 @@ class ServiceSerializer(serializers.ModelSerializer): 'id', 'name', 'description', 'duration', 'duration_minutes', 'price', 'display_order', 'photos', 'is_active', 'created_at', 'updated_at', 'is_archived_by_quota', - # Variable pricing fields - 'variable_pricing', 'deposit_amount', 'deposit_percent', - 'requires_saved_payment_method', 'deposit_display', + # Pricing fields + 'variable_pricing', 'deposit_amount', + 'requires_deposit', 'requires_saved_payment_method', 'deposit_display', ] - read_only_fields = ['created_at', 'updated_at', 'is_archived_by_quota', 'deposit_display'] + read_only_fields = ['created_at', 'updated_at', 'is_archived_by_quota', + 'deposit_display', 'requires_deposit', 'requires_saved_payment_method'] def get_deposit_display(self, obj): """Get a human-readable description of the deposit requirement""" - if obj.deposit_amount: + if obj.deposit_amount and obj.deposit_amount > 0: return f"${obj.deposit_amount} deposit" - elif obj.deposit_percent: - return f"{obj.deposit_percent}% deposit" return None - def validate(self, attrs): - """Validate deposit configuration""" - variable_pricing = attrs.get('variable_pricing', getattr(self.instance, 'variable_pricing', False)) - deposit_amount = attrs.get('deposit_amount', getattr(self.instance, 'deposit_amount', None)) - deposit_percent = attrs.get('deposit_percent', getattr(self.instance, 'deposit_percent', None)) - - # Variable pricing services should have a deposit configured - if variable_pricing and not deposit_amount and not deposit_percent: - raise serializers.ValidationError({ - 'deposit_amount': 'Variable pricing services require a deposit amount or percentage' - }) - - # Can't have both deposit types - if deposit_amount and deposit_percent: - raise serializers.ValidationError({ - 'deposit_amount': 'Cannot specify both deposit_amount and deposit_percent. Choose one.' - }) - - # Auto-set requires_saved_payment_method for variable pricing - if variable_pricing: - attrs['requires_saved_payment_method'] = True - - return attrs - class ResourceSerializer(serializers.ModelSerializer): """Serializer for Resource model"""