lambda-erp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- api/__init__.py +0 -0
- api/attachments.py +229 -0
- api/auth.py +511 -0
- api/bootstrap.py +498 -0
- api/chat.py +2764 -0
- api/demo_limits.py +400 -0
- api/deps.py +7 -0
- api/errors.py +56 -0
- api/main.py +182 -0
- api/pdf.py +151 -0
- api/providers.py +116 -0
- api/routers/__init__.py +0 -0
- api/routers/accounting.py +63 -0
- api/routers/admin.py +122 -0
- api/routers/analytics.py +1009 -0
- api/routers/bank_reconciliation.py +31 -0
- api/routers/documents.py +100 -0
- api/routers/masters.py +396 -0
- api/routers/reports.py +735 -0
- api/routers/setup.py +387 -0
- api/services.py +372 -0
- api/templates/document.html +197 -0
- lambda_erp/__init__.py +3 -0
- lambda_erp/accounting/__init__.py +0 -0
- lambda_erp/accounting/bank_transaction.py +76 -0
- lambda_erp/accounting/budget.py +117 -0
- lambda_erp/accounting/chart_of_accounts.py +183 -0
- lambda_erp/accounting/general_ledger.py +362 -0
- lambda_erp/accounting/journal_entry.py +235 -0
- lambda_erp/accounting/payment_entry.py +515 -0
- lambda_erp/accounting/pos_invoice.py +342 -0
- lambda_erp/accounting/purchase_invoice.py +504 -0
- lambda_erp/accounting/revaluation.py +172 -0
- lambda_erp/accounting/sales_invoice.py +523 -0
- lambda_erp/accounting/subscription.py +132 -0
- lambda_erp/buying/__init__.py +0 -0
- lambda_erp/buying/purchase_order.py +165 -0
- lambda_erp/controllers/__init__.py +0 -0
- lambda_erp/controllers/currency.py +52 -0
- lambda_erp/controllers/defaults.py +51 -0
- lambda_erp/controllers/pricing_rule.py +103 -0
- lambda_erp/controllers/taxes_and_totals.py +369 -0
- lambda_erp/database.py +1543 -0
- lambda_erp/exceptions.py +37 -0
- lambda_erp/hooks.py +37 -0
- lambda_erp/model.py +462 -0
- lambda_erp/selling/__init__.py +0 -0
- lambda_erp/selling/quotation.py +263 -0
- lambda_erp/selling/sales_order.py +214 -0
- lambda_erp/simulation.py +704 -0
- lambda_erp/stock/__init__.py +0 -0
- lambda_erp/stock/delivery_note.py +254 -0
- lambda_erp/stock/purchase_receipt.py +356 -0
- lambda_erp/stock/stock_entry.py +330 -0
- lambda_erp/stock/stock_ledger.py +337 -0
- lambda_erp/utils.py +167 -0
- lambda_erp-0.1.0.dist-info/METADATA +454 -0
- lambda_erp-0.1.0.dist-info/RECORD +60 -0
- lambda_erp-0.1.0.dist-info/WHEEL +4 -0
- lambda_erp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Payment Entry.
|
|
3
|
+
|
|
4
|
+
Payment Entry handles:
|
|
5
|
+
- Receiving money from customers (against Sales Invoices)
|
|
6
|
+
- Paying money to suppliers (against Purchase Invoices)
|
|
7
|
+
- Internal transfers between bank/cash accounts
|
|
8
|
+
|
|
9
|
+
GL entries on submit:
|
|
10
|
+
Receive from Customer:
|
|
11
|
+
Debit: Bank/Cash Account = received_amount
|
|
12
|
+
Credit: Accounts Receivable = paid_amount
|
|
13
|
+
|
|
14
|
+
Pay to Supplier:
|
|
15
|
+
Debit: Accounts Payable = paid_amount
|
|
16
|
+
Credit: Bank/Cash Account = paid_amount
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from lambda_erp.model import Document
|
|
20
|
+
from lambda_erp.utils import _dict, flt, nowdate
|
|
21
|
+
from lambda_erp.database import get_db
|
|
22
|
+
from lambda_erp.accounting.general_ledger import (
|
|
23
|
+
make_gl_entries,
|
|
24
|
+
make_reverse_gl_entries,
|
|
25
|
+
get_account_balances,
|
|
26
|
+
)
|
|
27
|
+
from lambda_erp.controllers.defaults import set_default_currency
|
|
28
|
+
from lambda_erp.exceptions import ValidationError
|
|
29
|
+
|
|
30
|
+
class PaymentEntry(Document):
|
|
31
|
+
DOCTYPE = "Payment Entry"
|
|
32
|
+
CHILD_TABLES = {
|
|
33
|
+
"references": ("Payment Entry Reference", None),
|
|
34
|
+
}
|
|
35
|
+
PREFIX = "PE"
|
|
36
|
+
|
|
37
|
+
LINK_FIELDS = {
|
|
38
|
+
"company": "Company",
|
|
39
|
+
"paid_from": "Account",
|
|
40
|
+
"paid_to": "Account",
|
|
41
|
+
}
|
|
42
|
+
# `party` and `references[].reference_name` are dynamic — target doctype
|
|
43
|
+
# resolves from party_type / reference_doctype at validation time.
|
|
44
|
+
DYNAMIC_LINK_FIELDS = {
|
|
45
|
+
"party": ("party_type", {"Customer": "Customer", "Supplier": "Supplier"}),
|
|
46
|
+
}
|
|
47
|
+
CHILD_DYNAMIC_LINK_FIELDS = {
|
|
48
|
+
"references": {
|
|
49
|
+
"reference_name": ("reference_doctype", {
|
|
50
|
+
"Sales Invoice": "Sales Invoice",
|
|
51
|
+
"Purchase Invoice": "Purchase Invoice",
|
|
52
|
+
"POS Invoice": "POS Invoice",
|
|
53
|
+
}),
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_PARTY_LEDGER_BY_TYPE = {
|
|
58
|
+
"Customer": "default_receivable_account",
|
|
59
|
+
"Supplier": "default_payable_account",
|
|
60
|
+
}
|
|
61
|
+
_ALLOWED_PARTY_TYPES = {"Customer", "Supplier"}
|
|
62
|
+
|
|
63
|
+
def validate(self):
|
|
64
|
+
if not self.payment_type:
|
|
65
|
+
raise ValidationError("Payment Type is required (Receive, Pay, or Internal Transfer)")
|
|
66
|
+
if not self.posting_date:
|
|
67
|
+
self.posting_date = nowdate()
|
|
68
|
+
if not self.paid_amount or flt(self.paid_amount) <= 0:
|
|
69
|
+
raise ValidationError("Paid Amount must be greater than zero")
|
|
70
|
+
|
|
71
|
+
if self.payment_type != "Internal Transfer":
|
|
72
|
+
if self.party_type not in self._ALLOWED_PARTY_TYPES:
|
|
73
|
+
raise ValidationError("Party Type is required and must be Customer or Supplier")
|
|
74
|
+
if not self.party:
|
|
75
|
+
raise ValidationError("Party is required for Receive and Pay entries")
|
|
76
|
+
|
|
77
|
+
self._set_missing_values()
|
|
78
|
+
self._set_currency()
|
|
79
|
+
self._validate_references()
|
|
80
|
+
|
|
81
|
+
def _set_currency(self):
|
|
82
|
+
"""Settle in the currency of the invoices being paid; fall back to the
|
|
83
|
+
party/company default. conversion_rate is the payment-date rate, looked
|
|
84
|
+
up automatically (or supplied) — see set_default_currency."""
|
|
85
|
+
db = get_db()
|
|
86
|
+
if not self._data.get("currency"):
|
|
87
|
+
for ref in self.get("references") or []:
|
|
88
|
+
inv_ccy = db.get_value(ref.get("reference_doctype"), ref.get("reference_name"), "currency")
|
|
89
|
+
if inv_ccy:
|
|
90
|
+
self._data["currency"] = inv_ccy
|
|
91
|
+
break
|
|
92
|
+
party_type = self.party_type if self.party else None
|
|
93
|
+
set_default_currency(self, party_type, "party")
|
|
94
|
+
|
|
95
|
+
def _set_missing_values(self):
|
|
96
|
+
db = get_db()
|
|
97
|
+
|
|
98
|
+
if self.payment_type == "Receive":
|
|
99
|
+
# For external receipts, money lands in Bank and the contra is the
|
|
100
|
+
# party ledger for the selected party type:
|
|
101
|
+
# - Customer -> AR (normal receipt)
|
|
102
|
+
# - Supplier -> AP (supplier refund)
|
|
103
|
+
if not self.paid_from and self.company:
|
|
104
|
+
self._data["paid_from"] = self._get_default_party_ledger_account()
|
|
105
|
+
if not self.paid_to and self.company:
|
|
106
|
+
self._data["paid_to"] = self._get_default_bank_account()
|
|
107
|
+
|
|
108
|
+
elif self.payment_type == "Pay":
|
|
109
|
+
# For external payments, money leaves Bank and the contra is the
|
|
110
|
+
# party ledger for the selected party type:
|
|
111
|
+
# - Supplier -> AP (normal supplier payment)
|
|
112
|
+
# - Customer -> AR (customer refund)
|
|
113
|
+
if not self.paid_from and self.company:
|
|
114
|
+
self._data["paid_from"] = self._get_default_bank_account()
|
|
115
|
+
if not self.paid_to and self.company:
|
|
116
|
+
self._data["paid_to"] = self._get_default_party_ledger_account()
|
|
117
|
+
|
|
118
|
+
if not self.received_amount:
|
|
119
|
+
self._data["received_amount"] = self.paid_amount
|
|
120
|
+
|
|
121
|
+
if self.party and not self.party_name:
|
|
122
|
+
if self.party_type == "Customer":
|
|
123
|
+
self._data["party_name"] = db.get_value("Customer", self.party, "customer_name")
|
|
124
|
+
elif self.party_type == "Supplier":
|
|
125
|
+
self._data["party_name"] = db.get_value("Supplier", self.party, "supplier_name")
|
|
126
|
+
|
|
127
|
+
if not self.cost_center and self.company:
|
|
128
|
+
self._data["cost_center"] = db.get_value("Company", self.company, "default_cost_center")
|
|
129
|
+
|
|
130
|
+
def _get_default_bank_account(self):
|
|
131
|
+
db = get_db()
|
|
132
|
+
bank = db.get_all(
|
|
133
|
+
"Account",
|
|
134
|
+
filters={"company": self.company, "account_type": "Bank", "is_group": 0},
|
|
135
|
+
fields=["name"],
|
|
136
|
+
limit=1,
|
|
137
|
+
)
|
|
138
|
+
return bank[0]["name"] if bank else None
|
|
139
|
+
|
|
140
|
+
def _get_default_party_ledger_account(self):
|
|
141
|
+
if not self.company:
|
|
142
|
+
return None
|
|
143
|
+
fieldname = self._PARTY_LEDGER_BY_TYPE.get(self.party_type)
|
|
144
|
+
if not fieldname:
|
|
145
|
+
return None
|
|
146
|
+
return get_db().get_value("Company", self.company, fieldname)
|
|
147
|
+
|
|
148
|
+
# Allowed (party_type, reference_doctype) combinations. Customer-side
|
|
149
|
+
# payments settle sales documents; Supplier-side payments settle purchase
|
|
150
|
+
# documents. Returns (credit/debit notes) use the same tables.
|
|
151
|
+
_ALLOWED_REFERENCE_DOCTYPES = {
|
|
152
|
+
"Customer": {"Sales Invoice", "POS Invoice"},
|
|
153
|
+
"Supplier": {"Purchase Invoice"},
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
def _validate_references(self):
|
|
157
|
+
"""Validate each reference row individually, then the aggregate.
|
|
158
|
+
|
|
159
|
+
Per-row checks catch the real footguns: cross-party allocation,
|
|
160
|
+
settling a cancelled invoice, over-allocation past remaining
|
|
161
|
+
outstanding, and mismatched direction (Customer PE referencing a
|
|
162
|
+
Purchase Invoice).
|
|
163
|
+
"""
|
|
164
|
+
db = get_db()
|
|
165
|
+
total_allocated = 0.0
|
|
166
|
+
allowed_dts = self._ALLOWED_REFERENCE_DOCTYPES.get(self.party_type, set())
|
|
167
|
+
|
|
168
|
+
for ref in self.get("references") or []:
|
|
169
|
+
doctype = ref.get("reference_doctype")
|
|
170
|
+
docname = ref.get("reference_name")
|
|
171
|
+
allocated = flt(ref.get("allocated_amount"))
|
|
172
|
+
if not doctype or not docname:
|
|
173
|
+
continue
|
|
174
|
+
if allocated <= 0:
|
|
175
|
+
raise ValidationError(
|
|
176
|
+
f"Allocated amount on {doctype} {docname} must be positive"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if doctype not in allowed_dts:
|
|
180
|
+
raise ValidationError(
|
|
181
|
+
f"Cannot allocate a {self.party_type} payment against "
|
|
182
|
+
f"{doctype} {docname}"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
invoice = db.get_value(
|
|
186
|
+
doctype,
|
|
187
|
+
docname,
|
|
188
|
+
["customer", "supplier", "docstatus", "outstanding_amount", "is_return", "currency"],
|
|
189
|
+
)
|
|
190
|
+
if not invoice:
|
|
191
|
+
raise ValidationError(f"{doctype} {docname} does not exist")
|
|
192
|
+
|
|
193
|
+
inv_ccy = invoice.get("currency") or "USD"
|
|
194
|
+
if inv_ccy != (self.currency or "USD"):
|
|
195
|
+
raise ValidationError(
|
|
196
|
+
f"{doctype} {docname} is in {inv_ccy}, but this Payment Entry is in "
|
|
197
|
+
f"{self.currency}. Settle a foreign invoice with a payment in the same currency."
|
|
198
|
+
)
|
|
199
|
+
if flt(invoice.get("docstatus")) != 1:
|
|
200
|
+
raise ValidationError(
|
|
201
|
+
f"{doctype} {docname} is not submitted (docstatus="
|
|
202
|
+
f"{invoice.get('docstatus')}); cannot allocate a payment to it"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
expected_party = invoice.get("customer") if self.party_type == "Customer" else invoice.get("supplier")
|
|
206
|
+
if expected_party != self.party:
|
|
207
|
+
raise ValidationError(
|
|
208
|
+
f"{doctype} {docname} belongs to {self.party_type} "
|
|
209
|
+
f"'{expected_party}', not '{self.party}'"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
current = flt(invoice.get("outstanding_amount"))
|
|
213
|
+
# Return invoices carry a negative outstanding. Allocations against
|
|
214
|
+
# them (refund flow) always reduce |outstanding| — compare on
|
|
215
|
+
# absolute value and allow a $0.01 tolerance for rounding noise.
|
|
216
|
+
if allocated > abs(current) + 0.01:
|
|
217
|
+
raise ValidationError(
|
|
218
|
+
f"Allocation {allocated} on {doctype} {docname} exceeds its "
|
|
219
|
+
f"remaining outstanding ({abs(current)})"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
total_allocated += allocated
|
|
223
|
+
|
|
224
|
+
if total_allocated > flt(self.paid_amount) + 0.01:
|
|
225
|
+
raise ValidationError(
|
|
226
|
+
f"Total allocated amount ({total_allocated}) exceeds paid amount ({self.paid_amount})"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def on_submit(self):
|
|
230
|
+
"""Post GL entries and update outstanding on referenced invoices.
|
|
231
|
+
|
|
232
|
+
This is the core payment logic from the reference implementation. The GL entries depend
|
|
233
|
+
on the payment_type:
|
|
234
|
+
|
|
235
|
+
Receive: Debit Bank, Credit Receivable (with party)
|
|
236
|
+
Pay: Debit Payable (with party), Credit Bank
|
|
237
|
+
"""
|
|
238
|
+
gl_entries = self._get_gl_entries()
|
|
239
|
+
make_gl_entries(gl_entries)
|
|
240
|
+
self._update_outstanding()
|
|
241
|
+
|
|
242
|
+
def on_cancel(self):
|
|
243
|
+
make_reverse_gl_entries(
|
|
244
|
+
voucher_type=self.DOCTYPE,
|
|
245
|
+
voucher_no=self.name,
|
|
246
|
+
)
|
|
247
|
+
self._update_outstanding(cancel=True)
|
|
248
|
+
|
|
249
|
+
def _party_ledger_base(self):
|
|
250
|
+
"""Base value of the party ledger (AR/AP) this payment clears.
|
|
251
|
+
|
|
252
|
+
Each allocation clears at the *invoice's* booked rate (so the AR/AP for
|
|
253
|
+
that invoice zeroes out in base currency); any unallocated on-account
|
|
254
|
+
amount is booked at the payment rate. Returns (allocated_total_in_ccy,
|
|
255
|
+
party_clear_base).
|
|
256
|
+
"""
|
|
257
|
+
db = get_db()
|
|
258
|
+
rate = flt(self.conversion_rate) or 1.0
|
|
259
|
+
allocated_total = 0.0
|
|
260
|
+
allocated_base = 0.0
|
|
261
|
+
for ref in self.get("references") or []:
|
|
262
|
+
allocated = flt(ref.get("allocated_amount"))
|
|
263
|
+
if allocated <= 0:
|
|
264
|
+
continue
|
|
265
|
+
inv_rate = flt(db.get_value(
|
|
266
|
+
ref.get("reference_doctype"), ref.get("reference_name"), "conversion_rate"
|
|
267
|
+
)) or 1.0
|
|
268
|
+
allocated_total += allocated
|
|
269
|
+
allocated_base += allocated * inv_rate
|
|
270
|
+
on_account = max(flt(self.paid_amount) - allocated_total, 0.0)
|
|
271
|
+
return allocated_total, flt(allocated_base + on_account * rate, 2)
|
|
272
|
+
|
|
273
|
+
def _bank_amounts(self, bank_account, amount_in_pe_ccy):
|
|
274
|
+
"""Resolve a bank/cash leg's (base, account_currency_amount, currency).
|
|
275
|
+
|
|
276
|
+
The bank either holds the company's base currency (the money is
|
|
277
|
+
converted on receipt) or holds the payment currency itself (a
|
|
278
|
+
foreign-currency account that accumulates that currency). Base value is
|
|
279
|
+
always amount * payment-rate; the account-currency amount is the base
|
|
280
|
+
amount for a base account, or the raw foreign amount for a foreign one.
|
|
281
|
+
"""
|
|
282
|
+
db = get_db()
|
|
283
|
+
rate = flt(self.conversion_rate) or 1.0
|
|
284
|
+
base_ccy = db.get_value("Company", self.company, "default_currency") or "USD"
|
|
285
|
+
bank_ccy = db.get_value("Account", bank_account, "account_currency") or base_ccy
|
|
286
|
+
base = flt(amount_in_pe_ccy * rate, 2)
|
|
287
|
+
if bank_ccy == base_ccy:
|
|
288
|
+
return base, base, base_ccy
|
|
289
|
+
if bank_ccy == (self.currency or base_ccy):
|
|
290
|
+
return base, flt(amount_in_pe_ccy, 2), bank_ccy
|
|
291
|
+
raise ValidationError(
|
|
292
|
+
f"Bank account {bank_account} is in {bank_ccy}; a {self.currency} payment "
|
|
293
|
+
f"must settle through a {base_ccy} or {self.currency} account."
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def _fx_gl_entry(self, *, is_loss, amount):
|
|
297
|
+
"""Realized FX gain/loss leg. Loss debits, gain credits the company's
|
|
298
|
+
Exchange Gain/Loss account."""
|
|
299
|
+
db = get_db()
|
|
300
|
+
fx_account = db.get_value("Company", self.company, "default_exchange_gain_loss_account")
|
|
301
|
+
if not fx_account:
|
|
302
|
+
raise ValidationError(
|
|
303
|
+
"No Exchange Gain/Loss account is configured on the company, so the "
|
|
304
|
+
"realized FX difference on this payment cannot be posted."
|
|
305
|
+
)
|
|
306
|
+
amt = flt(amount, 2)
|
|
307
|
+
return _dict(
|
|
308
|
+
account=fx_account,
|
|
309
|
+
debit=amt if is_loss else 0,
|
|
310
|
+
credit=0 if is_loss else amt,
|
|
311
|
+
debit_in_account_currency=amt if is_loss else 0,
|
|
312
|
+
credit_in_account_currency=0 if is_loss else amt,
|
|
313
|
+
cost_center=self.cost_center,
|
|
314
|
+
voucher_type=self.DOCTYPE,
|
|
315
|
+
voucher_no=self.name,
|
|
316
|
+
posting_date=self.posting_date,
|
|
317
|
+
company=self.company,
|
|
318
|
+
remarks=f"Realized FX {'loss' if is_loss else 'gain'} on {self.name}",
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def _get_gl_entries(self):
|
|
322
|
+
gl_entries = []
|
|
323
|
+
|
|
324
|
+
if self.payment_type == "Receive":
|
|
325
|
+
_, ar_base = self._party_ledger_base()
|
|
326
|
+
bank_base, bank_acct_amt, bank_ccy = self._bank_amounts(self.paid_to, self.received_amount)
|
|
327
|
+
fx = flt(ar_base - bank_base, 2)
|
|
328
|
+
# Debit: Bank/Cash (paid_to) — actual base cash at the payment rate.
|
|
329
|
+
gl_entries.append(
|
|
330
|
+
_dict(
|
|
331
|
+
account=self.paid_to,
|
|
332
|
+
debit=bank_base,
|
|
333
|
+
debit_in_account_currency=bank_acct_amt,
|
|
334
|
+
account_currency=bank_ccy,
|
|
335
|
+
credit=0,
|
|
336
|
+
credit_in_account_currency=0,
|
|
337
|
+
cost_center=self.cost_center,
|
|
338
|
+
voucher_type=self.DOCTYPE,
|
|
339
|
+
voucher_no=self.name,
|
|
340
|
+
posting_date=self.posting_date,
|
|
341
|
+
company=self.company,
|
|
342
|
+
remarks=self.remarks or f"Payment received from {self.party_name}",
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
# Credit: party ledger (paid_from) at the invoice's booked base
|
|
346
|
+
# value. Uses self.party_type so "Receive + Supplier" (supplier
|
|
347
|
+
# refund) posts against AP with the supplier tagged.
|
|
348
|
+
gl_entries.append(
|
|
349
|
+
_dict(
|
|
350
|
+
account=self.paid_from,
|
|
351
|
+
party_type=self.party_type,
|
|
352
|
+
party=self.party,
|
|
353
|
+
credit=ar_base,
|
|
354
|
+
credit_in_account_currency=flt(self.paid_amount, 2),
|
|
355
|
+
debit=0,
|
|
356
|
+
debit_in_account_currency=0,
|
|
357
|
+
against_voucher_type=self.DOCTYPE,
|
|
358
|
+
against_voucher=self.name,
|
|
359
|
+
voucher_type=self.DOCTYPE,
|
|
360
|
+
voucher_no=self.name,
|
|
361
|
+
posting_date=self.posting_date,
|
|
362
|
+
company=self.company,
|
|
363
|
+
remarks=self.remarks or f"Payment received from {self.party_name}",
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
# Cleared more receivable base than cash received -> loss; less -> gain.
|
|
367
|
+
if fx:
|
|
368
|
+
gl_entries.append(self._fx_gl_entry(is_loss=fx > 0, amount=abs(fx)))
|
|
369
|
+
|
|
370
|
+
elif self.payment_type == "Pay":
|
|
371
|
+
_, ap_base = self._party_ledger_base()
|
|
372
|
+
bank_base, bank_acct_amt, bank_ccy = self._bank_amounts(self.paid_from, self.paid_amount)
|
|
373
|
+
fx = flt(ap_base - bank_base, 2)
|
|
374
|
+
# Debit: party ledger (paid_to) at the invoice's booked base value.
|
|
375
|
+
# Uses self.party_type so "Pay + Customer" (customer refund) posts
|
|
376
|
+
# against AR with the customer tagged.
|
|
377
|
+
gl_entries.append(
|
|
378
|
+
_dict(
|
|
379
|
+
account=self.paid_to,
|
|
380
|
+
party_type=self.party_type,
|
|
381
|
+
party=self.party,
|
|
382
|
+
debit=ap_base,
|
|
383
|
+
debit_in_account_currency=flt(self.paid_amount, 2),
|
|
384
|
+
credit=0,
|
|
385
|
+
credit_in_account_currency=0,
|
|
386
|
+
against_voucher_type=self.DOCTYPE,
|
|
387
|
+
against_voucher=self.name,
|
|
388
|
+
voucher_type=self.DOCTYPE,
|
|
389
|
+
voucher_no=self.name,
|
|
390
|
+
posting_date=self.posting_date,
|
|
391
|
+
company=self.company,
|
|
392
|
+
remarks=self.remarks or f"Payment to {self.party_name}",
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
# Credit: Bank/Cash (paid_from) — actual base cash at the payment rate.
|
|
396
|
+
gl_entries.append(
|
|
397
|
+
_dict(
|
|
398
|
+
account=self.paid_from,
|
|
399
|
+
credit=bank_base,
|
|
400
|
+
credit_in_account_currency=bank_acct_amt,
|
|
401
|
+
account_currency=bank_ccy,
|
|
402
|
+
debit=0,
|
|
403
|
+
debit_in_account_currency=0,
|
|
404
|
+
cost_center=self.cost_center,
|
|
405
|
+
voucher_type=self.DOCTYPE,
|
|
406
|
+
voucher_no=self.name,
|
|
407
|
+
posting_date=self.posting_date,
|
|
408
|
+
company=self.company,
|
|
409
|
+
remarks=self.remarks or f"Payment to {self.party_name}",
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
# Cleared more payable base than cash paid -> gain; less -> loss.
|
|
413
|
+
if fx:
|
|
414
|
+
gl_entries.append(self._fx_gl_entry(is_loss=fx < 0, amount=abs(fx)))
|
|
415
|
+
|
|
416
|
+
elif self.payment_type == "Internal Transfer":
|
|
417
|
+
# Also handles currency conversion between a foreign-currency account
|
|
418
|
+
# and the base currency: the source is relieved at its average
|
|
419
|
+
# carrying value, the destination takes the actual proceeds (base
|
|
420
|
+
# side) or carries the purchase at cost (foreign side), and the
|
|
421
|
+
# difference is realized FX gain/loss. A same-base transfer is the
|
|
422
|
+
# original behaviour (no FX).
|
|
423
|
+
db = get_db()
|
|
424
|
+
base_ccy = db.get_value("Company", self.company, "default_currency") or "USD"
|
|
425
|
+
from_ccy = db.get_value("Account", self.paid_from, "account_currency") or base_ccy
|
|
426
|
+
to_ccy = db.get_value("Account", self.paid_to, "account_currency") or base_ccy
|
|
427
|
+
from_is_base = from_ccy == base_ccy
|
|
428
|
+
to_is_base = to_ccy == base_ccy
|
|
429
|
+
if not from_is_base and not to_is_base:
|
|
430
|
+
raise ValidationError(
|
|
431
|
+
"A currency conversion must have the base currency on one side "
|
|
432
|
+
f"({base_ccy}); converting {from_ccy} directly to {to_ccy} is not supported."
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
if from_is_base:
|
|
436
|
+
base_out = flt(self.paid_amount, 2)
|
|
437
|
+
else:
|
|
438
|
+
src_base, src_ccy = get_account_balances(self.paid_from, self.company)
|
|
439
|
+
carry_rate = (src_base / src_ccy) if src_ccy else 1.0
|
|
440
|
+
base_out = flt(flt(self.paid_amount) * carry_rate, 2)
|
|
441
|
+
|
|
442
|
+
# Base side receives the actual proceeds; a foreign destination is
|
|
443
|
+
# carried at the base cost we gave up (acquire FX at cost, no gain).
|
|
444
|
+
base_in = flt(self.received_amount, 2) if to_is_base else base_out
|
|
445
|
+
fx = flt(base_in - base_out, 2)
|
|
446
|
+
|
|
447
|
+
# Debit: paid_to
|
|
448
|
+
gl_entries.append(
|
|
449
|
+
_dict(
|
|
450
|
+
account=self.paid_to,
|
|
451
|
+
debit=base_in,
|
|
452
|
+
debit_in_account_currency=flt(self.received_amount, 2),
|
|
453
|
+
account_currency=to_ccy,
|
|
454
|
+
credit=0,
|
|
455
|
+
credit_in_account_currency=0,
|
|
456
|
+
voucher_type=self.DOCTYPE,
|
|
457
|
+
voucher_no=self.name,
|
|
458
|
+
posting_date=self.posting_date,
|
|
459
|
+
company=self.company,
|
|
460
|
+
remarks=self.remarks or "Internal Transfer",
|
|
461
|
+
)
|
|
462
|
+
)
|
|
463
|
+
# Credit: paid_from (relieved at its carrying value)
|
|
464
|
+
gl_entries.append(
|
|
465
|
+
_dict(
|
|
466
|
+
account=self.paid_from,
|
|
467
|
+
credit=base_out,
|
|
468
|
+
credit_in_account_currency=flt(self.paid_amount, 2),
|
|
469
|
+
account_currency=from_ccy,
|
|
470
|
+
debit=0,
|
|
471
|
+
debit_in_account_currency=0,
|
|
472
|
+
voucher_type=self.DOCTYPE,
|
|
473
|
+
voucher_no=self.name,
|
|
474
|
+
posting_date=self.posting_date,
|
|
475
|
+
company=self.company,
|
|
476
|
+
remarks=self.remarks or "Internal Transfer",
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
# Sold the foreign balance for more base than its carrying value ->
|
|
480
|
+
# gain; less -> loss. (Zero for a same-currency transfer.)
|
|
481
|
+
if fx:
|
|
482
|
+
gl_entries.append(self._fx_gl_entry(is_loss=fx < 0, amount=abs(fx)))
|
|
483
|
+
|
|
484
|
+
return gl_entries
|
|
485
|
+
|
|
486
|
+
def _update_outstanding(self, cancel=False):
|
|
487
|
+
"""Update outstanding_amount on referenced invoices.
|
|
488
|
+
|
|
489
|
+
Sign convention: every allocation brings |outstanding| closer to zero.
|
|
490
|
+
- A normal invoice starts with positive outstanding; allocation subtracts.
|
|
491
|
+
- A return invoice (credit/debit note) starts with negative outstanding;
|
|
492
|
+
allocation adds (refund flow).
|
|
493
|
+
Rounding residues below $0.01 snap to 0 so invoices don't linger in an
|
|
494
|
+
"almost paid" state after 100/3-style splits.
|
|
495
|
+
"""
|
|
496
|
+
db = get_db()
|
|
497
|
+
for ref in self.get("references") or []:
|
|
498
|
+
doctype = ref.get("reference_doctype")
|
|
499
|
+
docname = ref.get("reference_name")
|
|
500
|
+
allocated = flt(ref.get("allocated_amount"))
|
|
501
|
+
|
|
502
|
+
if not doctype or not docname or not allocated:
|
|
503
|
+
continue
|
|
504
|
+
|
|
505
|
+
current = flt(db.get_value(doctype, docname, "outstanding_amount"))
|
|
506
|
+
direction = 1 if current >= 0 else -1 # +: reduce, -: add (refund)
|
|
507
|
+
delta = allocated if not cancel else -allocated
|
|
508
|
+
new_outstanding = current - direction * delta
|
|
509
|
+
|
|
510
|
+
if abs(new_outstanding) < 0.01:
|
|
511
|
+
new_outstanding = 0
|
|
512
|
+
|
|
513
|
+
db.set_value(doctype, docname, "outstanding_amount", flt(new_outstanding, 2))
|
|
514
|
+
|
|
515
|
+
db.commit()
|