Skip to content

Commit 2d230b1

Browse files
Fix CashCtrl API error message and long descriptions (#217)
1 parent 56e4851 commit 2d230b1

File tree

6 files changed

+170
-52
lines changed

6 files changed

+170
-52
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
[![Download](https://img.shields.io/badge/Download-v1.3.0-%23007ec6)](https://github.com/tegonal/cohiva/releases/tag/v1.3.0)
44
[![AGPL 3](https://img.shields.io/badge/%E2%9A%96-AGPL%203-%230b45a6)](https://www.gnu.org/licenses/agpl-3.0.en.html "License")
5-
[![Quality Assurance](https://github.com/tegonal/cohiva/actions/workflows/quality-assurance.yml/badge.svg?event=push&branch=main)](https://github.com/tegonal/cohiva/actions/workflows/quality-assurance.yml?query=branch%3Amain)
5+
[![Quality Assurance](https://github.com/tegonal/cohiva/actions/workflows/quality-assurance.yml/badge.svg?event=push&branch=develop)](https://github.com/tegonal/cohiva/actions/workflows/quality-assurance.yml?query=branch%3Adevelop)
6+
[![Coverage](https://raw.githubusercontent.com/tegonal/cohiva/python-coverage-comment-action-data/badge.svg)](https://htmlpreview.github.io/?https://github.com/tegonal/cohiva/blob/python-coverage-comment-action-data/htmlcov/index.html)
67
[![Newcomers Welcome](https://img.shields.io/badge/%F0%9F%91%8B-Newcomers%20Welcome-blueviolet)](https://github.com/tegonal/cohiva/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22 "Ask in discussions for help")
78

89
<!-- for main end -->

django/cohiva/settings_for_tests.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
DEMO = False
4444
RESERVATION_BLOCKER_RULES = []
4545
FINANCIAL_ACCOUNTS[AccountKey.DEFAULT_DEBTOR]["iban"] = "CH7730000001250094239"
46+
FINANCIAL_ACCOUNTS[AccountKey.DEFAULT_DEBTOR]["account_code"] = "1020.1"
4647

4748
FINANCIAL_ACCOUNTING_CASHCTRL_LIVE_TESTS = False
4849

@@ -67,6 +68,15 @@
6768
"TENANT": "cohiva-test",
6869
},
6970
},
71+
"cashctrl_test_live": {
72+
"BACKEND": "finance.accounting.CashctrlBook",
73+
"DB_ID": 1,
74+
"OPTIONS": {
75+
"API_HOST": "cashctrl.com",
76+
"API_TOKEN": f"{cbc.CASHCTRL_API_TOKEN}",
77+
"TENANT": f"{cbc.CASHCTRL_TENANT}",
78+
},
79+
},
7080
"dummy_test": {
7181
"BACKEND": "finance.accounting.DummyBook",
7282
"OPTIONS": {

django/finance/accounting/cashctrl.py

Lines changed: 40 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -96,31 +96,12 @@ def _create_collective_transaction(self, transaction):
9696
amount_str = f"{amount:.2f}"
9797
split_items_o.append({"accountId": cct_account, credit_type: amount_str})
9898

99-
payload = dict(
100-
dateAdded=self.cct_book_ref.get_date(getattr(transaction, "date", None)).strftime(
101-
"%Y-%m-%d"
102-
),
103-
title=getattr(transaction, "description", ""),
104-
items=json.dumps(split_items_o),
105-
)
106-
107-
# Call create endpoint
108-
response = self._construct_request_post("journal/create.json", payload=payload)
109-
response.raise_for_status()
110-
data = response.json()
111-
self._raise_for_error(
112-
response, f"create collective transaction: len: {len(transaction.splits)}"
99+
payload = self._get_common_transaction_payload(transaction)
100+
payload["items"] = json.dumps(split_items_o)
101+
return self._create_transaction_api_call(
102+
payload, f"create collective transaction: len: {len(transaction.splits)}"
113103
)
114104

115-
txn_id = None
116-
if isinstance(data, dict):
117-
if data.get("success"):
118-
txn_id = data.get("insertId")
119-
# Record inserted transaction for the current BookTransaction
120-
transaction_id = self.cct_book_ref.build_transaction_id(txn_id)
121-
self.insert(transaction_id)
122-
return transaction_id
123-
124105
def _create_simple_transaction(self, transaction):
125106
# Expecting two splits: debit and credit
126107
if not getattr(transaction, "splits", None) or len(transaction.splits) != 2:
@@ -136,29 +117,22 @@ def _create_simple_transaction(self, transaction):
136117
else str(transaction.splits[1].amount)
137118
)
138119

139-
notes = "Added through API"
140-
description = getattr(transaction, "description", "")
141-
# in case description is longer than 250 chars, truncate and append fully to notes
142-
if len(description) > 250:
143-
notes += "\n" + description
144-
description = description[:250]
145-
146-
date_added = self.cct_book_ref.get_date(getattr(transaction, "date", None)).strftime(
147-
"%Y-%m-%d"
148-
)
149-
attributes = (
150-
f"amount={amount_str}&creditId={cct_account_credit}&debitId={cct_account_debit}"
151-
f"&title={urllib.parse.quote_plus(description)}"
152-
f"&dateAdded={date_added}&notes={urllib.parse.quote_plus(notes)}"
120+
payload = self._get_common_transaction_payload(transaction)
121+
payload["amount"] = amount_str
122+
payload["creditId"] = cct_account_credit
123+
payload["debitId"] = cct_account_debit
124+
return self._create_transaction_api_call(
125+
payload, f"create:{cct_account_debit}:{cct_account_credit}:{amount_str}"
153126
)
154127

128+
def _create_transaction_api_call(self, payload, request_info):
155129
# Call create endpoint
156-
response = self._construct_request_post("journal/create.json?" + attributes, None)
130+
response = self._construct_request_post("journal/create.json", payload=payload)
157131
response.raise_for_status()
158132
data = response.json()
159133
self._raise_for_error(
160134
response,
161-
f"create:{cct_account_debit}:{cct_account_credit}:{amount_str}",
135+
request_info,
162136
)
163137

164138
txn_id = None
@@ -170,6 +144,26 @@ def _create_simple_transaction(self, transaction):
170144
self.insert(transaction_id)
171145
return transaction_id
172146

147+
def _get_common_transaction_payload(self, transaction):
148+
date_added = self.cct_book_ref.get_date(getattr(transaction, "date", None)).strftime(
149+
"%Y-%m-%d"
150+
)
151+
description = getattr(transaction, "description", "")
152+
# in case description is longer than 250 chars, truncate and append fully to notes
153+
if len(description) > 250:
154+
notes = description
155+
description = description[:250]
156+
else:
157+
notes = None
158+
159+
payload = dict(
160+
dateAdded=date_added,
161+
title=description,
162+
)
163+
if notes:
164+
payload["notes"] = notes
165+
return payload
166+
173167
def save(self):
174168
# inserted transactions can be ignored as they were already saved to CashCtrl via API
175169
self._inserted_transaction_ids.clear()
@@ -266,7 +260,13 @@ def _raise_for_error(self, response, request_info=None):
266260
data = response.json()
267261
if isinstance(data, dict):
268262
if not data.get("success", True):
269-
error_message = data.get("message", "Unknown error")
263+
errors = data.get("errors")
264+
if errors:
265+
error_message = "; ".join(
266+
f"{error.get('field')}: {error.get('message')}" for error in errors
267+
)
268+
else:
269+
error_message = data.get("message") or "Unknown error"
270270
raise RuntimeError(
271271
f"CashCtrl API error: {error_message} - for request {request_info}"
272272
)

django/finance/tests/test_accounting.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def test_register_backends_from_settings(self):
125125
self.assertEqual(AccountingManager.default_backend_label, "test1")
126126

127127
AccountingManager.register_backends_from_settings()
128-
self.assertEqual(len(AccountingManager.backends), 4)
128+
self.assertEqual(len(AccountingManager.backends), 5)
129129
self.assertEqual(AccountingManager.default_backend_label, "dummy_test")
130130
self.assertEqual(AccountingManager.backends["dummy_test2"]["db_id"], 1)
131131

django/finance/tests/test_accounting_cct.py

Lines changed: 102 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def fetch_account_responses(url, **kw):
4545
"type": "MANUAL",
4646
"dateAdded": "2025-11-04 18:07:00.0",
4747
"title": "Test CashCtrl add_transaction",
48-
"notes": "Added through API",
48+
"notes": "",
4949
"amount": 100,
5050
"currencyRate": 1,
5151
"accountIds": '["1237","1477"]',
@@ -189,6 +189,12 @@ def fetch_account_responses(url, **kw):
189189
status_code=200,
190190
)
191191
return Exception("Unknown account number in URL")
192+
return Mock(
193+
json=lambda: {
194+
"success": False,
195+
},
196+
status_code=200,
197+
)
192198

193199
@staticmethod
194200
def fetch_transaction_responses(url, **kw):
@@ -247,10 +253,15 @@ def test_add_transaction(self, mock_post, mock_get):
247253
]
248254
)
249255
called_url = mock_post.call_args[0][0]
250-
assert (
251-
f"{self.cohiva_test_endpoint}journal/create.json?amount=100.00&creditId=1237&debitId=1477&title=Test+CashCtrl+add_transaction&dateAdded=2026-01-01"
252-
in called_url
256+
self.assertIn(f"{self.cohiva_test_endpoint}journal/create.json", called_url)
257+
form_data_constructed = mock_post.call_args[1]
258+
self.assertEqual("100.00", form_data_constructed["data"]["amount"])
259+
self.assertEqual(1237, form_data_constructed["data"]["creditId"])
260+
self.assertEqual(1477, form_data_constructed["data"]["debitId"])
261+
self.assertEqual(
262+
"Test CashCtrl add_transaction", form_data_constructed["data"]["title"]
253263
)
264+
self.assertEqual("2026-01-01", form_data_constructed["data"]["dateAdded"])
254265

255266
@patch("finance.accounting.cashctrl.requests.get")
256267
@patch("finance.accounting.cashctrl.requests.post")
@@ -273,7 +284,7 @@ def test_add_transaction_without_date(self, mock_post, mock_get):
273284
self.account1,
274285
self.account2,
275286
None,
276-
"Test CashCtrl add_transaction",
287+
"Test CashCtrl add_transaction without date",
277288
autosave=False,
278289
)
279290
self.assertTrue(transaction_id.startswith("cct_"))
@@ -283,11 +294,52 @@ def test_add_transaction_without_date(self, mock_post, mock_get):
283294
# verify that the API was called
284295
today = datetime.date.today().strftime("%Y-%m-%d")
285296
called_url = mock_post.call_args[0][0]
286-
assert (
287-
f"{self.cohiva_test_endpoint}journal/create.json?amount=100.00&creditId=1237"
288-
f"&debitId=1477&title=Test+CashCtrl+add_transaction&dateAdded={today}"
289-
in called_url
297+
self.assertIn(f"{self.cohiva_test_endpoint}journal/create.json", called_url)
298+
form_data_constructed = mock_post.call_args[1]
299+
self.assertEqual(
300+
"Test CashCtrl add_transaction without date",
301+
form_data_constructed["data"]["title"],
290302
)
303+
self.assertEqual(today, form_data_constructed["data"]["dateAdded"])
304+
305+
@patch("finance.accounting.cashctrl.requests.get")
306+
@patch("finance.accounting.cashctrl.requests.post")
307+
def test_add_transaction_with_long_description(self, mock_post, mock_get):
308+
messages = []
309+
with AccountingManager(messages) as book:
310+
# configure fake responses
311+
mock_get.return_value.raise_for_status.side_effect = None
312+
mock_get.side_effect = self.fetch_account_responses
313+
314+
mock_post.return_value.json.return_value = {
315+
"success": True,
316+
"message": "Buchung gespeichert",
317+
"insertId": 700,
318+
}
319+
mock_post.return_value.raise_for_status.side_effect = None
320+
321+
long_description = (
322+
"Test CashCtrl add_transaction with long description (254 chars) "
323+
+ 19 * "123456789_"
324+
)
325+
transaction_id = book.add_transaction(
326+
100.00,
327+
self.account1,
328+
self.account2,
329+
None,
330+
long_description,
331+
autosave=False,
332+
)
333+
self.assertTrue(transaction_id.startswith("cct_"))
334+
self.assertEqual("cct_0_700", transaction_id)
335+
book.save()
336+
337+
# verify that the API was called
338+
called_url = mock_post.call_args[0][0]
339+
self.assertIn(f"{self.cohiva_test_endpoint}journal/create.json", called_url)
340+
form_data_constructed = mock_post.call_args[1]
341+
self.assertEqual(long_description[:250], form_data_constructed["data"]["title"])
342+
self.assertEqual(long_description, form_data_constructed["data"]["notes"])
291343

292344
@patch("finance.accounting.cashctrl.requests.get")
293345
@patch("finance.accounting.cashctrl.requests.post")
@@ -430,3 +482,44 @@ def test_delete_transaction(self, mock_post, mock_get):
430482
mock_post.assert_called_once_with(
431483
f"{self.cohiva_test_endpoint}journal/delete.json?ids=801", data=None, auth=ANY
432484
)
485+
486+
@patch("finance.accounting.cashctrl.requests.get")
487+
@patch("finance.accounting.cashctrl.requests.post")
488+
def test_process_api_error(self, mock_post, mock_get):
489+
messages = []
490+
with AccountingManager(messages) as book:
491+
# configure fake responses
492+
mock_get.return_value.raise_for_status.side_effect = None
493+
mock_get.side_effect = self.fetch_account_responses
494+
495+
mock_post.return_value.json.return_value = {
496+
"success": False,
497+
"message": None,
498+
"errors": [
499+
{
500+
"field": "creditId",
501+
"message": "This account does not exist.",
502+
},
503+
{
504+
"field": "field2",
505+
"message": "Another error for field2",
506+
},
507+
],
508+
}
509+
mock_post.return_value.raise_for_status.side_effect = None
510+
511+
with self.assertRaisesRegex(
512+
RuntimeError,
513+
(
514+
"CashCtrl API error: creditId: This account does not exist.; "
515+
"field2: Another error for field2 - for request"
516+
),
517+
):
518+
book.add_transaction(
519+
100.00,
520+
self.account1,
521+
self.account2,
522+
None,
523+
"Test error processing",
524+
autosave=False,
525+
)

django/finance/tests/test_accounting_cct_live.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class CashctrlBookTestCase(TestCase):
2121
@classmethod
2222
def setUpClass(cls):
2323
super().setUpClass()
24-
AccountingManager.default_backend_label = "cashctrl_test"
24+
AccountingManager.default_backend_label = "cashctrl_test_live"
2525
cls.account1 = Account("TestAccount CashCtrl1", "43000")
2626
cls.account2 = Account("TestAccount CashCtrl2", "10220")
2727
cls.account3 = Account("TestAccount CashCtrl3", "47400")
@@ -51,6 +51,20 @@ def test_add_transaction(self):
5151
self.assertTrue(transaction_id.startswith("cct_"))
5252
book.save()
5353

54+
def test_add_transaction_with_long_description(self):
55+
messages = []
56+
with AccountingManager(messages) as book:
57+
transaction_id = book.add_transaction(
58+
101.00,
59+
self.account1,
60+
self.account2,
61+
"2026-01-01",
62+
"Test CashCtrl add_transaction with long description (254 chars) "
63+
+ 19 * "123456789_",
64+
autosave=True,
65+
)
66+
self.assertTrue(transaction_id.startswith("cct_"))
67+
5468
def test_add_transaction_no_commit(self):
5569
messages = []
5670
with AccountingManager(messages) as book:

0 commit comments

Comments
 (0)