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_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 {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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",
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user