Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions backend/common/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,8 +332,8 @@ class Meta:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["alternate_phone"].required = False
self.fields["phone"].required = False
self.fields["role"].required = True
self.fields["phone"].required = True


class UserSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -570,14 +570,20 @@ class UserCreateSwaggerSerializer(serializers.Serializer):

email = serializers.CharField(max_length=1000, required=True)
role = serializers.ChoiceField(choices=ROLE_CHOICES, required=True)
phone = serializers.CharField(max_length=12)
alternate_phone = serializers.CharField(max_length=12)
address_line = serializers.CharField(max_length=10000, required=True)
street = serializers.CharField(max_length=1000)
city = serializers.CharField(max_length=1000)
state = serializers.CharField(max_length=1000)
pincode = serializers.CharField(max_length=1000)
country = serializers.CharField(max_length=1000)
phone = serializers.CharField(
max_length=12, required=False, allow_blank=True, allow_null=True
)
alternate_phone = serializers.CharField(
max_length=12, required=False, allow_blank=True, allow_null=True
)
address_line = serializers.CharField(
max_length=10000, required=False, allow_blank=True
)
street = serializers.CharField(max_length=1000, required=False, allow_blank=True)
city = serializers.CharField(max_length=1000, required=False, allow_blank=True)
state = serializers.CharField(max_length=1000, required=False, allow_blank=True)
pincode = serializers.CharField(max_length=1000, required=False, allow_blank=True)
country = serializers.CharField(max_length=1000, required=False, allow_blank=True)


class UserUpdateStatusSwaggerSerializer(serializers.Serializer):
Expand Down
15 changes: 15 additions & 0 deletions backend/common/tests/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,21 @@ def test_create_user_empty_body(self, admin_client):
)
assert response.status_code == status.HTTP_400_BAD_REQUEST

def test_create_user_minimal_payload(self, admin_client, org_a):
"""Admin can create a user with just email and role."""
response = admin_client.post(
self.url,
{
"email": "new-member@test.com",
"role": "USER",
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert Profile.objects.filter(
user__email="new-member@test.com", org=org_a
).exists()


@pytest.mark.django_db
class TestUserDetailView:
Expand Down
157 changes: 155 additions & 2 deletions backend/invoices/serializer.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from decimal import Decimal

from rest_framework import serializers

from accounts.models import Account
Expand Down Expand Up @@ -209,6 +211,42 @@ class Meta:
)


class EstimateLineItemCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating/updating Estimate Line Items"""

class Meta:
model = EstimateLineItem
fields = (
"product",
"name",
"description",
"quantity",
"unit_price",
"discount_type",
"discount_value",
"tax_rate",
"order",
)


class RecurringInvoiceLineItemCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating/updating Recurring Invoice Line Items"""

class Meta:
model = RecurringInvoiceLineItem
fields = (
"product",
"name",
"description",
"quantity",
"unit_price",
"discount_type",
"discount_value",
"tax_rate",
"order",
)


# =============================================================================
# PAYMENT SERIALIZERS
# =============================================================================
Expand Down Expand Up @@ -681,6 +719,7 @@ class EstimateCreateSerializer(serializers.ModelSerializer):
opportunity_id = serializers.UUIDField(
write_only=True, required=False, allow_null=True
)
line_items = EstimateLineItemCreateSerializer(many=True, required=False)

class Meta:
model = Estimate
Expand All @@ -707,6 +746,7 @@ class Meta:
"public_link_enabled",
"notes",
"terms",
"line_items",
)

def __init__(self, *args, **kwargs):
Expand Down Expand Up @@ -748,9 +788,56 @@ def validate_opportunity_id(self, value):
)
return value

def validate(self, attrs):
"""Cross-field validation"""
account_id = attrs.get("account_id")
contact_id = attrs.get("contact_id")

if account_id and contact_id and self.org:
contact = Contact.objects.filter(id=contact_id, org=self.org).first()
if contact and contact.account_id and contact.account_id != account_id:
raise serializers.ValidationError(
{"contact_id": "Contact does not belong to the selected account"}
)
return attrs

def create(self, validated_data):
line_items_data = validated_data.pop("line_items", [])
validated_data["org"] = self.org
return super().create(validated_data)

estimate = Estimate.objects.create(**validated_data)

for idx, item_data in enumerate(line_items_data):
EstimateLineItem.objects.create(
estimate=estimate,
org=self.org,
order=item_data.get("order", idx),
**{k: v for k, v in item_data.items() if k != "order"},
)

estimate.recalculate_totals()
estimate.save()
return estimate
Comment on lines 804 to +820

def update(self, instance, validated_data):
line_items_data = validated_data.pop("line_items", None)

for attr, value in validated_data.items():
setattr(instance, attr, value)

if line_items_data is not None:
instance.line_items.all().delete()
for idx, item_data in enumerate(line_items_data):
EstimateLineItem.objects.create(
estimate=instance,
org=self.org or instance.org,
order=item_data.get("order", idx),
**{k: v for k, v in item_data.items() if k != "order"},
)

instance.recalculate_totals()
instance.save()
return instance
Comment on lines +822 to +840


