1717from __future__ import annotations
1818
1919import logging
20+ import math
2021
2122from dataclasses import dataclass
2223from collections import namedtuple
2324from typing import Dict , Union , Any
2425
2526from tabulate import tabulate
2627
28+ from ..util import uniq , commas , is_listlike
29+ from ..layout import Region , Text , Image , Box , Coordinate
2730from .ethereum import tokeninfo , tokenratio
28- from ..util import uniq
2931
3032"""
3133Invoice 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
8889class 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