From 3bc81676492755630df25234111b68b1d636e2bf Mon Sep 17 00:00:00 2001 From: poduck Date: Thu, 4 Dec 2025 13:33:03 -0500 Subject: [PATCH] feat(payments): Add variable pricing with deposit collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Services can now have variable pricing where: - Final price is determined after service completion - A deposit (fixed amount or percentage) is collected at booking - Customer's saved payment method is charged for remaining balance Changes: - Add variable_pricing, deposit_amount, deposit_percent fields to Service model - Add service FK and final_price fields to Event model - Add AWAITING_PAYMENT status to Event - Add SetFinalPriceView endpoint to charge customer's saved card - Add EventPricingInfoView endpoint for pricing details - Update Services page with variable pricing toggle and deposit config - Show "From $X" and deposit info in customer preview 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- frontend/src/pages/Services.tsx | 182 +++++++++++- frontend/src/types.ts | 6 + smoothschedule/payments/urls.py | 7 + smoothschedule/payments/views.py | 272 ++++++++++++++++++ .../migrations/0025_add_variable_pricing.py | 66 +++++ smoothschedule/schedule/models.py | 114 ++++++++ smoothschedule/schedule/serializers.py | 82 +++++- 7 files changed, 716 insertions(+), 13 deletions(-) create mode 100644 smoothschedule/schedule/migrations/0025_add_variable_pricing.py diff --git a/frontend/src/pages/Services.tsx b/frontend/src/pages/Services.tsx index 149fb08..619918c 100644 --- a/frontend/src/pages/Services.tsx +++ b/frontend/src/pages/Services.tsx @@ -12,6 +12,11 @@ interface ServiceFormData { price: number; description: string; photos: string[]; + // Variable pricing fields + variable_pricing: boolean; + deposit_type: 'amount' | 'percent' | null; + deposit_amount: number | null; + deposit_percent: number | null; } const Services: React.FC = () => { @@ -37,6 +42,10 @@ const Services: React.FC = () => { price: 0, description: '', photos: [], + variable_pricing: false, + deposit_type: null, + deposit_amount: null, + deposit_percent: null, }); // Photo gallery state @@ -203,18 +212,31 @@ const Services: React.FC = () => { price: 0, description: '', photos: [], + variable_pricing: false, + deposit_type: null, + deposit_amount: null, + deposit_percent: null, }); 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, price: service.price, 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, }); setIsModalOpen(true); }; @@ -227,14 +249,26 @@ 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, + price: formData.price, + 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, + }; + try { if (editingService) { await updateService.mutateAsync({ id: editingService.id, - updates: formData, + updates: apiData, }); } else { - await createService.mutateAsync(formData); + await createService.mutateAsync(apiData); } closeModal(); } catch (error) { @@ -379,8 +413,19 @@ const Services: React.FC = () => { - ${service.price.toFixed(2)} + {service.variable_pricing ? ( + <> + {t('services.fromPrice', 'From')} ${service.price.toFixed(2)} + + ) : ( + `$${service.price.toFixed(2)}` + )} + {service.variable_pricing && ( + + {t('services.variablePricingBadge', 'Variable')} + + )} {service.photos && service.photos.length > 0 && ( @@ -437,8 +482,17 @@ const Services: React.FC = () => { {service.durationMinutes} min - ${service.price.toFixed(2)} + {service.variable_pricing ? ( + <>From ${service.price.toFixed(2)} + ) : ( + `$${service.price.toFixed(2)}` + )} + {service.variable_pricing && service.deposit_display && ( + + ({service.deposit_display}) + + )} @@ -510,13 +564,13 @@ const Services: React.FC = () => {
setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })} - required + required={!formData.variable_pricing} 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" @@ -524,6 +578,122 @@ const Services: React.FC = () => {
+ {/* Variable Pricing Toggle */} +
+
+
+ +

+ {t('services.variablePricingDescription', 'Final price is determined after service completion')} +

+
+ +
+ + {/* 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.')} +

+
+ )} +
+