# =============================================================================
Expand Down Expand Up @@ -831,6 +918,7 @@ class RecurringInvoiceCreateSerializer(serializers.ModelSerializer):
opportunity_id = serializers.UUIDField(
write_only=True, required=False, allow_null=True
)
line_items = RecurringInvoiceLineItemCreateSerializer(many=True, required=False)

class Meta:
model = RecurringInvoice
Expand All @@ -855,6 +943,7 @@ class Meta:
"tax_rate",
"notes",
"terms",
"line_items",
)

def __init__(self, *args, **kwargs):
Expand Down Expand Up @@ -896,9 +985,73 @@ def validate_opportunity_id(self, value):
)
return value

def validate(self, attrs):
"""Cross-field validation"""
account_id = attrs.get("account_id")
contact_id = attrs.get("contact_id")

if account_id and contact_id and self.org:
contact = Contact.objects.filter(id=contact_id, org=self.org).first()
if contact and contact.account_id and contact.account_id != account_id:
raise serializers.ValidationError(
{"contact_id": "Contact does not belong to the selected account"}
)
return attrs

def _recalculate_totals(self, instance):
subtotal = sum(
(item.quantity * item.unit_price for item in instance.line_items.all()),
Decimal("0"),
)
instance.subtotal = subtotal

if instance.discount_type == "PERCENTAGE":
discount_amount = subtotal * (instance.discount_value / Decimal("100"))
else:
discount_amount = instance.discount_value

taxable = subtotal - discount_amount
instance.total_amount = taxable + (
taxable * (instance.tax_rate / Decimal("100"))
)

def create(self, validated_data):
line_items_data = validated_data.pop("line_items", [])
validated_data["org"] = self.org
return super().create(validated_data)

recurring = RecurringInvoice.objects.create(**validated_data)

for idx, item_data in enumerate(line_items_data):
RecurringInvoiceLineItem.objects.create(
recurring_invoice=recurring,
org=self.org,
order=item_data.get("order", idx),
**{k: v for k, v in item_data.items() if k != "order"},
)

self._recalculate_totals(recurring)
recurring.save()
return recurring
Comment on lines 1018 to +1034

def update(self, instance, validated_data):
line_items_data = validated_data.pop("line_items", None)

for attr, value in validated_data.items():
setattr(instance, attr, value)

if line_items_data is not None:
instance.line_items.all().delete()
for idx, item_data in enumerate(line_items_data):
RecurringInvoiceLineItem.objects.create(
recurring_invoice=instance,
org=self.org or instance.org,
order=item_data.get("order", idx),
**{k: v for k, v in item_data.items() if k != "order"},
)

self._recalculate_totals(instance)
instance.save()
return instance
Comment on lines +1036 to +1054


# =============================================================================
Expand Down
65 changes: 65 additions & 0 deletions backend/invoices/tests/test_invoices_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1145,6 +1145,37 @@ def test_create_estimate(self, admin_client, account_for_invoice, contact_for_in
assert data["error"] is False
assert data["estimate"]["title"] == "New Estimate"

def test_create_estimate_with_line_items(
self, admin_client, account_for_invoice, contact_for_invoice
):
response = admin_client.post(
"/api/invoices/estimates/",
{
"title": "Estimate With Items",
"account_id": str(account_for_invoice.id),
"contact_id": str(contact_for_invoice.id),
"client_name": "New Client",
"client_email": "new@example.com",
"currency": "USD",
"tax_rate": "10.00",
"line_items": [
{
"name": "Implementation",
"quantity": "2.00",
"unit_price": "100.00",
"order": 0,
}
],
},
format="json",
)
assert response.status_code == 201
data = response.json()
created = Estimate.objects.get(id=data["estimate"]["id"])
assert created.line_items.count() == 1
assert created.subtotal == Decimal("200.00")
assert created.total_amount == Decimal("220.00")

def test_create_estimate_invalid_account(
self, admin_client, contact_for_invoice
):
Expand Down Expand Up @@ -1417,6 +1448,40 @@ def test_create_recurring(
assert data["error"] is False
assert data["recurring_invoice"]["title"] == "New Recurring"

def test_create_recurring_with_line_items(
self, admin_client, account_for_invoice, contact_for_invoice
):
response = admin_client.post(
"/api/invoices/recurring/",
{
"title": "Recurring With Items",
"account_id": str(account_for_invoice.id),
"contact_id": str(contact_for_invoice.id),
"client_name": "Client",
"client_email": "client@example.com",
"frequency": "WEEKLY",
"start_date": "2026-03-01",
"next_generation_date": "2026-03-01",
"currency": "USD",
"tax_rate": "10.00",
"line_items": [
{
"name": "Hosting",
"quantity": "2.00",
"unit_price": "50.00",
"order": 0,
}
],
},
format="json",
)
assert response.status_code == 201
data = response.json()
created = RecurringInvoice.objects.get(id=data["recurring_invoice"]["id"])
assert created.line_items.count() == 1
assert created.subtotal == Decimal("100.00")
assert created.total_amount == Decimal("110.00")

def test_create_recurring_invalid_account(
self, admin_client, contact_for_invoice
):
Expand Down
Loading
Loading