Initial commit: standalone Django accounting API with admin JWT authorization

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Shayan Azadi
2026-07-01 00:29:25 +03:30
commit 066276743a
21 changed files with 1279 additions and 0 deletions

13
.env.example Normal file
View 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
View File

@@ -0,0 +1,7 @@
.env
.venv/
__pycache__/
*.py[cod]
*.sqlite3
db.sqlite3
*.log

54
README.md Normal file
View 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
View File

@@ -0,0 +1 @@

6
accounting/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountingConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounting'

View 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',
},
),
]

View 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),
]

View File

@@ -0,0 +1 @@

284
accounting/models.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@

7
config/asgi.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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