fix: Store service prices in cents and fix contracts permission
- Update Service model to use price_cents/deposit_amount_cents as IntegerField - Add @property methods for backward compatibility (price, deposit_amount return dollars) - Update ServiceSerializer to convert dollars <-> cents on read/write - Add migration to convert column types from numeric to integer - Fix BusinessEditModal to properly use typed PlatformBusiness interface - Add missing feature permission fields to PlatformBusiness TypeScript interface 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,24 @@ export interface PlatformBusiness {
|
||||
can_use_custom_domain: boolean;
|
||||
can_white_label: boolean;
|
||||
can_api_access: boolean;
|
||||
// Feature permissions (optional - returned by API but may not always be present in tests)
|
||||
can_add_video_conferencing?: boolean;
|
||||
can_connect_to_api?: boolean;
|
||||
can_book_repeated_events?: boolean;
|
||||
can_require_2fa?: boolean;
|
||||
can_download_logs?: boolean;
|
||||
can_delete_data?: boolean;
|
||||
can_use_sms_reminders?: boolean;
|
||||
can_use_masked_phone_numbers?: boolean;
|
||||
can_use_pos?: boolean;
|
||||
can_use_mobile_app?: boolean;
|
||||
can_export_data?: boolean;
|
||||
can_use_plugins?: boolean;
|
||||
can_use_tasks?: boolean;
|
||||
can_create_plugins?: boolean;
|
||||
can_use_webhooks?: boolean;
|
||||
can_use_calendar_sync?: boolean;
|
||||
can_use_contracts?: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformBusinessUpdate {
|
||||
|
||||
@@ -194,7 +194,6 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
||||
// Update form when business changes
|
||||
useEffect(() => {
|
||||
if (business) {
|
||||
const b = business as any;
|
||||
setEditForm({
|
||||
name: business.name,
|
||||
is_active: business.is_active,
|
||||
@@ -204,36 +203,38 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
||||
max_resources: business.max_resources || 10,
|
||||
// Platform Permissions (flat, matching backend)
|
||||
can_manage_oauth_credentials: business.can_manage_oauth_credentials || false,
|
||||
can_accept_payments: b.can_accept_payments || false,
|
||||
can_use_custom_domain: b.can_use_custom_domain || false,
|
||||
can_white_label: b.can_white_label || false,
|
||||
can_api_access: b.can_api_access || false,
|
||||
can_accept_payments: business.can_accept_payments || false,
|
||||
can_use_custom_domain: business.can_use_custom_domain || false,
|
||||
can_white_label: business.can_white_label || false,
|
||||
can_api_access: business.can_api_access || false,
|
||||
// Feature permissions (flat, matching backend)
|
||||
can_add_video_conferencing: b.can_add_video_conferencing || false,
|
||||
can_connect_to_api: b.can_connect_to_api || false,
|
||||
can_book_repeated_events: b.can_book_repeated_events ?? true,
|
||||
can_require_2fa: b.can_require_2fa || false,
|
||||
can_download_logs: b.can_download_logs || false,
|
||||
can_delete_data: b.can_delete_data || false,
|
||||
can_use_sms_reminders: b.can_use_sms_reminders || false,
|
||||
can_use_masked_phone_numbers: b.can_use_masked_phone_numbers || false,
|
||||
can_use_pos: b.can_use_pos || false,
|
||||
can_use_mobile_app: b.can_use_mobile_app || false,
|
||||
can_export_data: b.can_export_data || false,
|
||||
can_use_plugins: b.can_use_plugins ?? true,
|
||||
can_use_tasks: b.can_use_tasks ?? true,
|
||||
can_create_plugins: b.can_create_plugins || false,
|
||||
can_use_webhooks: b.can_use_webhooks || false,
|
||||
can_use_calendar_sync: b.can_use_calendar_sync || false,
|
||||
can_use_contracts: b.can_use_contracts || false,
|
||||
can_process_refunds: b.can_process_refunds || false,
|
||||
can_create_packages: b.can_create_packages || false,
|
||||
can_use_email_templates: b.can_use_email_templates || false,
|
||||
can_customize_booking_page: b.can_customize_booking_page || false,
|
||||
advanced_reporting: b.advanced_reporting || false,
|
||||
priority_support: b.priority_support || false,
|
||||
dedicated_support: b.dedicated_support || false,
|
||||
sso_enabled: b.sso_enabled || false,
|
||||
can_add_video_conferencing: business.can_add_video_conferencing || false,
|
||||
can_connect_to_api: business.can_connect_to_api || false,
|
||||
can_book_repeated_events: business.can_book_repeated_events ?? true,
|
||||
can_require_2fa: business.can_require_2fa || false,
|
||||
can_download_logs: business.can_download_logs || false,
|
||||
can_delete_data: business.can_delete_data || false,
|
||||
can_use_sms_reminders: business.can_use_sms_reminders || false,
|
||||
can_use_masked_phone_numbers: business.can_use_masked_phone_numbers || false,
|
||||
can_use_pos: business.can_use_pos || false,
|
||||
can_use_mobile_app: business.can_use_mobile_app || false,
|
||||
can_export_data: business.can_export_data || false,
|
||||
can_use_plugins: business.can_use_plugins ?? true,
|
||||
can_use_tasks: business.can_use_tasks ?? true,
|
||||
can_create_plugins: business.can_create_plugins || false,
|
||||
can_use_webhooks: business.can_use_webhooks || false,
|
||||
can_use_calendar_sync: business.can_use_calendar_sync || false,
|
||||
can_use_contracts: business.can_use_contracts || false,
|
||||
// Note: These fields are in the form but not yet on the backend model
|
||||
// They will be ignored by the backend serializer until added to the Tenant model
|
||||
can_process_refunds: false,
|
||||
can_create_packages: false,
|
||||
can_use_email_templates: false,
|
||||
can_customize_booking_page: false,
|
||||
advanced_reporting: false,
|
||||
priority_support: false,
|
||||
dedicated_support: false,
|
||||
sso_enabled: false,
|
||||
});
|
||||
}
|
||||
}, [business]);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated manually to change price/deposit_amount columns from decimal to integer
|
||||
# The database already has columns named price_cents and deposit_amount_cents
|
||||
# This migration converts them from numeric(10,2) to integer
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0031_convert_orphaned_staff_resources'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Convert price_cents from numeric to integer
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
ALTER TABLE schedule_service
|
||||
ALTER COLUMN price_cents TYPE integer
|
||||
USING (price_cents::integer);
|
||||
""",
|
||||
reverse_sql="""
|
||||
ALTER TABLE schedule_service
|
||||
ALTER COLUMN price_cents TYPE numeric(10,2);
|
||||
""",
|
||||
),
|
||||
# Convert deposit_amount_cents from numeric to integer
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
ALTER TABLE schedule_service
|
||||
ALTER COLUMN deposit_amount_cents TYPE integer
|
||||
USING (deposit_amount_cents::integer);
|
||||
""",
|
||||
reverse_sql="""
|
||||
ALTER TABLE schedule_service
|
||||
ALTER COLUMN deposit_amount_cents TYPE numeric(10,2);
|
||||
""",
|
||||
),
|
||||
]
|
||||
@@ -18,10 +18,9 @@ class Service(models.Model):
|
||||
help_text="Duration in minutes",
|
||||
default=60
|
||||
)
|
||||
price = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=Decimal('0.00')
|
||||
price_cents = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Price in cents (e.g., 1000 = $10.00)"
|
||||
)
|
||||
display_order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
@@ -42,13 +41,11 @@ class Service(models.Model):
|
||||
# Deposit configuration
|
||||
# - For fixed pricing: can use either deposit_amount OR deposit_percent
|
||||
# - For variable pricing: can only use deposit_amount (percent doesn't make sense)
|
||||
deposit_amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
deposit_amount_cents = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal('0'))],
|
||||
help_text="Fixed deposit amount to collect at booking"
|
||||
validators=[MinValueValidator(0)],
|
||||
help_text="Fixed deposit amount in cents (e.g., 500 = $5.00)"
|
||||
)
|
||||
deposit_percent = models.DecimalField(
|
||||
max_digits=5,
|
||||
@@ -79,12 +76,25 @@ class Service(models.Model):
|
||||
indexes = [models.Index(fields=['is_active', 'display_order'])]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.duration} min - ${self.price})"
|
||||
price_dollars = self.price_cents / 100 if self.price_cents else 0
|
||||
return f"{self.name} ({self.duration} min - ${price_dollars:.2f})"
|
||||
|
||||
@property
|
||||
def price(self):
|
||||
"""Return price as Decimal dollars (for backward compatibility and display)"""
|
||||
return Decimal(self.price_cents) / 100 if self.price_cents else Decimal('0.00')
|
||||
|
||||
@property
|
||||
def deposit_amount(self):
|
||||
"""Return deposit amount as Decimal dollars (for backward compatibility and display)"""
|
||||
if self.deposit_amount_cents is not None:
|
||||
return Decimal(self.deposit_amount_cents) / 100
|
||||
return None
|
||||
|
||||
@property
|
||||
def requires_deposit(self):
|
||||
"""Check if this service requires a deposit"""
|
||||
has_amount = self.deposit_amount and self.deposit_amount > 0
|
||||
has_amount = self.deposit_amount_cents and self.deposit_amount_cents > 0
|
||||
has_percent = self.deposit_percent and self.deposit_percent > 0
|
||||
return has_amount or has_percent
|
||||
|
||||
|
||||
@@ -150,19 +150,46 @@ class ServiceSerializer(serializers.ModelSerializer):
|
||||
requires_deposit = serializers.BooleanField(read_only=True)
|
||||
requires_saved_payment_method = serializers.BooleanField(read_only=True)
|
||||
|
||||
# Read as dollars from property, write converts to cents
|
||||
price = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
|
||||
deposit_amount = serializers.DecimalField(max_digits=10, decimal_places=2, required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = [
|
||||
'id', 'name', 'description', 'duration', 'duration_minutes',
|
||||
'price', 'display_order', 'photos', 'is_active', 'created_at', 'updated_at',
|
||||
'price', 'price_cents', 'display_order', 'photos', 'is_active', 'created_at', 'updated_at',
|
||||
'is_archived_by_quota',
|
||||
# Pricing fields
|
||||
'variable_pricing', 'deposit_amount', 'deposit_percent',
|
||||
'variable_pricing', 'deposit_amount', 'deposit_amount_cents', 'deposit_percent',
|
||||
'requires_deposit', 'requires_saved_payment_method', 'deposit_display',
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at', 'is_archived_by_quota',
|
||||
'deposit_display', 'requires_deposit', 'requires_saved_payment_method']
|
||||
|
||||
def to_internal_value(self, data):
|
||||
"""Convert price/deposit_amount from dollars to cents for writing"""
|
||||
internal = super().to_internal_value(data)
|
||||
|
||||
# Convert price to price_cents if price is provided
|
||||
if 'price' in data:
|
||||
from decimal import Decimal
|
||||
price = Decimal(str(data['price']))
|
||||
internal['price_cents'] = int(price * 100)
|
||||
internal.pop('price', None)
|
||||
|
||||
# Convert deposit_amount to deposit_amount_cents if provided
|
||||
if 'deposit_amount' in data and data['deposit_amount'] is not None:
|
||||
from decimal import Decimal
|
||||
deposit = Decimal(str(data['deposit_amount']))
|
||||
internal['deposit_amount_cents'] = int(deposit * 100)
|
||||
internal.pop('deposit_amount', None)
|
||||
elif 'deposit_amount' in data and data['deposit_amount'] is None:
|
||||
internal['deposit_amount_cents'] = None
|
||||
internal.pop('deposit_amount', None)
|
||||
|
||||
return internal
|
||||
|
||||
def get_deposit_display(self, obj):
|
||||
"""Get a human-readable description of the deposit requirement"""
|
||||
if obj.deposit_amount and obj.deposit_amount > 0:
|
||||
|
||||
Reference in New Issue
Block a user