Initial commit: standalone Django accounting API with admin JWT authorization
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
13
.env.example
Normal file
13
.env.example
Normal file
@@ -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
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.sqlite3
|
||||
db.sqlite3
|
||||
*.log
|
||||
54
README.md
Normal file
54
README.md
Normal file
@@ -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.
|
||||
1
accounting/__init__.py
Normal file
1
accounting/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
6
accounting/apps.py
Normal file
6
accounting/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountingConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'accounting'
|
||||
211
accounting/migrations/0001_initial.py
Normal file
211
accounting/migrations/0001_initial.py
Normal file
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
27
accounting/migrations/0002_seed_chart_of_accounts.py
Normal file
27
accounting/migrations/0002_seed_chart_of_accounts.py
Normal file
@@ -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),
|
||||
]
|
||||
1
accounting/migrations/__init__.py
Normal file
1
accounting/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
284
accounting/models.py
Normal file
284
accounting/models.py
Normal file
@@ -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'
|
||||
11
accounting/permissions.py
Normal file
11
accounting/permissions.py
Normal file
@@ -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))
|
||||
103
accounting/seeding.py
Normal file
103
accounting/seeding.py
Normal file
@@ -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
|
||||
220
accounting/serializers.py
Normal file
220
accounting/serializers.py
Normal file
@@ -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
|
||||
31
accounting/urls.py
Normal file
31
accounting/urls.py
Normal file
@@ -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'),
|
||||
]
|
||||
129
accounting/views.py
Normal file
129
accounting/views.py
Normal file
@@ -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)
|
||||
1
config/__init__.py
Normal file
1
config/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
7
config/asgi.py
Normal file
7
config/asgi.py
Normal file
@@ -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()
|
||||
121
config/settings.py
Normal file
121
config/settings.py
Normal file
@@ -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(',')
|
||||
13
config/urls.py
Normal file
13
config/urls.py
Normal file
@@ -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')),
|
||||
]
|
||||
7
config/wsgi.py
Normal file
7
config/wsgi.py
Normal file
@@ -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()
|
||||
21
manage.py
Normal file
21
manage.py
Normal file
@@ -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()
|
||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user