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 = () => {
- {t('services.price', 'Price ($)')} *
+ {t('services.price', 'Price ($)')} {formData.variable_pricing ? t('services.estimated', '(Estimated)') : '*'}
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.variablePricing', 'Variable Pricing')}
+
+
+ {t('services.variablePricingDescription', 'Final price is determined after service completion')}
+
+
+
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'
+ }`}
+ >
+
+
+
+
+ {/* Deposit Configuration */}
+ {formData.variable_pricing && (
+
+
+
+ {formData.deposit_type === 'amount' && (
+
+
+ {t('services.depositAmount', 'Deposit 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' && (
+
+
+ {t('services.depositPercent', 'Deposit Percentage (%)')} *
+
+
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.descriptionLabel', 'Description')}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 68f7fcc..9ab24e8 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -209,6 +209,12 @@ 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
+ 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_display?: string | null; // Human-readable deposit description
}
export interface Metric {
diff --git a/smoothschedule/payments/urls.py b/smoothschedule/payments/urls.py
index 3a44cd8..8a97fc2 100644
--- a/smoothschedule/payments/urls.py
+++ b/smoothschedule/payments/urls.py
@@ -39,6 +39,9 @@ from .views import (
CustomerSetupIntentView,
CustomerPaymentMethodDeleteView,
CustomerPaymentMethodDefaultView,
+ # Variable pricing / final charge
+ SetFinalPriceView,
+ EventPricingInfoView,
)
urlpatterns = [
@@ -84,4 +87,8 @@ urlpatterns = [
path('customer/setup-intent/', CustomerSetupIntentView.as_view(), name='customer-setup-intent'),
path('customer/payment-methods//', CustomerPaymentMethodDeleteView.as_view(), name='customer-payment-method-delete'),
path('customer/payment-methods//default/', CustomerPaymentMethodDefaultView.as_view(), name='customer-payment-method-default'),
+
+ # Variable pricing / final charge endpoints
+ path('events//final-price/', SetFinalPriceView.as_view(), name='set-final-price'),
+ path('events//pricing/', EventPricingInfoView.as_view(), name='event-pricing-info'),
]
diff --git a/smoothschedule/payments/views.py b/smoothschedule/payments/views.py
index c89e89a..40ba74a 100644
--- a/smoothschedule/payments/views.py
+++ b/smoothschedule/payments/views.py
@@ -1988,3 +1988,275 @@ class CustomerPaymentMethodDefaultView(APIView):
{'error': 'Unable to update payment method. Please try again later.'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
+
+
+# ============================================================================
+# Variable Pricing / Final Charge Endpoints
+# ============================================================================
+
+class SetFinalPriceView(APIView):
+ """
+ Set the final price for a variable-pricing event and charge the customer.
+
+ POST /payments/events//final-price/
+
+ Body: {
+ final_price: number, # The final price determined after service
+ charge_now: boolean # If true, immediately charge the customer's saved payment method
+ }
+
+ This endpoint:
+ 1. Sets the final_price on the event
+ 2. If charge_now is true, charges the customer's saved payment method
+ for the remaining balance (final_price - deposit_amount)
+ 3. Updates event status to PAID if successful
+ """
+ permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')]
+
+ def post(self, request, event_id):
+ """Set final price and optionally charge customer."""
+ import logging
+ logger = logging.getLogger(__name__)
+ from django.contrib.contenttypes.models import ContentType
+ from schedule.models import Participant
+ from smoothschedule.users.models import User
+
+ final_price = request.data.get('final_price')
+ charge_now = request.data.get('charge_now', True)
+
+ if final_price is None:
+ return Response(
+ {'error': 'final_price is required'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ try:
+ final_price = Decimal(str(final_price))
+ if final_price < 0:
+ raise ValueError("Price cannot be negative")
+ except (ValueError, TypeError) as e:
+ return Response(
+ {'error': f'Invalid final_price: {str(e)}'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ # Get the event
+ try:
+ event = Event.objects.get(id=event_id)
+ except Event.DoesNotExist:
+ return Response(
+ {'error': 'Event not found'},
+ status=status.HTTP_404_NOT_FOUND
+ )
+
+ # Verify event uses variable pricing
+ if not event.is_variable_pricing:
+ return Response(
+ {'error': 'This event does not use variable pricing'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ # Set the final price
+ event.final_price = final_price
+ event.status = Event.Status.AWAITING_PAYMENT
+ event.save()
+
+ # Calculate remaining balance
+ deposit = event.deposit_amount or Decimal('0.00')
+ remaining_balance = max(final_price - deposit, Decimal('0.00'))
+ overpaid = max(deposit - final_price, Decimal('0.00'))
+
+ response_data = {
+ 'event_id': event.id,
+ 'final_price': str(final_price),
+ 'deposit_amount': str(deposit),
+ 'remaining_balance': str(remaining_balance),
+ 'overpaid_amount': str(overpaid) if overpaid > 0 else None,
+ }
+
+ if not charge_now:
+ return Response(response_data)
+
+ # Charge the remaining balance if there is one
+ if remaining_balance <= 0:
+ # Final price is less than or equal to deposit - nothing to charge
+ event.status = Event.Status.PAID
+ event.save()
+ response_data['status'] = 'paid'
+ response_data['message'] = 'Deposit covered the final price. No additional charge needed.'
+ if overpaid > 0:
+ response_data['message'] += f' Customer overpaid by ${overpaid}. Consider issuing a refund.'
+ return Response(response_data)
+
+ # Find the customer for this event
+ user_content_type = ContentType.objects.get_for_model(User)
+ customer_participant = Participant.objects.filter(
+ event=event,
+ content_type=user_content_type,
+ role=Participant.Role.CUSTOMER
+ ).first()
+
+ if not customer_participant:
+ return Response(
+ {'error': 'No customer found for this event'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ customer = customer_participant.content_object
+
+ # Get customer's default payment method
+ stripe_customer_id = getattr(customer, 'stripe_customer_id', None)
+ default_payment_method = getattr(customer, 'default_payment_method_id', None)
+
+ if not stripe_customer_id or not default_payment_method:
+ return Response(
+ {'error': 'Customer does not have a saved payment method'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ tenant = request.tenant
+
+ try:
+ # Determine Stripe configuration
+ if tenant.payment_mode == 'direct_api' and tenant.stripe_secret_key:
+ stripe.api_key = tenant.stripe_secret_key
+ stripe_account_param = {}
+ elif tenant.payment_mode == 'connect' and tenant.stripe_connect_id:
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+ stripe_account_param = {'stripe_account': tenant.stripe_connect_id}
+ else:
+ return Response(
+ {'error': 'Payment processing is not configured'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ # Calculate platform fee (5%)
+ application_fee_amount = (remaining_balance * Decimal('5.0') / 100).quantize(Decimal('0.01'))
+ amount_cents = int(remaining_balance * 100)
+ fee_cents = int(application_fee_amount * 100)
+
+ # Create PaymentIntent and charge immediately
+ pi_params = {
+ 'amount': amount_cents,
+ 'currency': tenant.currency.lower() if hasattr(tenant, 'currency') and tenant.currency else 'usd',
+ 'customer': stripe_customer_id,
+ 'payment_method': default_payment_method,
+ 'off_session': True, # Customer not present
+ 'confirm': True, # Charge immediately
+ 'metadata': {
+ 'event_id': event.id,
+ 'event_title': event.title,
+ 'charge_type': 'final_balance',
+ 'final_price': str(final_price),
+ 'deposit_amount': str(deposit),
+ },
+ }
+
+ # Add application fee for Connect mode
+ if tenant.payment_mode == 'connect':
+ pi_params['application_fee_amount'] = fee_cents
+ pi_params.update(stripe_account_param)
+
+ payment_intent = stripe.PaymentIntent.create(**pi_params)
+
+ # Save transaction
+ transaction = TransactionLink.objects.create(
+ event=event,
+ payment_intent_id=payment_intent.id,
+ amount=remaining_balance,
+ application_fee_amount=application_fee_amount if tenant.payment_mode == 'connect' else Decimal('0'),
+ currency=payment_intent.currency.upper(),
+ status=TransactionLink.Status.SUCCEEDED if payment_intent.status == 'succeeded' else TransactionLink.Status.PENDING,
+ )
+
+ # Update event
+ event.final_charge_transaction_id = payment_intent.id
+ if payment_intent.status == 'succeeded':
+ event.status = Event.Status.PAID
+ event.save()
+
+ logger.info(f"[SetFinalPriceView] Charged ${remaining_balance} for event {event.id}")
+
+ response_data['status'] = 'paid' if payment_intent.status == 'succeeded' else 'pending'
+ response_data['payment_intent_id'] = payment_intent.id
+ response_data['transaction_id'] = transaction.id
+ response_data['message'] = f'Successfully charged ${remaining_balance} to customer\'s saved payment method.'
+
+ return Response(response_data)
+
+ except stripe.error.CardError as e:
+ logger.error(f"[SetFinalPriceView] CardError: {e}")
+ return Response(
+ {'error': f'Card declined: {e.user_message}'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+ except stripe.error.StripeError as e:
+ logger.error(f"[SetFinalPriceView] StripeError: {e}")
+ return Response(
+ {'error': f'Payment failed: {str(e)}'},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR
+ )
+ finally:
+ # Reset Stripe key
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+
+
+class EventPricingInfoView(APIView):
+ """
+ Get pricing information for an event.
+
+ GET /payments/events//pricing/
+
+ Returns deposit, final price, remaining balance, payment status, etc.
+ """
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request, event_id):
+ """Get event pricing info."""
+ try:
+ event = Event.objects.select_related('service').get(id=event_id)
+ except Event.DoesNotExist:
+ return Response(
+ {'error': 'Event not found'},
+ status=status.HTTP_404_NOT_FOUND
+ )
+
+ # Get associated transactions
+ transactions = TransactionLink.objects.filter(event=event).order_by('-created_at')
+
+ deposit_transaction = None
+ final_transaction = None
+ for tx in transactions:
+ if not deposit_transaction and event.deposit_transaction_id and tx.payment_intent_id == event.deposit_transaction_id:
+ deposit_transaction = tx
+ if not final_transaction and event.final_charge_transaction_id and tx.payment_intent_id == event.final_charge_transaction_id:
+ final_transaction = tx
+
+ response = {
+ 'event_id': event.id,
+ 'service_id': event.service_id,
+ 'service_name': event.service.name if event.service else None,
+ 'is_variable_pricing': event.is_variable_pricing,
+ 'status': event.status,
+ # Pricing
+ 'service_price': str(event.service.price) if event.service else None,
+ 'deposit_amount': str(event.deposit_amount) if event.deposit_amount else None,
+ 'final_price': str(event.final_price) if event.final_price else None,
+ 'remaining_balance': str(event.remaining_balance) if event.remaining_balance is not None else None,
+ 'overpaid_amount': str(event.overpaid_amount) if event.overpaid_amount else None,
+ # Transactions
+ 'deposit_transaction': {
+ 'id': deposit_transaction.id,
+ 'status': deposit_transaction.status,
+ 'amount': str(deposit_transaction.amount),
+ 'created_at': deposit_transaction.created_at.isoformat(),
+ } if deposit_transaction else None,
+ 'final_transaction': {
+ 'id': final_transaction.id,
+ 'status': final_transaction.status,
+ 'amount': str(final_transaction.amount),
+ 'created_at': final_transaction.created_at.isoformat(),
+ } if final_transaction else None,
+ }
+
+ return Response(response)
diff --git a/smoothschedule/schedule/migrations/0025_add_variable_pricing.py b/smoothschedule/schedule/migrations/0025_add_variable_pricing.py
new file mode 100644
index 0000000..2e1ff3f
--- /dev/null
+++ b/smoothschedule/schedule/migrations/0025_add_variable_pricing.py
@@ -0,0 +1,66 @@
+# Generated by Django 5.2.8 on 2025-12-04 18:10
+
+import django.core.validators
+import django.db.models.deletion
+from decimal import Decimal
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('schedule', '0024_resource_archived_by_quota_at_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='event',
+ name='deposit_amount',
+ field=models.DecimalField(blank=True, decimal_places=2, help_text='Deposit amount collected at booking', max_digits=10, null=True),
+ ),
+ migrations.AddField(
+ model_name='event',
+ name='deposit_transaction_id',
+ field=models.CharField(blank=True, help_text='Stripe PaymentIntent ID for the deposit', max_length=255),
+ ),
+ migrations.AddField(
+ model_name='event',
+ name='final_charge_transaction_id',
+ field=models.CharField(blank=True, help_text='Stripe PaymentIntent ID for the final charge', max_length=255),
+ ),
+ migrations.AddField(
+ model_name='event',
+ name='final_price',
+ field=models.DecimalField(blank=True, decimal_places=2, help_text='Final price entered after service completion (for variable pricing)', max_digits=10, null=True),
+ ),
+ migrations.AddField(
+ model_name='event',
+ name='service',
+ field=models.ForeignKey(blank=True, help_text='The service being provided in this appointment', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='schedule.service'),
+ ),
+ migrations.AddField(
+ model_name='service',
+ name='deposit_amount',
+ field=models.DecimalField(blank=True, decimal_places=2, help_text='Fixed deposit amount to collect at booking (used if deposit_percent is not set)', max_digits=10, null=True),
+ ),
+ migrations.AddField(
+ model_name='service',
+ name='deposit_percent',
+ field=models.DecimalField(blank=True, decimal_places=2, help_text='Deposit as percentage of estimated price (0-100)', max_digits=5, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0'))]),
+ ),
+ migrations.AddField(
+ model_name='service',
+ name='requires_saved_payment_method',
+ field=models.BooleanField(default=False, help_text='If true, customer must have a saved payment method to book (auto-set for variable pricing)'),
+ ),
+ migrations.AddField(
+ model_name='service',
+ name='variable_pricing',
+ field=models.BooleanField(default=False, help_text='If true, final price is determined after service completion'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='status',
+ field=models.CharField(choices=[('SCHEDULED', 'Scheduled'), ('CANCELED', 'Canceled'), ('COMPLETED', 'Completed'), ('AWAITING_PAYMENT', 'Awaiting Payment'), ('PAID', 'Paid'), ('NOSHOW', 'No Show')], db_index=True, default='SCHEDULED', max_length=20),
+ ),
+ ]
diff --git a/smoothschedule/schedule/models.py b/smoothschedule/schedule/models.py
index 8f4f7fb..f551cd9 100644
--- a/smoothschedule/schedule/models.py
+++ b/smoothschedule/schedule/models.py
@@ -34,6 +34,31 @@ class Service(models.Model):
)
is_active = models.BooleanField(default=True)
+ # Variable pricing - price determined after service completion
+ variable_pricing = models.BooleanField(
+ default=False,
+ help_text="If true, final price is determined after service completion"
+ )
+ 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)"
+ )
+
# Quota overage archiving
is_archived_by_quota = models.BooleanField(
default=False,
@@ -55,6 +80,30 @@ 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.
+
+ 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%'})
+
class ResourceType(models.Model):
"""
@@ -194,6 +243,7 @@ class Event(models.Model):
SCHEDULED = 'SCHEDULED', 'Scheduled'
CANCELED = 'CANCELED', 'Canceled'
COMPLETED = 'COMPLETED', 'Completed'
+ AWAITING_PAYMENT = 'AWAITING_PAYMENT', 'Awaiting Payment' # Service done, waiting for final charge
PAID = 'PAID', 'Paid'
NOSHOW = 'NOSHOW', 'No Show'
@@ -206,6 +256,42 @@ class Event(models.Model):
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, related_name='created_events')
+ # Service linkage for pricing
+ service = models.ForeignKey(
+ Service,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='events',
+ help_text="The service being provided in this appointment"
+ )
+
+ # Pricing fields for variable-price services
+ deposit_amount = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ help_text="Deposit amount collected at booking"
+ )
+ deposit_transaction_id = models.CharField(
+ max_length=255,
+ blank=True,
+ help_text="Stripe PaymentIntent ID for the deposit"
+ )
+ final_price = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ help_text="Final price entered after service completion (for variable pricing)"
+ )
+ final_charge_transaction_id = models.CharField(
+ max_length=255,
+ blank=True,
+ help_text="Stripe PaymentIntent ID for the final charge"
+ )
+
# Plugin attachments for resource-free scheduling
plugins = models.ManyToManyField(
'PluginInstallation',
@@ -230,6 +316,34 @@ class Event(models.Model):
"""Calculate event duration"""
return self.end_time - self.start_time
+ @property
+ def is_variable_pricing(self):
+ """Check if this event uses variable pricing"""
+ return self.service and self.service.variable_pricing
+
+ @property
+ def remaining_balance(self):
+ """
+ Calculate the remaining balance after deposit.
+ Returns None if final_price is not set.
+ """
+ if self.final_price is None:
+ return None
+ deposit = self.deposit_amount or Decimal('0.00')
+ return max(self.final_price - deposit, Decimal('0.00'))
+
+ @property
+ def overpaid_amount(self):
+ """
+ Calculate overpayment if deposit exceeds final price.
+ Returns amount to refund, or None if not applicable.
+ """
+ if self.final_price is None or self.deposit_amount is None:
+ return None
+ if self.deposit_amount > self.final_price:
+ return self.deposit_amount - self.final_price
+ return None
+
def execute_plugins(self, trigger='event_created'):
"""
Execute all attached plugins for this event.
diff --git a/smoothschedule/schedule/serializers.py b/smoothschedule/schedule/serializers.py
index f59ec85..4b50934 100644
--- a/smoothschedule/schedule/serializers.py
+++ b/smoothschedule/schedule/serializers.py
@@ -145,6 +145,7 @@ class StaffSerializer(serializers.ModelSerializer):
class ServiceSerializer(serializers.ModelSerializer):
"""Serializer for Service model"""
duration_minutes = serializers.IntegerField(source='duration', read_only=True)
+ deposit_display = serializers.SerializerMethodField()
class Meta:
model = Service
@@ -152,8 +153,43 @@ 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',
]
- read_only_fields = ['created_at', 'updated_at', 'is_archived_by_quota']
+ read_only_fields = ['created_at', 'updated_at', 'is_archived_by_quota', 'deposit_display']
+
+ def get_deposit_display(self, obj):
+ """Get a human-readable description of the deposit requirement"""
+ if obj.deposit_amount:
+ 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):
@@ -292,6 +328,11 @@ class EventSerializer(serializers.ModelSerializer):
service_name = serializers.SerializerMethodField()
is_paid = serializers.SerializerMethodField()
+ # Variable pricing fields
+ is_variable_pricing = serializers.SerializerMethodField()
+ remaining_balance = serializers.SerializerMethodField()
+ overpaid_amount = serializers.SerializerMethodField()
+
# Write-only fields for creating participants
resource_ids = serializers.ListField(
child=serializers.IntegerField(),
@@ -311,15 +352,27 @@ class EventSerializer(serializers.ModelSerializer):
help_text="Customer (User) ID to assign"
)
+ # Write-only field for setting service
+ service = serializers.PrimaryKeyRelatedField(
+ queryset=Service.objects.all(),
+ required=False,
+ allow_null=True,
+ write_only=True
+ )
+
class Meta:
model = Event
fields = [
'id', 'title', 'start_time', 'end_time', 'status', 'notes',
'duration_minutes', 'participants', 'resource_ids', 'staff_ids', 'customer',
'resource_id', 'customer_id', 'service_id', 'customer_name', 'service_name', 'is_paid',
+ # Pricing fields
+ 'service', 'deposit_amount', 'deposit_transaction_id', 'final_price',
+ 'final_charge_transaction_id', 'is_variable_pricing', 'remaining_balance', 'overpaid_amount',
'created_at', 'updated_at', 'created_by',
]
- read_only_fields = ['created_at', 'updated_at', 'created_by']
+ read_only_fields = ['created_at', 'updated_at', 'created_by', 'deposit_transaction_id',
+ 'final_charge_transaction_id', 'is_variable_pricing', 'remaining_balance', 'overpaid_amount']
def get_duration_minutes(self, obj):
return int(obj.duration.total_seconds() / 60)
@@ -335,9 +388,8 @@ class EventSerializer(serializers.ModelSerializer):
return customer_participant.object_id if customer_participant else None
def get_service_id(self, obj):
- """Get service ID - placeholder for now"""
- # TODO: Add service link to Event model or participants
- return 1
+ """Get service ID from the event's service FK"""
+ return obj.service_id if obj.service_id else None
def get_customer_name(self, obj):
"""Get customer name from participant"""
@@ -349,8 +401,10 @@ class EventSerializer(serializers.ModelSerializer):
return obj.title.split(' - ')[0] if ' - ' in obj.title else obj.title
def get_service_name(self, obj):
- """Get service name from title"""
- # Extract from title format "Customer Name - Service Name"
+ """Get service name from the service FK or fallback to title parsing"""
+ if obj.service:
+ return obj.service.name
+ # Fallback: Extract from title format "Customer Name - Service Name"
if ' - ' in obj.title:
return obj.title.split(' - ')[-1]
return "Service"
@@ -359,6 +413,20 @@ class EventSerializer(serializers.ModelSerializer):
"""Check if event is paid"""
return obj.status == 'PAID'
+ def get_is_variable_pricing(self, obj):
+ """Check if this event uses variable pricing"""
+ return obj.is_variable_pricing
+
+ def get_remaining_balance(self, obj):
+ """Get the remaining balance after deposit"""
+ balance = obj.remaining_balance
+ return str(balance) if balance is not None else None
+
+ def get_overpaid_amount(self, obj):
+ """Get overpayment amount if deposit exceeds final price"""
+ overpaid = obj.overpaid_amount
+ return str(overpaid) if overpaid is not None else None
+
def validate_status(self, value):
"""Map frontend status values to backend values"""
if value in self.STATUS_MAPPING: