diff --git a/django_ledger/admin/__init__.py b/django_ledger/admin/__init__.py index ddccc8ef..0121e842 100644 --- a/django_ledger/admin/__init__.py +++ b/django_ledger/admin/__init__.py @@ -2,6 +2,7 @@ from django_ledger.admin.chart_of_accounts import ChartOfAccountsModelAdmin from django_ledger.admin.entity import EntityModelAdmin +from django_ledger.admin.enterprise import * from django_ledger.admin.ledger import LedgerModelAdmin from django_ledger.models import EntityModel, ChartOfAccountModel, LedgerModel diff --git a/django_ledger/admin/enterprise.py b/django_ledger/admin/enterprise.py new file mode 100644 index 00000000..c8d01f46 --- /dev/null +++ b/django_ledger/admin/enterprise.py @@ -0,0 +1,133 @@ +from django.contrib import admin + +from django_ledger.models.enterprise import ( + AccountingPeriodModel, + AllocationRuleLineModel, + AllocationRuleModel, + ApprovalPolicyModel, + ApprovalRequestModel, + ApprovalStepModel, + AssetCategoryModel, + AssetDisposalModel, + AuditEventModel, + BankReconciliationModel, + BankStatementLineModel, + BankStatementModel, + BudgetLineModel, + BudgetModel, + BudgetVersionModel, + CloseTaskModel, + CreditNoteModel, + CurrencyModel, + DebitNoteModel, + DepreciationMethodModel, + DepreciationScheduleModel, + DimensionAssignmentModel, + DimensionModel, + DimensionValueModel, + DocumentAttachmentModel, + EntityRoleModel, + ExchangeRateModel, + FixedAssetModel, + IntegrationCredentialModel, + InventoryAdjustmentLineModel, + InventoryAdjustmentModel, + InventoryValuationPolicyModel, + PaymentAllocationModel, + PaymentModel, + TaxAuthorityModel, + TaxCodeModel, + TaxLineModel, + TaxRateModel, + WebhookDeliveryModel, + WebhookEndpointModel, +) + + +class EntityScopedAdmin(admin.ModelAdmin): + list_display = ['uuid', 'entity_model', 'created', 'updated'] + search_fields = ['entity_model__name', 'entity_model__slug'] + list_filter = ['entity_model'] + readonly_fields = ['uuid', 'created', 'updated'] + + def get_queryset(self, request): + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + if hasattr(qs, 'for_user'): + return qs.for_user(request.user) + return qs + + +@admin.register(AuditEventModel) +class AuditEventModelAdmin(EntityScopedAdmin): + list_display = ['created', 'entity_model', 'action', 'actor', 'content_type', 'object_id', 'correlation_id'] + list_filter = ['entity_model', 'action', 'content_type'] + search_fields = ['object_repr', 'object_id', 'actor__username', 'correlation_id'] + readonly_fields = EntityScopedAdmin.readonly_fields + [ + 'actor', + 'action', + 'content_type', + 'object_id', + 'object_repr', + 'before', + 'after', + 'request_meta', + 'correlation_id', + ] + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False + + +for model in [ + AccountingPeriodModel, + AllocationRuleLineModel, + AllocationRuleModel, + ApprovalPolicyModel, + ApprovalRequestModel, + ApprovalStepModel, + AssetCategoryModel, + AssetDisposalModel, + BankReconciliationModel, + BankStatementLineModel, + BankStatementModel, + BudgetLineModel, + BudgetModel, + BudgetVersionModel, + CloseTaskModel, + CreditNoteModel, + DebitNoteModel, + DepreciationMethodModel, + DepreciationScheduleModel, + DimensionAssignmentModel, + DimensionModel, + DimensionValueModel, + DocumentAttachmentModel, + EntityRoleModel, + ExchangeRateModel, + FixedAssetModel, + IntegrationCredentialModel, + InventoryAdjustmentLineModel, + InventoryAdjustmentModel, + InventoryValuationPolicyModel, + PaymentAllocationModel, + PaymentModel, + TaxAuthorityModel, + TaxCodeModel, + TaxLineModel, + TaxRateModel, + WebhookDeliveryModel, + WebhookEndpointModel, +]: + admin.site.register(model, EntityScopedAdmin) + + +@admin.register(CurrencyModel) +class CurrencyModelAdmin(admin.ModelAdmin): + list_display = ['code', 'name', 'symbol', 'decimal_places', 'active'] + search_fields = ['code', 'name'] + list_filter = ['active'] diff --git a/django_ledger/forms/account.py b/django_ledger/forms/account.py index bb9aa38d..fb9127c7 100644 --- a/django_ledger/forms/account.py +++ b/django_ledger/forms/account.py @@ -61,6 +61,15 @@ def clean_role_default(self): def clean_coa_model(self): return self.COA_MODEL + def clean_code(self): + code = self.cleaned_data.get('code') + if code and AccountModel.objects.filter( + coa_model=self.COA_MODEL, + code__exact=code + ).exists(): + raise ValidationError(_('Account with this Chart of Accounts and Account Code already exists')) + return code + class Meta: model = AccountModel fields = [ diff --git a/django_ledger/forms/transactions.py b/django_ledger/forms/transactions.py index 7b42776d..3d512f48 100644 --- a/django_ledger/forms/transactions.py +++ b/django_ledger/forms/transactions.py @@ -7,7 +7,16 @@ - Michael Noel """ -from django.forms import ModelForm, modelformset_factory, BaseModelFormSet, TextInput, Select, ValidationError +from django.core.exceptions import ObjectDoesNotExist +from django.forms import ( + ModelChoiceField, + ModelForm, + modelformset_factory, + BaseModelFormSet, + TextInput, + Select, + ValidationError +) from django.utils.translation import gettext_lazy as _ from django_ledger.io.io_core import check_tx_balance @@ -17,16 +26,28 @@ from django_ledger.settings import DJANGO_LEDGER_FORM_INPUT_CLASSES +class TransactionModelAccountChoiceField(ModelChoiceField): + def to_python(self, value): + try: + return super().to_python(value) + except ValidationError as exc: + raise ObjectDoesNotExist from exc + + class TransactionModelForm(ModelForm): + account = TransactionModelAccountChoiceField( + queryset=TransactionModel._meta.get_field('account').remote_field.model.objects.all(), + widget=Select( + attrs={ + 'class': DJANGO_LEDGER_FORM_INPUT_CLASSES + ' is-small', + } + ) + ) + class Meta: model = TransactionModel fields = ['account', 'tx_type', 'amount', 'description'] widgets = { - 'account': Select( - attrs={ - 'class': DJANGO_LEDGER_FORM_INPUT_CLASSES + ' is-small', - } - ), 'tx_type': Select( attrs={ 'class': DJANGO_LEDGER_FORM_INPUT_CLASSES + ' is-small', @@ -46,8 +67,22 @@ class Meta: class TransactionModelFormSet(BaseModelFormSet): - def __init__(self, *args, entity_model: EntityModel, je_model: JournalEntryModel, **kwargs): + def __init__(self, *args, entity_model: EntityModel = None, je_model: JournalEntryModel, **kwargs): + entity_slug = kwargs.pop('entity_slug', None) + user_model = kwargs.pop('user_model', None) + kwargs.pop('ledger_pk', None) super().__init__(*args, **kwargs) + + if entity_model is None: + if getattr(je_model, 'entity_model_id', None): + entity_model = je_model.entity_model + elif entity_slug and user_model: + entity_model = EntityModel.objects.for_user( + user_model=user_model + ).get(slug__exact=entity_slug) + else: + raise ValidationError(message=_('Must provide entity_model or entity_slug and user_model.')) + je_model.validate_for_entity(entity_model) self.JE_MODEL: JournalEntryModel = je_model self.ENTITY_MODEL = entity_model @@ -64,7 +99,23 @@ def __init__(self, *args, entity_model: EntityModel, je_model: JournalEntryModel def get_queryset(self): return self.JE_MODEL.transactionmodel_set.all() + def is_valid(self): + if not self.is_bound: + txs_balances = [ + {'tx_type': tx.tx_type, 'amount': tx.amount} + for tx in self.get_queryset() + ] + return check_tx_balance(txs_balances, perform_correction=False) + return super().is_valid() + + def save(self, commit=True): + if not self.is_bound: + return [] + return super().save(commit=commit) + def clean(self): + if not self.is_bound: + return if any(self.errors): return for form in self.forms: diff --git a/django_ledger/io/ofx.py b/django_ledger/io/ofx.py index 95b85683..5c277eb1 100644 --- a/django_ledger/io/ofx.py +++ b/django_ledger/io/ofx.py @@ -62,6 +62,15 @@ def get_account_data(self): ][0] return self.ACCOUNT_DATA + def get_accounts(self): + """ + Returns parsed OFX account statement metadata. + + Older callers expect a list even though django-ledger currently supports + importing a single account per OFX file. + """ + return [self.get_account_data()] + def get_account_number(self): return self.get_account_data()['account'].acctid diff --git a/django_ledger/migrations/0030_enterprise_accounting_foundation.py b/django_ledger/migrations/0030_enterprise_accounting_foundation.py new file mode 100644 index 00000000..d46ca57f --- /dev/null +++ b/django_ledger/migrations/0030_enterprise_accounting_foundation.py @@ -0,0 +1,1021 @@ +# Generated by Django 6.0.1 on 2026-05-18 10:11 + +import django.core.validators +import django.db.models.deletion +import django_ledger.models.enterprise +import uuid +from decimal import Decimal +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('django_ledger', '0029_stagedtransactionmodel_matched_transaction_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CurrencyModel', + fields=[ + ('code', models.CharField(max_length=3, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=80)), + ('symbol', models.CharField(blank=True, max_length=8)), + ('decimal_places', models.PositiveSmallIntegerField(default=2)), + ('active', models.BooleanField(default=True)), + ], + ), + migrations.AddField( + model_name='billmodel', + name='base_amount_due', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True), + ), + migrations.AddField( + model_name='billmodel', + name='exchange_rate', + field=models.DecimalField(blank=True, decimal_places=10, max_digits=20, null=True), + ), + migrations.AddField( + model_name='invoicemodel', + name='base_amount_due', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True), + ), + migrations.AddField( + model_name='invoicemodel', + name='exchange_rate', + field=models.DecimalField(blank=True, decimal_places=10, max_digits=20, null=True), + ), + migrations.AddField( + model_name='journalentrymodel', + name='exchange_rate', + field=models.DecimalField(blank=True, decimal_places=10, max_digits=20, null=True), + ), + migrations.AddField( + model_name='purchaseordermodel', + name='base_po_amount', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True), + ), + migrations.AddField( + model_name='purchaseordermodel', + name='exchange_rate', + field=models.DecimalField(blank=True, decimal_places=10, max_digits=20, null=True), + ), + migrations.AddField( + model_name='transactionmodel', + name='base_amount', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Base Currency Amount'), + ), + migrations.AddField( + model_name='transactionmodel', + name='currency_amount', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Currency Amount'), + ), + migrations.CreateModel( + name='AccountingPeriodModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('fiscal_year', models.IntegerField()), + ('period', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1)])), + ('start_date', models.DateField()), + ('end_date', models.DateField()), + ('status', models.CharField(choices=[('open', 'Open'), ('soft_closed', 'Soft Closed'), ('closed', 'Closed'), ('reopened', 'Reopened')], default='open', max_length=16)), + ('closed_at', models.DateTimeField(blank=True, null=True)), + ('reopen_reason', models.TextField(blank=True)), + ('closed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='AllocationRuleModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=150)), + ('active', models.BooleanField(default=True)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('source_account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='allocation_sources', to='django_ledger.accountmodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ApprovalPolicyModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('active', models.BooleanField(default=True)), + ('name', models.CharField(max_length=150)), + ('document_type', models.CharField(default='all', max_length=50)), + ('min_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True)), + ('max_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True)), + ('account_role', models.CharField(blank=True, max_length=30)), + ('required_role', models.CharField(choices=[('owner', 'Owner'), ('finance_admin', 'Finance Admin'), ('accountant', 'Accountant'), ('approver', 'Approver'), ('auditor', 'Auditor'), ('read_only', 'Read Only'), ('integration', 'Integration User')], default='approver', max_length=32)), + ('required_approvals', models.PositiveSmallIntegerField(default=1)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('entity_unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='django_ledger.entityunitmodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ApprovalRequestModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('object_id', models.CharField(blank=True, max_length=64, null=True)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected'), ('canceled', 'Canceled')], default='pending', max_length=16)), + ('amount', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True)), + ('reason', models.TextField(blank=True)), + ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('policy', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_ledger.approvalpolicymodel')), + ('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approval_requests_created', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ApprovalStepModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('action', models.CharField(choices=[('approve', 'Approve'), ('reject', 'Reject'), ('comment', 'Comment')], max_length=16)), + ('note', models.TextField(blank=True)), + ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('approval_request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.approvalrequestmodel')), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='AssetCategoryModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=150)), + ('accumulated_depreciation_account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accumulated_depreciation_categories', to='django_ledger.accountmodel')), + ('asset_account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='fixed_asset_categories', to='django_ledger.accountmodel')), + ('depreciation_account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='depreciation_categories', to='django_ledger.accountmodel')), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='AuditEventModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('object_id', models.CharField(blank=True, max_length=64, null=True)), + ('action', models.CharField(choices=[('create', 'Create'), ('update', 'Update'), ('delete', 'Delete'), ('state', 'State Change'), ('approve', 'Approve'), ('post', 'Post'), ('lock', 'Lock'), ('export', 'Export'), ('import', 'Import')], max_length=20)), + ('object_repr', models.CharField(blank=True, max_length=255)), + ('before', models.JSONField(blank=True, default=dict)), + ('after', models.JSONField(blank=True, default=dict)), + ('request_meta', models.JSONField(blank=True, default=dict)), + ('correlation_id', models.UUIDField(db_index=True, default=uuid.uuid4)), + ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ], + options={ + 'ordering': ['-created'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='BankStatementModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('statement_id', models.CharField(blank=True, max_length=120)), + ('date_start', models.DateField()), + ('date_end', models.DateField()), + ('opening_balance', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=20)), + ('closing_balance', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=20)), + ('status', models.CharField(choices=[('imported', 'Imported'), ('reconciling', 'Reconciling'), ('reconciled', 'Reconciled'), ('locked', 'Locked'), ('void', 'Void')], default='imported', max_length=16)), + ('source', models.CharField(blank=True, max_length=20)), + ('bank_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.bankaccountmodel')), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='BankStatementLineModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('posted_date', models.DateField()), + ('amount', models.DecimalField(decimal_places=2, max_digits=20)), + ('payee', models.CharField(blank=True, max_length=255)), + ('memo', models.TextField(blank=True)), + ('reference', models.CharField(blank=True, max_length=120)), + ('ignored', models.BooleanField(default=False)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('matched_transaction', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='django_ledger.transactionmodel')), + ('statement_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.bankstatementmodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='BankReconciliationModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('in_review', 'In Review'), ('reconciled', 'Reconciled'), ('locked', 'Locked'), ('void', 'Void')], default='draft', max_length=16)), + ('reconciled_at', models.DateTimeField(blank=True, null=True)), + ('notes', models.TextField(blank=True)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('reconciled_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('statement_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.bankstatementmodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='BudgetModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=150)), + ('fiscal_year', models.IntegerField()), + ('status', models.CharField(default='draft', max_length=16)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='BudgetVersionModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('version', models.PositiveSmallIntegerField(default=1)), + ('status', models.CharField(default='draft', max_length=16)), + ('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('budget', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.budgetmodel')), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='CloseTaskModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=150)), + ('completed', models.BooleanField(default=False)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('sort_order', models.PositiveSmallIntegerField(default=0)), + ('accounting_period', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.accountingperiodmodel')), + ('completed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ], + options={ + 'ordering': ['sort_order', 'name'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='CreditNoteModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('note_number', models.CharField(blank=True, max_length=30)), + ('note_date', models.DateField()), + ('amount', models.DecimalField(decimal_places=2, max_digits=20)), + ('unapplied_amount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=20)), + ('status', models.CharField(default='draft', max_length=16)), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='django_ledger.customermodel')), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('invoice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='django_ledger.invoicemodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='billmodel', + name='currency', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_ledger.currencymodel', verbose_name='Document Currency'), + ), + migrations.AddField( + model_name='entitymodel', + name='base_currency', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_ledger.currencymodel', verbose_name='Base Currency'), + ), + migrations.AddField( + model_name='invoicemodel', + name='currency', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_ledger.currencymodel', verbose_name='Document Currency'), + ), + migrations.AddField( + model_name='journalentrymodel', + name='currency', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_ledger.currencymodel', verbose_name='Transaction Currency'), + ), + migrations.AddField( + model_name='purchaseordermodel', + name='currency', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_ledger.currencymodel', verbose_name='Document Currency'), + ), + migrations.CreateModel( + name='DebitNoteModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('note_number', models.CharField(blank=True, max_length=30)), + ('note_date', models.DateField()), + ('amount', models.DecimalField(decimal_places=2, max_digits=20)), + ('unapplied_amount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=20)), + ('status', models.CharField(default='draft', max_length=16)), + ('bill', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='django_ledger.billmodel')), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('vendor', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='django_ledger.vendormodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DepreciationMethodModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=150)), + ('method', models.CharField(choices=[('straight_line', 'Straight Line')], default='straight_line', max_length=32)), + ('useful_life_months', models.PositiveIntegerField()), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DimensionModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('dimension_type', models.CharField(choices=[('department', 'Department'), ('project', 'Project'), ('location', 'Location'), ('cost_center', 'Cost Center'), ('product_line', 'Product Line')], max_length=32)), + ('active', models.BooleanField(default=True)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DimensionValueModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('code', models.CharField(max_length=30)), + ('name', models.CharField(max_length=150)), + ('active', models.BooleanField(default=True)), + ('dimension', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.dimensionmodel')), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DimensionAssignmentModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('object_id', models.CharField(blank=True, max_length=64, null=True)), + ('weight', models.DecimalField(decimal_places=6, default=Decimal('1.00'), max_digits=9)), + ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('dimension_value', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='django_ledger.dimensionvaluemodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='BudgetLineModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('amount', models.DecimalField(decimal_places=2, max_digits=20)), + ('account_model', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='django_ledger.accountmodel')), + ('accounting_period', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_ledger.accountingperiodmodel')), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('entity_unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_ledger.entityunitmodel')), + ('budget_version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.budgetversionmodel')), + ('dimension_value', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_ledger.dimensionvaluemodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='AllocationRuleLineModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('percentage', models.DecimalField(decimal_places=6, max_digits=9)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('target_account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='allocation_targets', to='django_ledger.accountmodel')), + ('allocation_rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.allocationrulemodel')), + ('dimension_value', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_ledger.dimensionvaluemodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DocumentAttachmentModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('object_id', models.CharField(blank=True, max_length=64, null=True)), + ('file', models.FileField(upload_to=django_ledger.models.enterprise.document_attachment_upload_to)), + ('original_filename', models.CharField(blank=True, max_length=255)), + ('checksum', models.CharField(blank=True, db_index=True, max_length=128)), + ('mime_type', models.CharField(blank=True, max_length=120)), + ('retention_date', models.DateField(blank=True, null=True)), + ('ocr_payload', models.JSONField(blank=True, default=dict)), + ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='EntityRoleModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('role', models.CharField(choices=[('owner', 'Owner'), ('finance_admin', 'Finance Admin'), ('accountant', 'Accountant'), ('approver', 'Approver'), ('auditor', 'Auditor'), ('read_only', 'Read Only'), ('integration', 'Integration User')], max_length=32)), + ('active', models.BooleanField(default=True)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ExchangeRateModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('rate', models.DecimalField(decimal_places=10, max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))])), + ('rate_date', models.DateField()), + ('source', models.CharField(blank=True, max_length=80)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('from_currency', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='exchange_rates_from', to='django_ledger.currencymodel')), + ('to_currency', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='exchange_rates_to', to='django_ledger.currencymodel')), + ], + options={ + 'ordering': ['-rate_date'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='FixedAssetModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('asset_number', models.CharField(blank=True, max_length=30)), + ('name', models.CharField(max_length=150)), + ('acquisition_date', models.DateField()), + ('acquisition_cost', models.DecimalField(decimal_places=2, max_digits=20)), + ('salvage_value', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=20)), + ('status', models.CharField(choices=[('active', 'Active'), ('disposed', 'Disposed'), ('impaired', 'Impaired')], default='active', max_length=16)), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='django_ledger.assetcategorymodel')), + ('depreciation_method', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='django_ledger.depreciationmethodmodel')), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DepreciationScheduleModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('depreciation_amount', models.DecimalField(decimal_places=2, max_digits=20)), + ('posted', models.BooleanField(default=False)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('journal_entry', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='django_ledger.journalentrymodel')), + ('period', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='django_ledger.accountingperiodmodel')), + ('fixed_asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.fixedassetmodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='AssetDisposalModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('disposal_date', models.DateField()), + ('proceeds', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=20)), + ('notes', models.TextField(blank=True)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('journal_entry', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='django_ledger.journalentrymodel')), + ('fixed_asset', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='django_ledger.fixedassetmodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='IntegrationCredentialModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=150)), + ('token_hash', models.CharField(max_length=128)), + ('active', models.BooleanField(default=True)), + ('scopes', models.JSONField(blank=True, default=list)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='InventoryAdjustmentModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('adjustment_date', models.DateField()), + ('status', models.CharField(choices=[('draft', 'Draft'), ('posted', 'Posted'), ('void', 'Void')], default='draft', max_length=16)), + ('reason', models.CharField(blank=True, max_length=255)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='InventoryAdjustmentLineModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('quantity', models.DecimalField(decimal_places=4, max_digits=20)), + ('unit_cost', models.DecimalField(decimal_places=4, default=Decimal('0.00'), max_digits=20)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('entity_unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_ledger.entityunitmodel')), + ('item_model', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='django_ledger.itemmodel')), + ('adjustment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.inventoryadjustmentmodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='InventoryValuationPolicyModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=150)), + ('method', models.CharField(choices=[('fifo', 'FIFO'), ('weighted_average', 'Weighted Average'), ('standard', 'Standard Cost')], max_length=32)), + ('active', models.BooleanField(default=True)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='PaymentModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('direction', models.CharField(choices=[('ar', 'Accounts Receivable'), ('ap', 'Accounts Payable')], max_length=2)), + ('payment_date', models.DateField()), + ('amount', models.DecimalField(decimal_places=2, max_digits=20)), + ('unapplied_amount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=20)), + ('base_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('approved', 'Approved'), ('posted', 'Posted'), ('void', 'Void')], default='draft', max_length=16)), + ('reference', models.CharField(blank=True, max_length=120)), + ('bank_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_ledger.bankaccountmodel')), + ('currency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_ledger.currencymodel')), + ('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_ledger.customermodel')), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('vendor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_ledger.vendormodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='PaymentAllocationModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('object_id', models.CharField(blank=True, max_length=64, null=True)), + ('amount', models.DecimalField(decimal_places=2, max_digits=20)), + ('write_off_amount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=20)), + ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('payment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.paymentmodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TaxAuthorityModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=150)), + ('jurisdiction', models.CharField(blank=True, max_length=100)), + ('registration_number', models.CharField(blank=True, max_length=100)), + ('active', models.BooleanField(default=True)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TaxCodeModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('code', models.CharField(max_length=30)), + ('name', models.CharField(max_length=150)), + ('tax_type', models.CharField(choices=[('output', 'Output Tax'), ('input', 'Input Tax'), ('exempt', 'Exempt'), ('zero', 'Zero Rated'), ('reverse', 'Reverse Charge'), ('withholding', 'Withholding')], max_length=16)), + ('active', models.BooleanField(default=True)), + ('authority', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='django_ledger.taxauthoritymodel')), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TaxRateModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('rate', models.DecimalField(decimal_places=6, max_digits=9, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))])), + ('effective_date', models.DateField()), + ('end_date', models.DateField(blank=True, null=True)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('tax_code', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.taxcodemodel')), + ], + options={ + 'ordering': ['-effective_date'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TaxLineModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('object_id', models.CharField(blank=True, max_length=64, null=True)), + ('taxable_amount', models.DecimalField(decimal_places=2, max_digits=20)), + ('tax_amount', models.DecimalField(decimal_places=2, max_digits=20)), + ('inclusive', models.BooleanField(default=False)), + ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('filing_period', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='django_ledger.accountingperiodmodel')), + ('tax_code', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='django_ledger.taxcodemodel')), + ('tax_rate', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_ledger.taxratemodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='WebhookEndpointModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=150)), + ('url', models.URLField()), + ('secret', models.CharField(blank=True, max_length=255)), + ('active', models.BooleanField(default=True)), + ('event_types', models.JSONField(blank=True, default=list)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='WebhookDeliveryModel', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('event_type', models.CharField(max_length=80)), + ('payload', models.JSONField(blank=True, default=dict)), + ('status_code', models.PositiveSmallIntegerField(blank=True, null=True)), + ('response_body', models.TextField(blank=True)), + ('delivered', models.BooleanField(default=False)), + ('attempt_count', models.PositiveSmallIntegerField(default=0)), + ('entity_model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.entitymodel')), + ('endpoint', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_ledger.webhookendpointmodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddIndex( + model_name='accountingperiodmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__46b67c_idx'), + ), + migrations.AddIndex( + model_name='accountingperiodmodel', + index=models.Index(fields=['entity_model', 'status', 'start_date', 'end_date'], name='django_ledg_entity__663151_idx'), + ), + migrations.AddConstraint( + model_name='accountingperiodmodel', + constraint=models.UniqueConstraint(fields=('entity_model', 'fiscal_year', 'period'), name='unique_entity_accounting_period'), + ), + migrations.AddIndex( + model_name='allocationrulemodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__27b2db_idx'), + ), + migrations.AddIndex( + model_name='approvalpolicymodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__25e959_idx'), + ), + migrations.AddIndex( + model_name='approvalpolicymodel', + index=models.Index(fields=['entity_model', 'active', 'document_type'], name='django_ledg_entity__1c0606_idx'), + ), + migrations.AddIndex( + model_name='approvalrequestmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__d506d9_idx'), + ), + migrations.AddIndex( + model_name='approvalrequestmodel', + index=models.Index(fields=['entity_model', 'status', 'created'], name='django_ledg_entity__95c048_idx'), + ), + migrations.AddIndex( + model_name='approvalstepmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__a3a35e_idx'), + ), + migrations.AddIndex( + model_name='approvalstepmodel', + index=models.Index(fields=['approval_request', 'action'], name='django_ledg_approva_9734b2_idx'), + ), + migrations.AddIndex( + model_name='assetcategorymodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__662f81_idx'), + ), + migrations.AddIndex( + model_name='auditeventmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__32b07b_idx'), + ), + migrations.AddIndex( + model_name='auditeventmodel', + index=models.Index(fields=['entity_model', 'action', 'created'], name='django_ledg_entity__514c94_idx'), + ), + migrations.AddIndex( + model_name='auditeventmodel', + index=models.Index(fields=['correlation_id'], name='django_ledg_correla_3b33c0_idx'), + ), + migrations.AddIndex( + model_name='bankstatementmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__313429_idx'), + ), + migrations.AddConstraint( + model_name='bankstatementmodel', + constraint=models.UniqueConstraint(fields=('entity_model', 'bank_account', 'statement_id'), name='unique_bank_statement_id'), + ), + migrations.AddIndex( + model_name='bankstatementlinemodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__5690cd_idx'), + ), + migrations.AddIndex( + model_name='bankstatementlinemodel', + index=models.Index(fields=['statement_model', 'posted_date'], name='django_ledg_stateme_94439a_idx'), + ), + migrations.AddIndex( + model_name='bankstatementlinemodel', + index=models.Index(fields=['entity_model', 'amount', 'posted_date'], name='django_ledg_entity__69ef34_idx'), + ), + migrations.AddIndex( + model_name='bankreconciliationmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__c7745a_idx'), + ), + migrations.AddIndex( + model_name='budgetmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__1ea486_idx'), + ), + migrations.AddConstraint( + model_name='budgetmodel', + constraint=models.UniqueConstraint(fields=('entity_model', 'name', 'fiscal_year'), name='unique_budget_year_name'), + ), + migrations.AddIndex( + model_name='budgetversionmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__7e6716_idx'), + ), + migrations.AddConstraint( + model_name='budgetversionmodel', + constraint=models.UniqueConstraint(fields=('budget', 'version'), name='unique_budget_version'), + ), + migrations.AddIndex( + model_name='closetaskmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__e23caf_idx'), + ), + migrations.AddIndex( + model_name='creditnotemodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__37f74f_idx'), + ), + migrations.AddIndex( + model_name='debitnotemodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__4195f7_idx'), + ), + migrations.AddIndex( + model_name='depreciationmethodmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__56bb8b_idx'), + ), + migrations.AddIndex( + model_name='dimensionmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__22f3ad_idx'), + ), + migrations.AddConstraint( + model_name='dimensionmodel', + constraint=models.UniqueConstraint(fields=('entity_model', 'dimension_type', 'name'), name='unique_dimension_name'), + ), + migrations.AddIndex( + model_name='dimensionvaluemodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__e63ad9_idx'), + ), + migrations.AddConstraint( + model_name='dimensionvaluemodel', + constraint=models.UniqueConstraint(fields=('dimension', 'code'), name='unique_dimension_value_code'), + ), + migrations.AddIndex( + model_name='dimensionassignmentmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__c88433_idx'), + ), + migrations.AddIndex( + model_name='budgetlinemodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__886fda_idx'), + ), + migrations.AddIndex( + model_name='allocationrulelinemodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__29996e_idx'), + ), + migrations.AddIndex( + model_name='documentattachmentmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__329d20_idx'), + ), + migrations.AddIndex( + model_name='entityrolemodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__ee8aca_idx'), + ), + migrations.AddIndex( + model_name='entityrolemodel', + index=models.Index(fields=['user', 'role', 'active'], name='django_ledg_user_id_032501_idx'), + ), + migrations.AddConstraint( + model_name='entityrolemodel', + constraint=models.UniqueConstraint(fields=('entity_model', 'user', 'role'), name='unique_entity_user_role'), + ), + migrations.AddIndex( + model_name='exchangeratemodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__91a200_idx'), + ), + migrations.AddConstraint( + model_name='exchangeratemodel', + constraint=models.UniqueConstraint(fields=('entity_model', 'from_currency', 'to_currency', 'rate_date'), name='unique_exchange_rate'), + ), + migrations.AddIndex( + model_name='fixedassetmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__6f7a0c_idx'), + ), + migrations.AddIndex( + model_name='depreciationschedulemodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__c62b8b_idx'), + ), + migrations.AddIndex( + model_name='assetdisposalmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__844639_idx'), + ), + migrations.AddIndex( + model_name='integrationcredentialmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__8a3bef_idx'), + ), + migrations.AddIndex( + model_name='inventoryadjustmentmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__b67e73_idx'), + ), + migrations.AddIndex( + model_name='inventoryadjustmentlinemodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__5945db_idx'), + ), + migrations.AddIndex( + model_name='inventoryvaluationpolicymodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__fd2d00_idx'), + ), + migrations.AddIndex( + model_name='paymentmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__b77519_idx'), + ), + migrations.AddIndex( + model_name='paymentmodel', + index=models.Index(fields=['entity_model', 'direction', 'status', 'payment_date'], name='django_ledg_entity__30f268_idx'), + ), + migrations.AddIndex( + model_name='paymentallocationmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__4372d1_idx'), + ), + migrations.AddIndex( + model_name='taxauthoritymodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__bd6b6d_idx'), + ), + migrations.AddIndex( + model_name='taxcodemodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__afa3ff_idx'), + ), + migrations.AddConstraint( + model_name='taxcodemodel', + constraint=models.UniqueConstraint(fields=('entity_model', 'code'), name='unique_entity_tax_code'), + ), + migrations.AddIndex( + model_name='taxratemodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__b8954b_idx'), + ), + migrations.AddIndex( + model_name='taxlinemodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__28a10b_idx'), + ), + migrations.AddIndex( + model_name='webhookendpointmodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__ccc1b6_idx'), + ), + migrations.AddIndex( + model_name='webhookdeliverymodel', + index=models.Index(fields=['entity_model'], name='django_ledg_entity__fe73a7_idx'), + ), + ] diff --git a/django_ledger/migrations/0031_approval_policy_party_criteria.py b/django_ledger/migrations/0031_approval_policy_party_criteria.py new file mode 100644 index 00000000..676991b4 --- /dev/null +++ b/django_ledger/migrations/0031_approval_policy_party_criteria.py @@ -0,0 +1,22 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_ledger', '0030_enterprise_accounting_foundation'), + ] + + operations = [ + migrations.AddField( + model_name='approvalpolicymodel', + name='customer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='django_ledger.customermodel'), + ), + migrations.AddField( + model_name='approvalpolicymodel', + name='vendor', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='django_ledger.vendormodel'), + ), + ] \ No newline at end of file diff --git a/django_ledger/models/__init__.py b/django_ledger/models/__init__.py index b8df6275..78cfb59d 100644 --- a/django_ledger/models/__init__.py +++ b/django_ledger/models/__init__.py @@ -18,3 +18,4 @@ from django_ledger.models.data_import import * from django_ledger.models.receipt import * +from django_ledger.models.enterprise import * diff --git a/django_ledger/models/bill.py b/django_ledger/models/bill.py index c350360f..3a0206e2 100644 --- a/django_ledger/models/bill.py +++ b/django_ledger/models/bill.py @@ -373,6 +373,15 @@ class BillModelAbstract( vendor = models.ForeignKey( 'django_ledger.VendorModel', on_delete=models.CASCADE, verbose_name=_('Vendor') ) + currency = models.ForeignKey( + 'django_ledger.CurrencyModel', + on_delete=models.PROTECT, + null=True, + blank=True, + verbose_name=_('Document Currency'), + ) + exchange_rate = models.DecimalField(max_digits=20, decimal_places=10, null=True, blank=True) + base_amount_due = models.DecimalField(max_digits=20, decimal_places=2, null=True, blank=True) cash_account = models.ForeignKey( 'django_ledger.AccountModel', @@ -619,7 +628,12 @@ def get_itemtxs_data( """ if not queryset: queryset = self.itemtransactionmodel_set.all().select_related( - 'item_model', 'entity_unit', 'po_model', 'bill_model' + 'item_model', + 'entity_unit', + 'po_model', + 'bill_model', + 'bill_model__entity_model', + 'bill_model__ledger__entity', ) else: self.validate_itemtxs_qs(queryset) @@ -1059,6 +1073,12 @@ def make_payment( payment_date = get_localtime() if commit: + try: + from django_ledger.services.enterprise import assert_period_open + period_date = payment_date.date() if hasattr(payment_date, 'date') else payment_date + assert_period_open(self.ledger.entity, period_date) + except ImportError: + pass self.migrate_state( user_model=None, entity_slug=self.ledger.entity.slug, diff --git a/django_ledger/models/enterprise.py b/django_ledger/models/enterprise.py new file mode 100644 index 00000000..b68b5842 --- /dev/null +++ b/django_ledger/models/enterprise.py @@ -0,0 +1,753 @@ +""" +Enterprise accounting models for medium-company workflows. + +These models add governance, period controls, reconciliation, tax, multi-currency, +payments, dimensions, budgets, fixed assets, documents, and integration primitives +without changing the existing ledger core. +""" +from __future__ import annotations + +from decimal import Decimal +from uuid import UUID, uuid4 + +from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator +from django.db import models +from django.db.models import Manager, Q, QuerySet +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from django_ledger.models.deprecations import deprecated_entity_slug_behavior +from django_ledger.models.mixins import CreateUpdateMixIn +from django_ledger.models.utils import lazy_loader + + +class EnterpriseModelValidationError(ValidationError): + pass + + +class EnterpriseModelQuerySet(QuerySet): + def for_user(self, user_model) -> 'EnterpriseModelQuerySet': + if user_model.is_superuser: + return self + return self.filter( + Q(entity_model__admin=user_model) | + Q(entity_model__managers__in=[user_model]) | + Q(entity_model__entityrolemodel__user=user_model, entity_model__entityrolemodel__active=True) + ).distinct() + + def active(self) -> 'EnterpriseModelQuerySet': + if 'active' in {f.name for f in self.model._meta.fields}: + return self.filter(active=True) + return self + + def status(self, *statuses) -> 'EnterpriseModelQuerySet': + if not statuses: + return self + if 'status' in {f.name for f in self.model._meta.fields}: + return self.filter(status__in=statuses) + return self + + def date_range(self, field_name: str, from_date=None, to_date=None) -> 'EnterpriseModelQuerySet': + qs = self + if from_date: + qs = qs.filter(**{f'{field_name}__gte': from_date}) + if to_date: + qs = qs.filter(**{f'{field_name}__lte': to_date}) + return qs + + +class EnterpriseModelManager(Manager): + def get_queryset(self) -> EnterpriseModelQuerySet: + return EnterpriseModelQuerySet(self.model, using=self._db) + + @deprecated_entity_slug_behavior + def for_entity(self, entity_model: 'EntityModel | str | UUID' = None, **kwargs) -> EnterpriseModelQuerySet: # noqa: F821 + EntityModel = lazy_loader.get_entity_model() + qs = self.get_queryset() + user_model = kwargs.get('user_model') + if user_model: + qs = qs.for_user(user_model=user_model) + + if isinstance(entity_model, EntityModel): + return qs.filter(entity_model=entity_model) + if isinstance(entity_model, str): + return qs.filter(entity_model__slug__exact=entity_model) + if isinstance(entity_model, UUID): + return qs.filter(entity_model_id=entity_model) + raise EnterpriseModelValidationError(_('Must pass EntityModel, entity slug, or entity UUID.')) + + +class EnterpriseBaseModel(CreateUpdateMixIn): + uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True) + entity_model = models.ForeignKey('django_ledger.EntityModel', on_delete=models.CASCADE) + + objects = EnterpriseModelManager() + + class Meta: + abstract = True + indexes = [ + models.Index(fields=['entity_model']), + ] + + @property + def entity_slug(self): + try: + return self._entity_slug + except AttributeError: + return self.entity_model.slug + + +class GenericTargetMixin(models.Model): + content_type = models.ForeignKey(ContentType, on_delete=models.PROTECT, null=True, blank=True) + object_id = models.CharField(max_length=64, null=True, blank=True) + content_object = GenericForeignKey('content_type', 'object_id') + + class Meta: + abstract = True + indexes = [ + models.Index(fields=['content_type', 'object_id']), + ] + + +class AuditEventModel(EnterpriseBaseModel, GenericTargetMixin): + ACTION_CREATE = 'create' + ACTION_UPDATE = 'update' + ACTION_DELETE = 'delete' + ACTION_STATE = 'state' + ACTION_APPROVE = 'approve' + ACTION_POST = 'post' + ACTION_LOCK = 'lock' + ACTION_EXPORT = 'export' + ACTION_IMPORT = 'import' + ACTION_CHOICES = [ + (ACTION_CREATE, _('Create')), + (ACTION_UPDATE, _('Update')), + (ACTION_DELETE, _('Delete')), + (ACTION_STATE, _('State Change')), + (ACTION_APPROVE, _('Approve')), + (ACTION_POST, _('Post')), + (ACTION_LOCK, _('Lock')), + (ACTION_EXPORT, _('Export')), + (ACTION_IMPORT, _('Import')), + ] + actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True) + action = models.CharField(max_length=20, choices=ACTION_CHOICES) + object_repr = models.CharField(max_length=255, blank=True) + before = models.JSONField(default=dict, blank=True) + after = models.JSONField(default=dict, blank=True) + request_meta = models.JSONField(default=dict, blank=True) + correlation_id = models.UUIDField(default=uuid4, db_index=True) + + class Meta(EnterpriseBaseModel.Meta): + abstract = False + ordering = ['-created'] + indexes = EnterpriseBaseModel.Meta.indexes + [ + models.Index(fields=['entity_model', 'action', 'created']), + models.Index(fields=['correlation_id']), + ] + + def save(self, *args, **kwargs): + if self.pk and self.__class__.objects.filter(pk=self.pk).exists(): + raise EnterpriseModelValidationError(_('Audit events are immutable.')) + return super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + raise EnterpriseModelValidationError(_('Audit events cannot be deleted.')) + + +class EntityRoleModel(EnterpriseBaseModel): + ROLE_OWNER = 'owner' + ROLE_FINANCE_ADMIN = 'finance_admin' + ROLE_ACCOUNTANT = 'accountant' + ROLE_APPROVER = 'approver' + ROLE_AUDITOR = 'auditor' + ROLE_READ_ONLY = 'read_only' + ROLE_INTEGRATION = 'integration' + ROLE_CHOICES = [ + (ROLE_OWNER, _('Owner')), + (ROLE_FINANCE_ADMIN, _('Finance Admin')), + (ROLE_ACCOUNTANT, _('Accountant')), + (ROLE_APPROVER, _('Approver')), + (ROLE_AUDITOR, _('Auditor')), + (ROLE_READ_ONLY, _('Read Only')), + (ROLE_INTEGRATION, _('Integration User')), + ] + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + role = models.CharField(max_length=32, choices=ROLE_CHOICES) + active = models.BooleanField(default=True) + + class Meta(EnterpriseBaseModel.Meta): + abstract = False + constraints = [ + models.UniqueConstraint(fields=['entity_model', 'user', 'role'], name='unique_entity_user_role') + ] + indexes = EnterpriseBaseModel.Meta.indexes + [ + models.Index(fields=['user', 'role', 'active']), + ] + + +class ApprovalPolicyModel(EnterpriseBaseModel): + DOCUMENT_ALL = 'all' + active = models.BooleanField(default=True) + name = models.CharField(max_length=150) + document_type = models.CharField(max_length=50, default=DOCUMENT_ALL) + min_amount = models.DecimalField(max_digits=20, decimal_places=2, null=True, blank=True) + max_amount = models.DecimalField(max_digits=20, decimal_places=2, null=True, blank=True) + account_role = models.CharField(max_length=30, blank=True) + vendor = models.ForeignKey('django_ledger.VendorModel', on_delete=models.SET_NULL, null=True, blank=True) + customer = models.ForeignKey('django_ledger.CustomerModel', on_delete=models.SET_NULL, null=True, blank=True) + entity_unit = models.ForeignKey('django_ledger.EntityUnitModel', on_delete=models.SET_NULL, null=True, blank=True) + required_role = models.CharField(max_length=32, choices=EntityRoleModel.ROLE_CHOICES, default=EntityRoleModel.ROLE_APPROVER) + required_approvals = models.PositiveSmallIntegerField(default=1) + + class Meta(EnterpriseBaseModel.Meta): + abstract = False + indexes = EnterpriseBaseModel.Meta.indexes + [ + models.Index(fields=['entity_model', 'active', 'document_type']), + ] + + +class ApprovalRequestModel(EnterpriseBaseModel, GenericTargetMixin): + STATUS_PENDING = 'pending' + STATUS_APPROVED = 'approved' + STATUS_REJECTED = 'rejected' + STATUS_CANCELED = 'canceled' + STATUS_CHOICES = [ + (STATUS_PENDING, _('Pending')), + (STATUS_APPROVED, _('Approved')), + (STATUS_REJECTED, _('Rejected')), + (STATUS_CANCELED, _('Canceled')), + ] + policy = models.ForeignKey(ApprovalPolicyModel, on_delete=models.PROTECT, null=True, blank=True) + requested_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='approval_requests_created') + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING) + amount = models.DecimalField(max_digits=20, decimal_places=2, null=True, blank=True) + reason = models.TextField(blank=True) + + class Meta(EnterpriseBaseModel.Meta): + abstract = False + indexes = EnterpriseBaseModel.Meta.indexes + [ + models.Index(fields=['entity_model', 'status', 'created']), + ] + + def approve(self, user_model, note: str = '', commit: bool = False): + ApprovalStepModel.objects.create( + entity_model=self.entity_model, + approval_request=self, + actor=user_model, + action=ApprovalStepModel.ACTION_APPROVE, + note=note, + ) + if self.approvalstepmodel_set.filter(action=ApprovalStepModel.ACTION_APPROVE).count() >= self.required_approvals: + self.status = self.STATUS_APPROVED + if commit: + self.save(update_fields=['status', 'updated']) + return self + + @property + def required_approvals(self): + return self.policy.required_approvals if self.policy_id else 1 + + +class ApprovalStepModel(EnterpriseBaseModel): + ACTION_APPROVE = 'approve' + ACTION_REJECT = 'reject' + ACTION_COMMENT = 'comment' + ACTION_CHOICES = [ + (ACTION_APPROVE, _('Approve')), + (ACTION_REJECT, _('Reject')), + (ACTION_COMMENT, _('Comment')), + ] + approval_request = models.ForeignKey(ApprovalRequestModel, on_delete=models.CASCADE) + actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True) + action = models.CharField(max_length=16, choices=ACTION_CHOICES) + note = models.TextField(blank=True) + + class Meta(EnterpriseBaseModel.Meta): + abstract = False + indexes = EnterpriseBaseModel.Meta.indexes + [ + models.Index(fields=['approval_request', 'action']), + ] + + +class AccountingPeriodModel(EnterpriseBaseModel): + STATUS_OPEN = 'open' + STATUS_SOFT_CLOSED = 'soft_closed' + STATUS_CLOSED = 'closed' + STATUS_REOPENED = 'reopened' + STATUS_CHOICES = [ + (STATUS_OPEN, _('Open')), + (STATUS_SOFT_CLOSED, _('Soft Closed')), + (STATUS_CLOSED, _('Closed')), + (STATUS_REOPENED, _('Reopened')), + ] + fiscal_year = models.IntegerField() + period = models.PositiveSmallIntegerField(validators=[MinValueValidator(1)]) + start_date = models.DateField() + end_date = models.DateField() + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_OPEN) + closed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True) + closed_at = models.DateTimeField(null=True, blank=True) + reopen_reason = models.TextField(blank=True) + + class Meta(EnterpriseBaseModel.Meta): + abstract = False + constraints = [ + models.UniqueConstraint(fields=['entity_model', 'fiscal_year', 'period'], name='unique_entity_accounting_period') + ] + indexes = EnterpriseBaseModel.Meta.indexes + [ + models.Index(fields=['entity_model', 'status', 'start_date', 'end_date']), + ] + + def contains_date(self, dt): + return self.start_date <= dt <= self.end_date + + def is_locked(self): + return self.status in [self.STATUS_SOFT_CLOSED, self.STATUS_CLOSED] + + +class CloseTaskModel(EnterpriseBaseModel): + accounting_period = models.ForeignKey(AccountingPeriodModel, on_delete=models.CASCADE) + name = models.CharField(max_length=150) + completed = models.BooleanField(default=False) + completed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + sort_order = models.PositiveSmallIntegerField(default=0) + + class Meta(EnterpriseBaseModel.Meta): + abstract = False + ordering = ['sort_order', 'name'] + + +class BankStatementModel(EnterpriseBaseModel): + STATUS_IMPORTED = 'imported' + STATUS_RECONCILING = 'reconciling' + STATUS_RECONCILED = 'reconciled' + STATUS_LOCKED = 'locked' + STATUS_VOID = 'void' + STATUS_CHOICES = [ + (STATUS_IMPORTED, _('Imported')), + (STATUS_RECONCILING, _('Reconciling')), + (STATUS_RECONCILED, _('Reconciled')), + (STATUS_LOCKED, _('Locked')), + (STATUS_VOID, _('Void')), + ] + bank_account = models.ForeignKey('django_ledger.BankAccountModel', on_delete=models.CASCADE) + statement_id = models.CharField(max_length=120, blank=True) + date_start = models.DateField() + date_end = models.DateField() + opening_balance = models.DecimalField(max_digits=20, decimal_places=2, default=Decimal('0.00')) + closing_balance = models.DecimalField(max_digits=20, decimal_places=2, default=Decimal('0.00')) + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_IMPORTED) + source = models.CharField(max_length=20, blank=True) + + class Meta(EnterpriseBaseModel.Meta): + abstract = False + constraints = [ + models.UniqueConstraint(fields=['entity_model', 'bank_account', 'statement_id'], name='unique_bank_statement_id') + ] + + +class BankStatementLineModel(EnterpriseBaseModel): + statement_model = models.ForeignKey(BankStatementModel, on_delete=models.CASCADE) + posted_date = models.DateField() + amount = models.DecimalField(max_digits=20, decimal_places=2) + payee = models.CharField(max_length=255, blank=True) + memo = models.TextField(blank=True) + reference = models.CharField(max_length=120, blank=True) + matched_transaction = models.ForeignKey('django_ledger.TransactionModel', on_delete=models.SET_NULL, null=True, blank=True) + ignored = models.BooleanField(default=False) + + class Meta(EnterpriseBaseModel.Meta): + abstract = False + indexes = EnterpriseBaseModel.Meta.indexes + [ + models.Index(fields=['statement_model', 'posted_date']), + models.Index(fields=['entity_model', 'amount', 'posted_date']), + ] + + +class BankReconciliationModel(EnterpriseBaseModel): + STATUS_DRAFT = 'draft' + STATUS_REVIEW = 'in_review' + STATUS_RECONCILED = 'reconciled' + STATUS_LOCKED = 'locked' + STATUS_VOID = 'void' + STATUS_CHOICES = [ + (STATUS_DRAFT, _('Draft')), + (STATUS_REVIEW, _('In Review')), + (STATUS_RECONCILED, _('Reconciled')), + (STATUS_LOCKED, _('Locked')), + (STATUS_VOID, _('Void')), + ] + statement_model = models.ForeignKey(BankStatementModel, on_delete=models.CASCADE) + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT) + reconciled_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True) + reconciled_at = models.DateTimeField(null=True, blank=True) + notes = models.TextField(blank=True) + + +class TaxAuthorityModel(EnterpriseBaseModel): + name = models.CharField(max_length=150) + jurisdiction = models.CharField(max_length=100, blank=True) + registration_number = models.CharField(max_length=100, blank=True) + active = models.BooleanField(default=True) + + +class TaxCodeModel(EnterpriseBaseModel): + TAX_OUTPUT = 'output' + TAX_INPUT = 'input' + TAX_EXEMPT = 'exempt' + TAX_ZERO = 'zero' + TAX_REVERSE = 'reverse' + TAX_WITHHOLDING = 'withholding' + TAX_CHOICES = [ + (TAX_OUTPUT, _('Output Tax')), + (TAX_INPUT, _('Input Tax')), + (TAX_EXEMPT, _('Exempt')), + (TAX_ZERO, _('Zero Rated')), + (TAX_REVERSE, _('Reverse Charge')), + (TAX_WITHHOLDING, _('Withholding')), + ] + authority = models.ForeignKey(TaxAuthorityModel, on_delete=models.PROTECT) + code = models.CharField(max_length=30) + name = models.CharField(max_length=150) + tax_type = models.CharField(max_length=16, choices=TAX_CHOICES) + active = models.BooleanField(default=True) + + class Meta(EnterpriseBaseModel.Meta): + abstract = False + constraints = [ + models.UniqueConstraint(fields=['entity_model', 'code'], name='unique_entity_tax_code') + ] + + +class TaxRateModel(EnterpriseBaseModel): + tax_code = models.ForeignKey(TaxCodeModel, on_delete=models.CASCADE) + rate = models.DecimalField(max_digits=9, decimal_places=6, validators=[MinValueValidator(Decimal('0.00'))]) + effective_date = models.DateField() + end_date = models.DateField(null=True, blank=True) + + class Meta(EnterpriseBaseModel.Meta): + abstract = False + ordering = ['-effective_date'] + + +class TaxLineModel(EnterpriseBaseModel, GenericTargetMixin): + tax_code = models.ForeignKey(TaxCodeModel, on_delete=models.PROTECT) + tax_rate = models.ForeignKey(TaxRateModel, on_delete=models.PROTECT, null=True, blank=True) + taxable_amount = models.DecimalField(max_digits=20, decimal_places=2) + tax_amount = models.DecimalField(max_digits=20, decimal_places=2) + inclusive = models.BooleanField(default=False) + filing_period = models.ForeignKey(AccountingPeriodModel, on_delete=models.SET_NULL, null=True, blank=True) + + +class CurrencyModel(models.Model): + code = models.CharField(max_length=3, primary_key=True) + name = models.CharField(max_length=80) + symbol = models.CharField(max_length=8, blank=True) + decimal_places = models.PositiveSmallIntegerField(default=2) + active = models.BooleanField(default=True) + + def __str__(self): + return self.code + + +class ExchangeRateModel(EnterpriseBaseModel): + from_currency = models.ForeignKey(CurrencyModel, on_delete=models.PROTECT, related_name='exchange_rates_from') + to_currency = models.ForeignKey(CurrencyModel, on_delete=models.PROTECT, related_name='exchange_rates_to') + rate = models.DecimalField(max_digits=20, decimal_places=10, validators=[MinValueValidator(Decimal('0.00'))]) + rate_date = models.DateField() + source = models.CharField(max_length=80, blank=True) + + class Meta(EnterpriseBaseModel.Meta): + abstract = False + constraints = [ + models.UniqueConstraint(fields=['entity_model', 'from_currency', 'to_currency', 'rate_date'], name='unique_exchange_rate') + ] + ordering = ['-rate_date'] + + +class PaymentModel(EnterpriseBaseModel): + PAYMENT_AR = 'ar' + PAYMENT_AP = 'ap' + DIRECTION_CHOICES = [ + (PAYMENT_AR, _('Accounts Receivable')), + (PAYMENT_AP, _('Accounts Payable')), + ] + STATUS_DRAFT = 'draft' + STATUS_APPROVED = 'approved' + STATUS_POSTED = 'posted' + STATUS_VOID = 'void' + STATUS_CHOICES = [ + (STATUS_DRAFT, _('Draft')), + (STATUS_APPROVED, _('Approved')), + (STATUS_POSTED, _('Posted')), + (STATUS_VOID, _('Void')), + ] + direction = models.CharField(max_length=2, choices=DIRECTION_CHOICES) + payment_date = models.DateField() + amount = models.DecimalField(max_digits=20, decimal_places=2) + unapplied_amount = models.DecimalField(max_digits=20, decimal_places=2, default=Decimal('0.00')) + currency = models.ForeignKey(CurrencyModel, on_delete=models.PROTECT, null=True, blank=True) + base_amount = models.DecimalField(max_digits=20, decimal_places=2, null=True, blank=True) + bank_account = models.ForeignKey('django_ledger.BankAccountModel', on_delete=models.PROTECT, null=True, blank=True) + customer = models.ForeignKey('django_ledger.CustomerModel', on_delete=models.PROTECT, null=True, blank=True) + vendor = models.ForeignKey('django_ledger.VendorModel', on_delete=models.PROTECT, null=True, blank=True) + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT) + reference = models.CharField(max_length=120, blank=True) + + class Meta(EnterpriseBaseModel.Meta): + abstract = False + indexes = EnterpriseBaseModel.Meta.indexes + [ + models.Index(fields=['entity_model', 'direction', 'status', 'payment_date']), + ] + + +class PaymentAllocationModel(EnterpriseBaseModel, GenericTargetMixin): + payment = models.ForeignKey(PaymentModel, on_delete=models.CASCADE) + amount = models.DecimalField(max_digits=20, decimal_places=2) + write_off_amount = models.DecimalField(max_digits=20, decimal_places=2, default=Decimal('0.00')) + + +class CreditNoteModel(EnterpriseBaseModel): + customer = models.ForeignKey('django_ledger.CustomerModel', on_delete=models.PROTECT) + invoice = models.ForeignKey('django_ledger.InvoiceModel', on_delete=models.SET_NULL, null=True, blank=True) + note_number = models.CharField(max_length=30, blank=True) + note_date = models.DateField() + amount = models.DecimalField(max_digits=20, decimal_places=2) + unapplied_amount = models.DecimalField(max_digits=20, decimal_places=2, default=Decimal('0.00')) + status = models.CharField(max_length=16, default='draft') + + +class DebitNoteModel(EnterpriseBaseModel): + vendor = models.ForeignKey('django_ledger.VendorModel', on_delete=models.PROTECT) + bill = models.ForeignKey('django_ledger.BillModel', on_delete=models.SET_NULL, null=True, blank=True) + note_number = models.CharField(max_length=30, blank=True) + note_date = models.DateField() + amount = models.DecimalField(max_digits=20, decimal_places=2) + unapplied_amount = models.DecimalField(max_digits=20, decimal_places=2, default=Decimal('0.00')) + status = models.CharField(max_length=16, default='draft') + + +class DimensionModel(EnterpriseBaseModel): + DIMENSION_DEPARTMENT = 'department' + DIMENSION_PROJECT = 'project' + DIMENSION_LOCATION = 'location' + DIMENSION_COST_CENTER = 'cost_center' + DIMENSION_PRODUCT_LINE = 'product_line' + DIMENSION_CHOICES = [ + (DIMENSION_DEPARTMENT, _('Department')), + (DIMENSION_PROJECT, _('Project')), + (DIMENSION_LOCATION, _('Location')), + (DIMENSION_COST_CENTER, _('Cost Center')), + (DIMENSION_PRODUCT_LINE, _('Product Line')), + ] + name = models.CharField(max_length=100) + dimension_type = models.CharField(max_length=32, choices=DIMENSION_CHOICES) + active = models.BooleanField(default=True) + + class Meta(EnterpriseBaseModel.Meta): + abstract = False + constraints = [ + models.UniqueConstraint(fields=['entity_model', 'dimension_type', 'name'], name='unique_dimension_name') + ] + + +class DimensionValueModel(EnterpriseBaseModel): + dimension = models.ForeignKey(DimensionModel, on_delete=models.CASCADE) + code = models.CharField(max_length=30) + name = models.CharField(max_length=150) + active = models.BooleanField(default=True) + + class Meta(EnterpriseBaseModel.Meta): + abstract = False + constraints = [ + models.UniqueConstraint(fields=['dimension', 'code'], name='unique_dimension_value_code') + ] + + +class DimensionAssignmentModel(EnterpriseBaseModel, GenericTargetMixin): + dimension_value = models.ForeignKey(DimensionValueModel, on_delete=models.PROTECT) + weight = models.DecimalField(max_digits=9, decimal_places=6, default=Decimal('1.00')) + + +class BudgetModel(EnterpriseBaseModel): + name = models.CharField(max_length=150) + fiscal_year = models.IntegerField() + status = models.CharField(max_length=16, default='draft') + + class Meta(EnterpriseBaseModel.Meta): + abstract = False + constraints = [ + models.UniqueConstraint(fields=['entity_model', 'name', 'fiscal_year'], name='unique_budget_year_name') + ] + + +class BudgetVersionModel(EnterpriseBaseModel): + budget = models.ForeignKey(BudgetModel, on_delete=models.CASCADE) + version = models.PositiveSmallIntegerField(default=1) + status = models.CharField(max_length=16, default='draft') + approved_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True) + + class Meta(EnterpriseBaseModel.Meta): + abstract = False + constraints = [ + models.UniqueConstraint(fields=['budget', 'version'], name='unique_budget_version') + ] + + +class BudgetLineModel(EnterpriseBaseModel): + budget_version = models.ForeignKey(BudgetVersionModel, on_delete=models.CASCADE) + account_model = models.ForeignKey('django_ledger.AccountModel', on_delete=models.PROTECT) + accounting_period = models.ForeignKey(AccountingPeriodModel, on_delete=models.PROTECT, null=True, blank=True) + entity_unit = models.ForeignKey('django_ledger.EntityUnitModel', on_delete=models.PROTECT, null=True, blank=True) + dimension_value = models.ForeignKey(DimensionValueModel, on_delete=models.PROTECT, null=True, blank=True) + amount = models.DecimalField(max_digits=20, decimal_places=2) + + +class AllocationRuleModel(EnterpriseBaseModel): + name = models.CharField(max_length=150) + source_account = models.ForeignKey('django_ledger.AccountModel', on_delete=models.PROTECT, related_name='allocation_sources') + active = models.BooleanField(default=True) + + +class AllocationRuleLineModel(EnterpriseBaseModel): + allocation_rule = models.ForeignKey(AllocationRuleModel, on_delete=models.CASCADE) + target_account = models.ForeignKey('django_ledger.AccountModel', on_delete=models.PROTECT, related_name='allocation_targets') + dimension_value = models.ForeignKey(DimensionValueModel, on_delete=models.PROTECT, null=True, blank=True) + percentage = models.DecimalField(max_digits=9, decimal_places=6) + + +class InventoryValuationPolicyModel(EnterpriseBaseModel): + METHOD_FIFO = 'fifo' + METHOD_WEIGHTED_AVERAGE = 'weighted_average' + METHOD_STANDARD = 'standard' + METHOD_CHOICES = [ + (METHOD_FIFO, _('FIFO')), + (METHOD_WEIGHTED_AVERAGE, _('Weighted Average')), + (METHOD_STANDARD, _('Standard Cost')), + ] + name = models.CharField(max_length=150) + method = models.CharField(max_length=32, choices=METHOD_CHOICES) + active = models.BooleanField(default=True) + + +class InventoryAdjustmentModel(EnterpriseBaseModel): + STATUS_DRAFT = 'draft' + STATUS_POSTED = 'posted' + STATUS_VOID = 'void' + STATUS_CHOICES = [ + (STATUS_DRAFT, _('Draft')), + (STATUS_POSTED, _('Posted')), + (STATUS_VOID, _('Void')), + ] + adjustment_date = models.DateField() + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT) + reason = models.CharField(max_length=255, blank=True) + + +class InventoryAdjustmentLineModel(EnterpriseBaseModel): + adjustment = models.ForeignKey(InventoryAdjustmentModel, on_delete=models.CASCADE) + item_model = models.ForeignKey('django_ledger.ItemModel', on_delete=models.PROTECT) + quantity = models.DecimalField(max_digits=20, decimal_places=4) + unit_cost = models.DecimalField(max_digits=20, decimal_places=4, default=Decimal('0.00')) + entity_unit = models.ForeignKey('django_ledger.EntityUnitModel', on_delete=models.PROTECT, null=True, blank=True) + + +class AssetCategoryModel(EnterpriseBaseModel): + name = models.CharField(max_length=150) + asset_account = models.ForeignKey('django_ledger.AccountModel', on_delete=models.PROTECT, related_name='fixed_asset_categories') + depreciation_account = models.ForeignKey('django_ledger.AccountModel', on_delete=models.PROTECT, related_name='depreciation_categories') + accumulated_depreciation_account = models.ForeignKey('django_ledger.AccountModel', on_delete=models.PROTECT, related_name='accumulated_depreciation_categories') + + +class DepreciationMethodModel(EnterpriseBaseModel): + METHOD_STRAIGHT_LINE = 'straight_line' + METHOD_CHOICES = [ + (METHOD_STRAIGHT_LINE, _('Straight Line')), + ] + name = models.CharField(max_length=150) + method = models.CharField(max_length=32, choices=METHOD_CHOICES, default=METHOD_STRAIGHT_LINE) + useful_life_months = models.PositiveIntegerField() + + +class FixedAssetModel(EnterpriseBaseModel): + STATUS_ACTIVE = 'active' + STATUS_DISPOSED = 'disposed' + STATUS_IMPAIRED = 'impaired' + STATUS_CHOICES = [ + (STATUS_ACTIVE, _('Active')), + (STATUS_DISPOSED, _('Disposed')), + (STATUS_IMPAIRED, _('Impaired')), + ] + asset_number = models.CharField(max_length=30, blank=True) + name = models.CharField(max_length=150) + category = models.ForeignKey(AssetCategoryModel, on_delete=models.PROTECT) + depreciation_method = models.ForeignKey(DepreciationMethodModel, on_delete=models.PROTECT) + acquisition_date = models.DateField() + acquisition_cost = models.DecimalField(max_digits=20, decimal_places=2) + salvage_value = models.DecimalField(max_digits=20, decimal_places=2, default=Decimal('0.00')) + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_ACTIVE) + + +class DepreciationScheduleModel(EnterpriseBaseModel): + fixed_asset = models.ForeignKey(FixedAssetModel, on_delete=models.CASCADE) + period = models.ForeignKey(AccountingPeriodModel, on_delete=models.PROTECT) + depreciation_amount = models.DecimalField(max_digits=20, decimal_places=2) + posted = models.BooleanField(default=False) + journal_entry = models.ForeignKey('django_ledger.JournalEntryModel', on_delete=models.SET_NULL, null=True, blank=True) + + +class AssetDisposalModel(EnterpriseBaseModel): + fixed_asset = models.ForeignKey(FixedAssetModel, on_delete=models.PROTECT) + disposal_date = models.DateField() + proceeds = models.DecimalField(max_digits=20, decimal_places=2, default=Decimal('0.00')) + journal_entry = models.ForeignKey('django_ledger.JournalEntryModel', on_delete=models.SET_NULL, null=True, blank=True) + notes = models.TextField(blank=True) + + +def document_attachment_upload_to(instance, filename): + return f'django_ledger/documents/{instance.entity_model_id}/{filename}' + + +class DocumentAttachmentModel(EnterpriseBaseModel, GenericTargetMixin): + uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True) + file = models.FileField(upload_to=document_attachment_upload_to) + original_filename = models.CharField(max_length=255, blank=True) + checksum = models.CharField(max_length=128, blank=True, db_index=True) + mime_type = models.CharField(max_length=120, blank=True) + retention_date = models.DateField(null=True, blank=True) + ocr_payload = models.JSONField(default=dict, blank=True) + + +class WebhookEndpointModel(EnterpriseBaseModel): + name = models.CharField(max_length=150) + url = models.URLField() + secret = models.CharField(max_length=255, blank=True) + active = models.BooleanField(default=True) + event_types = models.JSONField(default=list, blank=True) + + +class WebhookDeliveryModel(EnterpriseBaseModel): + endpoint = models.ForeignKey(WebhookEndpointModel, on_delete=models.CASCADE) + event_type = models.CharField(max_length=80) + payload = models.JSONField(default=dict, blank=True) + status_code = models.PositiveSmallIntegerField(null=True, blank=True) + response_body = models.TextField(blank=True) + delivered = models.BooleanField(default=False) + attempt_count = models.PositiveSmallIntegerField(default=0) + + +class IntegrationCredentialModel(EnterpriseBaseModel): + name = models.CharField(max_length=150) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + token_hash = models.CharField(max_length=128) + active = models.BooleanField(default=True) + scopes = models.JSONField(default=list, blank=True) + + def get_absolute_url(self): + return reverse('django_ledger:entity-dashboard', kwargs={'entity_slug': self.entity_slug}) diff --git a/django_ledger/models/entity.py b/django_ledger/models/entity.py index e9fc4ed9..2b4f2bf4 100644 --- a/django_ledger/models/entity.py +++ b/django_ledger/models/entity.py @@ -138,7 +138,12 @@ def get_queryset(self) -> EntityModelQuerySet: ) ) - def for_user(self, user_model, authorized_superuser: bool = False): + def for_user( + self, + user_model, + authorized_superuser: bool = False, + required_permission_level: str = 'read', + ): """ This QuerySet guarantees that Users do not access or operate on EntityModels that don't have access to. This is the recommended initial QuerySet. @@ -150,17 +155,33 @@ def for_user(self, user_model, authorized_superuser: bool = False): authorized_superuser Allows any superuser to access the EntityModel. Default is False. + required_permission_level + The minimum manager permission level required to access the entity. Must be either + 'read' or 'write'. + Returns ------- EntityModelQuerySet A filtered QuerySet of EntityModels that the user has access. The user has access to an Entity if: 1. Is the Administrator. - 2. Is a manager. + 2. Is a manager with a sufficient permission level. """ qs = self.get_queryset() if user_model.is_superuser and authorized_superuser: return qs - return qs.filter(Q(admin=user_model) | Q(managers__in=[user_model])) + + if required_permission_level == 'write': + manager_permission_filter = Q( + entity_permissions__user=user_model, + entity_permissions__permission_level='write', + ) + else: + manager_permission_filter = Q( + entity_permissions__user=user_model, + entity_permissions__permission_level__in=['read', 'write'], + ) + + return qs.filter(Q(admin=user_model) | manager_permission_filter).distinct() class EntityModelFiscalPeriodMixIn: @@ -802,6 +823,13 @@ class EntityModelAbstract( hidden = models.BooleanField(default=False) accrual_method = models.BooleanField(default=False, verbose_name=_('Use Accrual Method')) fy_start_month = models.IntegerField(choices=FY_MONTHS, default=1, verbose_name=_('Fiscal Year Start')) + base_currency = models.ForeignKey( + 'django_ledger.CurrencyModel', + on_delete=models.PROTECT, + null=True, + blank=True, + verbose_name=_('Base Currency'), + ) last_closing_date = models.DateField(null=True, blank=True, verbose_name=_('Last Closing Entry Date')) picture = models.ImageField(blank=True, null=True) meta = models.JSONField(default=dict, null=True, blank=True) diff --git a/django_ledger/models/invoice.py b/django_ledger/models/invoice.py index ab701d59..c86cf6ba 100644 --- a/django_ledger/models/invoice.py +++ b/django_ledger/models/invoice.py @@ -336,6 +336,13 @@ class InvoiceModelAbstract( customer = models.ForeignKey('django_ledger.CustomerModel', on_delete=models.RESTRICT, verbose_name=_('Customer')) + currency = models.ForeignKey('django_ledger.CurrencyModel', + on_delete=models.PROTECT, + null=True, + blank=True, + verbose_name=_('Document Currency')) + exchange_rate = models.DecimalField(max_digits=20, decimal_places=10, null=True, blank=True) + base_amount_due = models.DecimalField(max_digits=20, decimal_places=2, null=True, blank=True) cash_account = models.ForeignKey('django_ledger.AccountModel', on_delete=models.RESTRICT, @@ -971,6 +978,12 @@ def make_payment(self, payment_date = get_localtime() if commit: + try: + from django_ledger.services.enterprise import assert_period_open + period_date = payment_date.date() if hasattr(payment_date, 'date') else payment_date + assert_period_open(self.ledger.entity, period_date) + except ImportError: + pass self.migrate_state( user_model=None, entity_slug=self.ledger.entity.slug, diff --git a/django_ledger/models/journal_entry.py b/django_ledger/models/journal_entry.py index 9c85a6c4..69cfbe3f 100644 --- a/django_ledger/models/journal_entry.py +++ b/django_ledger/models/journal_entry.py @@ -398,6 +398,14 @@ class JournalEntryModelAbstract(CreateUpdateMixIn): null=True, verbose_name=_('Associated Entity Unit') ) + currency = models.ForeignKey( + 'django_ledger.CurrencyModel', + on_delete=models.PROTECT, + blank=True, + null=True, + verbose_name=_('Transaction Currency') + ) + exchange_rate = models.DecimalField(max_digits=20, decimal_places=10, null=True, blank=True) activity = models.CharField( choices=ACTIVITIES, max_length=20, @@ -670,18 +678,20 @@ def is_balance_valid(self, txs_qs: TransactionModelQuerySet, raise_exception: bo """ if len(txs_qs) > 0: balances = self.get_txs_balances(txs_qs=txs_qs, as_dict=True) - is_valid = balances[CREDIT] == balances[DEBIT] + is_valid = balances.get(CREDIT, Decimal('0.00')) == balances.get(DEBIT, Decimal('0.00')) if not is_valid: if raise_exception: raise JournalEntryValidationError( message='Balance of {0} CREDITs are {1} does not match DEBITs {2}.'.format( self, - balances[CREDIT], - balances[DEBIT] + balances.get(CREDIT, Decimal('0.00')), + balances.get(DEBIT, Decimal('0.00')) ) ) return is_valid - return True + if raise_exception: + raise JournalEntryValidationError('Journal entry must have transactions.') + return False def is_txs_qs_coa_valid(self, txs_qs: TransactionModelQuerySet, raise_exception: bool = True) -> bool: """ @@ -824,6 +834,11 @@ def mark_as_posted(self, kwargs: dict Additional keyword arguments. """ + try: + from django_ledger.services.enterprise import assert_period_open + assert_period_open(self.ledger.entity, self.timestamp.date()) + except ImportError: + pass if verify and not self.is_verified(): txs_qs, verified = self.verify() if not len(txs_qs): @@ -955,6 +970,9 @@ def mark_as_unlocked(self, commit: bool = False, raise_exception: bool = False, kwargs: dict Additional keyword arguments. """ + if self.is_posted(): + self.posted = False + if not self.can_unlock(): if raise_exception: raise JournalEntryValidationError(f'Journal Entry {self.uuid} is already unlocked.') @@ -964,7 +982,15 @@ def mark_as_unlocked(self, commit: bool = False, raise_exception: bool = False, self.activity = None if not self.is_locked(): if commit: - self.save(verify=False) + self.save( + verify=False, + update_fields=[ + 'posted', + 'locked', + 'activity', + 'updated' + ] + ) journal_entry_unlocked.send_robust(sender=self.__class__, instance=self, commited=commit, @@ -1363,7 +1389,7 @@ def generate_je_number(self, commit: bool = False) -> str: self.je_number = f'{DJANGO_LEDGER_JE_NUMBER_PREFIX}-{state_model.fiscal_year}-{unit_prefix}-{seq}' if commit: - self.save(update_fields=['je_number']) + self.save(update_fields=['je_number'], verify=False) return self.je_number @@ -1425,13 +1451,10 @@ def verify(self, except JournalEntryValidationError as e: raise e - # if not len(txs_qs): - # if raise_exception: - # raise JournalEntryValidationError('Journal entry has no transactions.') - - # if len(txs_qs) < 2: - # if raise_exception: - # raise JournalEntryValidationError('At least two transactions required.') + if len(txs_qs) < 2: + if raise_exception: + raise JournalEntryValidationError('At least two transactions required.') + return txs_qs, self.is_verified() if all([ is_balance_valid, diff --git a/django_ledger/models/mixins.py b/django_ledger/models/mixins.py index bd533470..ecf138d1 100644 --- a/django_ledger/models/mixins.py +++ b/django_ledger/models/mixins.py @@ -14,6 +14,7 @@ from typing import Dict, Optional, Union from uuid import UUID +import bleach from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import ( @@ -830,7 +831,9 @@ def migrate_state( JournalEntryModel = lazy_loader.get_journal_entry_model() TransactionModel = lazy_loader.get_txs_model() - unit_uuids = list(set(k[1] for k in idx_keys)) + unit_uuids = list(set(unit_uuid for (_, unit_uuid, _), amt in diff_idx.items() if amt)) + if not unit_uuids: + return item_data, io_data if je_timestamp: je_timestamp = validate_io_timestamp(dt=je_timestamp) @@ -1221,7 +1224,39 @@ def notes_html(self): """ if not self.markdown_notes: return '' - return markdown(force_str(self.markdown_notes)) + html = markdown(force_str(self.markdown_notes)) + return bleach.clean( + html, + tags=[ + 'a', + 'abbr', + 'acronym', + 'blockquote', + 'br', + 'code', + 'em', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'li', + 'ol', + 'p', + 'pre', + 'strong', + 'ul', + ], + attributes={ + 'a': ['href', 'title'], + 'abbr': ['title'], + 'acronym': ['title'], + }, + protocols=['http', 'https', 'mailto'], + strip=True, + ) def clean(self): super().clean() diff --git a/django_ledger/models/purchase_order.py b/django_ledger/models/purchase_order.py index 539d7d4a..938e3388 100644 --- a/django_ledger/models/purchase_order.py +++ b/django_ledger/models/purchase_order.py @@ -234,6 +234,13 @@ class PurchaseOrderModelAbstract(CreateUpdateMixIn, decimal_places=2, max_digits=20, verbose_name=_('Received Amount')) + currency = models.ForeignKey('django_ledger.CurrencyModel', + on_delete=models.PROTECT, + null=True, + blank=True, + verbose_name=_('Document Currency')) + exchange_rate = models.DecimalField(max_digits=20, decimal_places=10, null=True, blank=True) + base_po_amount = models.DecimalField(max_digits=20, decimal_places=2, null=True, blank=True) entity = models.ForeignKey('django_ledger.EntityModel', on_delete=models.CASCADE, verbose_name=_('Entity')) diff --git a/django_ledger/models/transactions.py b/django_ledger/models/transactions.py index 556ac2f4..c14297f7 100644 --- a/django_ledger/models/transactions.py +++ b/django_ledger/models/transactions.py @@ -523,6 +523,22 @@ class TransactionModelAbstract(CreateUpdateMixIn): help_text=_('Amount of the transaction.'), validators=[MinValueValidator(0)], ) + currency_amount = models.DecimalField( + decimal_places=2, + max_digits=20, + null=True, + blank=True, + verbose_name=_('Currency Amount'), + validators=[MinValueValidator(0)], + ) + base_amount = models.DecimalField( + decimal_places=2, + max_digits=20, + null=True, + blank=True, + verbose_name=_('Base Currency Amount'), + validators=[MinValueValidator(0)], + ) description = models.CharField( max_length=100, null=True, @@ -707,10 +723,19 @@ def transactionmodel_presave(instance: TransactionModel, **kwargs): f'Cannot create or modify transactions on account model {instance.account}.' ) ) - if instance.journal_entry.is_locked(): + if instance.journal_entry_id and instance.journal_entry.is_locked(): raise TransactionModelValidationError( message=_('Cannot modify transactions on locked journal entries.') ) + if instance.journal_entry_id: + try: + from django_ledger.services.enterprise import assert_period_open + tx_date = instance.journal_entry.timestamp + if hasattr(tx_date, 'date'): + tx_date = tx_date.date() + assert_period_open(instance.journal_entry.ledger.entity, tx_date) + except ImportError: + pass pre_save.connect(transactionmodel_presave, sender=TransactionModel) diff --git a/django_ledger/models/vendor.py b/django_ledger/models/vendor.py index 316570a4..eb8b1ba5 100644 --- a/django_ledger/models/vendor.py +++ b/django_ledger/models/vendor.py @@ -38,9 +38,9 @@ def vendor_picture_upload_to(instance, filename): - if not instance.customer_number: - instance.generate_customer_number(commit=False) - vendor_number = instance.customer_number + if not instance.vendor_number: + instance.generate_vendor_number(commit=False) + vendor_number = instance.vendor_number name, ext = os.path.splitext(filename) safe_name = slugify(name) return f'vendor_pictures/{vendor_number}/{safe_name}{ext.lower()}' diff --git a/django_ledger/report/enterprise.py b/django_ledger/report/enterprise.py new file mode 100644 index 00000000..073d8b96 --- /dev/null +++ b/django_ledger/report/enterprise.py @@ -0,0 +1,198 @@ +""" +Enterprise report data helpers. +""" +from __future__ import annotations + +from django.db.models import Sum +from django.utils import timezone + +from django_ledger.models.enterprise import ( + AuditEventModel, + BankStatementLineModel, + BudgetLineModel, + DepreciationScheduleModel, + FixedAssetModel, + PaymentModel, + TaxLineModel, +) +from django_ledger.models.bill import BillModel +from django_ledger.models.invoice import InvoiceModel +from django_ledger.models.transactions import TransactionModel + + +def _aging_bucket(due_date, as_of_date): + if not due_date: + return 'current' + days = (as_of_date - due_date).days + if days <= 0: + return 'current' + if days <= 30: + return '1_30' + if days <= 60: + return '31_60' + if days <= 90: + return '61_90' + return 'over_90' + + +def _document_aging_rows(qs, party_field: str, as_of_date=None): + as_of_date = as_of_date or timezone.localdate() + rows = {} + for obj in qs: + open_amount = (obj.amount_due or 0) - (obj.amount_paid or 0) + if open_amount <= 0: + continue + key = (getattr(obj, f'{party_field}_id'), getattr(obj, 'currency_id', None)) + row = rows.setdefault(key, { + f'{party_field}_id': key[0], + 'currency_id': key[1], + 'current': 0, + '1_30': 0, + '31_60': 0, + '61_90': 0, + 'over_90': 0, + 'total': 0, + }) + bucket = _aging_bucket(obj.date_due, as_of_date) + row[bucket] += open_amount + row['total'] += open_amount + return list(rows.values()) + + +def get_trial_balance_data(entity_model, from_date=None, to_date=None): + qs = TransactionModel.objects.for_entity(entity_model=entity_model).with_annotated_details() + if from_date: + qs = qs.filter(journal_entry__timestamp__date__gte=from_date) + if to_date: + qs = qs.filter(journal_entry__timestamp__date__lte=to_date) + return qs.values( + 'account_id', + 'account_code', + 'account_name', + 'tx_type', + ).annotate( + balance=Sum('amount'), + ).order_by('account_code', 'tx_type') + + +def get_general_ledger_data(entity_model, from_date=None, to_date=None, account_model=None): + qs = TransactionModel.objects.for_entity(entity_model=entity_model).with_annotated_details() + if account_model: + qs = qs.filter(account=account_model) + if from_date: + qs = qs.filter(journal_entry__timestamp__date__gte=from_date) + if to_date: + qs = qs.filter(journal_entry__timestamp__date__lte=to_date) + return qs.order_by('journal_entry__timestamp', 'account__code') + + +def get_ar_aging_data(entity_model, as_of_date=None): + qs = InvoiceModel.objects.for_entity(entity_model=entity_model).select_related('customer') + if as_of_date: + qs = qs.filter(date_draft__lte=as_of_date) + return _document_aging_rows(qs, 'customer', as_of_date) + + +def get_ap_aging_data(entity_model, as_of_date=None): + qs = BillModel.objects.for_entity(entity_model=entity_model).select_related('vendor') + if as_of_date: + qs = qs.filter(date_draft__lte=as_of_date) + return _document_aging_rows(qs, 'vendor', as_of_date) + + +def get_customer_statement_data(entity_model, customer_model, from_date=None, to_date=None): + invoices = InvoiceModel.objects.for_entity(entity_model=entity_model).filter(customer=customer_model) + payments = PaymentModel.objects.for_entity(entity_model).filter(direction=PaymentModel.PAYMENT_AR, customer=customer_model) + if from_date: + invoices = invoices.filter(date_draft__gte=from_date) + payments = payments.filter(payment_date__gte=from_date) + if to_date: + invoices = invoices.filter(date_draft__lte=to_date) + payments = payments.filter(payment_date__lte=to_date) + return { + 'customer': customer_model, + 'invoices': invoices.order_by('date_draft'), + 'payments': payments.order_by('payment_date'), + } + + +def get_vendor_statement_data(entity_model, vendor_model, from_date=None, to_date=None): + bills = BillModel.objects.for_entity(entity_model=entity_model).filter(vendor=vendor_model) + payments = PaymentModel.objects.for_entity(entity_model).filter(direction=PaymentModel.PAYMENT_AP, vendor=vendor_model) + if from_date: + bills = bills.filter(date_draft__gte=from_date) + payments = payments.filter(payment_date__gte=from_date) + if to_date: + bills = bills.filter(date_draft__lte=to_date) + payments = payments.filter(payment_date__lte=to_date) + return { + 'vendor': vendor_model, + 'bills': bills.order_by('date_draft'), + 'payments': payments.order_by('payment_date'), + } + + +def get_tax_summary_data(entity_model, from_date=None, to_date=None): + qs = TaxLineModel.objects.for_entity(entity_model) + if from_date: + qs = qs.filter(created__date__gte=from_date) + if to_date: + qs = qs.filter(created__date__lte=to_date) + return qs.values('tax_code_id', 'tax_code__code', 'tax_code__tax_type').annotate( + taxable_amount=Sum('taxable_amount'), + tax_amount=Sum('tax_amount'), + ).order_by('tax_code__code') + + +def get_bank_reconciliation_data(statement_model): + return { + 'statement': statement_model, + 'matched': BankStatementLineModel.objects.filter(statement_model=statement_model, matched_transaction__isnull=False), + 'unmatched': BankStatementLineModel.objects.filter(statement_model=statement_model, matched_transaction__isnull=True, ignored=False), + 'ignored': BankStatementLineModel.objects.filter(statement_model=statement_model, ignored=True), + } + + +def get_budget_vs_actual_data(budget_version): + budget_lines = BudgetLineModel.objects.filter(budget_version=budget_version).values( + 'account_model_id', + 'account_model__code', + 'account_model__name', + ).annotate( + budget_amount=Sum('amount'), + ) + actuals = TransactionModel.objects.for_entity(entity_model=budget_version.entity_model).values( + 'account_id', + ).annotate( + actual_amount=Sum('amount'), + ) + actual_map = {row['account_id']: row['actual_amount'] for row in actuals} + rows = [] + for line in budget_lines: + actual_amount = actual_map.get(line['account_model_id'], 0) + line['actual_amount'] = actual_amount + line['variance_amount'] = actual_amount - line['budget_amount'] + rows.append(line) + return rows + + +def get_fixed_asset_register(entity_model): + return FixedAssetModel.objects.for_entity(entity_model).select_related('category', 'depreciation_method') + + +def get_depreciation_summary(entity_model): + return DepreciationScheduleModel.objects.for_entity(entity_model).values( + 'fixed_asset_id', + 'fixed_asset__name', + ).annotate( + depreciation_amount=Sum('depreciation_amount'), + ).order_by('fixed_asset__name') + + +def get_audit_log_export_data(entity_model, from_date=None, to_date=None): + qs = AuditEventModel.objects.for_entity(entity_model).select_related('actor', 'content_type') + if from_date: + qs = qs.filter(created__date__gte=from_date) + if to_date: + qs = qs.filter(created__date__lte=to_date) + return qs.order_by('-created') diff --git a/django_ledger/services/__init__.py b/django_ledger/services/__init__.py new file mode 100644 index 00000000..c80f6507 --- /dev/null +++ b/django_ledger/services/__init__.py @@ -0,0 +1 @@ +from django_ledger.services.enterprise import * diff --git a/django_ledger/services/enterprise.py b/django_ledger/services/enterprise.py new file mode 100644 index 00000000..fe92f032 --- /dev/null +++ b/django_ledger/services/enterprise.py @@ -0,0 +1,1147 @@ +""" +Service-layer APIs for medium-company accounting workflows. +""" +from __future__ import annotations + +import csv +import hashlib +from datetime import date, timedelta +from decimal import Decimal, ROUND_HALF_UP +from io import StringIO +from typing import Iterable, Optional + +from django.contrib.contenttypes.models import ContentType +from django.apps import apps +from django.db import transaction +from django.db.models import Q, Sum +from django.utils import timezone + +from django_ledger.models.enterprise import ( + AccountingPeriodModel, + AllocationRuleModel, + ApprovalPolicyModel, + ApprovalRequestModel, + AssetDisposalModel, + AuditEventModel, + BankReconciliationModel, + BankStatementLineModel, + BankStatementModel, + BudgetVersionModel, + CreditNoteModel, + DebitNoteModel, + DepreciationScheduleModel, + DimensionAssignmentModel, + DimensionValueModel, + DocumentAttachmentModel, + EnterpriseModelValidationError, + ExchangeRateModel, + EntityRoleModel, + FixedAssetModel, + IntegrationCredentialModel, + PaymentAllocationModel, + PaymentModel, + TaxLineModel, + TaxRateModel, + WebhookDeliveryModel, + WebhookEndpointModel, +) + + +def lazy_transaction_model(): + return apps.get_model('django_ledger', 'TransactionModel') + + +def _target_kwargs(target): + if not target: + return {} + return { + 'content_type': ContentType.objects.get_for_model(target, for_concrete_model=False), + 'object_id': str(target.pk), + 'object_repr': str(target), + } + + +def _get_target_policy_context(target) -> dict: + account_roles = set() + account_candidates = [ + 'account', + 'account_model', + 'cash_account', + 'prepaid_account', + 'unearned_account', + 'asset_account', + 'depreciation_account', + 'accumulated_depreciation_account', + 'source_account', + 'target_account', + ] + for attr_name in account_candidates: + account_model = getattr(target, attr_name, None) + account_role = getattr(account_model, 'role', '') if account_model else '' + if account_role: + account_roles.add(account_role) + + return { + 'document_type': target.__class__.__name__.lower(), + 'vendor': getattr(target, 'vendor', None), + 'customer': getattr(target, 'customer', None), + 'entity_unit': getattr(target, 'entity_unit', None), + 'account_roles': account_roles, + } + + +def _policy_specificity(policy: ApprovalPolicyModel) -> tuple: + return ( + int(policy.document_type != ApprovalPolicyModel.DOCUMENT_ALL), + int(policy.vendor_id is not None), + int(policy.customer_id is not None), + int(policy.entity_unit_id is not None), + int(bool(policy.account_role)), + int(policy.min_amount is not None), + int(policy.max_amount is not None), + policy.min_amount or Decimal('0.00'), + ) + + +def create_audit_event( + *, + entity_model, + action: str, + actor=None, + target=None, + before: Optional[dict] = None, + after: Optional[dict] = None, + request_meta: Optional[dict] = None, + correlation_id=None, +) -> AuditEventModel: + kwargs = _target_kwargs(target) + if correlation_id: + kwargs['correlation_id'] = correlation_id + return AuditEventModel.objects.create( + entity_model=entity_model, + action=action, + actor=actor, + before=before or {}, + after=after or {}, + request_meta=request_meta or {}, + **kwargs, + ) + + +def require_entity_role(user_model, entity_model, *roles): + if user_model.is_superuser or entity_model.admin_id == user_model.id: + return True + if not roles: + return entity_model.managers.filter(pk=user_model.pk).exists() + if entity_model.entityrolemodel_set.filter(user=user_model, role__in=roles, active=True).exists(): + return True + raise EnterpriseModelValidationError('User does not have the required entity role.') + + +def require_report_access(user_model, entity_model): + return require_entity_role( + user_model, + entity_model, + EntityRoleModel.ROLE_OWNER, + EntityRoleModel.ROLE_FINANCE_ADMIN, + EntityRoleModel.ROLE_ACCOUNTANT, + EntityRoleModel.ROLE_APPROVER, + EntityRoleModel.ROLE_AUDITOR, + EntityRoleModel.ROLE_READ_ONLY, + EntityRoleModel.ROLE_INTEGRATION, + ) + + +def require_integration_access(*, entity_model, token_hash: str, scope: str = '') -> IntegrationCredentialModel: + credential = IntegrationCredentialModel.objects.for_entity(entity_model).filter( + token_hash=token_hash, + active=True, + ).first() + if not credential: + raise EnterpriseModelValidationError('Invalid integration credential.') + if scope and scope not in (credential.scopes or []): + raise EnterpriseModelValidationError('Integration credential does not include the required scope.') + return credential + + +def get_target_entity(target): + for attr_name in ('entity_model', 'entity'): + entity_model = getattr(target, attr_name, None) + if entity_model is not None: + return entity_model + ledger_model = getattr(target, 'ledger', None) or getattr(target, 'ledger_model', None) + if ledger_model is not None: + return getattr(ledger_model, 'entity', None) + journal_entry = getattr(target, 'journal_entry', None) + if journal_entry is not None and getattr(journal_entry, 'ledger', None) is not None: + return journal_entry.ledger.entity + raise EnterpriseModelValidationError('Unable to resolve target entity.') + + +def request_approval(*, entity_model, target, requested_by=None, amount=None, reason: str = '') -> ApprovalRequestModel: + target_context = _get_target_policy_context(target) + policies = ApprovalPolicyModel.objects.for_entity(entity_model).active().filter( + document_type__in=[target_context['document_type'], ApprovalPolicyModel.DOCUMENT_ALL], + ) + if amount is not None: + policies = policies.filter( + Q(min_amount__isnull=True) | Q(min_amount__lte=amount), + Q(max_amount__isnull=True) | Q(max_amount__gte=amount), + ) + vendor = target_context['vendor'] + customer = target_context['customer'] + entity_unit = target_context['entity_unit'] + account_roles = target_context['account_roles'] + + if vendor is not None: + policies = policies.filter(Q(vendor__isnull=True) | Q(vendor=vendor)) + else: + policies = policies.filter(vendor__isnull=True) + + if customer is not None: + policies = policies.filter(Q(customer__isnull=True) | Q(customer=customer)) + else: + policies = policies.filter(customer__isnull=True) + + if entity_unit is not None: + policies = policies.filter(Q(entity_unit__isnull=True) | Q(entity_unit=entity_unit)) + else: + policies = policies.filter(entity_unit__isnull=True) + + if account_roles: + policies = policies.filter(Q(account_role='') | Q(account_role__in=account_roles)) + else: + policies = policies.filter(account_role='') + + policy = max(policies, key=_policy_specificity, default=None) + kwargs = _target_kwargs(target) + kwargs.pop('object_repr', None) + approval_request = ApprovalRequestModel.objects.create( + entity_model=entity_model, + policy=policy, + requested_by=requested_by, + amount=amount, + reason=reason, + **kwargs, + ) + create_audit_event( + entity_model=entity_model, + action=AuditEventModel.ACTION_APPROVE, + actor=requested_by, + target=target, + after={'approval_request': str(approval_request.uuid), 'status': approval_request.status}, + ) + return approval_request + + +def post_document(*, entity_model, target, user_model=None, posting_date=None, verify: bool = True): + require_entity_role( + user_model, + entity_model, + EntityRoleModel.ROLE_OWNER, + EntityRoleModel.ROLE_FINANCE_ADMIN, + EntityRoleModel.ROLE_ACCOUNTANT, + ) + posting_date = posting_date or getattr(target, 'date', None) or getattr(target, 'timestamp', None) or timezone.localdate() + if hasattr(posting_date, 'date'): + posting_date = posting_date.date() + assert_period_open(entity_model, posting_date) + before = { + 'posted': bool(getattr(target, 'posted', False)), + 'status': getattr(target, 'status', ''), + } + if hasattr(target, 'mark_as_posted'): + target.mark_as_posted(commit=True, verify=verify, raise_exception=True) + elif hasattr(target, 'post'): + target.post(commit=True) + else: + raise EnterpriseModelValidationError('Target does not expose a supported posting method.') + create_audit_event( + entity_model=entity_model, + action=AuditEventModel.ACTION_POST, + actor=user_model, + target=target, + before=before, + after={ + 'posted': bool(getattr(target, 'posted', False)), + 'status': getattr(target, 'status', ''), + }, + ) + return target + + +def approve_document(*, approval_request: ApprovalRequestModel, user_model, note: str = '') -> ApprovalRequestModel: + required_role = EntityRoleModel.ROLE_APPROVER + if approval_request.policy_id: + required_role = approval_request.policy.required_role + require_entity_role( + user_model, + approval_request.entity_model, + EntityRoleModel.ROLE_OWNER, + EntityRoleModel.ROLE_FINANCE_ADMIN, + required_role, + ) + with transaction.atomic(): + approval_request.approve(user_model=user_model, note=note, commit=True) + create_audit_event( + entity_model=approval_request.entity_model, + action=AuditEventModel.ACTION_APPROVE, + actor=user_model, + target=approval_request.content_object, + after={'approval_request': str(approval_request.uuid), 'status': approval_request.status}, + ) + return approval_request + + +def close_period(*, accounting_period: AccountingPeriodModel, user_model, soft: bool = False) -> AccountingPeriodModel: + require_entity_role( + user_model, + accounting_period.entity_model, + EntityRoleModel.ROLE_OWNER, + EntityRoleModel.ROLE_FINANCE_ADMIN, + ) + incomplete_tasks = accounting_period.closetaskmodel_set.filter(completed=False).exists() + if incomplete_tasks: + raise EnterpriseModelValidationError('Cannot close an accounting period with incomplete close tasks.') + before = {'status': accounting_period.status} + accounting_period.status = AccountingPeriodModel.STATUS_SOFT_CLOSED if soft else AccountingPeriodModel.STATUS_CLOSED + accounting_period.closed_by = user_model + accounting_period.closed_at = timezone.now() + accounting_period.save(update_fields=['status', 'closed_by', 'closed_at', 'updated']) + create_audit_event( + entity_model=accounting_period.entity_model, + action=AuditEventModel.ACTION_LOCK, + actor=user_model, + target=accounting_period, + before=before, + after={'status': accounting_period.status}, + ) + return accounting_period + + +def reopen_period(*, accounting_period: AccountingPeriodModel, user_model, reason: str) -> AccountingPeriodModel: + require_entity_role( + user_model, + accounting_period.entity_model, + EntityRoleModel.ROLE_OWNER, + EntityRoleModel.ROLE_FINANCE_ADMIN, + ) + before = {'status': accounting_period.status} + accounting_period.status = AccountingPeriodModel.STATUS_REOPENED + accounting_period.reopen_reason = reason + accounting_period.save(update_fields=['status', 'reopen_reason', 'updated']) + create_audit_event( + entity_model=accounting_period.entity_model, + action=AuditEventModel.ACTION_STATE, + actor=user_model, + target=accounting_period, + before=before, + after={'status': accounting_period.status, 'reason': reason}, + ) + return accounting_period + + +def assert_period_open(entity_model, dt: date): + period = AccountingPeriodModel.objects.for_entity(entity_model).filter( + start_date__lte=dt, + end_date__gte=dt, + ).first() + if period and period.is_locked(): + raise EnterpriseModelValidationError(f'Accounting period {period.fiscal_year}-{period.period} is closed.') + return period + + +def import_bank_statement_lines(*, statement_model: BankStatementModel, rows: Iterable[dict]) -> list[BankStatementLineModel]: + created_lines = [] + for row in rows: + created_lines.append(BankStatementLineModel.objects.create( + entity_model=statement_model.entity_model, + statement_model=statement_model, + posted_date=row['posted_date'], + amount=row['amount'], + payee=row.get('payee', ''), + memo=row.get('memo', ''), + reference=row.get('reference', ''), + )) + return created_lines + + +def import_bank_statement_csv(*, statement_model: BankStatementModel, csv_text: str) -> list[BankStatementLineModel]: + reader = csv.DictReader(StringIO(csv_text)) + rows = [] + for row in reader: + posted_date = row.get('posted_date') or row.get('date') + if not posted_date: + raise EnterpriseModelValidationError('Statement CSV must include posted_date or date.') + rows.append({ + 'posted_date': date.fromisoformat(posted_date), + 'amount': Decimal(row['amount']), + 'payee': row.get('payee', ''), + 'memo': row.get('memo', '') or row.get('description', ''), + 'reference': row.get('reference', ''), + }) + created_lines = import_bank_statement_lines(statement_model=statement_model, rows=rows) + create_audit_event( + entity_model=statement_model.entity_model, + action=AuditEventModel.ACTION_IMPORT, + target=statement_model, + after={'source': 'csv', 'line_count': len(created_lines)}, + ) + return created_lines + + +def get_bank_match_candidates(*, statement_line: BankStatementLineModel, date_window_days: int = 3): + posted_date = statement_line.posted_date + start = posted_date - timedelta(days=date_window_days) + end = posted_date + timedelta(days=date_window_days) + transaction_model = lazy_transaction_model() + amount = statement_line.amount + amount_candidates = {amount, -amount, abs(amount)} + qs = transaction_model.objects.for_entity(entity_model=statement_line.entity_model).filter( + amount__in=amount_candidates, + journal_entry__timestamp__date__gte=start, + journal_entry__timestamp__date__lte=end, + reconciled=False, + ).select_related('journal_entry', 'account') + if statement_line.reference: + reference_q = Q(pk=statement_line.reference) | Q(journal_entry__description__icontains=statement_line.reference) + if statement_line.memo: + reference_q |= Q(journal_entry__description__icontains=statement_line.memo[:80]) + qs = qs.filter(reference_q) + return qs.order_by('journal_entry__timestamp') + + +def auto_match_bank_statement(*, statement_model: BankStatementModel, user_model=None, date_window_days: int = 3) -> list[BankStatementLineModel]: + matched = [] + for statement_line in statement_model.bankstatementlinemodel_set.filter(matched_transaction__isnull=True, ignored=False): + candidates = list(get_bank_match_candidates(statement_line=statement_line, date_window_days=date_window_days)[:1]) + if candidates: + matched.append(match_bank_statement_line( + statement_line=statement_line, + transaction_model=candidates[0], + user_model=user_model, + )) + return matched + + +def unmatch_bank_statement_line(*, statement_line: BankStatementLineModel, user_model=None) -> BankStatementLineModel: + transaction_model = statement_line.matched_transaction + before = {'matched_transaction': str(statement_line.matched_transaction_id) if statement_line.matched_transaction_id else ''} + if transaction_model: + transaction_model.__class__.objects.filter(pk=transaction_model.pk).update( + reconciled=False, + updated=timezone.now(), + ) + statement_line.matched_transaction = None + statement_line.save(update_fields=['matched_transaction', 'updated']) + create_audit_event( + entity_model=statement_line.entity_model, + action=AuditEventModel.ACTION_STATE, + actor=user_model, + target=statement_line, + before=before, + after={'matched_transaction': ''}, + ) + return statement_line + + +def ignore_bank_statement_line(*, statement_line: BankStatementLineModel, user_model=None, reason: str = '') -> BankStatementLineModel: + before = {'ignored': statement_line.ignored} + statement_line.ignored = True + statement_line.save(update_fields=['ignored', 'updated']) + create_audit_event( + entity_model=statement_line.entity_model, + action=AuditEventModel.ACTION_STATE, + actor=user_model, + target=statement_line, + before=before, + after={'ignored': True, 'reason': reason}, + ) + return statement_line + + +def lock_reconciliation(*, reconciliation: BankReconciliationModel, user_model=None) -> BankReconciliationModel: + if reconciliation.status != BankReconciliationModel.STATUS_RECONCILED: + raise EnterpriseModelValidationError('Only reconciled statements can be locked.') + before = {'status': reconciliation.status} + reconciliation.status = BankReconciliationModel.STATUS_LOCKED + reconciliation.statement_model.status = BankStatementModel.STATUS_LOCKED + reconciliation.statement_model.save(update_fields=['status', 'updated']) + reconciliation.save(update_fields=['status', 'updated']) + create_audit_event( + entity_model=reconciliation.entity_model, + action=AuditEventModel.ACTION_LOCK, + actor=user_model, + target=reconciliation.statement_model, + before=before, + after={'status': reconciliation.status}, + ) + return reconciliation + + +def void_reconciliation(*, reconciliation: BankReconciliationModel, user_model=None, reason: str = '') -> BankReconciliationModel: + before = {'status': reconciliation.status} + reconciliation.status = BankReconciliationModel.STATUS_VOID + reconciliation.statement_model.status = BankStatementModel.STATUS_VOID + reconciliation.statement_model.save(update_fields=['status', 'updated']) + reconciliation.save(update_fields=['status', 'updated']) + create_audit_event( + entity_model=reconciliation.entity_model, + action=AuditEventModel.ACTION_STATE, + actor=user_model, + target=reconciliation.statement_model, + before=before, + after={'status': reconciliation.status, 'reason': reason}, + ) + return reconciliation + + +def reconcile_statement(*, statement_model: BankStatementModel, user_model=None) -> BankReconciliationModel: + reconciliation, _ = BankReconciliationModel.objects.get_or_create( + entity_model=statement_model.entity_model, + statement_model=statement_model, + defaults={'status': BankReconciliationModel.STATUS_DRAFT}, + ) + unmatched = statement_model.bankstatementlinemodel_set.filter(matched_transaction__isnull=True, ignored=False).exists() + reconciliation.status = ( + BankReconciliationModel.STATUS_REVIEW if unmatched else BankReconciliationModel.STATUS_RECONCILED + ) + if not unmatched: + reconciliation.reconciled_by = user_model + reconciliation.reconciled_at = timezone.now() + statement_model.status = BankStatementModel.STATUS_RECONCILED + statement_model.save(update_fields=['status', 'updated']) + reconciliation.save(update_fields=['status', 'reconciled_by', 'reconciled_at', 'updated']) + create_audit_event( + entity_model=statement_model.entity_model, + action=AuditEventModel.ACTION_STATE, + actor=user_model, + target=statement_model, + after={'reconciliation_status': reconciliation.status}, + ) + return reconciliation + + +def match_bank_statement_line(*, statement_line: BankStatementLineModel, transaction_model, user_model=None) -> BankStatementLineModel: + if statement_line.entity_model_id != transaction_model.journal_entry.ledger.entity_id: + raise EnterpriseModelValidationError('Cannot match a statement line to a transaction from another entity.') + before = { + 'matched_transaction': str(statement_line.matched_transaction_id) if statement_line.matched_transaction_id else '', + 'reconciled': bool(getattr(transaction_model, 'reconciled', False)), + } + statement_line.matched_transaction = transaction_model + statement_line.save(update_fields=['matched_transaction', 'updated']) + transaction_model.__class__.objects.filter(pk=transaction_model.pk).update( + reconciled=True, + updated=timezone.now(), + ) + transaction_model.reconciled = True + create_audit_event( + entity_model=statement_line.entity_model, + action=AuditEventModel.ACTION_STATE, + actor=user_model, + target=statement_line, + before=before, + after={ + 'matched_transaction': str(transaction_model.pk), + 'reconciled': True, + }, + ) + return statement_line + + +def calculate_tax(*, entity_model, target, tax_code, taxable_amount: Decimal, inclusive: bool = False, on_date=None): + on_date = on_date or timezone.localdate() + rate_model = TaxRateModel.objects.for_entity(entity_model).filter( + tax_code=tax_code, + effective_date__lte=on_date, + ).filter( + Q(end_date__isnull=True) | Q(end_date__gte=on_date), + ).order_by('-effective_date').first() + if not rate_model: + raise EnterpriseModelValidationError('No active tax rate found for tax code.') + rate = rate_model.rate + if inclusive: + tax_amount = taxable_amount - (taxable_amount / (Decimal('1.00') + rate)) + else: + tax_amount = taxable_amount * rate + tax_amount = tax_amount.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + kwargs = _target_kwargs(target) + kwargs.pop('object_repr', None) + return TaxLineModel.objects.create( + entity_model=entity_model, + tax_code=tax_code, + tax_rate=rate_model, + taxable_amount=taxable_amount, + tax_amount=tax_amount, + inclusive=inclusive, + **kwargs, + ) + + +def allocate_payment(*, payment: PaymentModel, target, amount: Decimal, write_off_amount=Decimal('0.00')) -> PaymentAllocationModel: + allocated = payment.paymentallocationmodel_set.aggregate(total=Sum('amount'))['total'] or Decimal('0.00') + if allocated + amount > payment.amount: + raise EnterpriseModelValidationError('Cannot allocate more than the payment amount.') + kwargs = _target_kwargs(target) + kwargs.pop('object_repr', None) + allocation = PaymentAllocationModel.objects.create( + entity_model=payment.entity_model, + payment=payment, + amount=amount, + write_off_amount=write_off_amount, + **kwargs, + ) + payment.unapplied_amount = payment.amount - allocated - amount + payment.save(update_fields=['unapplied_amount', 'updated']) + return allocation + + +def create_payment( + *, + entity_model, + direction: str, + payment_date, + amount: Decimal, + user_model=None, + currency=None, + exchange_rate: Decimal | None = None, + bank_account=None, + customer=None, + vendor=None, + reference: str = '', +) -> PaymentModel: + require_entity_role( + user_model, + entity_model, + EntityRoleModel.ROLE_OWNER, + EntityRoleModel.ROLE_FINANCE_ADMIN, + EntityRoleModel.ROLE_ACCOUNTANT, + ) + assert_period_open(entity_model, payment_date) + base_amount = calculate_base_amount( + entity_model=entity_model, + amount=amount, + currency=currency, + exchange_rate=exchange_rate, + ) + payment = PaymentModel.objects.create( + entity_model=entity_model, + direction=direction, + payment_date=payment_date, + amount=amount, + unapplied_amount=amount, + base_amount=base_amount, + currency=currency, + bank_account=bank_account, + customer=customer, + vendor=vendor, + reference=reference, + ) + create_audit_event( + entity_model=entity_model, + action=AuditEventModel.ACTION_CREATE, + actor=user_model, + target=payment, + after={'amount': str(amount), 'direction': direction, 'status': payment.status}, + ) + enqueue_webhook_event(entity_model=entity_model, event_type='payment.created', payload={'payment': str(payment.uuid)}) + return payment + + +def approve_payment(*, payment: PaymentModel, user_model) -> PaymentModel: + require_entity_role( + user_model, + payment.entity_model, + EntityRoleModel.ROLE_OWNER, + EntityRoleModel.ROLE_FINANCE_ADMIN, + EntityRoleModel.ROLE_APPROVER, + ) + before = {'status': payment.status} + payment.status = PaymentModel.STATUS_APPROVED + payment.save(update_fields=['status', 'updated']) + create_audit_event( + entity_model=payment.entity_model, + action=AuditEventModel.ACTION_APPROVE, + actor=user_model, + target=payment, + before=before, + after={'status': payment.status}, + ) + return payment + + +def post_payment(*, payment: PaymentModel, user_model=None) -> PaymentModel: + require_entity_role( + user_model, + payment.entity_model, + EntityRoleModel.ROLE_OWNER, + EntityRoleModel.ROLE_FINANCE_ADMIN, + EntityRoleModel.ROLE_ACCOUNTANT, + ) + assert_period_open(payment.entity_model, payment.payment_date) + if payment.status not in [PaymentModel.STATUS_APPROVED, PaymentModel.STATUS_DRAFT]: + raise EnterpriseModelValidationError('Only draft or approved payments can be posted.') + before = {'status': payment.status} + payment.status = PaymentModel.STATUS_POSTED + payment.save(update_fields=['status', 'updated']) + create_audit_event( + entity_model=payment.entity_model, + action=AuditEventModel.ACTION_POST, + actor=user_model, + target=payment, + before=before, + after={'status': payment.status}, + ) + enqueue_webhook_event(entity_model=payment.entity_model, event_type='payment.posted', payload={'payment': str(payment.uuid)}) + return payment + + +def reverse_payment(*, payment: PaymentModel, user_model=None, reason: str = '') -> PaymentModel: + require_entity_role( + user_model, + payment.entity_model, + EntityRoleModel.ROLE_OWNER, + EntityRoleModel.ROLE_FINANCE_ADMIN, + EntityRoleModel.ROLE_ACCOUNTANT, + ) + assert_period_open(payment.entity_model, payment.payment_date) + before = {'status': payment.status, 'unapplied_amount': str(payment.unapplied_amount)} + payment.paymentallocationmodel_set.all().delete() + payment.unapplied_amount = payment.amount + payment.status = PaymentModel.STATUS_VOID + payment.save(update_fields=['unapplied_amount', 'status', 'updated']) + create_audit_event( + entity_model=payment.entity_model, + action=AuditEventModel.ACTION_STATE, + actor=user_model, + target=payment, + before=before, + after={'status': payment.status, 'reason': reason}, + ) + return payment + + +def unallocate_payment(*, allocation: PaymentAllocationModel, user_model=None) -> PaymentModel: + payment = allocation.payment + require_entity_role( + user_model, + payment.entity_model, + EntityRoleModel.ROLE_OWNER, + EntityRoleModel.ROLE_FINANCE_ADMIN, + EntityRoleModel.ROLE_ACCOUNTANT, + ) + amount = allocation.amount + allocation.delete() + allocated = payment.paymentallocationmodel_set.aggregate(total=Sum('amount'))['total'] or Decimal('0.00') + payment.unapplied_amount = payment.amount - allocated + payment.save(update_fields=['unapplied_amount', 'updated']) + create_audit_event( + entity_model=payment.entity_model, + action=AuditEventModel.ACTION_STATE, + actor=user_model, + target=payment, + after={'unallocated_amount': str(amount), 'unapplied_amount': str(payment.unapplied_amount)}, + ) + return payment + + +def apply_payment_to_document(*, payment: PaymentModel, target, amount: Decimal, user_model=None, write_off_amount=Decimal('0.00')): + entity_model = get_target_entity(target) + if entity_model.pk != payment.entity_model_id: + raise EnterpriseModelValidationError('Cannot allocate a payment across entities.') + allocation = allocate_payment(payment=payment, target=target, amount=amount, write_off_amount=write_off_amount) + if hasattr(target, 'make_payment'): + target.make_payment(payment_amount=amount + write_off_amount, payment_date=payment.payment_date, commit=True) + create_audit_event( + entity_model=payment.entity_model, + action=AuditEventModel.ACTION_STATE, + actor=user_model, + target=target, + after={ + 'payment': str(payment.uuid), + 'amount': str(amount), + 'write_off_amount': str(write_off_amount), + }, + ) + return allocation + + +def create_credit_note(*, entity_model, customer, amount: Decimal, note_date, user_model=None, invoice=None, note_number: str = '') -> CreditNoteModel: + require_entity_role(user_model, entity_model, EntityRoleModel.ROLE_OWNER, EntityRoleModel.ROLE_FINANCE_ADMIN, EntityRoleModel.ROLE_ACCOUNTANT) + credit_note = CreditNoteModel.objects.create( + entity_model=entity_model, + customer=customer, + invoice=invoice, + amount=amount, + unapplied_amount=amount, + note_date=note_date, + note_number=note_number, + ) + create_audit_event(entity_model=entity_model, action=AuditEventModel.ACTION_CREATE, actor=user_model, target=credit_note, after={'amount': str(amount)}) + return credit_note + + +def create_debit_note(*, entity_model, vendor, amount: Decimal, note_date, user_model=None, bill=None, note_number: str = '') -> DebitNoteModel: + require_entity_role(user_model, entity_model, EntityRoleModel.ROLE_OWNER, EntityRoleModel.ROLE_FINANCE_ADMIN, EntityRoleModel.ROLE_ACCOUNTANT) + debit_note = DebitNoteModel.objects.create( + entity_model=entity_model, + vendor=vendor, + bill=bill, + amount=amount, + unapplied_amount=amount, + note_date=note_date, + note_number=note_number, + ) + create_audit_event(entity_model=entity_model, action=AuditEventModel.ACTION_CREATE, actor=user_model, target=debit_note, after={'amount': str(amount)}) + return debit_note + + +def detect_duplicate_bill(*, bill_model): + vendor = getattr(bill_model, 'vendor', None) + if vendor is None: + return bill_model.__class__.objects.none() + qs = bill_model.__class__.objects.filter( + vendor=vendor, + amount_due=getattr(bill_model, 'amount_due', None), + ).exclude(pk=bill_model.pk) + bill_number = getattr(bill_model, 'bill_number', '') + if bill_number: + qs = qs.filter(bill_number=bill_number) + bill_date = getattr(bill_model, 'date_draft', None) or getattr(bill_model, 'date_due', None) + if bill_date: + qs = qs.filter(Q(date_draft=bill_date) | Q(date_due=bill_date)) + return qs + + +def get_exchange_rate(*, entity_model, from_currency, to_currency, on_date=None) -> ExchangeRateModel: + on_date = on_date or timezone.localdate() + if from_currency == to_currency: + return ExchangeRateModel( + entity_model=entity_model, + from_currency=from_currency, + to_currency=to_currency, + rate=Decimal('1.00'), + rate_date=on_date, + ) + rate_model = ExchangeRateModel.objects.for_entity(entity_model).filter( + from_currency=from_currency, + to_currency=to_currency, + rate_date__lte=on_date, + ).order_by('-rate_date').first() + if not rate_model: + raise EnterpriseModelValidationError('No exchange rate found.') + return rate_model + + +def calculate_base_amount(*, entity_model, amount: Decimal, currency=None, exchange_rate: Decimal | None = None, on_date=None) -> Decimal: + base_currency = getattr(entity_model, 'base_currency', None) + if not currency or not base_currency or currency == base_currency: + return amount.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + rate = exchange_rate + if rate is None: + rate = get_exchange_rate( + entity_model=entity_model, + from_currency=currency, + to_currency=base_currency, + on_date=on_date, + ).rate + return (amount * rate).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + + +def apply_document_currency(*, target, entity_model=None, currency=None, exchange_rate: Decimal | None = None, amount_field: str = 'amount_due'): + entity_model = entity_model or get_target_entity(target) + currency = currency or getattr(target, 'currency', None) + if currency and hasattr(target, 'currency'): + target.currency = currency + if exchange_rate is not None and hasattr(target, 'exchange_rate'): + target.exchange_rate = exchange_rate + amount = getattr(target, amount_field, None) + if amount is None and hasattr(target, 'po_amount'): + amount_field = 'po_amount' + amount = getattr(target, amount_field) + if amount is None: + raise EnterpriseModelValidationError('Target does not expose a supported amount field.') + base_amount = calculate_base_amount( + entity_model=entity_model, + amount=amount, + currency=currency, + exchange_rate=exchange_rate or getattr(target, 'exchange_rate', None), + ) + for base_field in ('base_amount_due', 'base_po_amount', 'base_amount'): + if hasattr(target, base_field): + setattr(target, base_field, base_amount) + break + return target + + +def calculate_document_taxes(*, entity_model, target, tax_code, inclusive: bool = False, on_date=None) -> list[TaxLineModel]: + itemtxs = getattr(target, 'itemtransactionmodel_set', None) + if itemtxs is None: + amount = getattr(target, 'amount_due', None) or getattr(target, 'po_amount', None) + return [calculate_tax( + entity_model=entity_model, + target=target, + tax_code=tax_code, + taxable_amount=amount, + inclusive=inclusive, + on_date=on_date, + )] + tax_lines = [] + for itemtx in itemtxs.all(): + amount = getattr(itemtx, 'total_amount', None) or getattr(itemtx, 'po_total_amount', None) + if amount: + tax_lines.append(calculate_tax( + entity_model=entity_model, + target=itemtx, + tax_code=tax_code, + taxable_amount=amount, + inclusive=inclusive, + on_date=on_date, + )) + return tax_lines + + +def get_realized_fx_gain_loss(*, settlement_amount: Decimal, settlement_rate: Decimal, document_amount: Decimal, document_rate: Decimal) -> Decimal: + settlement_base = settlement_amount * settlement_rate + document_base = document_amount * document_rate + return (settlement_base - document_base).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + + +def revalue_currency_balances(*, entity_model, from_currency, to_currency, on_date=None): + rate_model = get_exchange_rate( + entity_model=entity_model, + from_currency=from_currency, + to_currency=to_currency, + on_date=on_date, + ) + open_items = [] + for model_name, amount_field in [('InvoiceModel', 'amount_receivable'), ('BillModel', 'amount_receivable')]: + model_cls = apps.get_model('django_ledger', model_name) + for obj in model_cls.objects.for_entity(entity_model=entity_model).filter(currency=from_currency): + amount = getattr(obj, amount_field, None) or Decimal('0.00') + current_base = getattr(obj, 'base_amount_due', None) or Decimal('0.00') + revalued_base = (amount * rate_model.rate).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + delta = revalued_base - current_base + if delta: + open_items.append({ + 'target': obj, + 'amount': amount, + 'current_base': current_base, + 'revalued_base': revalued_base, + 'gain_loss': delta, + }) + return { + 'entity_model': entity_model, + 'from_currency': from_currency, + 'to_currency': to_currency, + 'rate': rate_model.rate, + 'entries': open_items, + } + + +def create_straight_line_depreciation_schedule(*, fixed_asset: FixedAssetModel) -> list[DepreciationScheduleModel]: + method = fixed_asset.depreciation_method + if method.method != method.METHOD_STRAIGHT_LINE: + raise EnterpriseModelValidationError('Only straight-line depreciation is supported by this scheduler.') + depreciable_amount = fixed_asset.acquisition_cost - fixed_asset.salvage_value + if depreciable_amount < Decimal('0.00'): + raise EnterpriseModelValidationError('Salvage value cannot exceed acquisition cost.') + monthly_amount = (depreciable_amount / Decimal(method.useful_life_months)).quantize( + Decimal('0.01'), + rounding=ROUND_HALF_UP, + ) + periods = AccountingPeriodModel.objects.for_entity(fixed_asset.entity_model).filter( + end_date__gte=fixed_asset.acquisition_date, + ).order_by('start_date')[:method.useful_life_months] + schedules = [] + for period in periods: + schedule, _ = DepreciationScheduleModel.objects.get_or_create( + entity_model=fixed_asset.entity_model, + fixed_asset=fixed_asset, + period=period, + defaults={'depreciation_amount': monthly_amount}, + ) + schedules.append(schedule) + return schedules + + +def assign_dimension(*, entity_model, target, dimension_value: DimensionValueModel, weight=Decimal('1.00')) -> DimensionAssignmentModel: + if dimension_value.entity_model_id != entity_model.pk: + raise EnterpriseModelValidationError('Dimension value belongs to a different entity.') + kwargs = _target_kwargs(target) + kwargs.pop('object_repr', None) + assignment, _ = DimensionAssignmentModel.objects.update_or_create( + entity_model=entity_model, + dimension_value=dimension_value, + content_type=kwargs['content_type'], + object_id=kwargs['object_id'], + defaults={'weight': weight}, + ) + return assignment + + +def approve_budget_version(*, budget_version: BudgetVersionModel, user_model) -> BudgetVersionModel: + require_entity_role( + user_model, + budget_version.entity_model, + EntityRoleModel.ROLE_OWNER, + EntityRoleModel.ROLE_FINANCE_ADMIN, + EntityRoleModel.ROLE_APPROVER, + ) + before = {'status': budget_version.status} + budget_version.status = 'approved' + budget_version.approved_by = user_model + budget_version.save(update_fields=['status', 'approved_by', 'updated']) + create_audit_event( + entity_model=budget_version.entity_model, + action=AuditEventModel.ACTION_APPROVE, + actor=user_model, + target=budget_version, + before=before, + after={'status': budget_version.status}, + ) + return budget_version + + +def calculate_allocation_rule(*, allocation_rule: AllocationRuleModel, amount: Decimal) -> list[dict]: + lines = [] + allocated_total = Decimal('0.00') + rule_lines = list(allocation_rule.allocationrulelinemodel_set.all().order_by('created')) + for index, line in enumerate(rule_lines): + if index == len(rule_lines) - 1: + line_amount = amount - allocated_total + else: + line_amount = (amount * line.percentage).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + allocated_total += line_amount + lines.append({ + 'target_account': line.target_account, + 'dimension_value': line.dimension_value, + 'percentage': line.percentage, + 'amount': line_amount, + }) + return lines + + +def mark_depreciation_posted(*, schedule: DepreciationScheduleModel, journal_entry=None, user_model=None) -> DepreciationScheduleModel: + assert_period_open(schedule.entity_model, schedule.period.end_date) + before = {'posted': schedule.posted} + schedule.posted = True + if journal_entry is not None: + schedule.journal_entry = journal_entry + schedule.save(update_fields=['posted', 'journal_entry', 'updated']) + create_audit_event( + entity_model=schedule.entity_model, + action=AuditEventModel.ACTION_POST, + actor=user_model, + target=schedule.fixed_asset, + before=before, + after={'posted': True, 'depreciation_amount': str(schedule.depreciation_amount)}, + ) + return schedule + + +def dispose_fixed_asset(*, fixed_asset: FixedAssetModel, disposal_date, proceeds=Decimal('0.00'), user_model=None, journal_entry=None, notes: str = '') -> AssetDisposalModel: + require_entity_role(user_model, fixed_asset.entity_model, EntityRoleModel.ROLE_OWNER, EntityRoleModel.ROLE_FINANCE_ADMIN, EntityRoleModel.ROLE_ACCOUNTANT) + assert_period_open(fixed_asset.entity_model, disposal_date) + disposal = AssetDisposalModel.objects.create( + entity_model=fixed_asset.entity_model, + fixed_asset=fixed_asset, + disposal_date=disposal_date, + proceeds=proceeds, + journal_entry=journal_entry, + notes=notes, + ) + before = {'status': fixed_asset.status} + fixed_asset.status = FixedAssetModel.STATUS_DISPOSED + fixed_asset.save(update_fields=['status', 'updated']) + create_audit_event( + entity_model=fixed_asset.entity_model, + action=AuditEventModel.ACTION_STATE, + actor=user_model, + target=fixed_asset, + before=before, + after={'status': fixed_asset.status, 'proceeds': str(proceeds)}, + ) + return disposal + + +def attach_document(*, entity_model, target, file_obj, uploaded_by=None, original_filename: str = '') -> DocumentAttachmentModel: + checksum = '' + if file_obj: + position = file_obj.tell() if hasattr(file_obj, 'tell') else None + checksum = hashlib.sha256(file_obj.read()).hexdigest() + if position is not None: + file_obj.seek(position) + kwargs = _target_kwargs(target) + kwargs.pop('object_repr', None) + document = DocumentAttachmentModel.objects.create( + entity_model=entity_model, + uploaded_by=uploaded_by, + file=file_obj, + original_filename=original_filename or getattr(file_obj, 'name', ''), + checksum=checksum, + **kwargs, + ) + create_audit_event( + entity_model=entity_model, + action=AuditEventModel.ACTION_CREATE, + actor=uploaded_by, + target=target, + after={'attachment': str(document.uuid), 'checksum': checksum}, + ) + return document + + +def export_rows_to_csv(rows: Iterable[dict], fieldnames: list[str]) -> str: + output = StringIO() + writer = csv.DictWriter(output, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow(row) + return output.getvalue() + + +def export_queryset_to_csv(*, entity_model, queryset, fieldnames: list[str], user_model=None) -> str: + require_entity_role( + user_model, + entity_model, + EntityRoleModel.ROLE_OWNER, + EntityRoleModel.ROLE_FINANCE_ADMIN, + EntityRoleModel.ROLE_ACCOUNTANT, + EntityRoleModel.ROLE_AUDITOR, + EntityRoleModel.ROLE_INTEGRATION, + ) + csv_text = export_rows_to_csv(queryset.values(*fieldnames), fieldnames) + create_audit_event( + entity_model=entity_model, + action=AuditEventModel.ACTION_EXPORT, + actor=user_model, + after={'fieldnames': fieldnames, 'row_count': queryset.count()}, + ) + return csv_text + + +def enqueue_webhook_event(*, entity_model, event_type: str, payload: dict) -> list[WebhookDeliveryModel]: + deliveries = [] + endpoints = WebhookEndpointModel.objects.for_entity(entity_model).active() + for endpoint in endpoints: + event_types = endpoint.event_types or [] + if event_types and event_type not in event_types: + continue + deliveries.append(WebhookDeliveryModel.objects.create( + entity_model=entity_model, + endpoint=endpoint, + event_type=event_type, + payload=payload, + )) + return deliveries + + +def record_webhook_delivery_attempt(*, delivery: WebhookDeliveryModel, status_code=None, response_body: str = '') -> WebhookDeliveryModel: + delivery.status_code = status_code + delivery.response_body = response_body + delivery.delivered = bool(status_code and 200 <= status_code < 300) + delivery.attempt_count += 1 + delivery.save(update_fields=['status_code', 'response_body', 'delivered', 'attempt_count', 'updated']) + return delivery diff --git a/django_ledger/settings.py b/django_ledger/settings.py index 7521a90b..8dedeb68 100644 --- a/django_ledger/settings.py +++ b/django_ledger/settings.py @@ -28,13 +28,13 @@ DJANGO_LEDGER_TRANSACTION_CORRECTION = getattr(settings, 'DJANGO_LEDGER_TRANSACTION_CORRECTION', Decimal('0.01')) DJANGO_LEDGER_ACCOUNT_CODE_GENERATE = getattr(settings, 'DJANGO_LEDGER_ACCOUNT_CODE_GENERATE', True) DJANGO_LEDGER_ACCOUNT_CODE_GENERATE_LENGTH = getattr(settings, 'DJANGO_LEDGER_ACCOUNT_CODE_GENERATE_LENGTH', 5) -DJANGO_LEDGER_ACCOUNT_CODE_USE_PREFIX = getattr(settings, 'DJANGO_LEDGER_ACCOUNT_CODE_GENERATE_LENGTH', True) +DJANGO_LEDGER_ACCOUNT_CODE_USE_PREFIX = getattr(settings, 'DJANGO_LEDGER_ACCOUNT_CODE_USE_PREFIX', True) DJANGO_LEDGER_JE_NUMBER_PREFIX = getattr(settings, 'DJANGO_LEDGER_JE_NUMBER_PREFIX', 'JE') DJANGO_LEDGER_PO_NUMBER_PREFIX = getattr(settings, 'DJANGO_LEDGER_PO_NUMBER_PREFIX', 'PO') DJANGO_LEDGER_ESTIMATE_NUMBER_PREFIX = getattr(settings, 'DJANGO_LEDGER_ESTIMATE_NUMBER_PREFIX', 'E') DJANGO_LEDGER_INVOICE_NUMBER_PREFIX = getattr(settings, 'DJANGO_LEDGER_INVOICE_NUMBER_PREFIX', 'I') DJANGO_LEDGER_BILL_NUMBER_PREFIX = getattr(settings, 'DJANGO_LEDGER_BILL_NUMBER_PREFIX', 'B') -DJANGO_LEDGER_RECEIPT_NUMBER_PREFIX = getattr(settings, 'DJANGO_LEDGER_BILL_NUMBER_PREFIX', 'R') +DJANGO_LEDGER_RECEIPT_NUMBER_PREFIX = getattr(settings, 'DJANGO_LEDGER_RECEIPT_NUMBER_PREFIX', 'R') DJANGO_LEDGER_VENDOR_NUMBER_PREFIX = getattr(settings, 'DJANGO_LEDGER_VENDOR_NUMBER_PREFIX', 'V') DJANGO_LEDGER_CUSTOMER_NUMBER_PREFIX = getattr(settings, 'DJANGO_LEDGER_CUSTOMER_NUMBER_PREFIX', 'C') DJANGO_LEDGER_EXPENSE_NUMBER_PREFIX = getattr(settings, 'DJANGO_LEDGER_EXPENSE_NUMBER_PREFIX', 'IEX') diff --git a/django_ledger/templates/django_ledger/account/tags/accounts_table.html b/django_ledger/templates/django_ledger/account/tags/accounts_table.html index 77e722ab..33c31901 100644 --- a/django_ledger/templates/django_ledger/account/tags/accounts_table.html +++ b/django_ledger/templates/django_ledger/account/tags/accounts_table.html @@ -97,32 +97,40 @@

{{ root_role | upper }}

{% if account.can_activate %} - - {% icon 'bi:play-fill' 16 %} - {% trans 'Activate' %} - +
+ {% csrf_token %} + +
{% endif %} {% if account.can_deactivate %} - - {% icon 'bi:stop-fill' 16 %} - {% trans 'Deactivate' %} - +
+ {% csrf_token %} + +
{% endif %} {% if account.can_lock %} - - {% icon 'bi:lock-fill' 16 %} - {% trans 'Lock' %} - +
+ {% csrf_token %} + +
{% endif %} {% if account.can_unlock %} - - {% icon 'bi:unlock-fill' 16 %} - {% trans 'Unlock' %} - +
+ {% csrf_token %} + +
{% endif %} diff --git a/django_ledger/templates/django_ledger/bank_account/tags/bank_accounts_table.html b/django_ledger/templates/django_ledger/bank_account/tags/bank_accounts_table.html index a42af466..894db41b 100644 --- a/django_ledger/templates/django_ledger/bank_account/tags/bank_accounts_table.html +++ b/django_ledger/templates/django_ledger/bank_account/tags/bank_accounts_table.html @@ -51,17 +51,21 @@ {% trans 'Update' %} {% if bank_acc.can_activate %} - - {% icon 'bi:play-fill' 16 %} - {% trans 'Activate' %} - +
+ {% csrf_token %} + +
{% elif bank_acc.can_inactivate %} - - {% icon 'bi:stop-fill' 16 %} - {% trans 'Inactivate' %} - +
+ {% csrf_token %} + +
{% endif %} diff --git a/django_ledger/templates/django_ledger/bills/bill_update.html b/django_ledger/templates/django_ledger/bills/bill_update.html index b6483bb9..14c4fb75 100644 --- a/django_ledger/templates/django_ledger/bills/bill_update.html +++ b/django_ledger/templates/django_ledger/bills/bill_update.html @@ -88,12 +88,18 @@

{% trans 'Ledger State' %}

href="{% url 'django_ledger:je-list' ledger_pk=bill_model.ledger.uuid entity_slug=view.kwargs.entity_slug %}"> {% trans 'Ledger Journal Entries' %} - {% trans 'Lock Ledger' %} - {% trans 'Unlock Ledger' %} - {% trans 'Force Migrate' %} +
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
diff --git a/django_ledger/templates/django_ledger/bills/includes/card_bill.html b/django_ledger/templates/django_ledger/bills/includes/card_bill.html index 338d85d1..b386828e 100644 --- a/django_ledger/templates/django_ledger/bills/includes/card_bill.html +++ b/django_ledger/templates/django_ledger/bills/includes/card_bill.html @@ -36,7 +36,7 @@

{{ bill.vendor.address_1 }}

{# MARK AS PAID MODAL #} - {% modal_action bill 'get' entity_slug %} + {% modal_action bill 'post' entity_slug %}