Skip to content

Add high-level API behavior coverage and public model fixes - #334

Open
aligalipcamli wants to merge 97 commits into
arrobalytics:masterfrom
aligalipcamli:tests/high-level-api-contracts
Open

Add high-level API behavior coverage and public model fixes#334
aligalipcamli wants to merge 97 commits into
arrobalytics:masterfrom
aligalipcamli:tests/high-level-api-contracts

Conversation

@aligalipcamli

@aligalipcamli aligalipcamli commented Jun 1, 2026

Copy link
Copy Markdown

Add High-Level Public API Behavior Coverage for Django Ledger Models

Summary

This PR adds a broad high-level API behavior test layer under:

django_ledger/tests/api/

The goal is to document and protect Django Ledger’s public model API behavior: model methods, managers, querysets, numbering helpers, lifecycle actions, URL/display helpers, cross-model bindings, import/staging workflows, and entity-scoped data integrity.

This is not intended to replace the existing test suite. It adds contract-style coverage around public APIs that downstream applications may call directly.

The test campaign also surfaced several public API bugs. Those production fixes are included with focused regression coverage.

Review note

The commit history reflects the chronological development of the campaign. It shows the work progressing model-by-model, with failures either characterized or fixed as they appeared.

For review, it may be easier to use the model-family sections below rather than reading the commits as the primary organization.

Some package/build metadata and generated asset files may also appear in the branch diff from the broader branch history. The main review focus is the API behavior test campaign and related public API fixes described below.

What changed

This PR adds or expands high-level API coverage for these model families:

Core accounting:
- EntityModel
- EntityStateModel
- EntityUnitModel
- ChartOfAccountModel / default CoA data
- AccountModel
- LedgerModel
- JournalEntryModel
- TransactionModel

Import / staging / receipts / closing:
- BankAccountModel
- ImportJobModel
- StagedTransactionModel
- ReceiptModel
- ClosingEntryModel
- ClosingEntryTransactionModel

Commercial documents:
- BillModel
- InvoiceModel
- EstimateModel
- PurchaseOrderModel

Items and line items:
- UnitOfMeasureModel
- ItemModel
- ItemTransactionModel

Commercial parties:
- CustomerModel
- VendorModel

Infrastructure / compatibility:
- LazyLoader model/report resolution
- schema top-level shape
- deprecated entity_slug= behavior

The README for django_ledger/tests/api/ was also updated to explain the purpose, scope, and design goals of this test layer.

Test design

The tests follow these principles:

  • deterministic setup instead of randomized fixtures,
  • public API behavior over private implementation details,
  • model-family and behavior-family organization,
  • regression tests paired with production fixes,
  • characterization coverage for surprising but stable current behavior,
  • minimal production changes only when a clear public bug was confirmed.

The tests avoid pinning brittle internals such as exact SQL annotation implementation, private dictionary shapes, full generated-number strings, and database error message text.

Production fixes included

Entity / Ledger

  • Fixed parent entity creation ordering so failed parent validation does not leave behind an invalid root entity.
  • Fixed explicit CoA lookup in Entity account helpers.
  • Fixed fiscal-year closing digest wrapper argument forwarding.
  • Fixed stale closing-entry metadata cache behavior after closing books.
  • Added a cross-entity guard for explicit bank-account ledger account bindings.
  • Fixed Entity URL helpers for import-job and account-list routes/kwargs.
  • Fixed LedgerModelQuerySet.unposted() to return unposted ledgers.
  • Fixed LedgerModelQuerySet.current() behavior when the entity has no closing date.
  • Fixed locked-period journal entry detection to consider the earliest posted journal entry.

Import / StagedTransaction / Receipt

  • Fixed ImportJob slug-based entity scoping.
  • Fixed stale ImportJob URL helper routes.
  • Fixed StagedTransactionModel.unmatch() so matched transaction state is fully cleared.
  • Fixed ReceiptModel.can_delete() when last_closing_date is None.
  • Fixed ReceiptModel.configure(...) lookup variants for vendor, customer, and entity unit inputs.

Closing entries

  • Fixed ClosingEntryTransactionModel.objects.for_entity(...) lazy-loader usage.
  • Fixed closing-entry transaction slug scoping.
  • Fixed ClosingEntryModel.get_delete_url() route resolution.
  • Fixed single-sided closing migration to raise ClosingEntryValidationError instead of leaking KeyError.

Invoice

  • Fixed InvoiceModelQuerySet.in_review() to filter review invoices instead of draft invoices.
  • Fixed mark_as_approved(date_approved=...) persistence.
  • Fixed mark_as_draft(draft_date=...) persistence.
  • Fixed generate_descriptive_title() to identify invoices as invoices, not bills.

Estimate

  • Fixed EstimateModel.configure(date_draft=...) to preserve explicit draft dates.
  • Fixed EstimateModel.configure(...) to reject customers from another entity.
  • Added the missing mark-as-void view and route for estimates.
  • Added a cross-entity guard to InvoiceModel.can_bind_estimate(...).

Purchase orders / Bill binding

  • Fixed non-DB PO item aggregation to use po_total_amount.
  • Fixed draft date persistence in mark_as_draft(...).
  • Fixed PO void transition query construction.
  • Fixed date_void persistence.
  • Fixed fulfilled date preservation.
  • Fixed po_amount_received persistence.
  • Fixed configure-time estimate binding order.
  • Added a cross-entity guard to BillModel.can_bind_po(...).
  • Fixed list-backed po_items handling in fulfillment.
  • Fixed get_po_bill_queryset() overmatching through shared item models.

Items / item transactions / inventory

  • Fixed estimate item eligibility so services are included.
  • Fixed ItemTransactionModelQuerySet.is_orphan() to exclude invoice-owned rows.
  • Fixed ItemTransactionModel.is_ordered() to compare against STATUS_ORDERED.
  • Fixed EntityModel.create_item_inventory(..., inventory_account=..., coa_model=None) default CoA resolution.

Customer / Vendor

  • Fixed vendor_picture_upload_to(...) to use vendor numbering.
  • Fixed invalid input handling in CustomerModel.validate_for_entity(...).
  • Fixed stale customer validation wording.
  • Fixed invalid input handling in VendorModel.validate_for_entity(...).
  • Fixed Customer/Vendor clean() methods so inherited contact validation runs.

Characterization coverage

Some behaviors were intentionally documented rather than changed.

Examples include:

  • commit=False may consume entity-state sequences.
  • commit=False may mutate in-memory state without persisting the main row.
  • Bill and invoice overdue() filters are date-based and not status-scoped.
  • Invoice payment dates before approval dates are currently accepted.
  • Estimate gross-margin helper currently behaves like markup.
  • Customer validation remains narrower than vendor validation.
  • Some signal payloads preserve existing spelling such as commited.
  • Hidden-but-active records are included by active() and excluded by visible().

These tests are not intended to force behavior changes. They document current public behavior so future refactors can be intentional.

Verification

Targeted tests were run for each batch.

Whenever production code changed, the full high-level API suite was run.

Recent verification milestones:

.venv/bin/python manage.py test django_ledger.tests.api
# Ran 816 tests
# OK (skipped=1)

Final isolated infrastructure and compatibility smoke tests also passed:

.venv/bin/python manage.py test \
  django_ledger.tests.api.test_model_infrastructure_api \
  django_ledger.tests.api.test_deprecated_entity_slug_api

# Ran 6 tests
# OK

Why this is useful

This coverage should help protect downstream users and future maintainers before larger changes involving:

  • manager/queryset refactors,
  • entity-scoped and fiscal-year-scoped numbering,
  • document lifecycle changes,
  • ledger/journal entry posting internals,
  • cross-entity data integrity,
  • swappable-model or extension work,
  • import/staging and reconciliation workflows,
  • commercial document and item-line behavior.

AI assistance disclosure

This test campaign was developed with AI assistance and human review. AI helped draft test structures and iterate on failures; the contributor inspected the source, ran the tests, reviewed behavior, and made the final decisions about which contracts and fixes to keep.

@aligalipcamli

aligalipcamli commented Jun 1, 2026

Copy link
Copy Markdown
Author

Detailed production-fix guide

Since this PR contains both broad high-level API coverage and production fixes, here is a more detailed guide to the production changes.

The production changes were made only when a focused public API behavior test exposed a clear bug. Surprising but stable current behavior was generally left unchanged and documented as characterization coverage.

Entity / Ledger

Entity parent creation ordering

Problem: EntityModel.create_entity(parent_entity=...) could save the new entity as a root before the parent entity was resolved and validated. If parent resolution failed, this could leave an unintended root entity behind.

Fix: Parent resolution and validation now happen before saving the new entity. The entity is inserted as a child only after the parent is known to be valid.

Why it matters: This protects entity tree integrity and avoids failed public API calls leaving persisted side effects.

Explicit CoA lookup in Entity account helpers

Problem: Entity account helper paths using explicit CoA slug/UUID referenced the wrong related manager.

Fix: The helper now uses the correct Chart of Accounts related manager.

Why it matters: Public account creation helpers now work consistently with model, slug, and UUID CoA inputs.

Fiscal-year closing digest wrapper

Problem: EntityModel.get_closing_entry_digest_for_fiscal_year() passed the wrong keyword argument into the date-based closing digest helper.

Fix: The wrapper now passes the expected closing_date argument.

Why it matters: Fiscal-year closing helpers now correctly delegate to the date-based API.

Closing metadata cache invalidation

Problem: Closing-entry date metadata could be cached on an entity instance and remain stale after closing books.

Fix: The closing-date cache is cleared when closing metadata is saved.

Why it matters: Reusing the same entity instance after closing books now reflects current closing metadata.

Explicit bank-account account guard

Problem: EntityModel.create_bank_account(..., account_model=...) could accept an explicit account from another entity.

Fix: The explicit account is now validated against the entity / selected CoA.

Why it matters: This prevents cross-entity financial account bindings.

Ledger queryset and locked-period fixes

Problem: Several public ledger helpers had edge-case issues: unposted() returned the wrong state, current() excluded ledgers when last_closing_date was None, and locked-period JE detection could miss older posted entries.

Fix: The queryset predicates were corrected, and locked-period detection now checks the earliest posted journal entry where appropriate.

Why it matters: Ledger state queries and locked-period guards are public accounting safety behavior.


Import / Staged transactions / Receipts

ImportJob entity slug scoping

Problem: ImportJobModel.objects.for_entity(entity.slug) used an incorrect relationship path.

Fix: Slug scoping now filters through the import job’s bank account entity.

Why it matters: Import jobs are entity-scoped; slug lookup should behave like other public for_entity(...) helpers.

ImportJob URL helpers

Problem: Some ImportJob URL helpers referenced stale route names.

Fix: The helpers were updated to current route names.

Why it matters: Public URL helpers should not raise NoReverseMatch.

StagedTransaction unmatch cleanup

Problem: StagedTransactionModel.unmatch() did not fully clear matched transaction state.

Fix: It now clears both match references.

Why it matters: Reconciliation state should be clean after unmatching.

Receipt delete with no closing date

Problem: ReceiptModel.can_delete() could fail when the entity had no last_closing_date.

Fix: The delete predicate now handles last_closing_date=None.

Why it matters: New entities often have no closing date; receipt undo/delete paths should still work.

Receipt configure lookup variants

Problem: Several receipt configure paths had lookup bugs: vendor UUID checked the wrong variable, customer lookup did not consistently scope through the resolved entity, and unit slug/UUID values could be assigned before resolution.

Fix: Vendor, customer, and unit inputs are now resolved through the intended entity-scoped paths before assignment.

Why it matters: Receipt configuration is used by import and receipt workflows and must not mix raw identifiers or cross-entity lookups.


Closing entries

ClosingEntryTransaction entity scoping

Problem: ClosingEntryTransactionModel.objects.for_entity(...) used an incorrect lazy-loader call and an incorrect slug relationship path.

Fix: Model resolution and slug scoping were corrected.

Why it matters: Closing-entry transaction querysets are public entity-scoped APIs.

ClosingEntry delete URL

Problem: ClosingEntryModel.get_delete_url() used a stale route name.

Fix: The helper now points to the existing delete route.

Why it matters: Model URL helpers should remain usable by views/templates/downstream code.

Single-sided closing migration

Problem: Invalid single-sided closing migration data leaked a KeyError.

Fix: It now raises ClosingEntryValidationError.

Why it matters: Public validation paths should raise domain-level validation errors, not implementation exceptions.


Invoices

Review queryset filter

Problem: InvoiceModelQuerySet.in_review() returned draft invoices instead of review invoices.

Fix: It now filters by the review status.

Why it matters: This is a direct public queryset contract.

Lifecycle date persistence

Problem: mark_as_approved(date_approved=...) and mark_as_draft(draft_date=...) did not correctly persist the supplied dates.

Fix: The correct date fields are now assigned and persisted.

Why it matters: Lifecycle dates are public document state.

Descriptive title label

Problem: InvoiceModel.generate_descriptive_title() identified invoices as bills.

Fix: It now uses the invoice label.

Why it matters: This is user-facing display/helper behavior.


Estimates

Explicit draft date

Problem: EstimateModel.configure(date_draft=...) ignored the supplied draft date.

Fix: Explicit draft dates are now preserved.

Why it matters: Public configuration APIs should respect caller-supplied document dates.

Cross-entity customer guard

Problem: EstimateModel.configure(...) could accept a customer from another entity.

Fix: Cross-entity customers are rejected.

Why it matters: Commercial documents must not bind parties across entity boundaries.

Missing void action route

Problem: Estimate mark-as-void URL helper pointed to a route/view that did not exist.

Fix: The missing view and URL route were added.

Why it matters: Public URL helpers should resolve to real routes.

Invoice / Estimate cross-entity binding

Problem: InvoiceModel.can_bind_estimate(...) allowed estimates from another entity.

Fix: A cross-entity guard was added.

Why it matters: Invoice-to-estimate binding must preserve entity isolation.


Purchase orders / Bill binding

PO item aggregation

Problem: Non-DB PO item aggregation summed the wrong field and could return 0.00 for nonzero PO rows.

Fix: It now sums po_total_amount.

Why it matters: Public aggregate helpers should produce consistent totals in DB-backed and in-memory paths.

Lifecycle date and amount persistence

Problem: Several PO lifecycle methods did not persist public state correctly: draft date, void date, fulfilled date, and received amount.

Fix: The relevant lifecycle methods now persist the intended fields.

Why it matters: Purchase order lifecycle methods should update durable public state.

Void transition query conflict

Problem: PO void transition could crash due to a queryset .only(...) conflict with manager select_related(...).

Fix: The conflicting query optimization was removed.

Why it matters: Public lifecycle actions should not fail due to internal queryset construction.

Configure-time estimate binding

Problem: PurchaseOrderModel.configure(..., estimate_model=...) attempted estimate binding before assigning self.entity.

Fix: Entity assignment now happens before binding.

Why it matters: Binding predicates rely on entity context.

Bill / PO cross-entity guard

Problem: BillModel.can_bind_po(...) could accept a PO from another entity.

Fix: Cross-entity PO binding is now rejected.

Why it matters: Bills and purchase orders must remain within the same entity.

List-backed fulfillment input

