Skip to content

Commit 4ccf623

Browse files
committed
Invoice formatting more correct; column selection improved
1 parent 404eb02 commit 4ccf623

File tree

4 files changed

+120
-81
lines changed

4 files changed

+120
-81
lines changed

slip39/defaults.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@
178178
# current prices for cryptocurrencies -- if you have access to an Ethereum blockchain (either
179179
# locally or via an HTTPS API like Alchemy), then we can securely and reliably get current prices.
180180
# To avoid conflicts, by convention we upper-case symbols, lower-case full names.
181-
INVOICE_ROWS = 15
181+
INVOICE_ROWS = 50
182182
INVOICE_CURRENCY = "USD"
183183
INVOICE_PROXIES = {
184184
"USD": "USDC",

slip39/invoice/artifact.py

Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ def headers( self ):
403403
"""Output the headers to use in tabular formatting of iterator. By default, we'd recommend hiding any starting
404404
with an _ prefix."""
405405
return (
406-
'Line',
406+
'_#',
407407
'Description',
408408
'Units',
409409
'Price',
@@ -413,14 +413,14 @@ def headers( self ):
413413
'Net',
414414
'Amount',
415415
'Currency',
416-
'Symbol',
416+
'_Symbol',
417417
'_Decimals',
418418
'_Token',
419419
) + tuple(
420-
f"Total {currency}"
420+
f"_Total {currency}"
421421
for currency in sorted( self.currencies )
422422
) + tuple(
423-
f"Taxes {currency}"
423+
f"_Taxes {currency}"
424424
for currency in sorted( self.currencies )
425425
)
426426

@@ -530,20 +530,31 @@ def tables(
530530
Each interior page includes a subtotals table; the final page also includes the full totals.
531531
532532
The 'columns' is a filter (a set/list/tuple or a predicate) that selects the columns
533-
desired; by default, elides any columns having names starting with '_'.
533+
desired; by default, elides any columns having names starting with '_'. We will accept
534+
matches case-insensively, and ignoring any leading/trailing '_'.
535+
536+
If any columns w/ leading '_' *are* selection, we trim the '_' for display.
534537
535538
"""
536539
headers = self.headers()
540+
541+
def can( c ):
542+
"""Canonicalize a header/column name for comparison"""
543+
return c.strip( '_' ).lower()
544+
545+
headers_can = [ can( h ) for h in headers ]
537546
if columns:
538547
if is_listlike( columns ):
539-
selected = tuple( self.headers().index( h ) for h in columns )
540-
assert not any( i < 0 for i in selected ), \
541-
f"Columns not found: {commas( h for h in headers if h not in columns )}"
548+
try:
549+
selected = tuple( headers_can.index( can( c )) for c in columns )
550+
except ValueError as exc:
551+
raise ValueError( f"Columns not found: {commas( c for c in columns if can( c ) not in headers_can )}" ) from exc
542552
elif hasattr( columns, '__contains__' ):
543-
selected = tuple( i for i,h in enumerate( headers ) if h in columns )
553+
selected = tuple( i for i,h in enumerate( headers ) if can( h ) in [ can( c ) for c in columns ] )
544554
assert selected, \
545555
"No columns matched"
546556
else:
557+
# User-provided column predicate; do not canonicalize
547558
selected = tuple( i for i,h in enumerate( headers ) if columns( h ) )
548559
else:
549560
# Default; just ignore _... columns
@@ -554,30 +565,31 @@ def tables(
554565
pages = list( self.pages( *args, **kwds ))
555566
# Overall formatted decimals for entire invoice; use the greatest desired decimals of any
556567
# token/line. Individual line items may have been rounded to lower decimals.
557-
decimals_i = headers.index( '_Decimals' )
568+
decimals_i = headers_can.index( can( '_Decimals' ))
558569
decimals = max( line[decimals_i] for page in pages for line in page )
559-
floatfmt = f",.{decimals}f"
560-
intfmt = ","
570+
floatfmt = f',.{decimals}f'
571+
intfmt = ','
561572
page_prev = None
562573
for p,page in enumerate( pages ):
563574
table = tabulate(
564575
# Tabulate the line items
565576
[[line[i] for i in selected] for line in page],
566-
headers = headers_selected,
577+
headers = [ h.strip('_') for h in headers_selected ],
567578
intfmt = intfmt,
568579
floatfmt = floatfmt,
569580
tablefmt = tablefmt or 'orgtbl',
570581
)
571582
first = page_prev is None
583+
final = p + 1 == len( pages )
572584

573585
def deci( c ):
574586
return self.currencies_proxy[c].decimals // 3
575587

576588
def toti( c ):
577-
return headers.index( f'Total {c}' )
589+
return headers_can.index( can( f'_Total {c}' ))
578590

579591
def taxi( c ):
580-
return headers.index( f'Taxes {c}' )
592+
return headers_can.index( can( f'_Taxes {c}' ))
581593

582594
subtotal = tabulate(
583595
# And the per-currency Sub-totals (for each page)
@@ -595,9 +607,15 @@ def taxi( c ):
595607
]
596608
for c in sorted( self.currencies )
597609
],
598-
headers = ( "Account", "Crypto", "Currency", f"Subtotal {p+1}/{len( pages )}", f"Subtotal {p+1}/{len( pages )} Taxes" ),
599-
intfmt = ",",
600-
floatfmt = ",g",
610+
headers = (
611+
'Account',
612+
'Crypto',
613+
'Currency',
614+
'Subtotal' if final else f'Subtotal {p+1}/{len( pages )}',
615+
'Taxes' if final else f'Taxes {p+1}/{len( pages )}'
616+
),
617+
intfmt = ',',
618+
floatfmt = ',g',
601619
tablefmt = tablefmt or 'orgtbl',
602620
)
603621
total = tabulate(
@@ -612,18 +630,24 @@ def taxi( c ):
612630
]
613631
for c in sorted( self.currencies )
614632
],
615-
headers = ( "Account", "Crypto", "Currency", f"Total {p+1}/{len( pages )}", f"Total {p+1}/{len( pages )} Taxes" ),
616-
intfmt = ",",
617-
floatfmt = ",g",
633+
headers = (
634+
'Account',
635+
'Crypto',
636+
'Currency',
637+
'Total' if final else f'Total {p+1}/{len( pages )}',
638+
'Taxes' if final else f'Taxes {p+1}/{len( pages )}'
639+
),
640+
intfmt = ',',
641+
floatfmt = ',g',
618642
tablefmt = tablefmt or 'orgtbl',
619643
)
620644

621645
yield page, table, subtotal, total
622646

623647

624648
def layout_invoice(
625-
inv_dim: Coordinate,
626-
inv_margin: Optional[int],
649+
inv_dim: Coordinate, # Printable invoice dimensions, in inches (net page margins).
650+
inv_margin: int, # Additional margin around invoice
627651
rows: int,
628652
):
629653
"""Layout an Invoice, in portrait format.
@@ -783,7 +807,6 @@ def produce_invoice(
783807
rows: Optional[int] = None,
784808
paper_format: Any = None, # 'Letter', 'Legal', 'A4', (x,y) dimensions in mm.
785809
orientation: Optional[str] = None, # available orientations; default portrait, landscape
786-
inv_margin: Optional[int] = None,
787810
inv_image: Optional[Union[Path,str]] = None, # A custom background image (Path or name relative to directory/'.'
788811
):
789812
"""Produces a PDF containing the supplied Invoice details, optionally with a PAID watermark.
@@ -794,12 +817,14 @@ def produce_invoice(
794817
if isinstance( directory, (str,type(None))):
795818
directory = Path( directory or '.' )
796819
assert isinstance( directory, (Path,type(None)))
820+
log.info( f"Dir.: {directory}" )
797821

798822
if isinstance( inv_image, (str,type(None))):
799823
inv_image = directory / ( inv_image or 'inv-image.*' )
800824
if not inv_image.exists():
801825
inv_image = None
802826
assert isinstance( inv_image, (Path,type(None)))
827+
log.info( f"Image: {inv_image}" )
803828

804829
# Any datetime WITHOUT a timezone designation is re-interpreted as the local timezone of the
805830
# invoice issuer, or UTC.
@@ -812,7 +837,7 @@ def produce_invoice(
812837
inv_zone = timezone.utc
813838
inv_date = inv_date.astimezone( inv_zone )
814839

815-
log.info( f"Date: {inv_date.strftime( INVOICE_STRFTIME )}" )
840+
log.info( f"Date: {inv_date.strftime( INVOICE_STRFTIME )}" )
816841
if not isinstance( inv_due, datetime ):
817842
if not inv_due:
818843
inv_due = INVOICE_DUE
@@ -824,15 +849,15 @@ def produce_invoice(
824849
inv_due = inv_date + inv_due
825850
else:
826851
raise ValueError( f"Unsupported Invoice due date: {inv_due!r}" )
827-
log.info( f"Due: {inv_due.strftime( INVOICE_STRFTIME )}" )
852+
log.info( f"Due: {inv_due.strftime( INVOICE_STRFTIME )}" )
828853
if inv_number is None:
829854
cli = client.name.split()[0].upper()[:3] if client else 'INV'
830855
ymd = inv_date.strftime( '%Y%m%d' )
831856
key = f"{cli}-{ymd}"
832857
produce_invoice.inv_count[key] += 1
833858
num = produce_invoice.inv_count[key]
834859
inv_number = f"{cli}-{ymd}-{num:04d}"
835-
log.info( f"Num.: {inv_number}" )
860+
log.info( f"Num.: {inv_number}" )
836861

837862
metadata = InvoiceMetadata(
838863
vendor = vendor,
@@ -846,17 +871,20 @@ def produce_invoice(
846871
if conversions is None:
847872
conversions = {}
848873

849-
# Default to full page, given the desired paper and orientation
850-
invs_pp,orientation,page_xy,pdf,inv_dim = layout_pdf(
874+
# Default to full page, given the desired paper and orientation. All PDF dimensions are mm.
875+
invs_pp,orientation,page_xy,pdf,comp_dim = layout_pdf(
851876
paper_format = paper_format,
852877
orientation = orientation,
853878
)
854-
log.info( f'Dim.: {inv_dim.x / MM_IN:6.3f}" x {inv_dim.y / MM_IN:6.3f}"' )
879+
log.info( f'Dim.: {comp_dim.x / MM_IN:6.3f}" x {comp_dim.y / MM_IN:6.3f}"' )
855880

856881
# TODO: compute rows based on line lengths; the longer the line, the smaller the lines required
857882
if rows is None:
858883
rows = INVOICE_ROWS
859-
inv = layout_invoice( inv_dim=inv_dim, inv_margin=inv_margin, rows=rows )
884+
885+
# Compute the Invoice layout on the page. All page layouts are specified in inches.
886+
inv_dim = Coordinate( comp_dim.x / MM_IN, comp_dim.y / MM_IN )
887+
inv = layout_invoice( inv_dim=inv_dim, inv_margin=0, rows=rows )
860888

861889
inv_elements = list( inv.elements() )
862890
if log.isEnabledFor( logging.DEBUG ):

0 commit comments

Comments
 (0)