commit 066276743aa8a5ad8fe67f090d8e44c629c056c6 Author: Shayan Azadi Date: Wed Jul 1 00:29:25 2026 +0330 Initial commit: standalone Django accounting API with admin JWT authorization Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e63f5cd --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# IMPORTANT: must match the FunZone backend SECRET_KEY so admin JWTs validate here. +SECRET_KEY=your-super-secret-key-change-in-production + +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,testserver + +# Database: leave unset to use the bundled SQLite file (zero setup). +# To use PostgreSQL instead, set e.g.: +# DATABASE_URL=postgresql://postgres:postgres@localhost:5432/funzone_accounting + +# CORS (the accounting frontend dev server runs on port 5176) +CORS_ALLOW_ALL_ORIGINS=True +CORS_ALLOWED_ORIGINS=http://localhost:5176,http://127.0.0.1:5176 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f916f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +.venv/ +__pycache__/ +*.py[cod] +*.sqlite3 +db.sqlite3 +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..765d400 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# FunZone Accounting Backend + +A standalone Django REST service that powers the FunZone accounting app +(`funzone-accounting`). It is **fully decoupled** from the main FunZone backend: + +- It keeps its own database (SQLite by default) with the chart of accounts, + journal vouchers, invoices, products, parties, treasury, payroll and settings. +- It does **not** have its own user table. Instead it authorizes requests + statelessly by validating the **admin JWT** issued by the FunZone backend, + using the shared `SECRET_KEY`. Only tokens carrying `is_staff`/`is_superuser` + claims (i.e. admin logins) are accepted. + +Operational data (owners, customers, payments, withdrawals) still comes directly +from the FunZone backend; the frontend talks to both services. + +## Endpoints + +Base path: `/api/accounting/` + +| Resource | Path | Methods | +| --- | --- | --- | +| Chart of accounts | `accounts/` | GET, POST, PATCH, PUT, DELETE | +| Parties | `parties/` | GET, POST, PATCH, PUT, DELETE | +| Products | `products/` | GET, POST, PATCH, PUT, DELETE | +| Journal vouchers | `vouchers/` | GET, POST, PATCH, PUT, DELETE | +| Invoices | `invoices/` | GET, POST, PATCH, PUT, DELETE | +| Treasury | `treasury/` | GET, POST, PATCH, PUT, DELETE | +| Employees | `employees/` | GET, POST, PATCH, PUT, DELETE | +| Payslips | `payslips/` | GET, POST, PATCH, PUT, DELETE | +| Company settings | `settings/` | GET, PATCH | +| Reset (wipe + reseed) | `reset/` | POST | + +All payloads use camelCase keys to match the frontend types. + +## Setup + +```bash +cd funzone-accounting-backend +python -m venv .venv +.venv\Scripts\activate # Windows +pip install -r requirements.txt +copy .env.example .env # then set SECRET_KEY to match FunZone +python manage.py migrate +python manage.py runserver 8010 +``` + +The first `migrate` seeds a clean chart of accounts (zeroed balances) and the +default company settings/account mapping. No demo business data is created. + +## Auth + +The frontend logs in via the FunZone admin endpoint +(`/api/auth/token/admin/`) and reuses the returned access token for this service. +Make sure `SECRET_KEY` here is identical to the FunZone backend's. diff --git a/accounting/__init__.py b/accounting/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/accounting/__init__.py @@ -0,0 +1 @@ + diff --git a/accounting/apps.py b/accounting/apps.py new file mode 100644 index 0000000..cde39bc --- /dev/null +++ b/accounting/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountingConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounting' diff --git a/accounting/migrations/0001_initial.py b/accounting/migrations/0001_initial.py new file mode 100644 index 0000000..3c94b70 --- /dev/null +++ b/accounting/migrations/0001_initial.py @@ -0,0 +1,211 @@ +# Generated by Django 4.2.7 on 2026-06-30 09:26 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Account', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('code', models.CharField(db_index=True, max_length=20, unique=True)), + ('name', models.CharField(max_length=150)), + ('type', models.CharField(choices=[('asset', 'Asset'), ('liability', 'Liability'), ('equity', 'Equity'), ('income', 'Income'), ('expense', 'Expense')], max_length=20)), + ('is_group', models.BooleanField(default=False)), + ('opening_balance', models.FloatField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='accounting.account')), + ], + options={ + 'db_table': 'accounting_accounts', + 'ordering': ['code'], + }, + ), + migrations.CreateModel( + name='Employee', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('code', models.CharField(blank=True, default='', max_length=50)), + ('name', models.CharField(max_length=150)), + ('position', models.CharField(blank=True, default='', max_length=120)), + ('base_salary', models.FloatField(default=0)), + ('insurance_no', models.CharField(blank=True, default='', max_length=50)), + ('hire_date', models.DateField(blank=True, null=True)), + ('active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'accounting_employees', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Invoice', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('kind', models.CharField(choices=[('sale', 'Sale'), ('purchase', 'Purchase')], max_length=20)), + ('number', models.PositiveIntegerField(default=0)), + ('date', models.DateField()), + ('status', models.CharField(choices=[('draft', 'Draft'), ('confirmed', 'Confirmed'), ('paid', 'Paid')], default='draft', max_length=20)), + ('note', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'accounting_invoices', + 'ordering': ['-date', '-number'], + }, + ), + migrations.CreateModel( + name='Party', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('kind', models.CharField(choices=[('customer', 'Customer'), ('supplier', 'Supplier')], max_length=20)), + ('name', models.CharField(max_length=150)), + ('phone', models.CharField(blank=True, default='', max_length=30)), + ('economic_code', models.CharField(blank=True, default='', max_length=50)), + ('address', models.TextField(blank=True, default='')), + ('opening_balance', models.FloatField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'accounting_parties', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('code', models.CharField(db_index=True, max_length=50)), + ('name', models.CharField(max_length=150)), + ('unit', models.CharField(blank=True, default='', max_length=30)), + ('sale_price', models.FloatField(default=0)), + ('purchase_price', models.FloatField(default=0)), + ('stock', models.FloatField(default=0)), + ('reorder_level', models.FloatField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'accounting_products', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Voucher', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('number', models.PositiveIntegerField(default=0)), + ('date', models.DateField()), + ('description', models.TextField(blank=True, default='')), + ('status', models.CharField(choices=[('draft', 'Draft'), ('posted', 'Posted')], default='draft', max_length=20)), + ('source', models.CharField(blank=True, db_index=True, default='', max_length=120)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'accounting_vouchers', + 'ordering': ['-date', '-number'], + }, + ), + migrations.CreateModel( + name='VoucherLine', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('description', models.CharField(blank=True, default='', max_length=255)), + ('debit', models.FloatField(default=0)), + ('credit', models.FloatField(default=0)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='voucher_lines', to='accounting.account')), + ('voucher', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='accounting.voucher')), + ], + options={ + 'db_table': 'accounting_voucher_lines', + }, + ), + migrations.CreateModel( + name='TreasuryTxn', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('number', models.PositiveIntegerField(default=0)), + ('kind', models.CharField(choices=[('receipt', 'Receipt'), ('payment', 'Payment')], max_length=20)), + ('method', models.CharField(choices=[('cash', 'Cash'), ('bank', 'Bank'), ('cheque', 'Cheque')], default='bank', max_length=20)), + ('date', models.DateField()), + ('amount', models.FloatField(default=0)), + ('reference', models.CharField(blank=True, default='', max_length=120)), + ('description', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('party', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='treasury_txns', to='accounting.party')), + ], + options={ + 'db_table': 'accounting_treasury_txns', + 'ordering': ['-date', '-number'], + }, + ), + migrations.CreateModel( + name='Payslip', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('period', models.CharField(blank=True, default='', max_length=20)), + ('base_salary', models.FloatField(default=0)), + ('overtime', models.FloatField(default=0)), + ('bonus', models.FloatField(default=0)), + ('deductions', models.FloatField(default=0)), + ('insurance', models.FloatField(default=0)), + ('tax', models.FloatField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payslips', to='accounting.employee')), + ], + options={ + 'db_table': 'accounting_payslips', + 'ordering': ['-period'], + }, + ), + migrations.CreateModel( + name='InvoiceLine', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('quantity', models.FloatField(default=0)), + ('unit_price', models.FloatField(default=0)), + ('discount', models.FloatField(default=0)), + ('tax_rate', models.FloatField(default=0)), + ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='accounting.invoice')), + ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='invoice_lines', to='accounting.product')), + ], + options={ + 'db_table': 'accounting_invoice_lines', + }, + ), + migrations.AddField( + model_name='invoice', + name='party', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invoices', to='accounting.party'), + ), + migrations.CreateModel( + name='CompanySettings', + fields=[ + ('id', models.PositiveSmallIntegerField(default=1, primary_key=True, serialize=False)), + ('name', models.CharField(blank=True, default='', max_length=150)), + ('economic_code', models.CharField(blank=True, default='', max_length=50)), + ('fiscal_year', models.CharField(blank=True, default='', max_length=20)), + ('base_currency', models.CharField(blank=True, default='تومان', max_length=20)), + ('tax_rate', models.FloatField(default=0)), + ('address', models.TextField(blank=True, default='')), + ('phone', models.CharField(blank=True, default='', max_length=40)), + ('bank_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounting.account')), + ('customer_payable_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounting.account')), + ('owner_payable_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounting.account')), + ('owner_share_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounting.account')), + ('sales_income_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounting.account')), + ], + options={ + 'db_table': 'accounting_company_settings', + }, + ), + ] diff --git a/accounting/migrations/0002_seed_chart_of_accounts.py b/accounting/migrations/0002_seed_chart_of_accounts.py new file mode 100644 index 0000000..efaa5d9 --- /dev/null +++ b/accounting/migrations/0002_seed_chart_of_accounts.py @@ -0,0 +1,27 @@ +from django.db import migrations + +from accounting.seeding import seed_default_data + + +def seed(apps, schema_editor): + Account = apps.get_model('accounting', 'Account') + CompanySettings = apps.get_model('accounting', 'CompanySettings') + seed_default_data(Account, CompanySettings) + + +def unseed(apps, schema_editor): + Account = apps.get_model('accounting', 'Account') + CompanySettings = apps.get_model('accounting', 'CompanySettings') + CompanySettings.objects.all().delete() + Account.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0001_initial'), + ] + + operations = [ + migrations.RunPython(seed, unseed), + ] diff --git a/accounting/migrations/__init__.py b/accounting/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/accounting/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/accounting/models.py b/accounting/models.py new file mode 100644 index 0000000..7a7fcf8 --- /dev/null +++ b/accounting/models.py @@ -0,0 +1,284 @@ +import uuid + +from django.db import models + + +class Account(models.Model): + """A node in the chart of accounts (کدینگ حساب‌ها).""" + + ACCOUNT_TYPES = [ + ('asset', 'Asset'), + ('liability', 'Liability'), + ('equity', 'Equity'), + ('income', 'Income'), + ('expense', 'Expense'), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + code = models.CharField(max_length=20, unique=True, db_index=True) + name = models.CharField(max_length=150) + type = models.CharField(max_length=20, choices=ACCOUNT_TYPES) + parent = models.ForeignKey( + 'self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children' + ) + is_group = models.BooleanField(default=False) + opening_balance = models.FloatField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'accounting_accounts' + ordering = ['code'] + + def __str__(self): + return f"{self.code} - {self.name}" + + +class Party(models.Model): + """A trading partner (customer or supplier) used by invoices and treasury.""" + + PARTY_KINDS = [ + ('customer', 'Customer'), + ('supplier', 'Supplier'), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + kind = models.CharField(max_length=20, choices=PARTY_KINDS) + name = models.CharField(max_length=150) + phone = models.CharField(max_length=30, blank=True, default='') + economic_code = models.CharField(max_length=50, blank=True, default='') + address = models.TextField(blank=True, default='') + opening_balance = models.FloatField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'accounting_parties' + ordering = ['name'] + + def __str__(self): + return self.name + + +class Product(models.Model): + """An inventory item.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + code = models.CharField(max_length=50, db_index=True) + name = models.CharField(max_length=150) + unit = models.CharField(max_length=30, blank=True, default='') + sale_price = models.FloatField(default=0) + purchase_price = models.FloatField(default=0) + stock = models.FloatField(default=0) + reorder_level = models.FloatField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'accounting_products' + ordering = ['name'] + + def __str__(self): + return f"{self.code} - {self.name}" + + +class Voucher(models.Model): + """A double-entry journal voucher (سند حسابداری).""" + + STATUS_CHOICES = [ + ('draft', 'Draft'), + ('posted', 'Posted'), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + number = models.PositiveIntegerField(default=0) + date = models.DateField() + description = models.TextField(blank=True, default='') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') + source = models.CharField(max_length=120, blank=True, default='', db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'accounting_vouchers' + ordering = ['-date', '-number'] + + def __str__(self): + return f"Voucher #{self.number}" + + +class VoucherLine(models.Model): + """A single debit/credit line within a voucher.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + voucher = models.ForeignKey(Voucher, on_delete=models.CASCADE, related_name='lines') + account = models.ForeignKey(Account, on_delete=models.PROTECT, related_name='voucher_lines') + description = models.CharField(max_length=255, blank=True, default='') + debit = models.FloatField(default=0) + credit = models.FloatField(default=0) + + class Meta: + db_table = 'accounting_voucher_lines' + + def __str__(self): + return f"Line {self.account_id} D:{self.debit} C:{self.credit}" + + +class Invoice(models.Model): + """A sales or purchase invoice (فاکتور).""" + + INVOICE_KINDS = [ + ('sale', 'Sale'), + ('purchase', 'Purchase'), + ] + STATUS_CHOICES = [ + ('draft', 'Draft'), + ('confirmed', 'Confirmed'), + ('paid', 'Paid'), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + kind = models.CharField(max_length=20, choices=INVOICE_KINDS) + number = models.PositiveIntegerField(default=0) + party = models.ForeignKey( + Party, on_delete=models.SET_NULL, null=True, blank=True, related_name='invoices' + ) + date = models.DateField() + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') + note = models.TextField(blank=True, default='') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'accounting_invoices' + ordering = ['-date', '-number'] + + def __str__(self): + return f"{self.kind} invoice #{self.number}" + + +class InvoiceLine(models.Model): + """A line item within an invoice.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name='lines') + product = models.ForeignKey( + Product, on_delete=models.PROTECT, null=True, blank=True, related_name='invoice_lines' + ) + quantity = models.FloatField(default=0) + unit_price = models.FloatField(default=0) + discount = models.FloatField(default=0) + tax_rate = models.FloatField(default=0) + + class Meta: + db_table = 'accounting_invoice_lines' + + def __str__(self): + return f"Line {self.product_id} x{self.quantity}" + + +class TreasuryTxn(models.Model): + """A treasury receipt or payment (دریافت/پرداخت).""" + + KIND_CHOICES = [ + ('receipt', 'Receipt'), + ('payment', 'Payment'), + ] + METHOD_CHOICES = [ + ('cash', 'Cash'), + ('bank', 'Bank'), + ('cheque', 'Cheque'), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + number = models.PositiveIntegerField(default=0) + kind = models.CharField(max_length=20, choices=KIND_CHOICES) + method = models.CharField(max_length=20, choices=METHOD_CHOICES, default='bank') + date = models.DateField() + party = models.ForeignKey( + Party, on_delete=models.SET_NULL, null=True, blank=True, related_name='treasury_txns' + ) + amount = models.FloatField(default=0) + reference = models.CharField(max_length=120, blank=True, default='') + description = models.TextField(blank=True, default='') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'accounting_treasury_txns' + ordering = ['-date', '-number'] + + def __str__(self): + return f"{self.kind} #{self.number}" + + +class Employee(models.Model): + """A payroll employee.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + code = models.CharField(max_length=50, blank=True, default='') + name = models.CharField(max_length=150) + position = models.CharField(max_length=120, blank=True, default='') + base_salary = models.FloatField(default=0) + insurance_no = models.CharField(max_length=50, blank=True, default='') + hire_date = models.DateField(null=True, blank=True) + active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'accounting_employees' + ordering = ['name'] + + def __str__(self): + return self.name + + +class Payslip(models.Model): + """A monthly payslip for an employee (فیش حقوقی).""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + employee = models.ForeignKey(Employee, on_delete=models.CASCADE, related_name='payslips') + period = models.CharField(max_length=20, blank=True, default='') + base_salary = models.FloatField(default=0) + overtime = models.FloatField(default=0) + bonus = models.FloatField(default=0) + deductions = models.FloatField(default=0) + insurance = models.FloatField(default=0) + tax = models.FloatField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'accounting_payslips' + ordering = ['-period'] + + def __str__(self): + return f"Payslip {self.employee_id} - {self.period}" + + +class CompanySettings(models.Model): + """Singleton holding company info and the auto-voucher account mapping.""" + + id = models.PositiveSmallIntegerField(primary_key=True, default=1) + name = models.CharField(max_length=150, blank=True, default='') + economic_code = models.CharField(max_length=50, blank=True, default='') + fiscal_year = models.CharField(max_length=20, blank=True, default='') + base_currency = models.CharField(max_length=20, blank=True, default='تومان') + tax_rate = models.FloatField(default=0) + address = models.TextField(blank=True, default='') + phone = models.CharField(max_length=40, blank=True, default='') + + bank_account = models.ForeignKey( + Account, on_delete=models.SET_NULL, null=True, blank=True, related_name='+' + ) + owner_payable_account = models.ForeignKey( + Account, on_delete=models.SET_NULL, null=True, blank=True, related_name='+' + ) + customer_payable_account = models.ForeignKey( + Account, on_delete=models.SET_NULL, null=True, blank=True, related_name='+' + ) + owner_share_account = models.ForeignKey( + Account, on_delete=models.SET_NULL, null=True, blank=True, related_name='+' + ) + sales_income_account = models.ForeignKey( + Account, on_delete=models.SET_NULL, null=True, blank=True, related_name='+' + ) + + class Meta: + db_table = 'accounting_company_settings' + + def __str__(self): + return self.name or 'Company Settings' diff --git a/accounting/permissions.py b/accounting/permissions.py new file mode 100644 index 0000000..bc890ab --- /dev/null +++ b/accounting/permissions.py @@ -0,0 +1,11 @@ +from rest_framework.permissions import BasePermission + + +class IsAdminAccount(BasePermission): + """Allows access only to authenticated staff/superuser accounts.""" + + message = 'دسترسی غیرمجاز. فقط مدیران می‌توانند به این بخش دسترسی داشته باشند.' + + def has_permission(self, request, view): + user = request.user + return bool(user and user.is_authenticated and (user.is_staff or user.is_superuser)) diff --git a/accounting/seeding.py b/accounting/seeding.py new file mode 100644 index 0000000..1630633 --- /dev/null +++ b/accounting/seeding.py @@ -0,0 +1,103 @@ +"""Default chart of accounts and company settings (zeroed opening balances). + +Shared by the initial data migration and the runtime "reset" endpoint so both +produce an identical clean starting point with no demo business data. +""" + +# (code, name, type, is_group, parent_code) +DEFAULT_ACCOUNTS = [ + ('10', 'دارایی‌های جاری', 'asset', True, None), + ('1001', 'صندوق', 'asset', False, '10'), + ('1002', 'بانک ملت', 'asset', False, '10'), + ('1003', 'بانک سامان', 'asset', False, '10'), + ('1004', 'حساب‌های دریافتنی (مشتریان)', 'asset', False, '10'), + ('1005', 'موجودی کالا', 'asset', False, '10'), + ('1006', 'پیش‌پرداخت‌ها', 'asset', False, '10'), + + ('15', 'دارایی‌های ثابت', 'asset', True, None), + ('1501', 'اموال و تجهیزات', 'asset', False, '15'), + ('1502', 'استهلاک انباشته', 'asset', False, '15'), + + ('20', 'بدهی‌های جاری', 'liability', True, None), + ('2001', 'حساب‌های پرداختنی (تأمین‌کنندگان)', 'liability', False, '20'), + ('2002', 'مالیات بر ارزش افزوده پرداختنی', 'liability', False, '20'), + ('2003', 'حقوق پرداختنی', 'liability', False, '20'), + ('2004', 'بیمه پرداختنی', 'liability', False, '20'), + ('2005', 'بدهی به مالکان (کیف پول)', 'liability', False, '20'), + ('2006', 'بدهی به مشتریان (کیف پول)', 'liability', False, '20'), + + ('30', 'حقوق صاحبان سهام', 'equity', True, None), + ('3001', 'سرمایه', 'equity', False, '30'), + ('3002', 'سود انباشته', 'equity', False, '30'), + + ('40', 'درآمدها', 'income', True, None), + ('4001', 'درآمد فروش', 'income', False, '40'), + ('4002', 'درآمد خدمات', 'income', False, '40'), + + ('50', 'هزینه‌ها', 'expense', True, None), + ('5001', 'بهای تمام‌شده کالای فروش‌رفته', 'expense', False, '50'), + ('5002', 'هزینه حقوق و دستمزد', 'expense', False, '50'), + ('5003', 'هزینه اجاره', 'expense', False, '50'), + ('5004', 'هزینه آب، برق و گاز', 'expense', False, '50'), + ('5005', 'هزینه تبلیغات', 'expense', False, '50'), + ('5006', 'هزینه استهلاک', 'expense', False, '50'), + ('5007', 'سهم مالکان از فروش', 'expense', False, '50'), +] + +DEFAULT_SETTINGS = { + 'name': 'مجموعه تفریحی فان‌زون', + 'economic_code': '', + 'fiscal_year': '1404', + 'base_currency': 'تومان', + 'tax_rate': 10, + 'address': '', + 'phone': '', +} + +# Mapping of CompanySettings FK attribute -> account code. +SETTINGS_ACCOUNT_MAP = { + 'bank_account_id': '1002', + 'owner_payable_account_id': '2005', + 'customer_payable_account_id': '2006', + 'owner_share_account_id': '5007', + 'sales_income_account_id': '4001', +} + + +def create_default_accounts(Account): + """Creates the standard chart of accounts with zeroed balances. + + Returns a dict mapping account code -> created Account instance. + """ + by_code = {} + # First pass: create all accounts without parents. + for code, name, type_, is_group, _parent in DEFAULT_ACCOUNTS: + by_code[code] = Account.objects.create( + code=code, name=name, type=type_, is_group=is_group, opening_balance=0, + ) + # Second pass: wire up parents. + for code, _name, _type, _is_group, parent_code in DEFAULT_ACCOUNTS: + if parent_code: + account = by_code[code] + account.parent = by_code[parent_code] + account.save(update_fields=['parent']) + return by_code + + +def ensure_company_settings(CompanySettings, accounts_by_code): + """Creates (or resets) the singleton settings with the default mapping.""" + defaults = dict(DEFAULT_SETTINGS) + for attr, code in SETTINGS_ACCOUNT_MAP.items(): + account = accounts_by_code.get(code) + defaults[attr] = account.id if account else None + CompanySettings.objects.update_or_create(id=1, defaults=defaults) + + +def seed_default_data(Account, CompanySettings): + """Seeds chart of accounts + settings if no accounts exist yet.""" + if Account.objects.exists(): + accounts_by_code = {a.code: a for a in Account.objects.all()} + else: + accounts_by_code = create_default_accounts(Account) + ensure_company_settings(CompanySettings, accounts_by_code) + return accounts_by_code diff --git a/accounting/serializers.py b/accounting/serializers.py new file mode 100644 index 0000000..11e59f8 --- /dev/null +++ b/accounting/serializers.py @@ -0,0 +1,220 @@ +from django.db import transaction +from rest_framework import serializers + +from .models import ( + Account, + CompanySettings, + Employee, + Invoice, + InvoiceLine, + Party, + Payslip, + Product, + TreasuryTxn, + Voucher, + VoucherLine, +) + + +class AccountSerializer(serializers.ModelSerializer): + parentId = serializers.PrimaryKeyRelatedField( + source='parent', queryset=Account.objects.all(), allow_null=True, required=False + ) + isGroup = serializers.BooleanField(source='is_group', required=False) + openingBalance = serializers.FloatField(source='opening_balance', required=False) + + class Meta: + model = Account + fields = ['id', 'code', 'name', 'type', 'parentId', 'isGroup', 'openingBalance'] + + +class PartySerializer(serializers.ModelSerializer): + economicCode = serializers.CharField(source='economic_code', required=False, allow_blank=True) + openingBalance = serializers.FloatField(source='opening_balance', required=False) + + class Meta: + model = Party + fields = ['id', 'kind', 'name', 'phone', 'economicCode', 'address', 'openingBalance'] + + +class ProductSerializer(serializers.ModelSerializer): + salePrice = serializers.FloatField(source='sale_price', required=False) + purchasePrice = serializers.FloatField(source='purchase_price', required=False) + reorderLevel = serializers.FloatField(source='reorder_level', required=False) + + class Meta: + model = Product + fields = ['id', 'code', 'name', 'unit', 'salePrice', 'purchasePrice', 'stock', 'reorderLevel'] + + +class VoucherLineSerializer(serializers.ModelSerializer): + # Lenient: accepts client-generated ids (ignored on write, recreated server-side). + id = serializers.CharField(required=False) + accountId = serializers.PrimaryKeyRelatedField(source='account', queryset=Account.objects.all()) + + class Meta: + model = VoucherLine + fields = ['id', 'accountId', 'description', 'debit', 'credit'] + + +class VoucherSerializer(serializers.ModelSerializer): + lines = VoucherLineSerializer(many=True) + + class Meta: + model = Voucher + fields = ['id', 'number', 'date', 'description', 'status', 'source', 'lines'] + + @transaction.atomic + def create(self, validated_data): + lines = validated_data.pop('lines', []) + voucher = Voucher.objects.create(**validated_data) + for line in lines: + line.pop('id', None) + VoucherLine.objects.create(voucher=voucher, **line) + return voucher + + @transaction.atomic + def update(self, instance, validated_data): + lines = validated_data.pop('lines', None) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + if lines is not None: + instance.lines.all().delete() + for line in lines: + line.pop('id', None) + VoucherLine.objects.create(voucher=instance, **line) + return instance + + +class InvoiceLineSerializer(serializers.ModelSerializer): + # Lenient: accepts client-generated ids (ignored on write, recreated server-side). + id = serializers.CharField(required=False) + productId = serializers.PrimaryKeyRelatedField( + source='product', queryset=Product.objects.all(), allow_null=True, required=False + ) + unitPrice = serializers.FloatField(source='unit_price', required=False) + taxRate = serializers.FloatField(source='tax_rate', required=False) + + class Meta: + model = InvoiceLine + fields = ['id', 'productId', 'quantity', 'unitPrice', 'discount', 'taxRate'] + + +class InvoiceSerializer(serializers.ModelSerializer): + partyId = serializers.PrimaryKeyRelatedField( + source='party', queryset=Party.objects.all(), allow_null=True, required=False + ) + lines = InvoiceLineSerializer(many=True) + + class Meta: + model = Invoice + fields = ['id', 'kind', 'number', 'partyId', 'date', 'status', 'note', 'lines'] + + @transaction.atomic + def create(self, validated_data): + lines = validated_data.pop('lines', []) + invoice = Invoice.objects.create(**validated_data) + for line in lines: + line.pop('id', None) + InvoiceLine.objects.create(invoice=invoice, **line) + return invoice + + @transaction.atomic + def update(self, instance, validated_data): + lines = validated_data.pop('lines', None) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + if lines is not None: + instance.lines.all().delete() + for line in lines: + line.pop('id', None) + InvoiceLine.objects.create(invoice=instance, **line) + return instance + + +class TreasuryTxnSerializer(serializers.ModelSerializer): + partyId = serializers.PrimaryKeyRelatedField( + source='party', queryset=Party.objects.all(), allow_null=True, required=False + ) + + class Meta: + model = TreasuryTxn + fields = ['id', 'number', 'kind', 'method', 'date', 'partyId', 'amount', 'reference', 'description'] + + +class EmployeeSerializer(serializers.ModelSerializer): + baseSalary = serializers.FloatField(source='base_salary', required=False) + insuranceNo = serializers.CharField(source='insurance_no', required=False, allow_blank=True) + hireDate = serializers.DateField(source='hire_date', required=False, allow_null=True) + + class Meta: + model = Employee + fields = ['id', 'code', 'name', 'position', 'baseSalary', 'insuranceNo', 'hireDate', 'active'] + + +class PayslipSerializer(serializers.ModelSerializer): + employeeId = serializers.PrimaryKeyRelatedField(source='employee', queryset=Employee.objects.all()) + baseSalary = serializers.FloatField(source='base_salary', required=False) + + class Meta: + model = Payslip + fields = [ + 'id', 'employeeId', 'period', 'baseSalary', 'overtime', 'bonus', + 'deductions', 'insurance', 'tax', + ] + + +class CompanySettingsSerializer(serializers.ModelSerializer): + economicCode = serializers.CharField(source='economic_code', required=False, allow_blank=True) + fiscalYear = serializers.CharField(source='fiscal_year', required=False, allow_blank=True) + baseCurrency = serializers.CharField(source='base_currency', required=False, allow_blank=True) + taxRate = serializers.FloatField(source='tax_rate', required=False) + voucherAccounts = serializers.SerializerMethodField() + + _MAPPING_FIELDS = { + 'bankAccountId': 'bank_account_id', + 'ownerPayableAccountId': 'owner_payable_account_id', + 'customerPayableAccountId': 'customer_payable_account_id', + 'ownerShareAccountId': 'owner_share_account_id', + 'salesIncomeAccountId': 'sales_income_account_id', + } + + class Meta: + model = CompanySettings + fields = [ + 'name', 'economicCode', 'fiscalYear', 'baseCurrency', + 'taxRate', 'address', 'phone', 'voucherAccounts', + ] + + def get_voucherAccounts(self, obj): + def to_id(value): + return str(value) if value else '' + + return { + 'bankAccountId': to_id(obj.bank_account_id), + 'ownerPayableAccountId': to_id(obj.owner_payable_account_id), + 'customerPayableAccountId': to_id(obj.customer_payable_account_id), + 'ownerShareAccountId': to_id(obj.owner_share_account_id), + 'salesIncomeAccountId': to_id(obj.sales_income_account_id), + } + + def _apply_voucher_accounts(self, instance): + mapping = self.initial_data.get('voucherAccounts') + if not isinstance(mapping, dict): + return + valid_ids = {str(v) for v in Account.objects.values_list('id', flat=True)} + for camel_key, model_attr in self._MAPPING_FIELDS.items(): + if camel_key not in mapping: + continue + raw = mapping.get(camel_key) + setattr(instance, model_attr, raw if raw in valid_ids else None) + + @transaction.atomic + def update(self, instance, validated_data): + for attr, value in validated_data.items(): + setattr(instance, attr, value) + self._apply_voucher_accounts(instance) + instance.save() + return instance diff --git a/accounting/urls.py b/accounting/urls.py new file mode 100644 index 0000000..0a74378 --- /dev/null +++ b/accounting/urls.py @@ -0,0 +1,31 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ( + AccountViewSet, + CompanySettingsView, + EmployeeViewSet, + InvoiceViewSet, + PartyViewSet, + PayslipViewSet, + ProductViewSet, + TreasuryTxnViewSet, + VoucherViewSet, + reset_accounting_data, +) + +router = DefaultRouter() +router.register(r'accounts', AccountViewSet, basename='accounting-account') +router.register(r'parties', PartyViewSet, basename='accounting-party') +router.register(r'products', ProductViewSet, basename='accounting-product') +router.register(r'vouchers', VoucherViewSet, basename='accounting-voucher') +router.register(r'invoices', InvoiceViewSet, basename='accounting-invoice') +router.register(r'treasury', TreasuryTxnViewSet, basename='accounting-treasury') +router.register(r'employees', EmployeeViewSet, basename='accounting-employee') +router.register(r'payslips', PayslipViewSet, basename='accounting-payslip') + +urlpatterns = [ + path('', include(router.urls)), + path('settings/', CompanySettingsView.as_view(), name='accounting-settings'), + path('reset/', reset_accounting_data, name='accounting-reset'), +] diff --git a/accounting/views.py b/accounting/views.py new file mode 100644 index 0000000..868f676 --- /dev/null +++ b/accounting/views.py @@ -0,0 +1,129 @@ +from django.db import transaction +from rest_framework import status, viewsets +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import ( + Account, + CompanySettings, + Employee, + Invoice, + Party, + Payslip, + Product, + TreasuryTxn, + Voucher, +) +from .permissions import IsAdminAccount +from .seeding import seed_default_data +from .serializers import ( + AccountSerializer, + CompanySettingsSerializer, + EmployeeSerializer, + InvoiceSerializer, + PartySerializer, + PayslipSerializer, + ProductSerializer, + TreasuryTxnSerializer, + VoucherSerializer, +) + + +class _AdminModelViewSet(viewsets.ModelViewSet): + """Base viewset restricting access to admin accounts.""" + + permission_classes = [IsAdminAccount] + + +class AccountViewSet(_AdminModelViewSet): + queryset = Account.objects.all() + serializer_class = AccountSerializer + pagination_class = None + + +class PartyViewSet(_AdminModelViewSet): + queryset = Party.objects.all() + serializer_class = PartySerializer + pagination_class = None + + +class ProductViewSet(_AdminModelViewSet): + queryset = Product.objects.all() + serializer_class = ProductSerializer + pagination_class = None + + +class VoucherViewSet(_AdminModelViewSet): + queryset = Voucher.objects.prefetch_related('lines').all() + serializer_class = VoucherSerializer + pagination_class = None + + +class InvoiceViewSet(_AdminModelViewSet): + queryset = Invoice.objects.prefetch_related('lines').all() + serializer_class = InvoiceSerializer + pagination_class = None + + +class TreasuryTxnViewSet(_AdminModelViewSet): + queryset = TreasuryTxn.objects.all() + serializer_class = TreasuryTxnSerializer + pagination_class = None + + +class EmployeeViewSet(_AdminModelViewSet): + queryset = Employee.objects.all() + serializer_class = EmployeeSerializer + pagination_class = None + + +class PayslipViewSet(_AdminModelViewSet): + queryset = Payslip.objects.all() + serializer_class = PayslipSerializer + pagination_class = None + + +class CompanySettingsView(APIView): + """Read/update the singleton company settings.""" + + permission_classes = [IsAdminAccount] + + def _get_instance(self): + instance, _created = CompanySettings.objects.get_or_create(id=1) + if instance.bank_account_id is None and Account.objects.exists(): + # Backfill the mapping for an empty/legacy settings row. + seed_default_data(Account, CompanySettings) + instance.refresh_from_db() + return instance + + def get(self, request): + serializer = CompanySettingsSerializer(self._get_instance()) + return Response(serializer.data) + + def patch(self, request): + serializer = CompanySettingsSerializer( + self._get_instance(), data=request.data, partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + +@api_view(['POST']) +@permission_classes([IsAdminAccount]) +@transaction.atomic +def reset_accounting_data(request): + """Wipes all accounting data and recreates a clean, zeroed starting point.""" + Voucher.objects.all().delete() + Invoice.objects.all().delete() + TreasuryTxn.objects.all().delete() + Payslip.objects.all().delete() + Employee.objects.all().delete() + Product.objects.all().delete() + Party.objects.all().delete() + CompanySettings.objects.all().delete() + Account.objects.all().delete() + + seed_default_data(Account, CompanySettings) + return Response({'message': 'اطلاعات حسابداری بازنشانی شد.'}, status=status.HTTP_200_OK) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1 @@ + diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..a00b3b5 --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..714e48c --- /dev/null +++ b/config/settings.py @@ -0,0 +1,121 @@ +""" +Settings for the standalone FunZone Accounting backend. + +This service is fully decoupled from the main FunZone backend. It stores its own +accounting data in its own database and authorizes requests statelessly using +admin JWTs minted by the FunZone backend (validated with the shared SECRET_KEY). +""" + +from datetime import timedelta +from pathlib import Path + +from decouple import config + +BASE_DIR = Path(__file__).resolve().parent.parent + +# Must match the FunZone backend SECRET_KEY so admin JWTs validate here. +SECRET_KEY = config('SECRET_KEY', default='your-super-secret-key-change-in-production') + +DEBUG = config('DEBUG', default=True, cast=bool) + +ALLOWED_HOSTS = config( + 'ALLOWED_HOSTS', + default='localhost,127.0.0.1,0.0.0.0,testserver', +).split(',') + +INSTALLED_APPS = [ + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.staticfiles', + 'rest_framework', + 'rest_framework_simplejwt', + 'corsheaders', + 'accounting', +] + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.middleware.common.CommonMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': {'context_processors': []}, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' +ASGI_APPLICATION = 'config.asgi.application' + +# Database: SQLite by default for zero-setup standalone runs; override via env. +DATABASE_URL = config('DATABASE_URL', default=None) +if DATABASE_URL: + import re + + match = re.match(r'postgresql://([^:]+):([^@]+)@([^:]+):(\d+)/(.+)', DATABASE_URL) + if match: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': match.group(5), + 'USER': match.group(1), + 'PASSWORD': match.group(2), + 'HOST': match.group(3), + 'PORT': match.group(4), + } + } + else: + DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3'}} +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } + } + +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +STATIC_URL = '/static/' +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +REST_FRAMEWORK = { + 'DEFAULT_RENDERER_CLASSES': ['rest_framework.renderers.JSONRenderer'], + 'DEFAULT_PARSER_CLASSES': ['rest_framework.parsers.JSONParser'], + # Stateless: build the user from token claims, never touch a user table. + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTTokenUserAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': ['rest_framework.permissions.IsAuthenticated'], +} + +# Mirror the FunZone JWT settings so tokens validate identically. +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': SECRET_KEY, + 'AUTH_HEADER_TYPES': ('Bearer',), + 'USER_ID_FIELD': 'id', + 'USER_ID_CLAIM': 'user_id', + 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), + 'TOKEN_TYPE_CLAIM': 'token_type', + 'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser', +} + +# CORS: allow the accounting frontend (and others) in development. +CORS_ALLOW_ALL_ORIGINS = config('CORS_ALLOW_ALL_ORIGINS', default=True, cast=bool) +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOWED_ORIGINS = config( + 'CORS_ALLOWED_ORIGINS', + default='http://localhost:5176,http://127.0.0.1:5176', +).split(',') diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..f430b9a --- /dev/null +++ b/config/urls.py @@ -0,0 +1,13 @@ +from django.http import JsonResponse +from django.urls import include, path + + +def health_check(_request): + return JsonResponse({'status': 'ok', 'service': 'funzone-accounting'}) + + +urlpatterns = [ + path('health', health_check, name='health'), + path('api/health', health_check, name='api_health'), + path('api/accounting/', include('accounting.urls')), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..a9f185c --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..58c5f10 --- /dev/null +++ b/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9ebf734 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +# Core +Django==5.0.1 +djangorestframework==3.14.0 +djangorestframework-simplejwt==5.3.0 +django-cors-headers==4.3.1 + +# Environment +python-decouple==3.8 + +# Optional PostgreSQL driver (SQLite is used by default) +psycopg2-binary==2.9.9