merit-api 0.4.4__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.
merit_api/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .client import MeritAPI
2
+ from .exceptions import MeritAPIError
3
+
4
+ __all__ = ["MeritAPI", "MeritAPIError"]
merit_api/client.py ADDED
@@ -0,0 +1,410 @@
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import time
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Callable, Dict, Optional
8
+
9
+ import requests
10
+
11
+ from .exceptions import MeritAPIError
12
+ from .namespaces import (
13
+ Assets,
14
+ Customers,
15
+ Dimensions,
16
+ Financial,
17
+ Inventory,
18
+ Items,
19
+ Purchases,
20
+ Pricing,
21
+ ReferenceData,
22
+ Reports,
23
+ Sales,
24
+ Taxes,
25
+ Vendors,
26
+ )
27
+
28
+
29
+ JsonDict = Dict[str, Any]
30
+ LoggerCallback = Callable[[JsonDict], None]
31
+ IdempotencyKeyFactory = Callable[[str, Any], Optional[str]]
32
+
33
+
34
+ class MeritAPI:
35
+ """Python client for Merit Aktiva API."""
36
+
37
+ BASE_URLS = {
38
+ "EE": "https://aktiva.merit.ee/api/",
39
+ "PL": "https://program.360ksiegowosc.pl/api/",
40
+ }
41
+ RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
42
+
43
+ def __init__(
44
+ self,
45
+ api_id: str,
46
+ api_key: str,
47
+ country: str = "EE",
48
+ *,
49
+ session: Optional[requests.Session] = None,
50
+ timeout: float = 30.0,
51
+ max_retries: int = 0,
52
+ retry_backoff: float = 0.5,
53
+ idempotency_key_factory: Optional[IdempotencyKeyFactory] = None,
54
+ request_logger: Optional[LoggerCallback] = None,
55
+ response_logger: Optional[LoggerCallback] = None,
56
+ ):
57
+ self.api_id = api_id
58
+ self.api_key = api_key
59
+ self.base_url = self.BASE_URLS.get(country.upper(), self.BASE_URLS["EE"])
60
+ self.session = session or requests.Session()
61
+ self.timeout = timeout
62
+ self.max_retries = max_retries
63
+ self.retry_backoff = retry_backoff
64
+ self.idempotency_key_factory = idempotency_key_factory
65
+ self.request_logger = request_logger
66
+ self.response_logger = response_logger
67
+
68
+ self.customers = Customers(self)
69
+ self.vendors = Vendors(self)
70
+ self.items = Items(self)
71
+ self.sales = Sales(self)
72
+ self.purchases = Purchases(self)
73
+ self.financial = Financial(self)
74
+ self.inventory = Inventory(self)
75
+ self.assets = Assets(self)
76
+ self.taxes = Taxes(self)
77
+ self.dimensions = Dimensions(self)
78
+ self.pricing = Pricing(self)
79
+ self.reports = Reports(self)
80
+ self.reference = ReferenceData(self)
81
+
82
+ def _serialize_body(self, body: Any) -> str:
83
+ """Serialize request bodies deterministically for signing and transport."""
84
+ return json.dumps(body, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
85
+
86
+ def _authenticate(self, serialized_body: str) -> Dict[str, str]:
87
+ """Generate authentication parameters for a request."""
88
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
89
+ data_to_sign = f"{self.api_id}{timestamp}{serialized_body}"
90
+ signature_bin = hmac.new(
91
+ self.api_key.encode("utf-8"),
92
+ data_to_sign.encode("utf-8"),
93
+ hashlib.sha256,
94
+ ).digest()
95
+ signature_b64 = base64.b64encode(signature_bin).decode("utf-8")
96
+ return {
97
+ "apiId": self.api_id,
98
+ "timestamp": timestamp,
99
+ "signature": signature_b64,
100
+ }
101
+
102
+ def _resolve_idempotency_key(self, endpoint: str, body: Any, idempotency_key: Optional[str]) -> Optional[str]:
103
+ if idempotency_key is not None:
104
+ return idempotency_key
105
+ if self.idempotency_key_factory is None:
106
+ return None
107
+ return self.idempotency_key_factory(endpoint, body)
108
+
109
+ def _build_headers(self, serialized_body: str, endpoint: str, body: Any, idempotency_key: Optional[str]) -> JsonDict:
110
+ headers: JsonDict = {"Content-Type": "application/json"}
111
+ resolved_idempotency_key = self._resolve_idempotency_key(endpoint, body, idempotency_key)
112
+ if resolved_idempotency_key:
113
+ headers["Idempotency-Key"] = resolved_idempotency_key
114
+ headers["Content-Length"] = str(len(serialized_body.encode("utf-8")))
115
+ return headers
116
+
117
+ def _sanitize_for_log(self, value: Any, *, path: str = "") -> Any:
118
+ secret_keys = {"signature", "idempotency-key", "authorization", "api_key", "api-key"}
119
+
120
+ if isinstance(value, dict):
121
+ sanitized: JsonDict = {}
122
+ for key, nested_value in value.items():
123
+ normalized_key = str(key).lower()
124
+ nested_path = f"{path}.{key}" if path else str(key)
125
+ if normalized_key in secret_keys:
126
+ sanitized[key] = "***"
127
+ else:
128
+ sanitized[key] = self._sanitize_for_log(nested_value, path=nested_path)
129
+ return sanitized
130
+
131
+ if isinstance(value, list):
132
+ return [self._sanitize_for_log(item, path=path) for item in value]
133
+
134
+ return value
135
+
136
+ def _log_request(
137
+ self,
138
+ *,
139
+ url: str,
140
+ endpoint: str,
141
+ version: str,
142
+ body: Any,
143
+ headers: JsonDict,
144
+ auth_params: JsonDict,
145
+ attempt: int,
146
+ ) -> None:
147
+ if self.request_logger is None:
148
+ return
149
+ self.request_logger(
150
+ {
151
+ "url": url,
152
+ "endpoint": endpoint,
153
+ "version": version,
154
+ "body": self._sanitize_for_log(body),
155
+ "headers": self._sanitize_for_log(headers),
156
+ "auth_params": self._sanitize_for_log(auth_params),
157
+ "attempt": attempt,
158
+ }
159
+ )
160
+
161
+ def _log_response(self, *, url: str, endpoint: str, response: requests.Response, payload: Any = None) -> None:
162
+ if self.response_logger is None:
163
+ return
164
+ event: JsonDict = {
165
+ "url": url,
166
+ "endpoint": endpoint,
167
+ "status_code": response.status_code,
168
+ "text": response.text,
169
+ }
170
+ if payload is not None:
171
+ event["payload"] = self._sanitize_for_log(payload)
172
+ self.response_logger(event)
173
+
174
+ def _raise_for_business_error(self, payload: Any) -> None:
175
+ if not isinstance(payload, dict):
176
+ return
177
+ if payload.get("Success", True) is not False:
178
+ return
179
+
180
+ error_code = payload.get("ErrorCode")
181
+ message = payload.get("Error") or payload.get("Message") or "Merit API business error"
182
+ raise MeritAPIError(
183
+ str(message),
184
+ status_code=200,
185
+ error_code=str(error_code) if error_code is not None else None,
186
+ response_body=payload,
187
+ )
188
+
189
+ def _post(
190
+ self,
191
+ endpoint: str,
192
+ body: Optional[Any] = None,
193
+ version: str = "v1",
194
+ *,
195
+ idempotency_key: Optional[str] = None,
196
+ ) -> Any:
197
+ """Make a POST request to the API."""
198
+ body = body or {}
199
+ serialized_body = self._serialize_body(body)
200
+ auth_params = self._authenticate(serialized_body)
201
+ url = f"{self.base_url}{version}/{endpoint.lstrip('/')}"
202
+ headers = self._build_headers(serialized_body, endpoint, body, idempotency_key)
203
+
204
+ for attempt in range(self.max_retries + 1):
205
+ self._log_request(
206
+ url=url,
207
+ endpoint=endpoint,
208
+ version=version,
209
+ body=body,
210
+ headers=headers,
211
+ auth_params=auth_params,
212
+ attempt=attempt + 1,
213
+ )
214
+ try:
215
+ response = self.session.post(
216
+ url,
217
+ params=auth_params,
218
+ data=serialized_body.encode("utf-8"),
219
+ headers=headers,
220
+ timeout=self.timeout,
221
+ )
222
+ except requests.exceptions.RequestException as exc:
223
+ if attempt < self.max_retries:
224
+ time.sleep(self.retry_backoff * (attempt + 1))
225
+ continue
226
+ raise MeritAPIError(f"Request failed: {exc}") from exc
227
+
228
+ try:
229
+ payload = response.json()
230
+ except ValueError:
231
+ payload = None
232
+
233
+ self._log_response(url=url, endpoint=endpoint, response=response, payload=payload)
234
+
235
+ if response.status_code != 200:
236
+ if response.status_code in self.RETRYABLE_STATUS_CODES and attempt < self.max_retries:
237
+ time.sleep(self.retry_backoff * (attempt + 1))
238
+ continue
239
+ raise MeritAPIError(
240
+ f"API Error ({response.status_code}) at {url}: {response.text}",
241
+ status_code=response.status_code,
242
+ response_body=payload if payload is not None else response.text,
243
+ )
244
+
245
+ if payload is None:
246
+ # Handle plain text responses (e.g., "OK" from email/einvoice endpoints)
247
+ text_response = response.text.strip()
248
+ if text_response:
249
+ # Wrap plain text response in a dict for consistency
250
+ return {"Message": text_response, "Success": True}
251
+ raise MeritAPIError(
252
+ f"Invalid JSON response from {url}: {response.text}",
253
+ status_code=response.status_code,
254
+ response_body=response.text,
255
+ )
256
+
257
+ # Handle JSON-encoded string responses (e.g., API returns "OK" as JSON string)
258
+ if isinstance(payload, str):
259
+ return {"Message": payload, "Success": True}
260
+
261
+ self._raise_for_business_error(payload)
262
+ return payload
263
+
264
+ raise MeritAPIError(f"Request failed after retries for {url}")
265
+
266
+ def _get(
267
+ self,
268
+ endpoint: str,
269
+ query: Optional[dict[str, Any]] = None,
270
+ version: str = "v1",
271
+ ) -> Any:
272
+ """Make a GET request to the API with signed auth params and extra query params."""
273
+ query = query or {}
274
+ serialized_body = ""
275
+ auth_params = self._authenticate(serialized_body)
276
+ url = f"{self.base_url}{version}/{endpoint.lstrip('/')}"
277
+ headers: JsonDict = {"Accept": "application/json"}
278
+ params = {**auth_params, **query}
279
+
280
+ for attempt in range(self.max_retries + 1):
281
+ self._log_request(
282
+ url=url,
283
+ endpoint=endpoint,
284
+ version=version,
285
+ body=query,
286
+ headers=headers,
287
+ auth_params=auth_params,
288
+ attempt=attempt + 1,
289
+ )
290
+ try:
291
+ response = self.session.get(
292
+ url,
293
+ params=params,
294
+ headers=headers,
295
+ timeout=self.timeout,
296
+ )
297
+ except requests.exceptions.RequestException as exc:
298
+ if attempt < self.max_retries:
299
+ time.sleep(self.retry_backoff * (attempt + 1))
300
+ continue
301
+ raise MeritAPIError(f"Request failed: {exc}") from exc
302
+
303
+ try:
304
+ payload = response.json()
305
+ except ValueError:
306
+ payload = None
307
+
308
+ self._log_response(url=url, endpoint=endpoint, response=response, payload=payload)
309
+
310
+ if response.status_code != 200:
311
+ if response.status_code in self.RETRYABLE_STATUS_CODES and attempt < self.max_retries:
312
+ time.sleep(self.retry_backoff * (attempt + 1))
313
+ continue
314
+ raise MeritAPIError(
315
+ f"API Error ({response.status_code}) at {url}: {response.text}",
316
+ status_code=response.status_code,
317
+ response_body=payload if payload is not None else response.text,
318
+ )
319
+
320
+ if payload is not None:
321
+ self._raise_for_business_error(payload)
322
+ return payload
323
+
324
+ if response.text:
325
+ return response.text
326
+
327
+ return None
328
+
329
+ raise MeritAPIError(f"Request failed after retries for {url}")
330
+
331
+ def _get_pdf(
332
+ self,
333
+ endpoint: str,
334
+ body: Optional[Any] = None,
335
+ version: str = "v1",
336
+ *,
337
+ idempotency_key: Optional[str] = None,
338
+ ) -> bytes:
339
+ """Make a POST request to retrieve a PDF file."""
340
+ body = body or {}
341
+ serialized_body = self._serialize_body(body)
342
+ auth_params = self._authenticate(serialized_body)
343
+ url = f"{self.base_url}{version}/{endpoint.lstrip('/')}"
344
+ headers = self._build_headers(serialized_body, endpoint, body, idempotency_key)
345
+
346
+ for attempt in range(self.max_retries + 1):
347
+ self._log_request(
348
+ url=url,
349
+ endpoint=endpoint,
350
+ version=version,
351
+ body=body,
352
+ headers=headers,
353
+ auth_params=auth_params,
354
+ attempt=attempt + 1,
355
+ )
356
+ try:
357
+ response = self.session.post(
358
+ url,
359
+ params=auth_params,
360
+ data=serialized_body.encode("utf-8"),
361
+ headers=headers,
362
+ timeout=self.timeout,
363
+ )
364
+ except requests.exceptions.RequestException as exc:
365
+ if attempt < self.max_retries:
366
+ time.sleep(self.retry_backoff * (attempt + 1))
367
+ continue
368
+ raise MeritAPIError(f"Request failed: {exc}") from exc
369
+
370
+ # Try to parse as JSON first (in case API returns base64-encoded PDF or error)
371
+ try:
372
+ payload = response.json()
373
+ # Check if it's an error response
374
+ self._raise_for_business_error(payload)
375
+ # If payload contains PDF data as base64, extract it
376
+ if isinstance(payload, dict) and "FileContent" in payload:
377
+ import base64
378
+ return base64.b64decode(payload["FileContent"])
379
+ if isinstance(payload, dict) and "Content" in payload:
380
+ import base64
381
+ return base64.b64decode(payload["Content"])
382
+ if isinstance(payload, dict) and "Pdf" in payload:
383
+ import base64
384
+ return base64.b64decode(payload["Pdf"])
385
+ except ValueError:
386
+ # Not JSON, so assume it's binary PDF
387
+ payload = None
388
+
389
+ self._log_response(url=url, endpoint=endpoint, response=response, payload=payload)
390
+
391
+ if response.status_code != 200:
392
+ if response.status_code in self.RETRYABLE_STATUS_CODES and attempt < self.max_retries:
393
+ time.sleep(self.retry_backoff * (attempt + 1))
394
+ continue
395
+ raise MeritAPIError(
396
+ f"API Error ({response.status_code}) at {url}: {response.text}",
397
+ status_code=response.status_code,
398
+ response_body=payload if payload is not None else response.text,
399
+ )
400
+
401
+ # Return binary content (either from JSON or raw binary response)
402
+ if payload is not None:
403
+ # JSON response was already processed above; shouldn't reach here
404
+ raise MeritAPIError(
405
+ f"Unexpected response format from {url}: {response.text}",
406
+ status_code=response.status_code,
407
+ )
408
+ return response.content
409
+
410
+ raise MeritAPIError(f"Request failed after retries for {url}")
@@ -0,0 +1,18 @@
1
+ from typing import Any, Optional
2
+
3
+
4
+ class MeritAPIError(Exception):
5
+ """Base exception for Merit API errors."""
6
+
7
+ def __init__(
8
+ self,
9
+ message: str,
10
+ *,
11
+ status_code: Optional[int] = None,
12
+ error_code: Optional[str] = None,
13
+ response_body: Any = None,
14
+ ) -> None:
15
+ super().__init__(message)
16
+ self.status_code = status_code
17
+ self.error_code = error_code
18
+ self.response_body = response_body
@@ -0,0 +1,545 @@
1
+ from datetime import datetime, timedelta
2
+ from typing import Any, Dict, List
3
+ import base64
4
+
5
+
6
+ def _to_yyyymmdd(date_str: str) -> str:
7
+ """Normalize Merit date strings to YYYYMMDD for API fields like PaymentDate.
8
+
9
+ Merit invoice lists return DueDate as ISO 8601 (2026-05-02T00:00:00),
10
+ but sendPaymentV expects YYYYMMDD (20260502).
11
+ Already-correct 8-char strings are returned unchanged.
12
+ """
13
+ if len(date_str) == 8 and date_str.isdigit():
14
+ return date_str
15
+ return datetime.fromisoformat(date_str).strftime("%Y%m%d")
16
+
17
+
18
+ class Namespace:
19
+ """Base class for API namespaces."""
20
+
21
+ def __init__(self, client):
22
+ self._client = client
23
+
24
+ def _apply_default_period(self, kwargs: dict) -> dict:
25
+ if "PeriodStart" not in kwargs or "PeriodEnd" not in kwargs:
26
+ today = datetime.now()
27
+ kwargs.setdefault("PeriodEnd", today.strftime("%Y%m%d"))
28
+ kwargs.setdefault("PeriodStart", (today - timedelta(days=90)).strftime("%Y%m%d"))
29
+ return kwargs
30
+
31
+
32
+ class Customers(Namespace):
33
+ def get_list(self, **kwargs) -> List[Dict]:
34
+ """Get customer list. Optional filters: Name, RegNo."""
35
+ return self._client._post("getcustomers", kwargs)
36
+
37
+ def get_groups(self, **kwargs) -> List[Dict]:
38
+ """Get customer groups."""
39
+ return self._client._post("getcustomergroups", kwargs, version="v2")
40
+
41
+ def send(self, customer: Dict[str, Any]) -> Dict:
42
+ """Create or update a customer."""
43
+ return self._client._post("sendcustomer", customer, version="v2")
44
+
45
+
46
+ class Vendors(Namespace):
47
+ def get_list(self, **kwargs) -> List[Dict]:
48
+ """Get vendor list. Optional filters: Name, RegNo."""
49
+ return self._client._post("getvendors", kwargs)
50
+
51
+ def get_groups(self, **kwargs) -> List[Dict]:
52
+ """Get vendor groups."""
53
+ return self._client._post("getvendorgroups", kwargs, version="v2")
54
+
55
+ def send(self, vendor: Dict[str, Any]) -> Dict:
56
+ """Create or update a vendor."""
57
+ return self._client._post("sendvendor", vendor)
58
+
59
+ def update(self, vendor: Dict[str, Any]) -> Dict:
60
+ """Update an existing vendor. Only Id is required; all other fields are optional.
61
+
62
+ Use this to keep vendor records current when new invoices arrive — especially
63
+ to sync BankAccount (IBAN) and SWIFT_BIC so future payments can be made without
64
+ manual IBAN lookup.
65
+
66
+ v2 fields: Id (guid, required), Name, CountryCode, Address, City, PostalCode,
67
+ PhoneNo, PhoneNo2, Email, RegNo, VatRegNo, SalesInvLang, VatAccountable,
68
+ BankAccount, ReferenceNo, VendGrCode, VendGrId, PayerReceiverName,
69
+ Dimensions ([{DimId, DimValueId, DimCode}]).
70
+ """
71
+ return self._client._post("updatevendor", vendor, version="v2")
72
+
73
+
74
+ class Items(Namespace):
75
+ def get_list(self, **kwargs) -> List[Dict]:
76
+ """Get items list. Optional filters: Code, Name."""
77
+ return self._client._post("getitems", kwargs)
78
+
79
+ def get_groups(self, **kwargs) -> List[Dict]:
80
+ """Get item groups."""
81
+ return self._client._post("getitemgroups", kwargs, version="v2")
82
+
83
+ def add(self, items: List[Dict[str, Any]]) -> List[Dict]:
84
+ """Add new items."""
85
+ return self._client._post("senditems", items, version="v2")
86
+
87
+ def update(self, item: Dict[str, Any]) -> Dict:
88
+ """Update an item."""
89
+ return self._client._post("updateitem", item)
90
+
91
+
92
+ class Sales(Namespace):
93
+ def get_invoices(self, **kwargs) -> List[Dict]:
94
+ """Get list of invoices.
95
+
96
+ PeriodStart/PeriodEnd (YYYYMMDD) default to last 3 months if omitted.
97
+ This is the safest way to find the latest sales InvoiceNo before creating the
98
+ next invoice; ``send_invoice`` requires an explicit invoice number.
99
+ """
100
+ return self._client._post("getinvoices", self._apply_default_period(kwargs), version="v2")
101
+
102
+ def get_invoice(self, id: str, add_attachment: bool = False) -> Dict:
103
+ """Get single invoice details."""
104
+ return self._client._post("getinvoice", {"Id": id, "AddAttachment": add_attachment})
105
+
106
+ def send_invoice(self, invoice: Dict[str, Any]) -> Dict:
107
+ """Create a sales invoice.
108
+
109
+ This posts the payload directly to Merit v1 ``sendinvoice``. The create
110
+ payload is not the same shape as ``get_invoice`` responses; do not copy a
111
+ GET response or rename ``Lines`` to ``InvoiceRows``.
112
+
113
+ Important requirements:
114
+ - Use ``InvoiceRow`` (singular), not ``InvoiceRows``.
115
+ - ``InvoiceNo`` is mandatory; Merit does not auto-assign it. Call
116
+ ``get_invoices`` first and choose the next sequential number.
117
+ - ``DocDate``, ``TransactionDate`` and ``DueDate`` are YYYYMMDD strings.
118
+ - ``Customer`` should contain ``{"Id": guid}`` for an existing customer.
119
+ - ``InvoiceRow[].Item`` is required; ``Description`` and ``UOMName`` live
120
+ inside ``Item``.
121
+ - ``InvoiceRow[].TaxId`` is the tax GUID. Use ``taxes.get_list()`` to find
122
+ valid IDs; do not send TaxName or TaxPct.
123
+ - ``InvoiceRow[].Account`` is the account number field; do not use
124
+ AccountCode for sales invoice rows.
125
+ - ``TaxAmount`` is required even when the amount is zero.
126
+ - ``TotalAmount`` is required at top level and should match the row
127
+ ``Price * Quantity`` total.
128
+ - ``FComment`` is an optional footer note.
129
+ - Do not set ``DelivNote``/``delivnote`` true when creating a draft invoice;
130
+ delivery is a separate manual step in Merit.
131
+
132
+ Minimal working example (0 % VAT)::
133
+
134
+ result = client.sales.send_invoice({
135
+ "Customer": {"Id": "<customer-guid>"},
136
+ "DocDate": "20260415",
137
+ "TransactionDate": "20260415",
138
+ "DueDate": "20260429",
139
+ "InvoiceNo": "21179",
140
+ "CurrencyCode": "EUR",
141
+ "PriceInclVat": False,
142
+ "InvoiceRow": [
143
+ {
144
+ "Item": {
145
+ "Code": "SVC01",
146
+ "Description": "Consulting services",
147
+ "UOMName": "tk",
148
+ },
149
+ "Quantity": 1.0,
150
+ "Price": 100.00,
151
+ "TaxId": "<tax-guid>",
152
+ "Account": "30001",
153
+ }
154
+ ],
155
+ "TaxAmount": [{"TaxId": "<tax-guid>", "Amount": 0.00}],
156
+ "TotalAmount": 100.00,
157
+ })
158
+ # Returns: {"CustomerId": "...", "InvoiceId": "...", "InvoiceNo": "...", "RefNo": "..."}
159
+ """
160
+ return self._client._post("sendinvoice", invoice)
161
+
162
+ def delete_invoice(self, id: str) -> Dict:
163
+ """Delete an invoice."""
164
+ return self._client._post("deleteinvoice", {"Id": id})
165
+
166
+ def send_credit_invoice(self, credit_data: Dict[str, Any]) -> Dict:
167
+ """Create a credit invoice.
168
+
169
+ Uses the same ``sendinvoice`` endpoint with negative Quantity / Price / TotalAmount.
170
+ Provide the original invoice's customer and rows with negated quantities/prices.
171
+ The same create-payload requirements apply as in ``send_invoice``: singular
172
+ ``InvoiceRow``, explicit ``InvoiceNo``, YYYYMMDD dates, row-level ``TaxId``,
173
+ ``Account``, ``TaxAmount`` and top-level ``TotalAmount``.
174
+
175
+ Example::
176
+
177
+ result = client.sales.send_credit_invoice({
178
+ "Customer": {"Id": "<customer-guid>"},
179
+ "DocDate": "20260415",
180
+ "TransactionDate": "20260415",
181
+ "DueDate": "20260415",
182
+ "InvoiceNo": "21180",
183
+ "CurrencyCode": "EUR",
184
+ "PriceInclVat": False,
185
+ "InvoiceRow": [
186
+ {
187
+ "Item": {"Code": "SVC01", "Description": "Credit: Consulting", "UOMName": "tk"},
188
+ "Quantity": -1.0,
189
+ "Price": 100.00,
190
+ "TaxId": "<tax-guid>",
191
+ "Account": "30001",
192
+ }
193
+ ],
194
+ "TaxAmount": [{"TaxId": "<tax-guid>", "Amount": -20.00}],
195
+ "TotalAmount": -120.00,
196
+ })
197
+ """
198
+ return self._client._post("sendinvoice", credit_data)
199
+
200
+ def send_invoice_by_email(self, id: str, delivnote: bool = False) -> Dict:
201
+ """Send a sales invoice by email to the customer.
202
+
203
+ This delivers the invoice. Do not call it as part of the normal draft
204
+ creation flow unless the user explicitly wants delivery from the API.
205
+ Keep ``delivnote`` false unless delivery note mode without prices is
206
+ explicitly required.
207
+
208
+ Args:
209
+ id: Sales invoice GUID (SIHId)
210
+ delivnote: If True, send invoice without prices (delivery note mode)
211
+
212
+ Returns:
213
+ Status message or error from mail server
214
+ """
215
+ return self._client._post(
216
+ "sendinvoicebyemail",
217
+ {"Id": id, "DelivNote": delivnote},
218
+ version="v2"
219
+ )
220
+
221
+ def send_invoice_by_einvoice(self, id: str, delivnote: bool = False) -> Dict:
222
+ """Send a sales invoice as a structured e-invoice.
223
+
224
+ This delivers the invoice. Do not call it as part of the normal draft
225
+ creation flow unless the user explicitly wants delivery from the API.
226
+ Keep ``delivnote`` false unless delivery note mode without prices is
227
+ explicitly required.
228
+
229
+ Args:
230
+ id: Sales invoice GUID (SIHId)
231
+ delivnote: If True, send invoice without prices (delivery note mode)
232
+
233
+ Returns:
234
+ "OK" on success, or "api-noeinv" if recipient lacks e-invoice capability
235
+ """
236
+ return self._client._post(
237
+ "sendinvoiceaseinv",
238
+ {"Id": id, "DelivNote": delivnote},
239
+ version="v2"
240
+ )
241
+
242
+ def get_invoice_pdf(self, id: str) -> Dict[str, Any]:
243
+ """Get a sales invoice as a PDF document (returned as base64-encoded data).
244
+
245
+ Args:
246
+ id: Sales invoice GUID (SIHId)
247
+
248
+ Returns:
249
+ Dict with 'pdf' containing base64-encoded PDF content that can be decoded:
250
+
251
+ ```python
252
+ result = client.sales.get_invoice_pdf(invoice_id)
253
+ pdf_bytes = base64.b64decode(result['pdf'])
254
+ with open('invoice.pdf', 'wb') as f:
255
+ f.write(pdf_bytes)
256
+ ```
257
+ """
258
+ pdf_bytes = self._client._get_pdf("getsalesinvpdf", {"Id": id}, version="v2")
259
+ return {"pdf": base64.b64encode(pdf_bytes).decode("utf-8")}
260
+
261
+ def get_offers(self, **kwargs) -> List[Dict]:
262
+ """Get list of sales offers. Required: PeriodStart, PeriodEnd, DateType, UnPaid."""
263
+ return self._client._post("getoffers", kwargs, version="v2")
264
+
265
+ def get_offer(self, id: str) -> Dict:
266
+ """Get sales offer details."""
267
+ return self._client._post("getoffer", {"Id": id}, version="v2")
268
+
269
+ def get_recurring_invoices(self, **kwargs) -> List[Dict]:
270
+ """Get recurring invoices. Required: PeriodStart, PeriodEnd, DateType."""
271
+ return self._client._post("getperinvoices", kwargs, version="v2")
272
+
273
+ def get_recurring_invoice(self, id: str) -> Dict:
274
+ """Get recurring invoice details."""
275
+ return self._client._post("getperinvoice", {"Id": id}, version="v2")
276
+
277
+ def get_recurring_invoice_addresses(self, **kwargs) -> List[Dict]:
278
+ """Get recurring invoice client shipping addresses."""
279
+ return self._client._post("getpershaddress", kwargs, version="v2")
280
+
281
+
282
+ class Purchases(Namespace):
283
+ def get_invoices(self, **kwargs) -> List[Dict]:
284
+ """Get list of purchase invoices. Required filters: PeriodStart, PeriodEnd (YYYYmmdd). Defaults to last 3 months."""
285
+ return self._client._post("getpurchorders", self._apply_default_period(kwargs))
286
+
287
+ def get_invoice(self, id: str, skip_attachment: bool = True) -> Dict:
288
+ """Get purchase invoice details."""
289
+ return self._client._post("getpurchorder", {"Id": id, "SkipAttachment": skip_attachment})
290
+
291
+ def get_orders(self, **kwargs) -> List[Dict]:
292
+ """Get purchase orders waiting approval."""
293
+ return self._client._post("GetPOrders", kwargs, version="v2")
294
+
295
+ def send_invoice(self, invoice: Dict[str, Any]) -> Dict:
296
+ """Create a purchase invoice.
297
+
298
+ Tricky requirements:
299
+ - Vendor: must include both ``Id`` AND ``Name`` even for an existing vendor.
300
+ - InvoiceRow.Item: required nested object — Description lives here, not on the row.
301
+ - InvoiceRow.TaxId: required on the row (use ``taxes.get_list()`` to find valid IDs).
302
+ - TaxAmount: required array of ``{TaxId, Amount}`` — one entry per distinct tax rate.
303
+ - Dates (DocDate, DueDate, TransactionDate): YYYYmmdd strings.
304
+ - Attachment (optional): ``{FileName: str, FileContent: base64-encoded PDF}``.
305
+
306
+ Minimal working example (0 % VAT, with PDF attachment)::
307
+
308
+ result = client.purchases.send_invoice({
309
+ "Vendor": {"Id": "<vendor-guid>", "Name": "Vendor Name OÜ"},
310
+ "DocDate": "20260415",
311
+ "DueDate": "20260429",
312
+ "TransactionDate": "20260415",
313
+ "BillNo": "INV-2026-001",
314
+ "CurrencyCode": "EUR",
315
+ "CurrencyRate": 1.0,
316
+ "InvoiceRow": [
317
+ {
318
+ "Item": {
319
+ "Code": "SVC01",
320
+ "Description": "Consulting services",
321
+ "UOMName": "tk",
322
+ "TaxId": "<tax-guid>",
323
+ },
324
+ "Quantity": 1.0,
325
+ "Price": 100.00,
326
+ "TaxId": "<tax-guid>",
327
+ "GLAccountCode": "4017",
328
+ }
329
+ ],
330
+ "TaxAmount": [{"TaxId": "<tax-guid>", "Amount": 0.0}],
331
+ "TotalAmount": 100.00,
332
+ "RoundingAmount": 0.0,
333
+ "Attachment": {
334
+ "FileName": "invoice.pdf",
335
+ "FileContent": "<base64-encoded PDF>",
336
+ },
337
+ })
338
+ # Returns: {"VendorId": "...", "BillId": "...", "BillNo": "...", "BatchInfo": "..."}
339
+ """
340
+ return self._client._post("sendpurchinvoice", invoice)
341
+
342
+
343
+ class Financial(Namespace):
344
+ def get_payments(self, **kwargs) -> List[Dict]:
345
+ """Get payments. PeriodStart/PeriodEnd (YYYYmmdd) default to last 3 months if omitted."""
346
+ return self._client._post("getpayments", self._apply_default_period(kwargs))
347
+
348
+ def get_payment_types(self, **kwargs) -> List[Dict]:
349
+ """Get payment types."""
350
+ return self._client._post("getpaymenttypes", kwargs, version="v2")
351
+
352
+ def get_payment_imports(self, **kwargs) -> List[Dict]:
353
+ """Get payment imports."""
354
+ return self._client._get("PaymentImports", kwargs, version="v2")
355
+
356
+ def get_expense_payments(self, bank_id: str, **kwargs) -> List[Dict]:
357
+ """Get expense payments for a bank."""
358
+ return self._client._get(f"Banks/{bank_id}/ExpensePayments", kwargs, version="v2")
359
+
360
+ def get_income_payments(self, bank_id: str, **kwargs) -> List[Dict]:
361
+ """Get income payments for a bank."""
362
+ return self._client._get(f"Banks/{bank_id}/IncomePayments", kwargs, version="v2")
363
+
364
+ def create_payment(self, payment: Dict[str, Any]) -> Dict:
365
+ """Create a payment for a purchase invoice (sendPaymentV).
366
+
367
+ Auto-resolves missing fields before sending:
368
+ - IBAN: looked up from the vendor's BankAccount by VendorName.
369
+ - PaymentDate: looked up from the invoice's DueDate by BillNo.
370
+
371
+ Raises ValueError if either field cannot be resolved, preventing a silent
372
+ internal payment without bank transfer details.
373
+
374
+ Returns the raw Merit API response (all fields as-is).
375
+ """
376
+ if not payment.get("IBAN"):
377
+ vendor_name = payment.get("VendorName", "")
378
+ if vendor_name:
379
+ vendors = self._client.vendors.get_list(Name=vendor_name)
380
+ vendor = next((v for v in vendors if v.get("Name") == vendor_name), None)
381
+ if vendor and vendor.get("BankAccount"):
382
+ payment = {**payment, "IBAN": vendor["BankAccount"]}
383
+
384
+ if not payment.get("IBAN"):
385
+ vendor_name = payment.get("VendorName", "unknown")
386
+ raise ValueError(
387
+ f"IBAN is missing for vendor '{vendor_name}'. "
388
+ "Add IBAN to the payload or update the vendor's bank account in Merit."
389
+ )
390
+
391
+ if not payment.get("PaymentDate"):
392
+ bill_no = payment.get("BillNo", "")
393
+ if bill_no:
394
+ today = datetime.now()
395
+ invoices = self._client.purchases.get_invoices(
396
+ PeriodStart=(today - timedelta(days=365)).strftime("%Y%m%d"),
397
+ PeriodEnd=(today + timedelta(days=730)).strftime("%Y%m%d"),
398
+ )
399
+ invoice = next((i for i in invoices if i.get("BillNo") == bill_no), None)
400
+ if invoice and invoice.get("DueDate"):
401
+ payment = {**payment, "PaymentDate": _to_yyyymmdd(invoice["DueDate"])}
402
+
403
+ if not payment.get("PaymentDate"):
404
+ bill_no = payment.get("BillNo", "unknown")
405
+ raise ValueError(
406
+ f"PaymentDate is missing for invoice '{bill_no}' and could not be resolved "
407
+ "from the invoice's DueDate. Provide PaymentDate explicitly (YYYYMMDD)."
408
+ )
409
+
410
+ version = "v2" if payment.get("CurrencyCode") else "v1"
411
+ return self._client._post("sendPaymentV", payment, version=version)
412
+
413
+ def get_gl_batches(self, **kwargs) -> List[Dict]:
414
+ """Get GL transactions. PeriodStart/PeriodEnd (YYYYmmdd) default to last 3 months if omitted."""
415
+ return self._client._post("getglbatches", self._apply_default_period(kwargs))
416
+
417
+ def get_gl_batch(self, id: str) -> Dict:
418
+ """Get GL transaction details."""
419
+ return self._client._post("getglbatch", {"Id": id})
420
+
421
+ def get_gl_batches_full(self, **kwargs) -> List[Dict]:
422
+ """Get GL transactions with full details."""
423
+ return self._client._post("GetGLBatchesFull", self._apply_default_period(kwargs))
424
+
425
+ def get_banks(self) -> List[Dict]:
426
+ """Get list of banks."""
427
+ return self._client._post("getbanks")
428
+
429
+ def get_accounts(self, **kwargs) -> List[Dict]:
430
+ """Get chart of accounts."""
431
+ return self._client._post("getaccounts", kwargs)
432
+
433
+ def get_costs(self) -> List[Dict]:
434
+ """Get cost centers."""
435
+ return self._client._post("getcostcenters")
436
+
437
+ def get_projects(self) -> List[Dict]:
438
+ """Get projects."""
439
+ return self._client._post("getprojects")
440
+
441
+ def get_departments(self, **kwargs) -> List[Dict]:
442
+ """Get departments."""
443
+ return self._client._post("getdepartments", kwargs)
444
+
445
+ def get_financial_years(self, **kwargs) -> Dict:
446
+ """Get financial years."""
447
+ return self._client._post("getaccperiods", kwargs, version="v2")
448
+
449
+
450
+ class Inventory(Namespace):
451
+ def get_locations(self, **kwargs) -> List[Dict]:
452
+ """Get inventory locations."""
453
+ return self._client._post("getlocations", kwargs, version="v2")
454
+
455
+ def get_movements(self, **kwargs) -> List[Dict]:
456
+ """Get inventory movements."""
457
+ return self._client._post("getinvmovements", kwargs, version="v2")
458
+
459
+
460
+ class Assets(Namespace):
461
+ def get_locations(self, **kwargs) -> List[Dict]:
462
+ """Get fixed asset locations."""
463
+ return self._client._post("getfalocations", kwargs, version="v2")
464
+
465
+ def get_responsible_persons(self, **kwargs) -> List[Dict]:
466
+ """Get fixed asset responsible persons."""
467
+ return self._client._post("getfaresppersons", kwargs, version="v2")
468
+
469
+ def get_fixed_assets(self, **kwargs) -> List[Dict]:
470
+ """Get fixed assets."""
471
+ return self._client._post("getfixassets", kwargs, version="v2")
472
+
473
+
474
+ class Taxes(Namespace):
475
+ def get_list(self) -> List[Dict]:
476
+ """Get tax rates list."""
477
+ return self._client._post("gettaxes")
478
+
479
+ def send(self, tax: Dict[str, Any]) -> Dict:
480
+ """Create or update a tax rate."""
481
+ return self._client._post("sendtax", tax, version="v2")
482
+
483
+
484
+ class Dimensions(Namespace):
485
+ def get_list(self, all_values: bool = False) -> List[Dict]:
486
+ """Get dimensions list. all_values=True includes expired dimension values."""
487
+ return self._client._post("getdimensions", {"AllValues": all_values}, version="v2")
488
+
489
+ def add(self, dimensions: List[Dict[str, Any]]) -> List[Dict]:
490
+ """Add dimensions."""
491
+ return self._client._post("senddimensions", dimensions, version="v2")
492
+
493
+
494
+ class Pricing(Namespace):
495
+ def get_prices(self, **kwargs) -> List[Dict]:
496
+ """Get sales prices."""
497
+ return self._client._post("getprices", kwargs, version="v2")
498
+
499
+ def get_discounts(self, **kwargs) -> List[Dict]:
500
+ """Get sales discounts."""
501
+ return self._client._post("getdiscounts", kwargs, version="v2")
502
+
503
+ def get_price(self, **kwargs) -> Dict:
504
+ """Get a single effective sales price."""
505
+ return self._client._post("getprice", kwargs, version="v2")
506
+
507
+
508
+ class Reports(Namespace):
509
+ def get_customer_debts(self, **kwargs) -> List[Dict]:
510
+ """Get customer debts report."""
511
+ return self._client._post("getcustdebtrep", kwargs)
512
+
513
+ def get_customer_payments(self, **kwargs) -> Dict:
514
+ """Get customer payment report."""
515
+ return self._client._post("getcustpaymrep", kwargs, version="v2")
516
+
517
+ def get_more_data(self, **kwargs) -> Dict:
518
+ """Get next page / additional report data for paged report APIs."""
519
+ return self._client._post("getmoredata", kwargs, version="v2")
520
+
521
+ def get_profit(self, **kwargs) -> Dict:
522
+ """Get statement of profit or loss."""
523
+ return self._client._post("getprofitrep", kwargs)
524
+
525
+ def get_balance(self, **kwargs) -> Dict:
526
+ """Get statement of financial position."""
527
+ return self._client._post("getbalancerep", kwargs)
528
+
529
+ def get_inventory(self, **kwargs) -> List[Dict]:
530
+ """Get inventory report."""
531
+ return self._client._post("getinventoryreport", kwargs, version="v2")
532
+
533
+ def get_sales(self, **kwargs) -> List[Dict]:
534
+ """Get sales report."""
535
+ return self._client._post("getsalesrep", kwargs, version="v2")
536
+
537
+ def get_purchases(self, **kwargs) -> List[Dict]:
538
+ """Get purchase report."""
539
+ return self._client._post("getpurchrep", kwargs, version="v2")
540
+
541
+
542
+ class ReferenceData(Namespace):
543
+ def get_units(self, **kwargs) -> List[Dict]:
544
+ """Get units of measure."""
545
+ return self._client._post("getunits", kwargs)
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: merit-api
3
+ Version: 0.4.4
4
+ Summary: Python client for Merit Aktiva API
5
+ Author-email: Jaak <jaak@example.com>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: requests>=2.31.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
11
+ Requires-Dist: python-dotenv>=1.0.0; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # merit-api
15
+
16
+ Python SDK for the Merit Aktiva API.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install merit-api
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```python
27
+ from merit_api import MeritAPI
28
+
29
+ client = MeritAPI(api_id="YOUR_API_ID", api_key="YOUR_API_KEY")
30
+
31
+ customers = client.customers.get_list()
32
+ invoices = client.sales.get_invoices(
33
+ PeriodStart="2024-01-01",
34
+ PeriodEnd="2024-01-31",
35
+ )
36
+ ```
37
+
38
+ ## Features
39
+
40
+ - deterministic request-body serialization for signing
41
+ - configurable timeout and retry handling
42
+ - request and response logging hooks with secret redaction
43
+ - optional idempotency header generation
44
+ - API-level business error parsing from HTTP 200 responses
45
+
46
+ ## Testing and method coverage report
47
+
48
+ Run tests:
49
+
50
+ ```bash
51
+ pytest -q
52
+ ```
53
+
54
+ Regenerate the method-level read/write coverage report:
55
+
56
+ ```bash
57
+ python scripts_report_method_test_coverage.py
58
+ ```
59
+
60
+ The report is written to `reports/method_test_coverage.md` and CI checks that this file stays up to date.
@@ -0,0 +1,7 @@
1
+ merit_api/__init__.py,sha256=4vzY3VOTPTOBuLhCk0rVbRdn0YFQUvC2xvWjE11GM3U,108
2
+ merit_api/client.py,sha256=p1MxNui5l8xRWAgUuhznbr1UtX_X8-vHlqWS0_kYbd8,15345
3
+ merit_api/exceptions.py,sha256=tRfsZsI3DOmos8fjup-72Of1vpY6yANvQyP9QYucyE0,468
4
+ merit_api/namespaces.py,sha256=Lqxao3UxFkACuiTUA2203KhaM1EgruLaypCLfvxMCrc,22954
5
+ merit_api-0.4.4.dist-info/METADATA,sha256=Uo0I42acKYcJp5Z-aPp36f_fi4-TxlG7CODjC_ewplc,1291
6
+ merit_api-0.4.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
7
+ merit_api-0.4.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any