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:
poduck
2025-12-10 03:37:13 -05:00
parent 30ec150d90
commit 18c9a69d75
5 changed files with 138 additions and 43 deletions

View File

@@ -33,6 +33,24 @@ export interface PlatformBusiness {
can_use_custom_domain: boolean; can_use_custom_domain: boolean;
can_white_label: boolean; can_white_label: boolean;
can_api_access: 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 { export interface PlatformBusinessUpdate {

View File

@@ -194,7 +194,6 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
// Update form when business changes // Update form when business changes
useEffect(() => { useEffect(() => {
if (business) { if (business) {
const b = business as any;
setEditForm({ setEditForm({
name: business.name, name: business.name,
is_active: business.is_active, is_active: business.is_active,
@@ -204,36 +203,38 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
max_resources: business.max_resources || 10, max_resources: business.max_resources || 10,
// Platform Permissions (flat, matching backend) // Platform Permissions (flat, matching backend)
can_manage_oauth_credentials: business.can_manage_oauth_credentials || false, can_manage_oauth_credentials: business.can_manage_oauth_credentials || false,
can_accept_payments: b.can_accept_payments || false, can_accept_payments: business.can_accept_payments || false,
can_use_custom_domain: b.can_use_custom_domain || false, can_use_custom_domain: business.can_use_custom_domain || false,
can_white_label: b.can_white_label || false, can_white_label: business.can_white_label || false,
can_api_access: b.can_api_access || false, can_api_access: business.can_api_access || false,
// Feature permissions (flat, matching backend) // Feature permissions (flat, matching backend)
can_add_video_conferencing: b.can_add_video_conferencing || false, can_add_video_conferencing: business.can_add_video_conferencing || false,
can_connect_to_api: b.can_connect_to_api || false, can_connect_to_api: business.can_connect_to_api || false,
can_book_repeated_events: b.can_book_repeated_events ?? true, can_book_repeated_events: business.can_book_repeated_events ?? true,
can_require_2fa: b.can_require_2fa || false, can_require_2fa: business.can_require_2fa || false,
can_download_logs: b.can_download_logs || false, can_download_logs: business.can_download_logs || false,
can_delete_data: b.can_delete_data || false, can_delete_data: business.can_delete_data || false,
can_use_sms_reminders: b.can_use_sms_reminders || false, can_use_sms_reminders: business.can_use_sms_reminders || false,
can_use_masked_phone_numbers: b.can_use_masked_phone_numbers || false, can_use_masked_phone_numbers: business.can_use_masked_phone_numbers || false,
can_use_pos: b.can_use_pos || false, can_use_pos: business.can_use_pos || false,
can_use_mobile_app: b.can_use_mobile_app || false, can_use_mobile_app: business.can_use_mobile_app || false,
can_export_data: b.can_export_data || false, can_export_data: business.can_export_data || false,
can_use_plugins: b.can_use_plugins ?? true, can_use_plugins: business.can_use_plugins ?? true,
can_use_tasks: b.can_use_tasks ?? true, can_use_tasks: business.can_use_tasks ?? true,
can_create_plugins: b.can_create_plugins || false, can_create_plugins: business.can_create_plugins || false,
can_use_webhooks: b.can_use_webhooks || false, can_use_webhooks: business.can_use_webhooks || false,
can_use_calendar_sync: b.can_use_calendar_sync || false, can_use_calendar_sync: business.can_use_calendar_sync || false,
can_use_contracts: b.can_use_contracts || false, can_use_contracts: business.can_use_contracts || false,
can_process_refunds: b.can_process_refunds || false, // Note: These fields are in the form but not yet on the backend model
can_create_packages: b.can_create_packages || false, // They will be ignored by the backend serializer until added to the Tenant model
can_use_email_templates: b.can_use_email_templates || false, can_process_refunds: false,
can_customize_booking_page: b.can_customize_booking_page || false, can_create_packages: false,
advanced_reporting: b.advanced_reporting || false, can_use_email_templates: false,
priority_support: b.priority_support || false, can_customize_booking_page: false,
dedicated_support: b.dedicated_support || false, advanced_reporting: false,
sso_enabled: b.sso_enabled || false, priority_support: false,
dedicated_support: false,
sso_enabled: false,
}); });
} }
}, [business]); }, [business]);

View File

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

View File

@@ -18,10 +18,9 @@ class Service(models.Model):
help_text="Duration in minutes", help_text="Duration in minutes",
default=60 default=60
) )
price = models.DecimalField( price_cents = models.IntegerField(
max_digits=10, default=0,
decimal_places=2, help_text="Price in cents (e.g., 1000 = $10.00)"
default=Decimal('0.00')
) )
display_order = models.PositiveIntegerField( display_order = models.PositiveIntegerField(
default=0, default=0,
@@ -42,13 +41,11 @@ class Service(models.Model):
# Deposit configuration # Deposit configuration
# - For fixed pricing: can use either deposit_amount OR deposit_percent # - For fixed pricing: can use either deposit_amount OR deposit_percent
# - For variable pricing: can only use deposit_amount (percent doesn't make sense) # - For variable pricing: can only use deposit_amount (percent doesn't make sense)
deposit_amount = models.DecimalField( deposit_amount_cents = models.IntegerField(
max_digits=10,
decimal_places=2,
null=True, null=True,
blank=True, blank=True,
validators=[MinValueValidator(Decimal('0'))], validators=[MinValueValidator(0)],
help_text="Fixed deposit amount to collect at booking" help_text="Fixed deposit amount in cents (e.g., 500 = $5.00)"
) )
deposit_percent = models.DecimalField( deposit_percent = models.DecimalField(
max_digits=5, max_digits=5,
@@ -79,12 +76,25 @@ class Service(models.Model):
indexes = [models.Index(fields=['is_active', 'display_order'])] indexes = [models.Index(fields=['is_active', 'display_order'])]
def __str__(self): 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 @property
def requires_deposit(self): def requires_deposit(self):
"""Check if this service requires a deposit""" """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 has_percent = self.deposit_percent and self.deposit_percent > 0
return has_amount or has_percent return has_amount or has_percent

View File

@@ -150,19 +150,46 @@ class ServiceSerializer(serializers.ModelSerializer):
requires_deposit = serializers.BooleanField(read_only=True) requires_deposit = serializers.BooleanField(read_only=True)
requires_saved_payment_method = 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: class Meta:
model = Service model = Service
fields = [ fields = [
'id', 'name', 'description', 'duration', 'duration_minutes', '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', 'is_archived_by_quota',
# Pricing fields # 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', 'requires_deposit', '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', 'requires_deposit', 'requires_saved_payment_method'] '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): def get_deposit_display(self, obj):
"""Get a human-readable description of the deposit requirement""" """Get a human-readable description of the deposit requirement"""
if obj.deposit_amount and obj.deposit_amount > 0: if obj.deposit_amount and obj.deposit_amount > 0: