Skip to content

Commit 0922cf0

Browse files
committed
Clean up table formatting for invoices
1 parent a4388b4 commit 0922cf0

File tree

3 files changed

+203
-83
lines changed

3 files changed

+203
-83
lines changed

slip39/invoice/artifact.py

Lines changed: 171 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,17 @@
1717
from __future__ import annotations
1818

1919
import logging
20+
import math
2021

2122
from dataclasses import dataclass
2223
from collections import namedtuple
2324
from typing import Dict, Union, Any
2425

2526
from tabulate import tabulate
2627

28+
from ..util import uniq, commas, is_listlike
29+
from ..layout import Region, Text, Image, Box, Coordinate
2730
from .ethereum import tokeninfo, tokenratio
28-
from ..util import uniq
2931

3032
"""
3133
Invoice artifacts:
@@ -63,35 +65,37 @@ class Item:
6365
price: Any # 1.98 eg. $USDC, Fraction( 10000, 12 ) * 10**9, eg. ETH Gwei per dozen, in Wei
6466
units: Union[int,float] = 1 # 198, 1.5
6567
tax: Any = None # 0.05, Fraction( 5, 100 ) eg. GST, added to amount, 1.05 GST, included in amount
66-
decimals: int = 2 # Number of decimals to display computed amounts, eg. 2.
68+
decimals: int = None # Number of decimals to display computed amounts, eg. 2.; default is 1/3 of token decimals
6769
currency: str = "USDC"
6870

6971

70-
class Line( Item ):
71-
def amounts( self ):
72-
"""Computes the total amount, and taxes for the Line"""
72+
class LineItem( Item ):
73+
def net( self ):
74+
"""Computes the LineItem total 'amount', the 'taxes', and info on tax charged."""
7375
amount = self.units * self.price
7476
taxes = 0
7577
taxinf = 'no tax'
7678
if self.tax:
7779
if self.tax < 1:
78-
taxinf = f"{float( self.tax * 100 ):.2f}% added"
80+
taxinf = f"{round( float( self.tax * 100 ), 2):g}% added"
7981
taxes = amount * self.tax
82+
amount += taxes
8083
elif self.tax > 1:
81-
taxinf = f"{float(( self.tax - 1 ) * 100 ):.2f}% incl."
84+
taxinf = f"{round( float(( self.tax - 1 ) * 100 ), 2):g}% incl."
8285
taxes = amount - amount / self.tax
83-
amount -= taxes
84-
8586
return amount, taxes, taxinf # denominated in self.currencies
8687

8788

8889
class Total:
89-
"""The totals for some invoice line items.
90+
"""The totals for some invoice line items, in terms of some Crypto-currencies.
9091
9192
Can emit the line items in groups with summary sub-totals and a final total.
9293
93-
Reframes the price of each line in terms of the Total's currency (default: USDC).
94+
Reframes the price of each line in terms of the Total's currencies (default: USDC), providing
95+
"Total <SYMBOL>" and "Taxes <SYMBOL>" for each Cryptocurrency symbols.
9496
97+
Now that we have Web3 details, we can query tokeninfos and hence format currency decimals
98+
correctly.
9599
96100
"""
97101
def __init__(
@@ -124,24 +128,33 @@ def headers( self ):
124128
'Description',
125129
'Units',
126130
'Currency',
127-
'Price',
131+
'Price #', # Price value
132+
'Price', # Price (formatted)
133+
'Amount #',
128134
'Amount',
129135
'Tax #',
130136
'Tax %',
137+
'Taxes #',
131138
'Taxes',
132139
] + [
133-
f"Total {currency['symbol']}"
140+
f"Total # {currency['symbol']}"
134141
for currency in self.currencies
135142
] + [
136-
f"Taxes {currency['symbol']}"
143+
f"Taxes # {currency['symbol']}"
137144
for currency in self.currencies
138145
]
139146

140147
def __iter__( self ):
141-
"""Iterate of lines, computing totals in each Total.currencies"""
142-
# Get all the eg. BTC / USDC ratios for the Line-item currency, vs. each Total currency at
143-
# once, in case the iterator takes considerable time; we don't want to "refresh" the token
144-
# values while generating the invoice!
148+
"""Iterate of lines, computing totals in each Total.currencies
149+
150+
Get all the eg. wBTC / USDC ratios for the Line-item currency, vs. each Total currency at
151+
once, in case the iterator takes considerable time; we don't want to "refresh" the token
152+
values while generating the invoice!
153+
154+
Note that numeric values may be int, float or Fraction, and these are retained through
155+
normal mathematical operations.
156+
157+
"""
145158
currencies = {}
146159
for line in self.lines:
147160
line_curr = tokeninfo( line.currency, w3_url=self.w3_url, use_provider=self.use_provider )
@@ -159,26 +172,29 @@ def __iter__( self ):
159172
}
160173

161174
for i,line in enumerate( self.lines ):
162-
amount,taxes,taxinf = line.amounts()
175+
line_amount,line_taxes,line_taxinf = line.net()
163176
line_curr = tokeninfo( line.currency, w3_url=self.w3_url, use_provider=self.use_provider )
177+
line_decimals = line_curr['decimals'] // 3 if line.decimals is None else line.decimals
164178
for self_curr in self.currencies:
165-
if line_curr['address'] == self_curr['address']:
166-
tot[self_curr['symbol']] += amount
167-
tax[self_curr['symbol']] += taxes
179+
if line_curr == self_curr:
180+
tot[self_curr['symbol']] += line_amount
181+
tax[self_curr['symbol']] += line_taxes
168182
else:
169-
tot[self_curr['symbol']] += amount * currencies[line_curr['address'],self_curr['address']]
170-
tax[self_curr['symbol']] += taxes * currencies[line_curr['address'],self_curr['address']]
171-
183+
tot[self_curr['symbol']] += line_amount * currencies[line_curr['address'],self_curr['address']]
184+
tax[self_curr['symbol']] += line_taxes * currencies[line_curr['address'],self_curr['address']]
172185
yield (
173186
i,
174187
line.description,
175188
line.units,
176-
line.price,
177189
line_curr['symbol'],
190+
line.price,
191+
f"{float(line.price):.0{line_decimals}f}",
192+
line_amount,
193+
f"{float(line_amount):.0{line_decimals}f}",
178194
line.tax,
179-
taxinf,
180-
amount,
181-
taxes,
195+
line_taxinf,
196+
line_taxes,
197+
f"{float(line_taxes):.0{line_decimals}f}",
182198
) + tuple(
183199
tot[self_curr['symbol']]
184200
for self_curr in self.currencies
@@ -187,8 +203,14 @@ def __iter__( self ):
187203
for self_curr in self.currencies
188204
)
189205

190-
def pages( self, page = None, rows = 10 ):
191-
"""Yields a sequence of lists containing rows, paginated into 'rows' chunks."""
206+
def pages(
207+
self,
208+
page = None,
209+
rows = 10,
210+
):
211+
"""Yields a sequence of lists containing rows, paginated into 'rows' chunks.
212+
213+
"""
192214
page_i,page_l = 0, []
193215
for row in iter( self ):
194216
page_l.append( row )
@@ -200,35 +222,135 @@ def pages( self, page = None, rows = 10 ):
200222
if page is None or page == page_i:
201223
yield page_l
202224

203-
def tables( self, *args, tablefmt=None, **kwds ):
225+
def tables(
226+
self, *args,
227+
tablefmt = None,
228+
columns = None, # list (ordered) set/predicate (unordered)
229+
**kwds # page, rows, ...
230+
):
231+
"""Tabulate columns in a textual table.
232+
233+
The 'columns' is a filter (a set/list/tuple or a predicate) that selects the columns desired.
234+
235+
"""
236+
headers = self.headers()
237+
columns_select = list( range( len( headers )))
238+
if columns:
239+
if is_listlike( columns ):
240+
columns_select = [headers.index( h ) for h in columns]
241+
assert not any( i < 0 for i in columns_select ), \
242+
f"Columns not found: {commas( h for h in headers if h not in columns )}"
243+
headers = [headers[i] for i in columns_select]
244+
elif hasattr( columns, '__contains__' ):
245+
columns_select = [i for i,h in enumerate( headers ) if h in columns]
246+
assert columns_select, \
247+
"No columns matched"
248+
headers = [headers[i] for i in columns_select]
249+
else:
250+
columns_select = [i for i,h in enumerate( headers ) if columns( h )]
204251
for page in self.pages( *args, **kwds ):
205252
yield tabulate(
206-
page,
207-
headers = self.headers(),
253+
[[line[i] for i in columns_select ] for line in page],
254+
headers = headers,
208255
tablefmt = tablefmt or 'orgtbl',
209256
)
210257

211258

212-
#
213-
# Prices
214-
#
215-
# Beware:
216-
# https://samczsun.com/so-you-want-to-use-a-price-oracle/
217-
#
218-
# We use a time-weighted average oracle provided by 1inch to avoid some of these issues:
219-
# https://docs.1inch.io/docs/spot-price-aggregator/introduction
220-
#
221-
class Prices:
222-
"""Retrieve current Crypto prices, using a time-weighted oracle API.
223-
259+
def layout_invoice(
260+
inv_size: Coordinate,
261+
inv_margin: int,
262+
num_lines: int = 10,
263+
):
264+
"""Layout an Invoice, in portrait format.
265+
266+
Rotate the watermark, etc. so its angle is from the lower-left to the upper-right.
267+
268+
b
269+
+--------------+ +--------------+
270+
| . |I.. (img) Na.|
271+
| D. |--------------|
272+
| . |# D.. u.u. |
273+
| I. |... |
274+
| . | ---- |
275+
| A. c | BTC #.## |
276+
| . | ETH #.## |
277+
a | P. | (terms) |
278+
|β. | (tax #, ..) |
279+
+ 90-β +--------------+
280+
281+
tan(β) = b / a, so
282+
β = arctan(b / a)
283+
284+
Sets priority:
285+
-3 : Hindmost backgrounds
286+
-2 : Things atop background, but beneath contrast-enhancement
287+
-1 : Contrast-enhancing partially transparent images
288+
0 : Text, etc. (the default)
224289
"""
225-
pass
290+
prio_backing = -3
291+
prio_normal = -2 # noqa: F841
292+
prio_contrast = -1
293+
294+
inv = Box( 'invoice', 0, 0, inv_size.x, inv_size.y )
295+
inv_interior = inv.add_region_relative(
296+
Region( 'inv-interior', x1=+inv_margin, y1=+inv_margin, x2=-inv_margin, y2=-inv_margin )
297+
)
298+
299+
a = inv_interior.h
300+
b = inv_interior.w
301+
c = math.sqrt( a * a + b * b )
302+
β = math.atan( b / a ) # noqa: F841
303+
rotate = 90 - math.degrees( β ) # noqa: F841
304+
305+
inv_top = inv_interior.add_region_proportional(
306+
Region( 'inv-top', x1=0, y1=0, x2=1, y2=1/6 )
307+
).add_region_proportional(
308+
Image(
309+
'inv-top-bg',
310+
priority = prio_backing,
311+
),
312+
)
313+
314+
inv_top.add_region_proportional(
315+
Image(
316+
'inv-label-bg',
317+
x1 = 0,
318+
y1 = 5/8,
319+
x2 = 1/4,
320+
y2 = 7/8,
321+
priority = prio_contrast,
322+
)
323+
).add_region(
324+
Text(
325+
'inv-label',
326+
font = 'mono',
327+
text = "Invoice",
328+
)
329+
)
330+
inv_body = inv_interior.add_region_proportional(
331+
Region( 'inv-body', x1=0, y1=1/6, x2=1, y2=1 )
332+
)
333+
334+
rows = 15
335+
for r in range( rows ):
336+
inv_body.add_region_proportional(
337+
Text(
338+
f"line-{c}",
339+
x1 = 0,
340+
y1 = r/rows,
341+
x2 = 1,
342+
y2 = (r+1)/rows,
343+
font = 'mono',
344+
bold = True,
345+
size_ratio = 9/16,
346+
)
347+
)
226348

227349

228-
def produce_pdf(
350+
def produce_invoice(
229351
client: Dict[str,str], # Client's identifying info (eg. Name, Attn, Address)
230352
vendor: Dict[str,str], # Vendor's identifying info
231-
number: str, # eg. "INV-20230930"
353+
invoice_number: str, # eg. "INV-20230930"
232354
terms: str, # eg. "Payable on receipt in $USDC, $ETH, $BTC"
233355
):
234356
"""Produces a PDF containing the supplied Invoice details, optionally with a PAID watermark.

0 commit comments

Comments
 (0)