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

1from datetime import date, datetime 1a

2from pathlib import Path 1a

3from typing import ClassVar, Self 1a

4 

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

14 

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

20 

21 

22def format_currency(amount: int, currency: str) -> str: 1a

23 return _format_currency(amount / 100, currency.upper(), locale="en_US") 

24 

25 

26def format_number(n: int) -> str: 1a

27 return _format_decimal(n, locale="en_US") 

28 

29 

30def format_percent(basis_points: int) -> str: 1a

31 return _format_percent(basis_points / 10000, locale="en_US") 

32 

33 

34def format_date(date: date | datetime) -> str: 1a

35 return _format_date(date, format="long", locale="en_US") 

36 

37 

38class InvoiceItem(BaseModel): 1a

39 description: str 1a

40 quantity: int 1a

41 unit_amount: int 1a

42 amount: int 1a

43 

44 

45class InvoiceHeadingItem(BaseModel): 1a

46 label: str 1a

47 value: str | datetime 1a

48 

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 

54 

55 

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

75 

76 @property 1a

77 def formatted_subtotal_amount(self) -> str: 1a

78 return format_currency(self.subtotal_amount, self.currency) 

79 

80 @property 1a

81 def formatted_discount_amount(self) -> str: 1a

82 return format_currency(-self.discount_amount, self.currency) 

83 

84 @property 1a

85 def formatted_tax_amount(self) -> str: 1a

86 return format_currency(self.tax_amount, self.currency) 

87 

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) 

92 

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 

99 

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 

107 

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 } 

114 

115 @property 1a

116 def tax_label(self) -> str: 1a

117 if self.tax_rate is None: 

118 return "Tax" 

119 

120 label = self.tax_rate["display_name"] 

121 

122 if self.taxability_reason == TaxabilityReason.reverse_charge: 

123 return f"{label} (0% Reverse Charge)" 

124 

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}" 

129 

130 if self.tax_rate["basis_points"] is not None: 

131 label += f" ({format_percent(self.tax_rate['basis_points'])})" 

132 

133 return label 

134 

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 ] 

142 

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 

148 

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 ) 

175 

176 

177class InvoiceGenerator(FPDF): 1a

178 """Class to generate an invoice PDF using fpdf2.""" 

179 

180 logo: ClassVar[Path] = Path(__file__).parent / "invoice-logo.svg" 1a

181 """Path to the logo image for the invoice.""" 1a

182 

183 regular_font_file = Path(__file__).parent / "fonts/Geist-Regular.otf" 1a

184 """Path to the regular font file.""" 1a

185 

186 bold_font_file = Path(__file__).parent / "fonts/Geist-Bold.otf" 1a

187 """Path to the bold font file.""" 1a

188 

189 font_name: ClassVar[str] = "geist" 1a

190 """Font family name.""" 1a

191 

192 base_font_size: ClassVar[int] = 10 1a

193 """Base font size in points.""" 1a

194 

195 footer_font_size: ClassVar[int] = 8 1a

196 """Font size for the footer in points.""" 1a

197 

198 table_header_font_size: ClassVar[int] = 8 1a

199 """Font size for table headers in points.""" 1a

200 

201 table_borders_color: ClassVar[tuple[int, int, int]] = (220, 220, 220) 1a

202 """Color for table borders in RGB format.""" 1a

203 

204 line_height_percentage: ClassVar[float] = 1.5 1a

205 """Line height as a percentage of the font size.""" 1a

206 

207 elements_y_margin: ClassVar[int] = 10 1a

208 """Vertical margin between elements.""" 1a

209 

210 items_table_row_height: ClassVar[int] = 7 1a

211 """Height of each row in the items table in points.""" 1a

212 

213 totals_table_row_height: ClassVar[int] = 6 1a

214 """Height of each row in the totals table in points.""" 1a

215 

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__() 

223 

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) 

227 

228 self.alias_nb_pages() 

229 self.data = data 

230 self.heading_title = heading_title 

231 self.add_sandbox_warning = add_sandbox_warning 

232 

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 

236 

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) 

249 

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) 

258 

259 def generate(self) -> None: 1a

260 self.set_metadata() 

261 self.add_page() 

262 

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 ) 

270 

271 # Logo on top right 

272 self.image(str(self.logo), x=Align.R, y=10, w=15) 

273 

274 self.set_y(self.get_y() + self.elements_y_margin) 

275 

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 ) 

291 

292 # Billing addresses 

293 self.set_y(self.get_y() + self.elements_y_margin) 

294 addresses_y_start = self.get_y() 

295 

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() 

321 

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) 

353 

354 # Add spacing before table 

355 self.set_y(bottom + self.elements_y_margin) 

356 

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") 

372 

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)) 

380 

381 # Add totals section after the table 

382 self.set_y(self.get_y() + self.elements_y_margin) 

383 

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) 

398 

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) 

406 

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) 

414 

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) 

420 

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) 

430 

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) 

436 

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 ) 

447 

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()) 

454 

455 

456__all__ = ["InvoiceGenerator", "Invoice", "InvoiceItem"] 1a