Skip to content

Journal Entry Line Model (account.move.line)

Overview

The Journal Entry Line model (account.move.line) represents individual debit and credit entries within a journal entry. It is the most granular level of accounting data in Netforce, forming the actual ledger entries that comprise the general ledger. Each line belongs to a journal entry (account.move) and posts to a specific account. This model handles reconciliation, tracking categories, tax components, and foreign currency amounts. It serves as the foundation for all account balances, financial reports, and reconciliation processes.


Model Information

Model Name: account.move.line
Display Name: Ledger Entry
Key Fields: None (lines are identified by move_id and sequence)
Source File: netforce_account/models/account_move_line.py

Features

  • ✅ Cascade deletion with parent move (on_delete="cascade")
  • ✅ Indexed for performance (account_id, move_date)
  • ✅ Reconciliation support via reconcile_id
  • ✅ Multi-currency support via amount_cur
  • ✅ Tracking/analytics integration via track_id, track2_id

Understanding Journal Entry Lines

What are Journal Entry Lines?

Journal entry lines are the individual debit and credit entries that make up a complete accounting transaction. In double-entry bookkeeping:

  • Every transaction has at least 2 lines (one debit, one credit)
  • Total debits must equal total credits for the parent move to balance
  • Each line posts to one account and affects that account's balance

Example Transaction:

Cash Sale of $1,000

Line 1: Cash Account (101)           DR: $1,000  CR: $0
Line 2: Revenue Account (401)        DR: $0      CR: $1,000

Line Relationship Structure

account.move (Journal Entry)
    └── account.move.line (Line 1) → account.account (Cash)
    └── account.move.line (Line 2) → account.account (Revenue)
    └── account.move.line (Line 3) → account.account (Tax)

Reconciliation States

State Code Description
Not Reconciled not_reconciled Line has not been matched with offsetting entries
Reconciled reconciled Line has been matched and balanced with other entries

Reconciliation is used primarily for: - Receivables/Payables: Matching invoices with payments - Bank Accounts: Matching transactions with bank statements - Clearing Accounts: Ensuring temporary accounts balance to zero


Key Fields Reference

Core Fields

Field Type Required Description
move_id Many2One Parent journal entry
sequence Integer Line order within entry
description Text Line description (defaults to move narration)
account_id Many2One General ledger account
debit Decimal Debit amount (must be ≥ 0)
credit Decimal Credit amount (must be ≥ 0)
state Selection Reconciliation state

Parent Move Fields (Computed/Related)

Field Type Description
move_date Date Transaction date (from move)
move_state Selection Move state (draft/posted/voided)
move_narration Char Move narration
move_type Selection Move type (invoice/payment/transfer/etc.)
move_ref Char Move reference
move_number Char Move/journal entry number

Account Fields (Computed/Related)

Field Type Description
account_name Char Account name
account_code Char Account code

Tax Fields

Field Type Description
tax_comp_id Many2One Tax component (VAT, WHT, etc.)
tax_base Decimal Base amount for tax calculation
tax_no Char Tax invoice number
tax_date Date Tax invoice date

Tracking/Analytics Fields

Field Type Description
track_id Many2One Primary tracking category (e.g., department, project)
track2_id Many2One Secondary tracking category

Relationship Fields

Field Type Description
contact_id Many2One Related contact (customer/supplier)
product_id Many2One Related product
invoice_id Many2One Related invoice
stock_move_id Many2One Related stock movement
due_date Date Payment due date (for AR/AP)

Reconciliation Fields

Field Type Description
reconcile_id Many2One Reconciliation record
statement_lines Many2Many Related bank statement lines
statement_line_id Many2One Statement line (deprecated)
statement_line_search Many2One Statement line search helper
bank_reconcile_id Many2One Bank reconciliation (deprecated)
is_account_reconciled Boolean Whether reconciliation is balanced (computed)

Additional Fields

Field Type Description
qty Decimal Quantity (for inventory tracking)
amount_cur Decimal Amount in foreign currency
empty_contact Boolean Search helper for lines without contact

API Methods

1. Create Line

Method: create(vals, context)

Creates a new journal entry line. Usually called as part of journal entry creation.

Parameters:

vals = {
    "move_id": 123,                   # Required: Parent move ID
    "account_id": 101,                # Required: Account ID
    "description": "Cash received",   # Optional (defaults to move narration)
    "debit": 1000.00,                # Required: Debit amount
    "credit": 0.00,                  # Required: Credit amount
    "contact_id": 456,               # Optional: Contact ID
    "track_id": 10,                  # Optional: Tracking category
    "tax_comp_id": 5,                # Optional: Tax component
    "tax_base": 1000.00,             # Optional: Tax base amount
    "due_date": "2025-02-15"         # Optional: Due date
}

Returns: int - New line ID

Example:

# Usually created as part of journal entry
move_id = get_model("account.move").create({
    "journal_id": journal_id,
    "date": "2025-01-15",
    "narration": "Cash sale",
    "lines": [
        ("create", {
            "account_id": cash_account_id,
            "debit": 1000.00,
            "credit": 0
        }),
        ("create", {
            "account_id": revenue_account_id,
            "debit": 0,
            "credit": 1000.00
        })
    ]
})


2. Reconcile Lines

Method: reconcile(ids, context)

Reconciles multiple lines together, typically matching invoices with payments.

Parameters: - ids (list): Line IDs to reconcile together

Behavior: - Creates new reconciliation record - Includes lines from existing reconciliations (merges) - Validates all lines use same account - Links all lines to reconciliation - Updates related invoice states

Returns: None

Example:

# Reconcile invoice with payment
invoice_line_id = 123  # From invoice move
payment_line_id = 456  # From payment move

get_model("account.move.line").reconcile([invoice_line_id, payment_line_id])

# Verify reconciliation
line = get_model("account.move.line").browse(invoice_line_id)
print(f"Reconciled: {line.reconcile_id is not None}")
print(f"Balanced: {line.is_account_reconciled}")

Validation Errors: - "Can only reconcile transactions of same account" - Lines must be from same account


3. Unreconcile Lines

Method: unreconcile(ids, context)

Removes reconciliation from lines, typically used for bank statement lines.

Parameters: - ids (list): Line IDs to unreconcile

Behavior: - Finds related bank statement lines - Unreconciles statement lines - Sets line state to not_reconciled

Returns: None

Example:

get_model("account.move.line").unreconcile([line_id])


4. Unreconcile Manually

Method: unreconcile_manual(ids, context)

Removes reconciliation manually, including all lines in the reconciliation.

Parameters: - ids (list): Line IDs to unreconcile

Behavior: - Finds all lines in same reconciliation(s) - Removes reconcile_id from all lines - Does not update statement lines

Returns: None

Example:

# Remove reconciliation from invoice/payment match
get_model("account.move.line").unreconcile_manual([invoice_line_id])


5. Remove from All Reconciliations

Method: reconcile_remove_from_all(ids, context)

Removes lines from all bank statement reconciliations.

Parameters: - ids (list): Line IDs

Behavior: - Clears all statement_lines relationships

Returns: None

Example:

get_model("account.move.line").reconcile_remove_from_all([line_id])


6. View Transaction

Method: view_transaction(ids, context)

Navigates to the parent journal entry.

Parameters: - ids (list): Line IDs

Returns: dict with next navigation action

Example:

# Navigate to journal entry from line
result = get_model("account.move.line").view_transaction([line_id])
# Redirects to journal entry form


7. Delete Line

Method: delete(ids, context)

Deletes journal entry lines.

Parameters: - ids (list): Line IDs to delete

Behavior: - Validates parent move is not posted - Updates related reconciliations - Deletes lines

Returns: None

Example:

# Can only delete lines from draft entries
get_model("account.move.line").delete([line_id])

Validation Errors: - "Can not delete line of posted journal entry"


Search Functions

Search by Account

# Find all lines for specific account
condition = [["account_id", "=", account_id]]

Search by Date Range

# Find lines in date range (uses indexed field)
condition = [
    ["move_date", ">=", "2025-01-01"],
    ["move_date", "<=", "2025-01-31"]
]

Search by Contact

# Find lines for specific contact
condition = [["contact_id", "=", contact_id]]

Search by Move Number

# Find lines by journal entry number
condition = [["move_number", "=", "JE-2025-001"]]

Search Unreconciled Lines

# Find unreconciled lines for an account
condition = [
    ["account_id", "=", account_id],
    ["state", "=", "not_reconciled"]
]

Search Lines Without Contact

# Find lines missing contact (where required)
condition = [["empty_contact", "=", True]]

Search by Statement Line

# Find lines linked to statement line
condition = [["statement_line_search", "=", statement_line_id]]

Computed Fields Functions

_is_account_reconciled(ids, context)

Checks if the reconciliation this line belongs to is fully balanced: - Returns True if abs(reconcile_id.balance) == 0 - Returns False if no reconciliation or unbalanced

_get_related(ids, context)

Generic function to retrieve related field values: - Retrieves values from parent move (date, state, narration, etc.) - Retrieves values from account (name, code)

_search_empty_contact(clause, context)

Search helper for finding lines with/without contacts: - Used to locate lines requiring contact assignment


Database Indexes

The model has composite indexes for performance:

_indexes = [
    ("account_id", "move_date"),  # For account balance queries
]

This makes queries filtering by account and date range very fast, which is critical for: - Account balance calculations - Trial balance generation - Aged receivables/payables reports - Period-based financial reports


Best Practices

1. Always Set Either Debit or Credit (Never Both)

# Bad: Both debit and credit set
line_vals = {
    "account_id": account_id,
    "debit": 1000.00,
    "credit": 500.00  # Wrong!
}

# Good: Only one side set
line_vals = {
    "account_id": account_id,
    "debit": 1000.00,
    "credit": 0  # Correct
}

2. Use Description for Line-Level Detail

# Bad: Generic description
line_vals = {
    "description": "Transaction",  # Not helpful
    "account_id": account_id,
    ...
}

# Good: Specific description
line_vals = {
    "description": "Payment received for Invoice INV-2025-001",
    "account_id": account_id,
    ...
}

3. Set amount_cur for Foreign Currency Accounts

# Good: Include currency amount
line_vals = {
    "account_id": usd_bank_account_id,
    "debit": 1350.00,      # Base currency (THB)
    "credit": 0,
    "amount_cur": 1000.00  # Foreign currency (USD)
}

4. Use Tracking Categories for Analytics

# Good: Set tracking for analytics
line_vals = {
    "account_id": expense_account_id,
    "debit": 5000.00,
    "credit": 0,
    "track_id": department_id,      # Department tracking
    "track2_id": project_id,        # Project tracking
    "contact_id": vendor_id
}

# Good: Reconcile invoice with payment
invoice = get_model("account.invoice").browse(invoice_id)
payment = get_model("account.payment").browse(payment_id)

# Find AR/AP lines
invoice_line = invoice.move_id.lines[0]  # First line is usually AR/AP
payment_lines = [l for l in payment.move_id.lines 
                 if l.account_id.id == invoice_line.account_id.id]

if payment_lines:
    get_model("account.move.line").reconcile([
        invoice_line.id,
        payment_lines[0].id
    ])

Model Relationship Description
account.move Many2One Parent journal entry
account.account Many2One General ledger account
account.reconcile Many2One Reconciliation record
account.tax.component Many2One Tax component
account.track.categ Many2One Tracking category
contact Many2One Customer/supplier
product Many2One Product
account.invoice Many2One Related invoice
stock.move Many2One Related stock movement
account.statement.line Many2Many Bank statement lines

Common Use Cases

Use Case 1: Query Account Balance

# Calculate account balance for a date range
account_id = 101  # Cash account
start_date = "2025-01-01"
end_date = "2025-01-31"

lines = get_model("account.move.line").search_browse([
    ["account_id", "=", account_id],
    ["move_date", ">=", start_date],
    ["move_date", "<=", end_date],
    ["move_state", "=", "posted"]
])

total_debit = sum(line.debit for line in lines)
total_credit = sum(line.credit for line in lines)
balance = total_debit - total_credit

print(f"Account Balance: {balance}")

Use Case 2: Find Unreconciled Receivables

# Find outstanding customer invoices
ar_account_id = 120  # Accounts Receivable

unreconciled_lines = get_model("account.move.line").search_browse([
    ["account_id", "=", ar_account_id],
    ["state", "=", "not_reconciled"],
    ["move_state", "=", "posted"]
])

for line in unreconciled_lines:
    amount = line.debit - line.credit
    print(f"Invoice: {line.move_number}")
    print(f"Customer: {line.contact_id.name if line.contact_id else 'N/A'}")
    print(f"Amount: {amount}")
    print(f"Due Date: {line.due_date or 'N/A'}")
    print("---")

Use Case 3: Reconcile Invoice with Payment

# Match invoice with payment
invoice_id = 123
payment_id = 456

# Get invoice AR line
invoice = get_model("account.invoice").browse(invoice_id)
invoice_move = invoice.move_id
invoice_ar_line = None
for line in invoice_move.lines:
    if line.account_id.type in ("receivable", "payable"):
        invoice_ar_line = line
        break

# Get payment AR line
payment = get_model("account.payment").browse(payment_id)
payment_move = payment.move_id
payment_ar_line = None
for line in payment_move.lines:
    if line.account_id.id == invoice_ar_line.account_id.id:
        payment_ar_line = line
        break

# Reconcile
if invoice_ar_line and payment_ar_line:
    get_model("account.move.line").reconcile([
        invoice_ar_line.id,
        payment_ar_line.id
    ])
    print("Invoice and payment reconciled")

Use Case 4: Generate Aged Receivables Report

from datetime import datetime, timedelta

# Define aging periods
today = datetime.now().date()
periods = [
    ("Current", 0),
    ("1-30 days", 30),
    ("31-60 days", 60),
    ("61-90 days", 90),
    ("Over 90 days", 999)
]

ar_account_id = 120

# Get all unreconciled AR lines
lines = get_model("account.move.line").search_browse([
    ["account_id", "=", ar_account_id],
    ["state", "=", "not_reconciled"],
    ["move_state", "=", "posted"]
])

# Group by customer and age
customer_aging = {}
for line in lines:
    customer_id = line.contact_id.id if line.contact_id else None
    customer_name = line.contact_id.name if line.contact_id else "Unknown"

    amount = line.debit - line.credit
    if amount <= 0:
        continue

    # Calculate age
    invoice_date = datetime.strptime(line.move_date, "%Y-%m-%d").date()
    age_days = (today - invoice_date).days

    # Determine period
    period_name = "Over 90 days"
    for name, max_days in periods:
        if age_days <= max_days:
            period_name = name
            break

    # Aggregate
    if customer_id not in customer_aging:
        customer_aging[customer_id] = {
            "name": customer_name,
            "periods": {p[0]: 0 for p in periods}
        }

    customer_aging[customer_id]["periods"][period_name] += amount

# Print report
print("AGED RECEIVABLES REPORT")
print("=" * 80)
for customer_id, data in customer_aging.items():
    print(f"\nCustomer: {data['name']}")
    for period in periods:
        amount = data["periods"][period[0]]
        if amount > 0:
            print(f"  {period[0]}: {amount:,.2f}")

Use Case 5: Find Lines Requiring Contact Assignment

# Find lines where account requires contact but none assigned
lines = get_model("account.move.line").search_browse([
    ["empty_contact", "=", True],
    ["move_state", "=", "posted"]
])

print("Lines requiring contact assignment:")
for line in lines:
    if line.account_id.require_contact:
        print(f"Move: {line.move_number}")
        print(f"Account: {line.account_code} - {line.account_name}")
        print(f"Amount: DR {line.debit} CR {line.credit}")
        print("---")

Use Case 6: Track Expenses by Department and Project

# Analyze expenses with dual tracking
expense_account_id = 501

lines = get_model("account.move.line").search_browse([
    ["account_id", "=", expense_account_id],
    ["move_date", ">=", "2025-01-01"],
    ["move_date", "<=", "2025-01-31"],
    ["move_state", "=", "posted"]
])

# Group by department and project
tracking = {}
for line in lines:
    dept = line.track_id.name if line.track_id else "Unassigned"
    proj = line.track2_id.name if line.track2_id else "Unassigned"

    key = (dept, proj)
    if key not in tracking:
        tracking[key] = 0

    tracking[key] += line.debit

# Print analysis
print("EXPENSE ANALYSIS BY DEPARTMENT AND PROJECT")
print("=" * 80)
for (dept, proj), amount in sorted(tracking.items()):
    print(f"{dept:30} | {proj:30} | {amount:>15,.2f}")

Performance Tips

1. Use Indexed Fields for Queries

# Good: Uses indexed fields (account_id, move_date)
lines = get_model("account.move.line").search_browse([
    ["account_id", "=", account_id],
    ["move_date", ">=", start_date],
    ["move_date", "<=", end_date]
])

2. Filter by Posted State

# Good: Exclude draft entries
lines = get_model("account.move.line").search_browse([
    ["account_id", "=", account_id],
    ["move_state", "=", "posted"]  # Only posted entries
])

3. Use Direct SQL for Large Reports

# For very large datasets, use direct SQL
from netforce import database
db = database.get_connection()

sql = """
    SELECT 
        account_id,
        SUM(debit) as total_debit,
        SUM(credit) as total_credit
    FROM account_move_line l
    JOIN account_move m ON l.move_id = m.id
    WHERE m.state = 'posted'
        AND m.date >= %s
        AND m.date <= %s
    GROUP BY account_id
"""
res = db.query(sql, start_date, end_date)

Troubleshooting

"Can not delete line of posted journal entry"

Cause: Attempting to delete line from posted entry
Solution: Unpost the journal entry first (to_draft), then delete

"Can only reconcile transactions of same account"

Cause: Attempting to reconcile lines from different accounts
Solution: Only reconcile lines that post to the same account (e.g., same AR account)

Lines not appearing in balance queries

Cause: Parent move not posted
Solution: Ensure move_state = 'posted' in search conditions

Reconciliation not clearing invoice

Cause: Reconciliation not fully balanced
Solution: Check reconcile_id.balance = 0, may need additional lines

Missing currency amount error

Cause: Foreign currency account line without amount_cur
Solution: Always set amount_cur for multi-currency accounts


Testing Examples

Unit Test: Create and Query Lines

def test_create_and_query_lines():
    # Create journal entry with lines
    move_id = get_model("account.move").create({
        "journal_id": 1,
        "date": "2025-01-15",
        "narration": "Test entry",
        "lines": [
            ("create", {
                "account_id": 101,
                "description": "Test debit",
                "debit": 500.00,
                "credit": 0
            }),
            ("create", {
                "account_id": 401,
                "description": "Test credit",
                "debit": 0,
                "credit": 500.00
            })
        ]
    })

    # Query lines
    lines = get_model("account.move.line").search_browse([
        ["move_id", "=", move_id]
    ])

    # Verify
    assert len(lines) == 2
    assert lines[0].debit == 500.00
    assert lines[1].credit == 500.00

    # Cleanup
    move = get_model("account.move").browse(move_id)
    move.delete(force=True)

Unit Test: Reconcile Lines

def test_reconcile_lines():
    # Create two offsetting entries
    move1_id = get_model("account.move").create({
        "journal_id": 1,
        "date": "2025-01-15",
        "narration": "Invoice",
        "lines": [
            ("create", {
                "account_id": 120,  # AR
                "debit": 1000.00,
                "credit": 0
            }),
            ("create", {
                "account_id": 401,  # Revenue
                "debit": 0,
                "credit": 1000.00
            })
        ]
    })
    get_model("account.move").post([move1_id])

    move2_id = get_model("account.move").create({
        "journal_id": 1,
        "date": "2025-01-20",
        "narration": "Payment",
        "lines": [
            ("create", {
                "account_id": 101,  # Cash
                "debit": 1000.00,
                "credit": 0
            }),
            ("create", {
                "account_id": 120,  # AR
                "debit": 0,
                "credit": 1000.00
            })
        ]
    })
    get_model("account.move").post([move2_id])

    # Find AR lines
    move1 = get_model("account.move").browse(move1_id)
    move2 = get_model("account.move").browse(move2_id)

    ar_line1 = [l for l in move1.lines if l.account_id.id == 120][0]
    ar_line2 = [l for l in move2.lines if l.account_id.id == 120][0]

    # Reconcile
    get_model("account.move.line").reconcile([ar_line1.id, ar_line2.id])

    # Verify
    ar_line1 = ar_line1.browse()[0]  # Refresh
    assert ar_line1.reconcile_id is not None
    assert ar_line1.is_account_reconciled == True

    # Cleanup
    get_model("account.move").void([move1_id, move2_id])

Security Considerations

Permission Model

Lines inherit permissions from parent moves. Users need: - move_view: View lines - move_create: Create lines (via move creation) - move_edit: Edit lines (on draft moves) - move_delete: Delete lines

Data Access

  • Lines are visible based on parent move company
  • Reconciliation requires appropriate permissions
  • Sensitive fields (amounts, contacts) protected by field-level security

Integration Points

Internal Modules

  • Journal Entries: Parent container for lines
  • Accounts: Posts to general ledger
  • Reconciliation: Matches transactions
  • Bank Statements: Links to statement lines
  • Invoices: Creates AR/AP lines
  • Payments: Creates payment lines
  • Tracking: Creates analytics entries
  • Reports: Source for all financial reports

Version History

Last Updated: November 7, 2025
Model File: netforce_account/models/account_move_line.py
Framework: Netforce
Document Version: 1.0.0
Complexity: ⭐⭐⭐⭐⭐ (Very High - Foundation of accounting)


Additional Resources


Support & Feedback

For issues or questions about this module: 1. Check parent journal entry is posted 2. Verify account types and requirements 3. Check reconciliation balance 4. Review indexed field usage for performance 5. Test queries in development environment


This documentation is generated for developer onboarding and reference purposes.