From 18c9a69d75a646f22104dd1a34ae736e19a46e68 Mon Sep 17 00:00:00 2001 From: poduck Date: Wed, 10 Dec 2025 03:37:13 -0500 Subject: [PATCH] fix: Store service prices in cents and fix contracts permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/api/platform.ts | 18 ++++++ .../platform/components/BusinessEditModal.tsx | 61 ++++++++++--------- .../migrations/0032_rename_price_to_cents.py | 39 ++++++++++++ .../scheduling/schedule/models.py | 32 ++++++---- .../scheduling/schedule/serializers.py | 31 +++++++++- 5 files changed, 138 insertions(+), 43 deletions(-) create mode 100644 smoothschedule/smoothschedule/scheduling/schedule/migrations/0032_rename_price_to_cents.py diff --git a/frontend/src/api/platform.ts b/frontend/src/api/platform.ts index 43ea283..d8d01d7 100644 --- a/frontend/src/api/platform.ts +++ b/frontend/src/api/platform.ts @@ -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 { diff --git a/frontend/src/pages/platform/components/BusinessEditModal.tsx b/frontend/src/pages/platform/components/BusinessEditModal.tsx index 48762ea..c4b7500 100644 --- a/frontend/src/pages/platform/components/BusinessEditModal.tsx +++ b/frontend/src/pages/platform/components/BusinessEditModal.tsx @@ -194,7 +194,6 @@ const BusinessEditModal: React.FC = ({ 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 = ({ 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]); diff --git a/smoothschedule/smoothschedule/scheduling/schedule/migrations/0032_rename_price_to_cents.py b/smoothschedule/smoothschedule/scheduling/schedule/migrations/0032_rename_price_to_cents.py new file mode 100644 index 0000000..76546e7 --- /dev/null +++ b/smoothschedule/smoothschedule/scheduling/schedule/migrations/0032_rename_price_to_cents.py @@ -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); + """, + ), + ] diff --git a/smoothschedule/smoothschedule/scheduling/schedule/models.py b/smoothschedule/smoothschedule/scheduling/schedule/models.py index 47e7303..3ae54a7 100644 --- a/smoothschedule/smoothschedule/scheduling/schedule/models.py +++ b/smoothschedule/smoothschedule/scheduling/schedule/models.py @@ -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 diff --git a/smoothschedule/smoothschedule/scheduling/schedule/serializers.py b/smoothschedule/smoothschedule/scheduling/schedule/serializers.py index abd74f1..0db40a0 100644 --- a/smoothschedule/smoothschedule/scheduling/schedule/serializers.py +++ b/smoothschedule/smoothschedule/scheduling/schedule/serializers.py @@ -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: