ledgerkit 1.0.0.dev1__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.
ledgerkit/reports.py ADDED
@@ -0,0 +1,573 @@
1
+ """Report generators for ledgerkit.
2
+
3
+ Each function accepts a Journal and returns structured data — not formatted
4
+ strings. Formatting is handled by cli.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime
10
+ import re
11
+ from collections.abc import Mapping, Sequence
12
+ from dataclasses import dataclass, replace
13
+ from decimal import Decimal
14
+ from typing import Iterator
15
+
16
+ from ledgerkit.models import (
17
+ BalanceRow,
18
+ Journal,
19
+ Posting,
20
+ Query,
21
+ RegisterRow,
22
+ ReportSection,
23
+ ReportSpec,
24
+ ReportSectionResult,
25
+ Transaction,
26
+ )
27
+ from ledgerkit.parser import resolve_elision
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Transparent result wrappers with to_dataframe() support
32
+ # ---------------------------------------------------------------------------
33
+
34
+ class BalanceResult(Mapping):
35
+ """Return type of Journal.balance() (flat mode). Behaves like dict[str, dict[str, Decimal]]."""
36
+
37
+ def __init__(self, data: dict, commodity_styles: dict) -> None:
38
+ self._data = data
39
+ self._commodity_styles = commodity_styles
40
+
41
+ def __getitem__(self, key: str) -> dict:
42
+ return self._data[key]
43
+
44
+ def __iter__(self) -> Iterator:
45
+ return iter(self._data)
46
+
47
+ def __len__(self) -> int:
48
+ return len(self._data)
49
+
50
+ def __eq__(self, other: object) -> bool:
51
+ if isinstance(other, BalanceResult):
52
+ return self._data == other._data
53
+ if isinstance(other, dict):
54
+ return self._data == other
55
+ return NotImplemented
56
+
57
+ def __repr__(self) -> str:
58
+ return repr(self._data)
59
+
60
+ def to_dataframe(self):
61
+ """Export balance to a DataFrame with account, amount, commodity, amount_formatted.
62
+
63
+ One row per (account, commodity) pair (non-zero amounts only).
64
+ Requires pandas: pip install ledgerkit[pandas]
65
+ """
66
+ from ledgerkit._pandas_compat import require_pandas
67
+ pd = require_pandas()
68
+ rows = []
69
+ for account in sorted(self._data):
70
+ for commodity, qty in sorted(self._data[account].items()):
71
+ if qty == 0:
72
+ continue
73
+ style = self._commodity_styles.get(commodity)
74
+ from ledgerkit.commodity_style import CommodityStyle
75
+ formatted = style.format(qty) if style else str(qty)
76
+ rows.append({
77
+ "account": account,
78
+ "amount": qty,
79
+ "commodity": commodity,
80
+ "amount_formatted": formatted,
81
+ })
82
+ df = pd.DataFrame(rows, columns=["account", "amount", "commodity", "amount_formatted"])
83
+ if not df.empty:
84
+ df["amount"] = df["amount"].astype(object)
85
+ return df
86
+
87
+
88
+ class RegisterResult(Sequence):
89
+ """Return type of Journal.register(). Behaves like list[RegisterRow]."""
90
+
91
+ def __init__(self, data: list, commodity_styles: dict) -> None:
92
+ self._data = data
93
+ self._commodity_styles = commodity_styles
94
+
95
+ def __getitem__(self, index):
96
+ return self._data[index]
97
+
98
+ def __len__(self) -> int:
99
+ return len(self._data)
100
+
101
+ def __eq__(self, other: object) -> bool:
102
+ if isinstance(other, RegisterResult):
103
+ return self._data == other._data
104
+ if isinstance(other, list):
105
+ return self._data == other
106
+ return NotImplemented
107
+
108
+ def __repr__(self) -> str:
109
+ return repr(self._data)
110
+
111
+ def to_dataframe(self):
112
+ """Export register rows to a DataFrame.
113
+
114
+ Columns: date, description, cleared, pending, account,
115
+ amount (Decimal), commodity, amount_formatted.
116
+ Requires pandas: pip install ledgerkit[pandas]
117
+ """
118
+ from ledgerkit._pandas_compat import require_pandas
119
+ pd = require_pandas()
120
+ rows = []
121
+ for row in self._data:
122
+ commodity = row.amount.commodity
123
+ style = self._commodity_styles.get(commodity)
124
+ formatted = style.format(row.amount.quantity) if style else str(row.amount.quantity)
125
+ rows.append({
126
+ "date": row.date,
127
+ "description": row.description,
128
+ "cleared": False,
129
+ "pending": False,
130
+ "account": row.account,
131
+ "amount": row.amount.quantity,
132
+ "commodity": commodity,
133
+ "amount_formatted": formatted,
134
+ })
135
+ df = pd.DataFrame(rows, columns=[
136
+ "date", "description", "cleared", "pending", "account",
137
+ "amount", "commodity", "amount_formatted",
138
+ ])
139
+ if not df.empty:
140
+ df["amount"] = df["amount"].astype(object)
141
+ return df
142
+
143
+
144
+ class AccountsResult(Sequence):
145
+ """Return type of Journal.accounts(). Behaves like list[str]."""
146
+
147
+ def __init__(self, data: list) -> None:
148
+ self._data = data
149
+
150
+ def __getitem__(self, index):
151
+ return self._data[index]
152
+
153
+ def __len__(self) -> int:
154
+ return len(self._data)
155
+
156
+ def __eq__(self, other: object) -> bool:
157
+ if isinstance(other, AccountsResult):
158
+ return self._data == other._data
159
+ if isinstance(other, list):
160
+ return self._data == other
161
+ return NotImplemented
162
+
163
+ def __repr__(self) -> str:
164
+ return repr(self._data)
165
+
166
+ def to_dataframe(self):
167
+ """Export account names to a single-column DataFrame.
168
+
169
+ Requires pandas: pip install ledgerkit[pandas]
170
+ """
171
+ from ledgerkit._pandas_compat import require_pandas
172
+ pd = require_pandas()
173
+ return pd.DataFrame({"account": self._data})
174
+
175
+
176
+ @dataclass
177
+ class JournalStats:
178
+ """Summary statistics for a journal.
179
+
180
+ Contains only deterministic journal data. Runtime stats (elapsed time,
181
+ txns/s) are measured and formatted by the CLI layer, not stored here.
182
+ """
183
+
184
+ source_file: str | None
185
+ included_files: int
186
+ transaction_count: int
187
+ date_range: tuple[datetime.date, datetime.date] | None # (first, last)
188
+ last_txn_date: datetime.date | None
189
+ last_txn_days_ago: int | None # (today - last_txn_date).days
190
+ txns_span_days: int | None # (last - first).days + 1
191
+ txns_per_day: float # transaction_count / txns_span_days, else 0.0
192
+ txns_last_30_days: int
193
+ txns_per_day_last_30: float
194
+ txns_last_7_days: int
195
+ txns_per_day_last_7: float
196
+ payee_count: int # unique descriptions
197
+ account_count: int
198
+ account_depth: int # max colon-segment depth across all accounts
199
+ commodity_count: int
200
+ commodities: list[str] # sorted commodity symbols (shown in verbose mode)
201
+ price_count: int # len(journal.prices)
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Private helpers
206
+ # ---------------------------------------------------------------------------
207
+
208
+ # Detects whether a user-supplied pattern contains any regex metacharacter,
209
+ # used to decide between plain substring matching and re.search().
210
+ #
211
+ # Purpose: distinguish plain strings like "expenses:food" from regex patterns
212
+ # like "^expenses" or "food.*". Any character that has special meaning
213
+ # in a Python regex triggers regex mode.
214
+ #
215
+ # Group breakdown: no capture groups — result used only as a boolean via .search().
216
+ #
217
+ # Edge cases:
218
+ # - A lone '.' is treated as a regex wildcard (matches any character), matching
219
+ # hledger's behaviour where '.' in an account filter is a metacharacter.
220
+ # - Backslash sequences like '\\(' are detected because '\\' is in the set,
221
+ # so escaped literals always go through regex mode.
222
+ # - An empty pattern never contains a metacharacter → plain substring mode.
223
+ _REGEX_META = re.compile(r'[\\^$.()\[\]{}*+?|]')
224
+
225
+
226
+ def _matches_pattern(pattern: str, value: str) -> bool:
227
+ """Return True if pattern matches value using hledger substring/regex rules.
228
+
229
+ If pattern contains any regex metacharacter it is compiled as a regex and
230
+ matched via re.search (partial match, case-insensitive). Otherwise it is
231
+ treated as a plain case-insensitive substring match.
232
+ """
233
+ if _REGEX_META.search(pattern):
234
+ return bool(re.search(pattern, value, re.IGNORECASE))
235
+ return pattern.lower() in value.lower()
236
+
237
+
238
+ def _posting_matches(
239
+ posting: Posting,
240
+ txn: Transaction,
241
+ query: Query | None,
242
+ ) -> bool:
243
+ """Return True if the posting should be included given the query.
244
+
245
+ Transaction-level filters (date, payee) are checked against txn.
246
+ Posting-level filters (account, not_account, depth) are checked against posting.
247
+
248
+ A None query or a Query() with all None fields always returns True.
249
+ """
250
+ if query is None:
251
+ return True
252
+ if query.date_from is not None and txn.date < query.date_from:
253
+ return False
254
+ if query.date_to is not None and txn.date > query.date_to:
255
+ return False
256
+ if query.payee is not None and not _matches_pattern(query.payee, txn.description):
257
+ return False
258
+ if query.account is not None and not _matches_pattern(query.account, posting.account):
259
+ return False
260
+ if query.not_account is not None and _matches_pattern(query.not_account, posting.account):
261
+ return False
262
+ if query.depth is not None and len(posting.account.split(":")) > query.depth:
263
+ return False
264
+ return True
265
+
266
+
267
+ def _aggregate_posting_amounts(
268
+ pairs: list[tuple[str, Decimal]],
269
+ ) -> dict[str, Decimal]:
270
+ """Sum (account_name, quantity) pairs into an account → balance dict."""
271
+ result: dict[str, Decimal] = {}
272
+ for account, qty in pairs:
273
+ result[account] = result.get(account, Decimal(0)) + qty
274
+ return result
275
+
276
+
277
+ def _build_balance_tree(
278
+ totals: dict[str, dict[str, Decimal]],
279
+ ) -> list[BalanceRow]:
280
+ """Convert a flat account→commodity→net dict into a sorted tree row list.
281
+
282
+ Collects all implicit parent accounts (all colon-prefixes of every account
283
+ name in `totals`), computes aggregate amounts (own postings + all
284
+ descendants) for each, and returns a list of BalanceRow sorted
285
+ alphabetically by account name.
286
+
287
+ is_subtotal is True for accounts not present in `totals` directly (pure
288
+ intermediate parents with no direct postings of their own).
289
+ """
290
+ all_accounts: set[str] = set(totals.keys())
291
+ for account in list(totals.keys()):
292
+ parts = account.split(":")
293
+ for i in range(1, len(parts)):
294
+ all_accounts.add(":".join(parts[:i]))
295
+
296
+ rows: list[BalanceRow] = []
297
+ for account in sorted(all_accounts):
298
+ prefix = account + ":"
299
+ agg: dict[str, Decimal] = {}
300
+ for src, amounts in totals.items():
301
+ if src == account or src.startswith(prefix):
302
+ for commodity, qty in amounts.items():
303
+ agg[commodity] = agg.get(commodity, Decimal(0)) + qty
304
+ rows.append(BalanceRow(
305
+ account=account,
306
+ depth=account.count(":"),
307
+ amounts=agg,
308
+ is_subtotal=(account not in totals),
309
+ ))
310
+ return rows
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # Report functions
315
+ # ---------------------------------------------------------------------------
316
+
317
+ def accounts(journal: Journal, query: Query | None = None) -> list[str]:
318
+ """Return a sorted list of all unique account names in the journal.
319
+
320
+ Args:
321
+ journal: The parsed journal.
322
+ query: Optional filter. When None or Query(), all accounts are returned.
323
+
324
+ Returns:
325
+ Sorted list of account name strings that appear in at least one
326
+ matching posting.
327
+ """
328
+ seen: set[str] = set()
329
+ for txn in journal.transactions:
330
+ for posting in txn.postings:
331
+ if _posting_matches(posting, txn, query):
332
+ seen.add(posting.account)
333
+ return AccountsResult(sorted(seen))
334
+
335
+
336
+ def balance(
337
+ journal: Journal,
338
+ query: Query | None = None,
339
+ tree: bool = False,
340
+ ) -> dict[str, dict[str, Decimal]] | list[BalanceRow]:
341
+ """Return per-commodity net balances for each account.
342
+
343
+ Args:
344
+ journal: The parsed journal.
345
+ query: Optional filter. When None or Query(), all postings are included.
346
+ tree: When True, returns list[BalanceRow] with implicit parent accounts
347
+ and aggregate subtotals. When False (default), returns a flat
348
+ dict[str, dict[str, Decimal]] mapping account name to a
349
+ commodity→net dict.
350
+
351
+ Returns:
352
+ When tree=False: dict mapping account name to {commodity: net_balance}.
353
+ When tree=True: list[BalanceRow] sorted alphabetically, including
354
+ implicit parent accounts with is_subtotal=True.
355
+
356
+ Note on depth: depth in the query causes account names to be *truncated*
357
+ (rolled up) rather than excluded — matching hledger's --depth behaviour.
358
+ expenses:food:groceries at depth=2 contributes to expenses:food.
359
+ """
360
+ # Strip depth from the query before _posting_matches so deep postings are
361
+ # not excluded, then apply truncation to the account name manually.
362
+ matching_query = (
363
+ replace(query, depth=None)
364
+ if query is not None and query.depth is not None
365
+ else query
366
+ )
367
+ totals: dict[str, dict[str, Decimal]] = {}
368
+ for txn in journal.transactions:
369
+ for posting in resolve_elision(txn):
370
+ if not _posting_matches(posting, txn, matching_query):
371
+ continue
372
+ if posting.amount is None:
373
+ continue
374
+ account = posting.account
375
+ if query is not None and query.depth is not None:
376
+ account = ":".join(account.split(":")[:query.depth])
377
+ commodity = posting.amount.commodity
378
+ if account not in totals:
379
+ totals[account] = {}
380
+ totals[account][commodity] = (
381
+ totals[account].get(commodity, Decimal(0)) + posting.amount.quantity
382
+ )
383
+ if tree:
384
+ return _build_balance_tree(totals)
385
+ return BalanceResult(totals, journal.commodity_styles)
386
+
387
+
388
+ def register(
389
+ journal: Journal,
390
+ query: Query | None = None,
391
+ ) -> list[RegisterRow]:
392
+ """Return a chronological list of register rows.
393
+
394
+ Args:
395
+ journal: The parsed journal.
396
+ query: Optional filter. When None or Query(), all postings are included.
397
+
398
+ Returns:
399
+ List of RegisterRow objects in journal order. running_balance is the
400
+ cumulative sum of amount.quantity across all rows in output order.
401
+ """
402
+ rows: list[RegisterRow] = []
403
+ running: Decimal = Decimal(0)
404
+ for txn in sorted(journal.transactions, key=lambda t: t.date):
405
+ for posting in resolve_elision(txn):
406
+ if not _posting_matches(posting, txn, query):
407
+ continue
408
+ if posting.amount is None:
409
+ continue
410
+ running += posting.amount.quantity
411
+ rows.append(RegisterRow(
412
+ date=txn.date,
413
+ description=txn.description,
414
+ account=posting.account,
415
+ amount=posting.amount,
416
+ running_balance=running,
417
+ ))
418
+ return RegisterResult(rows, journal.commodity_styles)
419
+
420
+
421
+ def stats(journal: Journal, query: Query | None = None) -> JournalStats:
422
+ """Return summary statistics for the journal.
423
+
424
+ Args:
425
+ journal: The parsed journal.
426
+ query: Optional filter. When None or Query(), behaviour is identical to
427
+ the original implementation (all transactions). When a date or
428
+ payee filter is provided, statistics are computed over the
429
+ matching transaction subset.
430
+
431
+ Note: account-level filters (account, not_account, depth) in the query are
432
+ not yet applied to stats fields — those fields still reflect the full
433
+ journal.
434
+ # TODO: Apply account-level query filters to account_count and account_depth.
435
+ """
436
+ today = datetime.date.today()
437
+ txns = journal.transactions
438
+
439
+ # Apply transaction-level query filters when provided.
440
+ if query is not None:
441
+ txns = [
442
+ t for t in txns
443
+ if (query.date_from is None or t.date >= query.date_from)
444
+ and (query.date_to is None or t.date <= query.date_to)
445
+ and (query.payee is None or _matches_pattern(query.payee, t.description))
446
+ ]
447
+
448
+ all_accounts: set[str] = {p.account for t in txns for p in t.postings}
449
+ all_commodities: set[str] = {
450
+ p.amount.commodity for t in txns for p in t.postings if p.amount is not None
451
+ }
452
+ dates = [t.date for t in txns]
453
+ date_range = (min(dates), max(dates)) if dates else None
454
+ last_txn_date = max(dates) if dates else None
455
+ span_days = (date_range[1] - date_range[0]).days + 1 if date_range else None
456
+
457
+ cutoff_30 = today - datetime.timedelta(days=30)
458
+ cutoff_7 = today - datetime.timedelta(days=7)
459
+ txns_30 = sum(1 for t in txns if t.date >= cutoff_30)
460
+ txns_7 = sum(1 for t in txns if t.date >= cutoff_7)
461
+
462
+ depth = max((len(a.split(":")) for a in all_accounts), default=0)
463
+
464
+ return JournalStats(
465
+ source_file=journal.source_file,
466
+ included_files=journal.included_files,
467
+ transaction_count=len(txns),
468
+ date_range=date_range,
469
+ last_txn_date=last_txn_date,
470
+ last_txn_days_ago=(today - last_txn_date).days if last_txn_date else None,
471
+ txns_span_days=span_days,
472
+ txns_per_day=len(txns) / span_days if span_days else 0.0,
473
+ txns_last_30_days=txns_30,
474
+ txns_per_day_last_30=txns_30 / 30,
475
+ txns_last_7_days=txns_7,
476
+ txns_per_day_last_7=txns_7 / 7,
477
+ payee_count=len({t.description for t in txns}),
478
+ account_count=len(all_accounts),
479
+ account_depth=depth,
480
+ commodity_count=len(all_commodities),
481
+ commodities=sorted(all_commodities),
482
+ price_count=len(journal.prices),
483
+ )
484
+
485
+
486
+ def balance_from_spec(
487
+ journal: Journal,
488
+ spec: ReportSpec,
489
+ query: Query | None = None,
490
+ ) -> list[ReportSectionResult]:
491
+ """Compute a structured balance report driven by a ReportSpec.
492
+
493
+ For each section in spec.sections:
494
+ 1. Apply the outer query's date and payee filters at transaction level.
495
+ 2. Include postings whose account matches any of section.accounts (OR logic).
496
+ 3. Exclude postings whose account matches any of section.exclude.
497
+ 4. Apply the outer query's account/not_account filters if set.
498
+ 5. Apply depth truncation: section.depth overrides query.depth.
499
+ 6. Aggregate using _aggregate_posting_amounts (shared with balance()).
500
+ 7. Apply sign inversion if section.invert is True.
501
+ 8. Return a ReportSectionResult per section.
502
+
503
+ The outer query acts as a uniform time/payee filter across all sections.
504
+ Section-level account patterns are OR-combined within each section.
505
+
506
+ Args:
507
+ journal: The parsed journal.
508
+ spec: The report layout definition.
509
+ query: Optional uniform filter (date range, payee). Applied across all
510
+ sections before section-level account matching.
511
+
512
+ Returns:
513
+ One ReportSectionResult per section in spec.sections order.
514
+ """
515
+ results: list[ReportSectionResult] = []
516
+ commodity_styles = journal.commodity_styles
517
+
518
+ for section in spec.sections:
519
+ pairs: list[tuple[str, Decimal]] = []
520
+
521
+ for txn in journal.transactions:
522
+ # Apply outer query transaction-level filters.
523
+ if query is not None:
524
+ if query.date_from is not None and txn.date < query.date_from:
525
+ continue
526
+ if query.date_to is not None and txn.date > query.date_to:
527
+ continue
528
+ if query.payee is not None and not _matches_pattern(query.payee, txn.description):
529
+ continue
530
+
531
+ for posting in resolve_elision(txn):
532
+ # Section account patterns: OR logic — posting must match at least one.
533
+ if not any(_matches_pattern(pat, posting.account) for pat in section.accounts):
534
+ continue
535
+
536
+ # Section exclude patterns: posting must not match any.
537
+ if any(_matches_pattern(pat, posting.account) for pat in section.exclude):
538
+ continue
539
+
540
+ # Outer query account/not_account filters.
541
+ if query is not None:
542
+ if query.account is not None and not _matches_pattern(query.account, posting.account):
543
+ continue
544
+ if query.not_account is not None and _matches_pattern(query.not_account, posting.account):
545
+ continue
546
+
547
+ if posting.amount is None:
548
+ continue
549
+
550
+ # Depth: section.depth overrides query.depth.
551
+ depth = section.depth if section.depth is not None else (
552
+ query.depth if query is not None else None
553
+ )
554
+ account = posting.account
555
+ if depth is not None:
556
+ account = ":".join(account.split(":")[:depth])
557
+
558
+ pairs.append((account, posting.amount.quantity))
559
+
560
+ rows = _aggregate_posting_amounts(pairs)
561
+
562
+ if section.invert:
563
+ rows = {k: -v for k, v in rows.items()}
564
+
565
+ subtotal = sum(rows.values(), Decimal(0))
566
+ results.append(ReportSectionResult(
567
+ section=section,
568
+ rows=rows,
569
+ subtotal=subtotal,
570
+ _commodity_styles=commodity_styles,
571
+ ))
572
+
573
+ return results
ledgerkit/writer.py ADDED
@@ -0,0 +1,97 @@
1
+ """Journal serialiser for ledgerkit.
2
+
3
+ Converts Transaction and Journal objects back to hledger journal-format text.
4
+ No file I/O; no imports from parser, loader, checks, or cli.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ledgerkit.models import Amount, Journal, Transaction
10
+
11
+
12
+ def _fmt_amount(amount: Amount) -> str:
13
+ """Format an Amount to its hledger journal string representation.
14
+
15
+ Prefix-symbol style (£, $, €, ...) for non-alpha commodities; suffix-symbol
16
+ style (30.00 EUR) for alpha-initial commodity codes.
17
+ """
18
+ qty = amount.quantity
19
+ sym = amount.commodity
20
+ if not sym:
21
+ return str(qty)
22
+ if sym[0].isalpha():
23
+ return f"{qty} {sym}"
24
+ if qty < 0:
25
+ return f"-{sym}{-qty}"
26
+ return f"{sym}{qty}"
27
+
28
+
29
+ def transaction_to_text(txn: Transaction) -> str:
30
+ """Serialise a Transaction to a journal-format string.
31
+
32
+ The returned string ends with a single newline. When parsed by
33
+ parse_string(), the result round-trips to an equivalent Transaction
34
+ (same date, flag, description, postings, amounts, comments;
35
+ source_span and raw_text are not checked).
36
+ """
37
+ # --- Header line ---
38
+ parts: list[str] = [str(txn.date)]
39
+ if txn.cleared:
40
+ parts.append("*")
41
+ elif txn.pending:
42
+ parts.append("!")
43
+ if txn.code:
44
+ parts.append(f"({txn.code})")
45
+ if txn.description:
46
+ parts.append(txn.description)
47
+ header = " ".join(parts)
48
+ if txn.inline_comment is not None:
49
+ header += f" ; {txn.inline_comment}"
50
+
51
+ # --- Posting lines ---
52
+ # Build amount strings first to calculate alignment.
53
+ amount_strs: list[str | None] = [
54
+ _fmt_amount(p.amount) if p.amount is not None else None
55
+ for p in txn.postings
56
+ ]
57
+
58
+ # Find the widest amount string and the widest account name (among postings
59
+ # that carry an explicit amount) to determine the alignment column.
60
+ max_amt_w = max((len(s) for s in amount_strs if s is not None), default=0)
61
+ acct_lens_with_amount = [
62
+ len(p.account)
63
+ for p, s in zip(txn.postings, amount_strs)
64
+ if s is not None
65
+ ]
66
+ max_acct_w = max(acct_lens_with_amount, default=0)
67
+ # amount_col = position of the first character of the (right-justified)
68
+ # amount block, counting from the start of the posting line (4-space indent
69
+ # + longest account + minimum 2 spaces).
70
+ amount_col = 4 + max_acct_w + 2
71
+
72
+ posting_lines: list[str] = []
73
+ for posting, amt_str in zip(txn.postings, amount_strs):
74
+ if amt_str is None:
75
+ line = f" {posting.account}"
76
+ else:
77
+ gap = amount_col - 4 - len(posting.account)
78
+ line = f" {posting.account}{' ' * gap}{amt_str.rjust(max_amt_w)}"
79
+ if posting.inline_comment is not None:
80
+ line += f" ; {posting.inline_comment}"
81
+ posting_lines.append(line)
82
+
83
+ return "\n".join([header] + posting_lines) + "\n"
84
+
85
+
86
+ def journal_to_text(journal: Journal) -> str:
87
+ """Serialise all transactions in journal.transactions to text.
88
+
89
+ Transactions are separated by blank lines; the output ends with a single
90
+ newline. Directives (account, commodity, payee, P) are not serialised in
91
+ v1 — callers that need full round-trip fidelity should work with the raw
92
+ source file rather than re-serialising the Journal.
93
+ """
94
+ if not journal.transactions:
95
+ return "\n"
96
+ blocks = [transaction_to_text(t).rstrip("\n") for t in journal.transactions]
97
+ return "\n\n".join(blocks) + "\n"