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/__init__.py +6 -0
- akt/cli.py +211 -0
- akt/client.py +240 -0
- akt/commands.py +88 -0
- akt/config.py +121 -0
- akt/output.py +84 -0
- akt/registry.py +263 -0
- akt/resources.py +467 -0
- akt_cli-0.1.0.dist-info/METADATA +274 -0
- akt_cli-0.1.0.dist-info/RECORD +13 -0
- akt_cli-0.1.0.dist-info/WHEEL +4 -0
- akt_cli-0.1.0.dist-info/entry_points.txt +3 -0
- akt_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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}"
|