PartyLedger — розрахунки з контрагентами¶
Статус: ✅ реалізовано (2026-04-22) — заміна для декларованих
ClientLedger/SupplierLedger
Коротко: один регістр замість двох. Відповідає на питання «скільки нам винен клієнт» і «скільки ми винні постачальнику» через фільтр direction.
1. Навіщо цей регістр¶
Без окремого регістра розрахунків бухгалтер змушений щоразу агрегувати Invoice + IncomingPayment (для клієнтів) або PurchaseInvoice + OutgoingPayment (для постачальників) — це дорогі SQL-запити з JOIN-ами, які не масштабуються на тисячі контрагентів.
Типові запити, на які відповідає PartyLedger:
- 💰 «Скільки нам винен ТОВ "Альфа" станом на 30.04?» — один рядок з running_balance.
- 📊 «Загальна дебіторка по всіх клієнтах» — SUM по остатнім рядкам кожного client_id.
- 📈 «Aging 0-30 / 31-60 / 61-90 / 90+ днів» — BUCKETSUM за датою документа.
- 🧾 «Які документи формують борг клієнта Х?» — drill-down по PartyLedger WHERE client=X.
- 🕒 Графік зміни балансу в часі — running_balance відсортований по даті.
2. Модель (backend)¶
Файл: backend/registers/models.py
class PartyLedger(TenantAwareModel):
DIRECTION_CHOICES = [
('receivable', 'Receivable (client debt to us)'),
('payable', 'Payable (our debt to supplier)'),
]
direction = models.CharField(max_length=20, choices=DIRECTION_CHOICES)
client = models.ForeignKey('essentials.Client', ...) # той самий Client — є is_buyer / is_supplier
organization = models.ForeignKey('essentials.Organization', ...)
currency = models.ForeignKey('essentials.Currency', ...)
date = models.DateField(db_index=True)
document_type = models.CharField(max_length=50) # 'Invoice' | 'IncomingPayment' | 'PurchaseInvoice' | 'OutgoingPayment'
document_id = models.PositiveIntegerField()
debit = models.DecimalField(max_digits=18, decimal_places=2, default=0)
credit = models.DecimalField(max_digits=18, decimal_places=2, default=0)
running_balance = models.DecimalField(max_digits=18, decimal_places=2, default=0)
comment = models.CharField(max_length=500, blank=True)
class Meta:
indexes = [
models.Index(fields=['tenant', 'direction', 'client', 'date']),
models.Index(fields=['tenant', 'document_type', 'document_id']),
]
Семантика дебету/кредиту:
| Напрямок | Документ | Debit | Credit | Логіка |
|---|---|---|---|---|
receivable |
Invoice (продаж) |
total_gross |
0 | клієнт тепер винен нам |
receivable |
IncomingPayment |
0 | amount |
клієнт заплатив — борг зменшився |
payable |
PurchaseInvoice (купівля) |
0 | total_amount |
ми тепер винні постачальнику |
payable |
OutgoingPayment |
amount |
0 | ми заплатили — борг зменшився |
running_balance завжди перераховується хронологічно (по date, id) після будь-якого post/unpost — функція recompute_running_balance(tenant, direction, client).
3. Автопостинг (signals)¶
Файл: backend/essentials/services/posting.py
Функції post_* / unpost_* викликаються з Django signals при збереженні / видаленні транзакційних документів:
| Документ | Signal post | Signal unpost |
|---|---|---|
Invoice (sales) |
post_invoice_party_ledger(invoice) |
unpost_invoice_party_ledger(invoice) |
IncomingPayment |
post_incoming_payment_party_ledger(payment) |
unpost_incoming_payment_party_ledger(payment) |
PurchaseInvoice |
post_purchase_invoice_party_ledger(pi) |
unpost_purchase_invoice_party_ledger(pi) |
OutgoingPayment |
post_outgoing_payment_party_ledger(payment) |
unpost_outgoing_payment_party_ledger(payment) |
Після кожного post/unpost викликається recompute_running_balance(...) для відповідного клієнта.
Idempotent: повторний post_* не створює дублікат — сигнал спершу видаляє існуючий запис по (document_type, document_id).
3-bis. Синхронізація з планом рахунків (PartyLedger ↔ PostingEntry)¶
✅ Оновлення 2026-04-22 — PartyLedger і проводки на рахунки плану рахунків (наприклад, 4100 — Customers, 4000/4010 — Suppliers, 5300/5310 — Cash у європейському PCG) завжди в синхроні.
Проблема до цього фіксу: post_*_party_ledger спрацьовував безумовно (навіть без business_operation на документі), а post_accounting_entry — тільки якщо документ мав явну BusinessOperation. Наслідок: баланс PartyLedger міг показувати 1200 грн дебіторки, а рахунок 4100 у Balance Sheet / Trial Balance / ПСБО Формі 1 — 0 грн.
Як вирішено: додано fallback-ланцюжок вибору BusinessOperation:
document.business_operation— якщо явно встановлено на документі;EssentialsModuleSettings.default_*_operation— дефолт для тенанту.
Поля в EssentialsModuleSettings (налаштовуються адміністратором один раз):
| Поле settings | Для документа | Очікувані рахунки (PCG) |
|---|---|---|
default_sales_operation |
Invoice (продаж) | Dt 4100 / Ct 7000 |
default_incoming_payment_operation |
IncomingPayment | Dt 5310 / Ct 4100 |
default_purchase_operation |
PurchaseInvoice | Dt 6xx / Ct 4000 |
default_outgoing_payment_operation |
OutgoingPayment | Dt 4000 / Ct 5310 |
Якщо жодного BO не знайдено — PartyLedger все одно створюється (для операційного обліку), а проводка по плану рахунків просто пропускається з log-повідомленням. Це навмисний graceful degradation для історичних / seed-даних, де BO не встановлено.
Гарантія: коли default_sales_operation сконфігурованe, для Invoice на 1200 грн:
- створюється
PartyLedger(direction='receivable', debit=1200); - створюється
PostingGroup+ 2PostingEntry: Dt 4100 1200 / Ct 7000 1200; - обидва сальдо по рахунку 4100 та PartyLedger — ідентичні.
Тест консистентності: TestPartyLedgerMatchesChartOfAccounts у test_party_ledger.py.
Чому це важливо: Balance Sheet / ПСБО Форма 1 / Trial Balance рахують з PostingEntry. Якщо PartyLedger і PostingEntry розсинхронізовані — офіційний звіт брехатиме. Тепер — одне джерело правди.
4. API¶
4.1. Звіт залишків (/report/)¶
GET /api/v1/registers/ledgers/party/report/?direction=receivable&as_of=2026-04-30
Повертає сумарний баланс по кожному контрагенту на дату as_of.
Параметри:
| Параметр | Обов'язково | Опис |
|----------|-------------|------|
| direction | ✅ | receivable / payable |
| as_of | ⏳ (default: today) | дата зрізу |
| client | ⏳ | фільтр по одному клієнту |
| organization | ⏳ | фільтр по організації |
Відповідь:
{
"direction": "receivable",
"as_of": "2026-04-30",
"count": 12,
"total_balance": "45823.00",
"rows": [
{
"client_id": 42,
"client_name": "ТОВ Альфа",
"organization_id": 1,
"balance": "1200.00",
"last_operation_date": "2026-04-15"
},
...
]
}
4.2. Drill-down по документах (/by-document/)¶
GET /api/v1/registers/ledgers/party/by-document/?direction=receivable&client=42
Повертає хронологічний список усіх документів конкретного клієнта з дебет/кредит/running_balance.
Відповідь:
{
"client_id": 42,
"client_name": "ТОВ Альфа",
"direction": "receivable",
"entries": [
{
"date": "2026-04-10",
"document_type": "Invoice",
"document_id": 123,
"document_number": "INV-123",
"debit": "1000.00",
"credit": "0.00",
"running_balance": "1000.00"
},
{
"date": "2026-04-15",
"document_type": "IncomingPayment",
"document_id": 456,
"document_number": "IP-456",
"debit": "0.00",
"credit": "300.00",
"running_balance": "700.00"
}
]
}
5. Frontend¶
Компонент: PartyLedgerReport.tsx
Розташування у sidebar: Essentials → Фінансові звіти → Розрахунки з контрагентами.
UI:
- SegmentedControl — перемикання Дебіторка / Кредиторка (direction).
- Поле дати — фільтр as_of.
- Таблиця залишків — сума, остання операція.
- Клік по рядку → панель drill-down з хронологією документів.
- Клік по документу → перехід до його форми (через DOC_ROUTE мапу).
6. Тести¶
Файл: backend/essentials/tests/test_party_ledger.py — 12 тестів.
| Тест | Що перевіряє |
|---|---|
TestInvoicePartyLedger.test_post_creates_receivable_debit |
Invoice створює рядок з Debit = total_gross |
TestInvoicePartyLedger.test_unpost_removes_row |
Unpost ідемпотентно видаляє запис |
TestIncomingPaymentPartyLedger.test_post_creates_receivable_credit |
Оплата створює рядок з Credit = amount |
TestReceivableRunningBalance.test_running_balance_chronological |
running_balance перераховується правильно: 1000 → 1500 → 800 |
TestPurchaseInvoicePartyLedger.test_post_creates_payable_credit |
Закупівля створює payable з Credit |
TestOutgoingPaymentPartyLedger.test_post_creates_payable_debit |
Оплата постачальнику — Debit |
TestPartyLedgerReportEndpoint.test_valid_request_returns_200 |
Ендпоінт /report/ повертає валідні дані |
TestPartyLedgerByDocumentEndpoint.test_returns_entries |
Ендпоінт /by-document/ повертає список |
⭐ TestPartyLedgerMatchesChartOfAccounts.test_invoice_posts_to_party_ledger_and_chart_of_accounts |
sync PartyLedger ↔ PostingEntry на рахунку 4100 через default_sales_operation |
⭐ TestPartyLedgerMatchesChartOfAccounts.test_unpost_clears_both_party_ledger_and_postings |
unpost очищає обидва регістри разом |
Усі 12 тестів проходять (див. аудит 2026-04-21).
7. Чому один регістр, а не два¶
Історично (документація 2024): ClientLedger окремо, SupplierLedger окремо.
Реальність: 95% полів однакові, логіка посту однакова (лише знак інший), а клієнт може бути і покупцем, і постачальником одночасно (is_buyer=True, is_supplier=True).
Плюси об'єднання:
- Один queryset + один serializer + один endpoint — ~40% менше коду.
- Client залишається єдиною сутністю — не треба дублювати в Supplier.
- Фільтр direction='receivable' дає той самий результат, що окремий ClientLedger.
- Легше робити консолідовані звіти (сальдо клієнт-постачальник, коли одна сторона — обидва одночасно).
🔮 Deferred / Ideas¶
Aging Report (0-30 / 31-60 / 61-90 / 90+)¶
Мотивація: стандартний звіт для CFO — «який відсоток дебіторки простроченої».
Чому відкладено: потрібен due_date у документі + bucket-агрегація.
Trigger: запит клієнта з великою дебіторкою.
Currency-aware ledger¶
Мотивація: контракт у USD → payment теж USD — running_balance має бути у валюті документа, а не лише UAH. Чому відкладено: поточна реалізація агрегує у functional currency. Trigger: імпортер/експортер.
Автоматичне закриття (matching Invoice ↔ Payment)¶
Мотивація: бухгалтер хоче бачити, який рахунок яким платежем закритий.
Чому відкладено: FIFO-matching потребує окремого регістру Settlement.
Trigger: робота з великою кількістю часткових оплат.
Statement of account (акт звірки) PDF/email¶
Мотивація: юристу/бухгалтеру — документ на підпис клієнту. Чому відкладено: потрібен print template + signing flow. Trigger: B2B-операції з формальною звіркою.