akt-cli 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.
akt/resources.py ADDED
@@ -0,0 +1,467 @@
1
+ """Declarative resource registry and generic CRUD handlers.
2
+
3
+ Each :class:`Resource` maps a CLI noun (``customer``, ``invoice`` …) to an
4
+ Akaunting API endpoint plus the metadata needed to build create/update bodies
5
+ and render list tables. Resources whose create/update bodies are non-trivial
6
+ (documents, payments) override the body builders.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime as _dt
12
+ import json
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Callable
15
+
16
+ from .client import Client
17
+
18
+
19
+ # --------------------------------------------------------------------------
20
+ # field specs
21
+ # --------------------------------------------------------------------------
22
+
23
+ @dataclass
24
+ class Field:
25
+ name: str # CLI flag (without --), e.g. "currency-code"
26
+ dest: str # body key, e.g. "currency_code"
27
+ help: str = ""
28
+ required: bool = False # required on create
29
+ default: Any = None # default applied on create when omitted
30
+ is_flag: bool = False # store_true boolean flag
31
+ choices: list[str] | None = None
32
+
33
+
34
+ def f(name: str, help: str = "", **kw) -> Field:
35
+ dest = kw.pop("dest", name.replace("-", "_"))
36
+ return Field(name=name, dest=dest, help=help, **kw)
37
+
38
+
39
+ # --------------------------------------------------------------------------
40
+ # resource definition
41
+ # --------------------------------------------------------------------------
42
+
43
+ @dataclass
44
+ class Resource:
45
+ noun: str
46
+ endpoint: str
47
+ fields: list[Field] = field(default_factory=list)
48
+ type_scope: str | None = None # search=type:X for ACL (contacts/documents)
49
+ body_type: str | None = None # inject {"type": ...} into body
50
+ columns: list[tuple[str, str]] = field(default_factory=list) # (header, dotted path)
51
+ search_default: str | None = None # always-applied search filter
52
+ supports_toggle: bool = True # enable/disable verbs
53
+ help: str = ""
54
+
55
+ # hooks (override for documents/payments)
56
+ build_create: Callable[["Resource", Client, Any], dict] | None = None
57
+ build_update: Callable[["Resource", Client, Any, dict], dict] | None = None
58
+ # returns (path, type_scope) for delete; lets payments use the nested route
59
+ delete_resolver: Callable[["Resource", Client, str], "tuple[str, str | None]"] | None = None
60
+
61
+ def contact_scope(self) -> str:
62
+ """ACL scope of the contact tied to a document resource."""
63
+ return "customer" if self.body_type == "invoice" else "vendor"
64
+
65
+
66
+ # --------------------------------------------------------------------------
67
+ # helpers
68
+ # --------------------------------------------------------------------------
69
+
70
+ def parse_set(values: list[str] | None) -> dict:
71
+ """Parse repeated ``--set key=value`` into a dict (values JSON-coerced)."""
72
+ out: dict[str, Any] = {}
73
+ for item in values or []:
74
+ if "=" not in item:
75
+ raise ValueError(f"--set expects key=value, got {item!r}")
76
+ k, _, v = item.partition("=")
77
+ out[k.strip()] = _coerce(v.strip())
78
+ return out
79
+
80
+
81
+ def _coerce(v: str) -> Any:
82
+ try:
83
+ return json.loads(v)
84
+ except ValueError:
85
+ return v
86
+
87
+
88
+ def load_data_arg(value: str | None) -> dict:
89
+ """Parse ``--data`` which is inline JSON or @path/to/file.json."""
90
+ if not value:
91
+ return {}
92
+ if value.startswith("@"):
93
+ with open(value[1:]) as fh:
94
+ return json.load(fh)
95
+ return json.loads(value)
96
+
97
+
98
+ def now_dt() -> str:
99
+ return _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
100
+
101
+
102
+ def today_dt() -> str:
103
+ return _dt.date.today().strftime("%Y-%m-%d 00:00:00")
104
+
105
+
106
+ def add_days(date_str: str, days: int) -> str:
107
+ base = _dt.datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
108
+ return (base + _dt.timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
109
+
110
+
111
+ def _normalize_date(value: str) -> str:
112
+ """Accept YYYY-MM-DD or full datetime; return Akaunting datetime string."""
113
+ value = value.strip()
114
+ if len(value) == 10:
115
+ return value + " 00:00:00"
116
+ return value
117
+
118
+
119
+ def body_from_fields(res: Resource, ns: Any, *, for_update: bool,
120
+ current: dict | None = None) -> dict:
121
+ """Assemble a request body from declared fields + --set + --data.
122
+
123
+ On update, Akaunting validates required fields on PUT (it's a full replace,
124
+ not a patch), so when ``current`` is supplied unspecified fields fall back to
125
+ the existing record's values.
126
+ """
127
+ body: dict[str, Any] = {}
128
+ for fld in res.fields:
129
+ val = getattr(ns, fld.dest, None)
130
+ if fld.is_flag:
131
+ # tri-state: only include if explicitly toggled
132
+ if val is None:
133
+ if for_update and current is not None and fld.dest in current:
134
+ body[fld.dest] = 1 if current.get(fld.dest) else 0
135
+ elif not for_update and fld.default is not None:
136
+ body[fld.dest] = fld.default
137
+ continue
138
+ body[fld.dest] = 1 if val else 0
139
+ continue
140
+ if val is None:
141
+ if for_update and current is not None and current.get(fld.dest) is not None:
142
+ body[fld.dest] = current.get(fld.dest)
143
+ elif not for_update and fld.default is not None:
144
+ body[fld.dest] = fld.default
145
+ continue
146
+ body[fld.dest] = val
147
+
148
+ body.update(parse_set(getattr(ns, "set_", None)))
149
+ body.update(load_data_arg(getattr(ns, "data", None)))
150
+
151
+ if res.body_type and "type" not in body:
152
+ body["type"] = res.body_type
153
+ return body
154
+
155
+
156
+ # --------------------------------------------------------------------------
157
+ # document (invoice / bill) body builder
158
+ # --------------------------------------------------------------------------
159
+
160
+ def parse_item(spec: str) -> dict:
161
+ """Parse ``--item 'name=Widget,price=10,quantity=2,tax_id=1'``."""
162
+ item: dict[str, Any] = {"quantity": 1}
163
+ for part in spec.split(","):
164
+ if not part.strip():
165
+ continue
166
+ if "=" not in part:
167
+ raise ValueError(f"--item field must be key=value, got {part!r}")
168
+ k, _, v = part.partition("=")
169
+ item[k.strip()] = _coerce(v.strip())
170
+ if "name" not in item:
171
+ raise ValueError(f"--item requires a name=… field: {spec!r}")
172
+ if "price" not in item:
173
+ raise ValueError(f"--item requires a price=… field: {spec!r}")
174
+ # Akaunting reads $item['description'] without a default when item_id is
175
+ # absent (CreateDocumentItemsAndTotals), so it must always be present.
176
+ item.setdefault("description", "")
177
+ return item
178
+
179
+
180
+ def _normalize_items(items: list[dict]) -> list[dict]:
181
+ """Ensure every line item carries the keys Akaunting accesses unguarded."""
182
+ for it in items:
183
+ it.setdefault("description", "")
184
+ it.setdefault("quantity", 1)
185
+ return items
186
+
187
+
188
+ def _document_default_category(client: Client, doc_type: str) -> int | None:
189
+ key = "default.income_category" if doc_type == "invoice" else "default.expense_category"
190
+ val = client.setting(key)
191
+ try:
192
+ return int(val) if val is not None else None
193
+ except (TypeError, ValueError):
194
+ return None
195
+
196
+
197
+ def _next_document_number(client: Client, res: Resource, prefix: str) -> str:
198
+ """Best-effort unique document number: prefix + zero-padded max+1."""
199
+ setting_prefix = "invoice" if res.body_type == "invoice" else "bill"
200
+ pre = client.setting(f"{setting_prefix}.number_prefix") or prefix
201
+ digit = client.setting(f"{setting_prefix}.number_digit")
202
+ try:
203
+ width = int(digit)
204
+ except (TypeError, ValueError):
205
+ width = 5
206
+ existing = client.list(res.endpoint, type_scope=res.type_scope, all_pages=True)
207
+ maxn = 0
208
+ for row in existing:
209
+ num = str(row.get("document_number", ""))
210
+ tail = "".join(ch for ch in num if ch.isdigit())
211
+ if tail:
212
+ maxn = max(maxn, int(tail))
213
+ return f"{pre}{maxn + 1:0{width}d}"
214
+
215
+
216
+ def build_document_create(res: Resource, client: Client, ns: Any) -> dict:
217
+ doc_type = res.body_type or "invoice" # invoice | bill
218
+ contact_id = getattr(ns, "contact", None)
219
+ if contact_id is None:
220
+ raise ValueError(f"--contact <id> is required to create a {res.noun}")
221
+
222
+ contact = client.show("contacts", contact_id, type_scope=res.contact_scope())
223
+ currency = getattr(ns, "currency_code", None) or contact.get("currency_code") or "USD"
224
+
225
+ items_specs = getattr(ns, "item", None) or []
226
+ items = [parse_item(s) for s in items_specs]
227
+ extra = load_data_arg(getattr(ns, "data", None))
228
+ if not items and "items" not in extra:
229
+ raise ValueError("at least one --item 'name=…,price=…' is required (or supply --data with items)")
230
+
231
+ issued = _normalize_date(getattr(ns, "issued_at", None) or today_dt())
232
+ due = getattr(ns, "due_at", None)
233
+ due = _normalize_date(due) if due else add_days(issued, 30)
234
+
235
+ category_id = getattr(ns, "category_id", None) or _document_default_category(client, doc_type)
236
+ if category_id is None:
237
+ raise ValueError("no category_id given and no default category configured; pass --category-id")
238
+
239
+ number = getattr(ns, "number", None)
240
+ if not number:
241
+ number = _next_document_number(client, res, "INV-" if doc_type == "invoice" else "BILL-")
242
+
243
+ body: dict[str, Any] = {
244
+ "type": doc_type,
245
+ "document_number": number,
246
+ "status": getattr(ns, "status", None) or "draft",
247
+ "issued_at": issued,
248
+ "due_at": due,
249
+ "currency_code": currency,
250
+ "currency_rate": getattr(ns, "currency_rate", None) or 1,
251
+ "contact_id": int(contact_id),
252
+ "contact_name": contact.get("name", ""),
253
+ "contact_email": contact.get("email"),
254
+ "contact_tax_number": contact.get("tax_number"),
255
+ "contact_phone": contact.get("phone"),
256
+ "contact_address": contact.get("address"),
257
+ "category_id": int(category_id),
258
+ # Akaunting recomputes the document total from the line items and ADDS it
259
+ # to whatever `amount` we send (CreateDocumentItemsAndTotals: amount +=
260
+ # actual_total). Send 0 so the server-computed total is authoritative.
261
+ "amount": 0,
262
+ "items": items,
263
+ }
264
+ if getattr(ns, "notes", None):
265
+ body["notes"] = ns.notes
266
+ if getattr(ns, "order_number", None):
267
+ body["order_number"] = ns.order_number
268
+ body.update(parse_set(getattr(ns, "set_", None)))
269
+ body.update(extra)
270
+ if isinstance(body.get("items"), list):
271
+ _normalize_items(body["items"])
272
+ return body
273
+
274
+
275
+ def _items_from_current(current: dict) -> list[dict]:
276
+ """Rebuild request items from a fetched document so an update that doesn't
277
+ touch line items doesn't wipe them (UpdateDocument deletes & recreates all
278
+ items from the request)."""
279
+ out: list[dict] = []
280
+ for it in current.get("items", {}).get("data", []):
281
+ price = float(it.get("price") or 0)
282
+ total = float(it.get("total") or 0)
283
+ qty = it.get("quantity")
284
+ if qty is None:
285
+ qty = round(total / price, 4) if price else 1
286
+ row = {
287
+ "name": it.get("name"),
288
+ "description": it.get("description") or "",
289
+ "price": price,
290
+ "quantity": qty,
291
+ }
292
+ if it.get("item_id"):
293
+ row["item_id"] = int(it["item_id"])
294
+ tax_ids = [t.get("tax_id") for t in it.get("taxes", {}).get("data", []) if t.get("tax_id")]
295
+ if tax_ids:
296
+ row["tax_id"] = tax_ids
297
+ out.append(row)
298
+ return out
299
+
300
+
301
+ def _normalize_dt_field(value: str) -> str:
302
+ return _normalize_date(value[:19].replace("T", " "))
303
+
304
+
305
+ def build_document_update(res: Resource, client: Client, ns: Any, current: dict) -> dict:
306
+ """Full update: Akaunting recreates items & totals from the request, so we
307
+ resend the whole document, overlaying any provided fields."""
308
+ body: dict[str, Any] = {
309
+ "type": current.get("type", res.body_type),
310
+ "document_number": current.get("document_number"),
311
+ "status": current.get("status"),
312
+ "issued_at": _normalize_dt_field(current.get("issued_at") or today_dt()),
313
+ "due_at": _normalize_dt_field(current.get("due_at") or today_dt()),
314
+ "currency_code": current.get("currency_code"),
315
+ "currency_rate": current.get("currency_rate", 1),
316
+ "contact_id": current.get("contact_id"),
317
+ "contact_name": current.get("contact_name"),
318
+ "contact_email": current.get("contact_email"),
319
+ "category_id": current.get("category_id"),
320
+ # send 0: server recomputes total from items and adds to amount
321
+ "amount": 0,
322
+ "items": _items_from_current(current),
323
+ }
324
+ if current.get("notes"):
325
+ body["notes"] = current["notes"]
326
+
327
+ for attr, key in [
328
+ ("status", "status"), ("issued_at", "issued_at"), ("due_at", "due_at"),
329
+ ("number", "document_number"), ("category_id", "category_id"),
330
+ ("currency_code", "currency_code"), ("currency_rate", "currency_rate"),
331
+ ("notes", "notes"), ("order_number", "order_number"),
332
+ ]:
333
+ v = getattr(ns, attr, None)
334
+ if v is not None:
335
+ body[key] = _normalize_date(v) if key in ("issued_at", "due_at") else v
336
+
337
+ items_specs = getattr(ns, "item", None) or []
338
+ if items_specs:
339
+ body["items"] = [parse_item(s) for s in items_specs]
340
+
341
+ body.update(parse_set(getattr(ns, "set_", None)))
342
+ body.update(load_data_arg(getattr(ns, "data", None)))
343
+ if isinstance(body.get("items"), list):
344
+ _normalize_items(body["items"])
345
+ return body
346
+
347
+
348
+ # --------------------------------------------------------------------------
349
+ # payment (transaction) body builder
350
+ # --------------------------------------------------------------------------
351
+
352
+ def build_payment_create(res: Resource, client: Client, ns: Any) -> dict:
353
+ invoice_id = getattr(ns, "invoice", None)
354
+ bill_id = getattr(ns, "bill", None)
355
+ ptype = getattr(ns, "type", None)
356
+ document = None
357
+ document_id = getattr(ns, "document_id", None)
358
+ contact_id = getattr(ns, "contact_id", None)
359
+ category_id = getattr(ns, "category_id", None)
360
+
361
+ if invoice_id:
362
+ document = client.show("documents", invoice_id, type_scope="invoice")
363
+ ptype = ptype or "income"
364
+ document_id = document_id or int(invoice_id)
365
+ elif bill_id:
366
+ document = client.show("documents", bill_id, type_scope="bill")
367
+ ptype = ptype or "expense"
368
+ document_id = document_id or int(bill_id)
369
+ ptype = ptype or "income"
370
+
371
+ if document is not None:
372
+ contact_id = contact_id or document.get("contact_id")
373
+ category_id = category_id or document.get("category_id")
374
+
375
+ if category_id is None:
376
+ key = "default.income_category" if ptype == "income" else "default.expense_category"
377
+ val = client.setting(key)
378
+ category_id = int(val) if val else None
379
+ if category_id is None:
380
+ raise ValueError("no --category-id and no default category configured")
381
+
382
+ amount = getattr(ns, "amount", None)
383
+ if amount is None and document is not None:
384
+ amount = document.get("amount_due", document.get("amount"))
385
+ if amount is None:
386
+ raise ValueError("--amount is required")
387
+
388
+ account_id = getattr(ns, "account_id", None)
389
+ if account_id is None:
390
+ val = client.setting("default.account")
391
+ account_id = int(val) if val else 1
392
+
393
+ currency = getattr(ns, "currency_code", None)
394
+ if currency is None and document is not None:
395
+ currency = document.get("currency_code")
396
+ currency = currency or "USD"
397
+
398
+ number = getattr(ns, "number", None) or _next_transaction_number(client)
399
+
400
+ body: dict[str, Any] = {
401
+ "type": ptype,
402
+ "number": number,
403
+ "account_id": int(account_id),
404
+ "paid_at": _normalize_date(getattr(ns, "paid_at", None) or now_dt()),
405
+ "amount": amount,
406
+ "currency_code": currency,
407
+ "currency_rate": getattr(ns, "currency_rate", None) or 1,
408
+ "category_id": int(category_id),
409
+ "payment_method": getattr(ns, "payment_method", None) or "offline-payments.cash.1",
410
+ }
411
+ if contact_id:
412
+ body["contact_id"] = int(contact_id)
413
+ if getattr(ns, "reference", None):
414
+ body["reference"] = ns.reference
415
+ if getattr(ns, "description", None):
416
+ body["description"] = ns.description
417
+ body.update(parse_set(getattr(ns, "set_", None)))
418
+ body.update(load_data_arg(getattr(ns, "data", None)))
419
+
420
+ # A payment tied to a document must be posted to the nested route
421
+ # POST /documents/{id}/transactions (the flat /transactions endpoint rejects
422
+ # document_id). That route's ACL is derived from the document type, so it
423
+ # needs the matching search=type:<invoice|bill> scope. Routing is conveyed
424
+ # to cmd_create via reserved __endpoint__ / __type_scope__ keys.
425
+ if document_id:
426
+ doc_scope = "invoice" if ptype == "income" else "bill"
427
+ body["__endpoint__"] = f"documents/{int(document_id)}/transactions"
428
+ body["__type_scope__"] = doc_scope
429
+ return body
430
+
431
+
432
+ def resolve_payment_delete(res: Resource, client: Client, ident: str) -> "tuple[str, str | None]":
433
+ """A payment linked to a document must be deleted via the nested route
434
+ DELETE /documents/{doc}/transactions/{id} (the flat /transactions endpoint
435
+ rejects it). Standalone payments/transfers delete via /transactions/{id}."""
436
+ try:
437
+ txn = client.show("transactions", ident)
438
+ except Exception:
439
+ return f"transactions/{ident}", None
440
+ doc_id = txn.get("document_id")
441
+ if doc_id:
442
+ scope = "invoice" if txn.get("type") == "income" else "bill"
443
+ return f"documents/{doc_id}/transactions/{ident}", scope
444
+ return f"transactions/{ident}", None
445
+
446
+
447
+ def build_transfer_create(res: Resource, client: Client, ns: Any) -> dict:
448
+ body = body_from_fields(res, ns, for_update=False)
449
+ # Transfers validate transferred_at as date-only (Y-m-d), unlike transactions.
450
+ raw = str(body.get("transferred_at") or _dt.date.today().isoformat())
451
+ body["transferred_at"] = raw[:10]
452
+ body.setdefault("payment_method", "offline-payments.cash.1")
453
+ for key in ("from_account_id", "to_account_id"):
454
+ if key in body:
455
+ body[key] = int(body[key])
456
+ return body
457
+
458
+
459
+ def _next_transaction_number(client: Client) -> str:
460
+ pre = client.setting("transaction.number_prefix") or "PAY-"
461
+ existing = client.list("transactions", all_pages=True)
462
+ maxn = 0
463
+ for row in existing:
464
+ tail = "".join(ch for ch in str(row.get("number", "")) if ch.isdigit())
465
+ if tail:
466
+ maxn = max(maxn, int(tail))
467
+ return f"{pre}{maxn + 1:05d}"