Problem: mark_as_fulfilled(..., po_items=list) crashed because the method assumed queryset-style .update(...).

Fix: List-backed input is now handled safely.

Why it matters: The public helper is more robust for prefetched item lists.

PO bill queryset specificity

Problem: get_po_bill_queryset() could overmatch bills from another PO when both POs shared the same ItemModel.

Fix: It now scopes through this PO’s actual ItemTransactionModel rows.

Why it matters: PO-to-bill relationship lookup should not overmatch through shared catalog items.


Items / Item transactions / Inventory

Estimate item eligibility

Problem: ItemModel.objects.for_estimate(...) excluded service items even though estimate itemization treats product and service items as eligible.

Fix: It now uses estimate eligibility semantics.

Why it matters: Manager and queryset helpers now agree on estimate-eligible items.

Orphan item transactions

Problem: ItemTransactionModelQuerySet.is_orphan() included invoice-owned rows.

Fix: Invoice-owned item transactions are now excluded.

Why it matters: Document-owned rows should not be classified as orphaned.

Ordered status helper

Problem: ItemTransactionModel.is_ordered() compared against the received status instead of the ordered status.

Fix: It now compares against STATUS_ORDERED.

Why it matters: Public status helpers and CSS helpers now reflect the actual item status.

Inventory account CoA resolution

Problem: EntityModel.create_item_inventory(..., inventory_account=..., coa_model=None) could dereference None before resolving the selected/default CoA.

Fix: The selected/default CoA is resolved before validating the explicit account.

Why it matters: Entity inventory factory helpers now work with default CoA behavior and explicit inventory accounts.


Customer / Vendor

Vendor picture upload path

Problem: vendor_picture_upload_to(...) used customer numbering fields/helpers on a vendor instance.

Fix: It now uses vendor numbering.

Why it matters: Vendor upload paths should be based on vendor identity.

Customer/Vendor validate_for_entity invalid inputs

Problem: Invalid inputs leaked implementation exceptions: AttributeError for customer validation and UnboundLocalError for vendor validation.

Fix: Both now raise model-specific validation errors.

Why it matters: Public validation helpers should expose domain errors.

Customer validation wording

Problem: Customer validation used stale “Vendor” wording.

Fix: The wording was corrected.

Why it matters: Validation output should match the model being validated.

ContactInfoMixin validation

Problem: Customer/Vendor clean() methods bypassed inherited contact validation.

Fix: Both now call super().clean().

Why it matters: Concrete party models now apply mixin-level validation such as address consistency.


Notes on non-fix characterization

Some surprising behavior was intentionally left unchanged and covered by characterization tests instead. Examples:

  • commit=False may consume entity-state sequences.
  • commit=False may mutate in-memory state without persisting the main row.
  • Bill and invoice overdue() filters are date-based and not status-scoped.
  • Invoice payment dates before approval dates are currently accepted.
  • Estimate gross-margin helper currently behaves like markup.
  • Customer validation remains narrower than vendor validation.
  • Existing signal payload spelling such as commited is preserved.

These tests document current public behavior so future refactors can change it intentionally rather than accidentally.

@aligalipcamli

Copy link
Copy Markdown
Author

I opened a follow-up / stacked PR for the next layer of this work:

aligalipcamli#1

This PR builds on top of the high-level API behavior/contracts branch here. The intention is not to mix the two review scopes, but to make the next direction visible early.

In short, the follow-up branch explores Swapper-backed custom model support for non-core/domain-facing Django Ledger models, while keeping the accounting core fixed. It assumes the model behavior coverage and public API fixes from this PR as its safety foundation.

Important boundary: the follow-up work is still scoped as new-project / before-initial-migrate support. It does not claim that existing installations can freely switch from built-in tables to custom model tables without project-specific data migrations.

I’m sharing it here mostly for visibility and architectural context. This PR should still be reviewed on its own merits as the high-level API behavior/test foundation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant