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:
@@ -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')}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user