Coverage for modules\erp\services.py: 62%
99 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 17:01 +0800
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 17:01 +0800
1import uuid
2from decimal import Decimal
3from datetime import date, timedelta
4from django.db import transaction
5from django.utils import timezone
6from core.email_service import send_invoice_email
7from core.exceptions import NotFoundException, BadRequestException
8from .models import Account, JournalEntry, JournalItem, Product, Warehouse, StockMove, Invoice, InvoiceLine, Shipment
10class ERPService:
11 @staticmethod
12 @transaction.atomic
13 def create_shipment(tenant_id, data):
14 count = Shipment.objects.filter(tenant_id=tenant_id).count() + 1
15 return Shipment.objects.create(
16 tenant_id=tenant_id,
17 reference_number=f"SHP-{count:05d}",
18 **data.dict()
19 )
21 @staticmethod
22 def get_or_create_chart_of_accounts(tenant_id):
23 """Seed default chart of accounts if empty."""
24 if not Account.objects.filter(tenant_id=tenant_id).exists():
25 default_accounts = [
26 ("1010", "Cash & Bank", "ASSET"),
27 ("1020", "Accounts Receivable", "ASSET"),
28 ("1030", "Inventory", "ASSET"),
29 ("2010", "Accounts Payable", "LIABILITY"),
30 ("2020", "Sales Tax Payable", "LIABILITY"),
31 ("3010", "Retained Earnings", "EQUITY"),
32 ("4010", "Sales Revenue", "REVENUE"),
33 ("5010", "Cost of Goods Sold", "EXPENSE"),
34 ("5020", "General Operating Expense", "EXPENSE"),
35 ]
36 for code, name, acct_type in default_accounts:
37 Account.objects.create(
38 tenant_id=tenant_id,
39 code=code,
40 name=name,
41 type=acct_type,
42 currency='PHP'
43 )
45 @staticmethod
46 @transaction.atomic
47 def create_invoice(tenant_id, data):
48 # Generate sequential invoice number
49 count = Invoice.objects.filter(tenant_id=tenant_id).count() + 1
50 invoice_number = f"{count:05d}"
52 subtotal = Decimal('0.00')
53 tax_total = Decimal('0.00')
54 lines_data = []
56 for line in data.lines:
57 line_subtotal = (line.quantity * line.unit_price).quantize(Decimal('0.01'))
58 line_tax = (line_subtotal * (line.tax_rate / Decimal('100.00'))).quantize(Decimal('0.01'))
59 line_total = line_subtotal + line_tax
61 subtotal += line_subtotal
62 tax_total += line_tax
63 lines_data.append({
64 'product_id': line.product_id,
65 'description': line.description,
66 'quantity': line.quantity,
67 'unit_price': line.unit_price,
68 'tax_rate': line.tax_rate,
69 'subtotal': line_subtotal,
70 'tax_amount': line_tax,
71 'total': line_total,
72 })
74 total_amount = subtotal + tax_total
76 invoice = Invoice.objects.create(
77 tenant_id=tenant_id,
78 invoice_number=invoice_number,
79 customer_name=data.customer_name,
80 customer_email=data.customer_email,
81 deal_id=data.deal_id,
82 issue_date=data.issue_date or timezone.now().date(),
83 due_date=data.due_date,
84 currency=data.currency or 'PHP',
85 subtotal=subtotal,
86 tax_total=tax_total,
87 total_amount=total_amount,
88 notes=data.notes,
89 terms=data.terms,
90 status='SENT',
91 )
93 for ld in lines_data:
94 InvoiceLine.objects.create(
95 tenant_id=tenant_id,
96 invoice=invoice,
97 **ld
98 )
100 # Create Ledger Journal Entry (Double-Entry: Debit AR / Credit Revenue & Tax)
101 ERPService.post_invoice_journal(tenant_id, invoice)
103 if invoice.customer_email:
104 transaction.on_commit(
105 lambda: send_invoice_email(invoice, invoice.customer_email)
106 )
108 return invoice
110 @staticmethod
111 def post_invoice_journal(tenant_id, invoice: Invoice):
112 """Create balanced double-entry journal entry for invoice."""
113 ERPService.get_or_create_chart_of_accounts(tenant_id)
114 ar_acct = Account.objects.filter(tenant_id=tenant_id, code='1020').first()
115 rev_acct = Account.objects.filter(tenant_id=tenant_id, code='4010').first()
116 tax_acct = Account.objects.filter(tenant_id=tenant_id, code='2020').first()
118 if ar_acct and rev_acct:
119 entry_number = f"JE-INV-{invoice.invoice_number}"
120 je = JournalEntry.objects.create(
121 tenant_id=tenant_id,
122 entry_number=entry_number,
123 date=invoice.issue_date,
124 status='POSTED',
125 reference_type='INVOICE',
126 reference_id=invoice.id,
127 posted_at=timezone.now()
128 )
130 # Debit Accounts Receivable
131 JournalItem.objects.create(
132 tenant_id=tenant_id,
133 journal_entry=je,
134 account=ar_acct,
135 debit_amount=invoice.total_amount,
136 credit_amount=Decimal('0.00'),
137 description=f"Invoice #{invoice.invoice_number} AR"
138 )
140 # Credit Revenue
141 JournalItem.objects.create(
142 tenant_id=tenant_id,
143 journal_entry=je,
144 account=rev_acct,
145 debit_amount=Decimal('0.00'),
146 credit_amount=invoice.subtotal,
147 description=f"Invoice #{invoice.invoice_number} Sales Revenue"
148 )
150 # Credit Tax (if any)
151 if invoice.tax_total > 0 and tax_acct:
152 JournalItem.objects.create(
153 tenant_id=tenant_id,
154 journal_entry=je,
155 account=tax_acct,
156 debit_amount=Decimal('0.00'),
157 credit_amount=invoice.tax_total,
158 description=f"Invoice #{invoice.invoice_number} Sales Tax"
159 )
161 @staticmethod
162 @transaction.atomic
163 def record_payment(tenant_id, invoice_id, amount: Decimal):
164 invoice = Invoice.objects.filter(id=invoice_id, tenant_id=tenant_id).first()
165 if not invoice:
166 raise NotFoundException("Invoice not found.")
168 invoice.amount_paid += amount
169 if invoice.amount_paid >= invoice.total_amount:
170 invoice.status = 'PAID'
171 else:
172 invoice.status = 'PARTIALLY_PAID'
173 invoice.save()
175 # Double-entry: Debit Cash (1010) / Credit Accounts Receivable (1020)
176 cash_acct = Account.objects.filter(tenant_id=tenant_id, code='1010').first()
177 ar_acct = Account.objects.filter(tenant_id=tenant_id, code='1020').first()
179 if cash_acct and ar_acct:
180 count = JournalEntry.objects.filter(tenant_id=tenant_id).count() + 1
181 je = JournalEntry.objects.create(
182 tenant_id=tenant_id,
183 entry_number=f"JE-PAY-{count:05d}",
184 date=timezone.now().date(),
185 status='POSTED',
186 reference_type='PAYMENT',
187 reference_id=invoice.id,
188 posted_at=timezone.now()
189 )
190 JournalItem.objects.create(
191 tenant_id=tenant_id,
192 journal_entry=je,
193 account=cash_acct,
194 debit_amount=amount,
195 credit_amount=Decimal('0.00'),
196 description=f"Payment for Invoice #{invoice.invoice_number}"
197 )
198 JournalItem.objects.create(
199 tenant_id=tenant_id,
200 journal_entry=je,
201 account=ar_acct,
202 debit_amount=Decimal('0.00'),
203 credit_amount=amount,
204 description=f"Payment applied to Invoice #{invoice.invoice_number}"
205 )
207 return invoice
209 @staticmethod
210 def create_invoice_from_deal(deal):
211 """Triggered automatically when a CRM deal is won."""
212 tenant_id = deal.tenant_id
213 customer_name = deal.company.name if deal.company else (deal.contact.full_name if deal.contact else deal.title)
214 customer_email = deal.contact.email if deal.contact else None
216 class DealInvoicePayload:
217 pass
219 data = DealInvoicePayload()
220 data.customer_name = customer_name
221 data.customer_email = customer_email
222 data.deal_id = deal.id
223 data.issue_date = timezone.now().date()
224 data.due_date = timezone.now().date() + timedelta(days=30)
225 data.currency = deal.currency
226 data.notes = f"Generated from Won Deal: {deal.title}"
227 data.terms = "Net 30"
229 class LinePayload:
230 product_id = None
231 description = f"Services for deal: {deal.title}"
232 quantity = Decimal('1.00')
233 unit_price = deal.value
234 tax_rate = Decimal('0.00')
236 data.lines = [LinePayload()]
237 return ERPService.create_invoice(tenant_id, data)