feat(payments): Add variable pricing with deposit collection

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 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-04 13:33:03 -05:00
parent b0512a660c
commit 3bc8167649
7 changed files with 716 additions and 13 deletions

View File

@@ -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 = () => {
</span>
<span className="text-gray-600 dark:text-gray-300 flex items-center gap-1">
<DollarSign className="h-3.5 w-3.5" />
${service.price.toFixed(2)}
{service.variable_pricing ? (
<>
{t('services.fromPrice', 'From')} ${service.price.toFixed(2)}
</>
) : (
`$${service.price.toFixed(2)}`
)}
</span>
{service.variable_pricing && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 text-purple-700 dark:bg-purple-800/50 dark:text-purple-300">
{t('services.variablePricingBadge', 'Variable')}
</span>
)}
{service.photos && service.photos.length > 0 && (
<span className="text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Image className="h-3.5 w-3.5" />
@@ -437,8 +482,17 @@ const Services: React.FC = () => {
{service.durationMinutes} min
</span>
<span className="font-semibold text-brand-600 dark:text-brand-400">
${service.price.toFixed(2)}
{service.variable_pricing ? (
<>From ${service.price.toFixed(2)}</>
) : (
`$${service.price.toFixed(2)}`
)}
</span>
{service.variable_pricing && service.deposit_display && (
<span className="text-xs text-gray-500 dark:text-gray-400">
({service.deposit_display})
</span>
)}
</div>
</div>
<ChevronRight className="h-5 w-5 text-gray-400 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors shrink-0 ml-4" />
@@ -510,13 +564,13 @@ const Services: React.FC = () => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.price', 'Price ($)')} *
{t('services.price', 'Price ($)')} {formData.variable_pricing ? t('services.estimated', '(Estimated)') : '*'}
</label>
<input
type="number"
value={formData.price}
onChange={(e) => 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 = () => {
</div>
</div>
{/* Variable Pricing Toggle */}
<div className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-900 dark:text-white">
{t('services.variablePricing', 'Variable Pricing')}
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{t('services.variablePricingDescription', 'Final price is determined after service completion')}
</p>
</div>
<button
type="button"
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'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
formData.variable_pricing ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Deposit Configuration */}
{formData.variable_pricing && (
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600 space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('services.depositType', 'Deposit Type')} *
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="deposit_type"
checked={formData.deposit_type === 'amount'}
onChange={() => setFormData({ ...formData, deposit_type: 'amount', deposit_percent: null })}
className="w-4 h-4 text-brand-600 border-gray-300 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
{t('services.fixedAmount', 'Fixed Amount')}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="deposit_type"
checked={formData.deposit_type === 'percent'}
onChange={() => setFormData({ ...formData, deposit_type: 'percent', deposit_amount: null })}
className="w-4 h-4 text-brand-600 border-gray-300 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
{t('services.percentage', 'Percentage')}
</span>
</label>
</div>
</div>
{formData.deposit_type === 'amount' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.depositAmount', 'Deposit Amount ($)')} *
</label>
<input
type="number"
value={formData.deposit_amount || ''}
onChange={(e) => 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"
/>
</div>
)}
{formData.deposit_type === 'percent' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.depositPercent', 'Deposit Percentage (%)')} *
</label>
<input
type="number"
value={formData.deposit_percent || ''}
onChange={(e) => 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 && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('services.depositEstimate', 'Estimated deposit: ${amount}', { amount: ((formData.price * formData.deposit_percent) / 100).toFixed(2) })}
</p>
)}
</div>
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
{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.')}
</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.descriptionLabel', 'Description')}

View File

@@ -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 {

View File

@@ -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/<str:payment_method_id>/', CustomerPaymentMethodDeleteView.as_view(), name='customer-payment-method-delete'),
path('customer/payment-methods/<str:payment_method_id>/default/', CustomerPaymentMethodDefaultView.as_view(), name='customer-payment-method-default'),
# Variable pricing / final charge endpoints
path('events/<int:event_id>/final-price/', SetFinalPriceView.as_view(), name='set-final-price'),
path('events/<int:event_id>/pricing/', EventPricingInfoView.as_view(), name='event-pricing-info'),
]

View File

@@ -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/<event_id>/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/<event_id>/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)

View File

@@ -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),
),
]

View File

@@ -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.

View File

@@ -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: