Coverage for polar/invoice/generator.py: 37%
245 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 15:52 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 15:52 +0000
1from datetime import date, datetime 1a
2from pathlib import Path 1a
3from typing import ClassVar, Self 1a
5import pycountry 1a
6from babel.dates import format_date as _format_date 1a
7from babel.numbers import format_currency as _format_currency 1a
8from babel.numbers import format_decimal as _format_decimal 1a
9from babel.numbers import format_percent as _format_percent 1a
10from fpdf import FPDF 1a
11from fpdf.enums import Align, TableBordersLayout, XPos, YPos 1a
12from fpdf.fonts import FontFace 1a
13from pydantic import BaseModel 1a
15from polar.config import Environment, settings 1a
16from polar.kit.address import Address 1a
17from polar.kit.tax import TaxabilityReason, TaxRate 1a
18from polar.kit.utils import utc_now 1a
19from polar.models import Order 1a
22def format_currency(amount: int, currency: str) -> str: 1a
23 return _format_currency(amount / 100, currency.upper(), locale="en_US")
26def format_number(n: int) -> str: 1a
27 return _format_decimal(n, locale="en_US")
30def format_percent(basis_points: int) -> str: 1a
31 return _format_percent(basis_points / 10000, locale="en_US")
34def format_date(date: date | datetime) -> str: 1a
35 return _format_date(date, format="long", locale="en_US")
38class InvoiceItem(BaseModel): 1a
39 description: str 1a
40 quantity: int 1a
41 unit_amount: int 1a
42 amount: int 1a
45class InvoiceHeadingItem(BaseModel): 1a
46 label: str 1a
47 value: str | datetime 1a
49 @property 1a
50 def display_value(self) -> str: 1a
51 if isinstance(self.value, datetime):
52 return format_date(self.value)
53 return self.value
56class Invoice(BaseModel): 1a
57 number: str 1a
58 date: datetime 1a
59 seller_name: str 1a
60 seller_address: Address 1a
61 seller_additional_info: str | None = None 1a
62 customer_name: str 1a
63 customer_address: Address 1a
64 customer_additional_info: str | None = None 1a
65 subtotal_amount: int 1a
66 applied_balance_amount: int | None = None 1a
67 discount_amount: int 1a
68 taxability_reason: TaxabilityReason | None 1a
69 tax_amount: int 1a
70 tax_rate: TaxRate | None 1a
71 currency: str 1a
72 items: list[InvoiceItem] 1a
73 notes: str | None = None 1a
74 extra_heading_items: list[InvoiceHeadingItem] | None = None 1a
76 @property 1a
77 def formatted_subtotal_amount(self) -> str: 1a
78 return format_currency(self.subtotal_amount, self.currency)
80 @property 1a
81 def formatted_discount_amount(self) -> str: 1a
82 return format_currency(-self.discount_amount, self.currency)
84 @property 1a
85 def formatted_tax_amount(self) -> str: 1a
86 return format_currency(self.tax_amount, self.currency)
88 @property 1a
89 def formatted_total_amount(self) -> str: 1a
90 total = self.subtotal_amount - self.discount_amount + self.tax_amount
91 return format_currency(total, self.currency)
93 @property 1a
94 def formatted_applied_balance_amount(self) -> str | None: 1a
95 if self.applied_balance_amount:
96 return format_currency(self.applied_balance_amount, self.currency)
97 else:
98 return None
100 @property 1a
101 def formatted_due_amount(self) -> str | None: 1a
102 total = self.subtotal_amount - self.discount_amount + self.tax_amount
103 if self.applied_balance_amount:
104 return format_currency(total + self.applied_balance_amount, self.currency)
105 else:
106 return None
108 @property 1a
109 def tax_displayed(self) -> bool: 1a
110 return self.taxability_reason is not None and self.taxability_reason in {
111 TaxabilityReason.standard_rated,
112 TaxabilityReason.reverse_charge,
113 }
115 @property 1a
116 def tax_label(self) -> str: 1a
117 if self.tax_rate is None:
118 return "Tax"
120 label = self.tax_rate["display_name"]
122 if self.taxability_reason == TaxabilityReason.reverse_charge:
123 return f"{label} (0% Reverse Charge)"
125 if self.tax_rate["country"] is not None:
126 country = pycountry.countries.get(alpha_2=self.tax_rate["country"])
127 if country is not None:
128 label += f" — {country.name}"
130 if self.tax_rate["basis_points"] is not None:
131 label += f" ({format_percent(self.tax_rate['basis_points'])})"
133 return label
135 @property 1a
136 def heading_items(self) -> list[InvoiceHeadingItem]: 1a
137 return [
138 InvoiceHeadingItem(label="Invoice number", value=self.number),
139 InvoiceHeadingItem(label="Date of issue", value=self.date),
140 *(self.extra_heading_items or []),
141 ]
143 @classmethod 1a
144 def from_order(cls, order: Order) -> Self: 1a
145 assert order.billing_name is not None
146 assert order.billing_address is not None
147 assert order.invoice_number is not None
149 return cls(
150 number=order.invoice_number,
151 date=order.created_at,
152 seller_name=settings.INVOICES_NAME,
153 seller_address=settings.INVOICES_ADDRESS,
154 seller_additional_info=settings.INVOICES_ADDITIONAL_INFO,
155 customer_name=order.billing_name,
156 customer_additional_info=order.tax_id[0] if order.tax_id else None,
157 customer_address=order.billing_address,
158 subtotal_amount=order.subtotal_amount,
159 applied_balance_amount=order.applied_balance_amount,
160 discount_amount=order.discount_amount,
161 taxability_reason=order.taxability_reason,
162 tax_amount=order.tax_amount,
163 tax_rate=order.tax_rate,
164 currency=order.currency,
165 items=[
166 InvoiceItem(
167 description=item.label,
168 quantity=1,
169 unit_amount=item.amount,
170 amount=item.amount,
171 )
172 for item in order.items
173 ],
174 )
177class InvoiceGenerator(FPDF): 1a
178 """Class to generate an invoice PDF using fpdf2."""
180 logo: ClassVar[Path] = Path(__file__).parent / "invoice-logo.svg" 1a
181 """Path to the logo image for the invoice.""" 1a
183 regular_font_file = Path(__file__).parent / "fonts/Geist-Regular.otf" 1a
184 """Path to the regular font file.""" 1a
186 bold_font_file = Path(__file__).parent / "fonts/Geist-Bold.otf" 1a
187 """Path to the bold font file.""" 1a
189 font_name: ClassVar[str] = "geist" 1a
190 """Font family name.""" 1a
192 base_font_size: ClassVar[int] = 10 1a
193 """Base font size in points.""" 1a
195 footer_font_size: ClassVar[int] = 8 1a
196 """Font size for the footer in points.""" 1a
198 table_header_font_size: ClassVar[int] = 8 1a
199 """Font size for table headers in points.""" 1a
201 table_borders_color: ClassVar[tuple[int, int, int]] = (220, 220, 220) 1a
202 """Color for table borders in RGB format.""" 1a
204 line_height_percentage: ClassVar[float] = 1.5 1a
205 """Line height as a percentage of the font size.""" 1a
207 elements_y_margin: ClassVar[int] = 10 1a
208 """Vertical margin between elements.""" 1a
210 items_table_row_height: ClassVar[int] = 7 1a
211 """Height of each row in the items table in points.""" 1a
213 totals_table_row_height: ClassVar[int] = 6 1a
214 """Height of each row in the totals table in points.""" 1a
216 def __init__( 1a
217 self,
218 data: Invoice,
219 heading_title: str = "Invoice",
220 add_sandbox_warning: bool = settings.ENV == Environment.sandbox,
221 ) -> None:
222 super().__init__()
224 self.add_font(self.font_name, fname=self.regular_font_file)
225 self.add_font(self.font_name, fname=self.bold_font_file, style="B")
226 self.set_font(self.font_name, size=self.base_font_size)
228 self.alias_nb_pages()
229 self.data = data
230 self.heading_title = heading_title
231 self.add_sandbox_warning = add_sandbox_warning
233 def cell_height(self, font_size: float | None = None) -> float: 1a
234 font_size = font_size or self.base_font_size
235 return font_size * 0.35 * self.line_height_percentage
237 def header(self) -> None: 1a
238 if self.add_sandbox_warning:
239 self.set_xy(0, 0)
240 self.set_fill_color(239, 177, 0)
241 self.cell(
242 self.w,
243 10,
244 "SANDBOX ENVIRONMENT: This invoice is for testing purposes only. No actual payment has been processed.",
245 align=Align.C,
246 fill=True,
247 )
248 self.ln(10)
250 def footer(self) -> None: 1a
251 # Position footer at 15mm from bottom
252 self.set_y(-self.b_margin)
253 self.set_font(size=self.footer_font_size)
254 # Invoice number on the left
255 self.cell(self.epw / 2, 10, f"{self.data.number}", align=Align.L)
256 # Page number on the right
257 self.cell(self.epw / 2, 10, f"Page {self.page_no()} of {{nb}}", align=Align.R)
259 def generate(self) -> None: 1a
260 self.set_metadata()
261 self.add_page()
263 # Title
264 self.set_font(style="B", size=18)
265 self.cell(
266 text=self.heading_title,
267 new_x=XPos.LMARGIN,
268 new_y=YPos.NEXT,
269 )
271 # Logo on top right
272 self.image(str(self.logo), x=Align.R, y=10, w=15)
274 self.set_y(self.get_y() + self.elements_y_margin)
276 # Heading items
277 label_width = 30
278 self.set_font(size=self.base_font_size)
279 for heading_item in self.data.heading_items:
280 self.set_font(style="B")
281 self.cell(
282 label_width, self.cell_height(), text=heading_item.label, align=Align.L
283 )
284 self.set_font(style="")
285 self.cell(
286 h=self.cell_height(),
287 text=heading_item.display_value,
288 new_x=XPos.LMARGIN,
289 new_y=YPos.NEXT,
290 )
292 # Billing addresses
293 self.set_y(self.get_y() + self.elements_y_margin)
294 addresses_y_start = self.get_y()
296 # Seller on left column
297 self.set_font(style="B")
298 self.multi_cell(
299 80,
300 self.cell_height(),
301 text=self.data.seller_name,
302 new_x=XPos.LMARGIN,
303 new_y=YPos.NEXT,
304 )
305 self.set_font(style="")
306 self.multi_cell(
307 80,
308 self.cell_height(),
309 text=self.data.seller_address.to_text(),
310 new_x=XPos.LEFT,
311 new_y=YPos.NEXT,
312 )
313 if self.data.seller_additional_info:
314 self.multi_cell(
315 80,
316 self.cell_height(),
317 text=self.data.seller_additional_info,
318 markdown=True,
319 )
320 left_seller_end_y = self.get_y()
322 # Customer on right column
323 self.set_xy(110, addresses_y_start)
324 self.set_font(style="B")
325 self.cell(
326 h=self.cell_height(), text="Bill to", new_x=XPos.LEFT, new_y=YPos.NEXT
327 )
328 self.set_font(style="B")
329 self.multi_cell(
330 80,
331 self.cell_height(),
332 text=self.data.customer_name,
333 new_x=XPos.LEFT,
334 new_y=YPos.NEXT,
335 )
336 self.set_font(style="")
337 self.multi_cell(
338 80,
339 self.cell_height(),
340 self.data.customer_address.to_text(),
341 new_x=XPos.LEFT,
342 new_y=YPos.NEXT,
343 )
344 if self.data.customer_additional_info:
345 self.multi_cell(
346 80,
347 self.cell_height(),
348 text=self.data.customer_additional_info,
349 markdown=True,
350 )
351 right_seller_end_y = self.get_y()
352 bottom = max(left_seller_end_y, right_seller_end_y)
354 # Add spacing before table
355 self.set_y(bottom + self.elements_y_margin)
357 # Invoice items table
358 self.set_draw_color(*self.table_borders_color) # Light grey color for borders
359 with self.table(
360 col_widths=(90, 30, 30, 30),
361 text_align=(Align.L, Align.R, Align.R, Align.R),
362 headings_style=FontFace(size_pt=self.table_header_font_size),
363 line_height=self.items_table_row_height,
364 borders_layout=TableBordersLayout.HORIZONTAL_LINES,
365 ) as table:
366 # Header
367 header = table.row()
368 header.cell("Description")
369 header.cell("Quantity")
370 header.cell("Unit Price")
371 header.cell("Amount")
373 # Body
374 for item in self.data.items:
375 row = table.row()
376 row.cell(item.description)
377 row.cell(format_number(item.quantity))
378 row.cell(format_currency(item.unit_amount, self.data.currency))
379 row.cell(format_currency(item.amount, self.data.currency))
381 # Add totals section after the table
382 self.set_y(self.get_y() + self.elements_y_margin)
384 # Create a table for totals
385 with self.table(
386 col_widths=(150, 30),
387 text_align=(Align.R, Align.R),
388 first_row_as_headings=False,
389 line_height=self.totals_table_row_height,
390 borders_layout=TableBordersLayout.NONE,
391 ) as totals_table:
392 # Subtotal row
393 self.set_font(style="B")
394 subtotal_row = totals_table.row()
395 subtotal_row.cell("Subtotal")
396 self.set_font(style="")
397 subtotal_row.cell(self.data.formatted_subtotal_amount)
399 # Discount row (only if discount amount > 0)
400 if self.data.discount_amount > 0:
401 self.set_font(style="B")
402 discount_row = totals_table.row()
403 discount_row.cell("Discount")
404 self.set_font(style="")
405 discount_row.cell(self.data.formatted_discount_amount)
407 # Tax row
408 if self.data.tax_displayed:
409 self.set_font(style="B")
410 tax_row = totals_table.row()
411 tax_row.cell(self.data.tax_label)
412 self.set_font(style="")
413 tax_row.cell(self.data.formatted_tax_amount)
415 # Total row
416 self.set_font(style="B")
417 total_row = totals_table.row()
418 total_row.cell("Total")
419 total_row.cell(self.data.formatted_total_amount)
421 if (
422 self.data.formatted_applied_balance_amount
423 and self.data.formatted_due_amount
424 ):
425 # Applied balance row
426 self.set_font(style="B")
427 total_row = totals_table.row()
428 total_row.cell("Applied balance")
429 total_row.cell(self.data.formatted_applied_balance_amount)
431 # To be paid row
432 self.set_font(style="B")
433 total_row = totals_table.row()
434 total_row.cell("To be paid")
435 total_row.cell(self.data.formatted_due_amount)
437 # Add notes section
438 self.set_font(style="")
439 if self.data.notes:
440 self.set_xy(self.l_margin, self.get_y() + self.elements_y_margin)
441 self.multi_cell(
442 w=0,
443 h=self.cell_height(),
444 text=self.data.notes,
445 markdown=True,
446 )
448 def set_metadata(self) -> None: 1a
449 """Set metadata for the PDF document."""
450 self.set_title(f"Invoice {self.data.number}")
451 self.set_creator("Polar")
452 self.set_author(settings.INVOICES_NAME)
453 self.set_creation_date(utc_now())
456__all__ = ["InvoiceGenerator", "Invoice", "InvoiceItem"] 1a