Coverage for functions \ flipdare \ payments \ receipt_generator.py: 98%

94 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-05-08 12:22 +1000

1#!/usr/bin/env python3 

2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved. 

3# 

4# This file is part of Flipdare's proprietary software and contains 

5# confidential and copyrighted material. Unauthorised copying, 

6# modification, distribution, or use of this file is strictly 

7# prohibited without prior written permission from Flipdare Pty Ltd. 

8# 

9# This software includes third-party components licensed under MIT, 

10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details. 

11# 

12 

13from fpdf import FPDF 

14from flipdare.app_log import LOG 

15from flipdare.constants import COMPANY_ADDRESS, COMPANY_LOG_MED, IS_DEBUG 

16from flipdare.payments.payment_types import AppReceipt 

17from flipdare.util.public.public_url_factory import PublicUrlFactory 

18from flipdare.backend.app_storage_client import AppStorageClient 

19from flipdare.util.time_util import TimeUtil 

20 

21__all__ = ["ReceiptGenerator"] 

22 

23 

24class ReceiptGenerator: 

25 def __init__(self, storage_client: AppStorageClient) -> None: 

26 self.client = storage_client 

27 

28 def generate(self, info: AppReceipt) -> str | None: 

29 try: 

30 if IS_DEBUG: 

31 msg = ( 

32 f"Generating receipt PDF for receipt_id '{info.receipt_id}' with info: {info}" 

33 ) 

34 LOG().debug(msg) 

35 

36 pdf_bytes = self._create_receipt(info) 

37 content = PublicUrlFactory.create_receipt_content(info, pdf_bytes) 

38 client = self.client 

39 perm_url = client.generate_public_url(content) 

40 

41 if IS_DEBUG: 

42 msg = f"Generated receipt URL for receipt_id '{info.receipt_id}': {perm_url}" 

43 LOG().debug(msg) 

44 

45 return perm_url 

46 except Exception as e: 

47 LOG().error(f"Error generating receipt for receipt_id '{info.receipt_id}': {e}") 

48 return None 

49 

50 def _create_receipt(self, receipt_data: AppReceipt) -> bytes: # noqa: PLR0915 

51 pdf = FPDF() 

52 pdf.add_page() 

53 

54 customer_id = receipt_data.customer_id 

55 account_id = receipt_data.account_id 

56 receipt_id = receipt_data.receipt_id 

57 dare_id = receipt_data.dare_id 

58 dare_description = receipt_data.dare_description 

59 amount = receipt_data.amount 

60 currency_code = receipt_data.currency_code 

61 

62 date_str = TimeUtil.formatted_user_accurate(receipt_data.receipt_date) 

63 

64 # --- Header & Logo --- 

65 logo = COMPANY_LOG_MED 

66 use_logo = True 

67 if not logo.exists(): 

68 LOG().warning(f"Company logo not found at {logo}. Not using logo.") 

69 use_logo = False 

70 

71 if use_logo: 

72 pdf.image(logo, x=12, y=8, w=30) 

73 

74 pdf.set_font("helvetica", "B", 24) 

75 pdf.cell(0, 10, "PAYMENT RECEIPT", align="R", new_x="LMARGIN", new_y="NEXT") 

76 pdf.ln(10) 

77 

78 # Add Business Address/Description 

79 pdf.set_font("helvetica", "", 10) 

80 pdf.multi_cell(0, 5, COMPANY_ADDRESS, align="R") 

81 

82 # dynamic position for the horizontal line and metadata section based on content height 

83 start_y = pdf.get_y() + 10 

84 

85 # Draw a Horizontal Line for styling 

86 pdf.line(10, start_y - 5, 200, start_y - 5) 

87 

88 # --- 2. Two-Column Metadata Layout --- 

89 pdf.set_font("helvetica", "B", 10) 

90 pdf.set_text_color(100, 100, 100) 

91 

92 # Left Column 

93 pdf.set_xy(10, start_y) 

94 pdf.cell(95, 5, "BILLED TO:", new_x="LMARGIN", new_y="NEXT") 

95 # ... (rest of left column) 

96 

97 # Right Column 

98 pdf.set_xy(110, start_y) 

99 pdf.set_font("helvetica", "B", 10) 

100 pdf.set_text_color(100, 100, 100) 

101 pdf.cell(90, 5, "RECEIPT DETAILS:", align="R", new_x="LEFT", new_y="NEXT") 

102 

103 pdf.set_font("helvetica", "", 10) 

104 pdf.set_text_color(0, 0, 0) 

105 pdf.set_x(110) 

106 pdf.cell(90, 5, f"Date: {date_str}", align="R", new_x="LEFT", new_y="NEXT") 

107 pdf.set_x(110) 

108 pdf.cell(90, 5, f"Receipt #: {receipt_id}", align="R", new_x="LEFT", new_y="NEXT") 

109 

110 pdf.ln(5) # 5mm gap 

111 pdf.set_font("helvetica", "", 10) 

112 pdf.set_text_color(0, 0, 0) 

113 

114 # customer info 

115 # Define a fixed label width (e.g., 30mm) 

116 is_vip = receipt_data.fee_type.is_vip 

117 label_w = 22 

118 value_w = 65 

119 

120 # Line 1 

121 pdf.cell(label_w, 5, "Name") 

122 name = f": {receipt_data.first_name} {receipt_data.last_name}" 

123 if is_vip: 

124 name += " - (V.I.P)" 

125 pdf.cell(value_w, 5, name, new_x="LMARGIN", new_y="NEXT") 

126 

127 # Line 2 

128 pdf.cell(label_w, 5, "Customer ID") 

129 pdf.cell(value_w, 5, f": {customer_id}", new_x="LMARGIN", new_y="NEXT") 

130 # Line 3 

131 pdf.cell(label_w, 5, "Account ID") 

132 pdf.cell(value_w, 5, f": {account_id}", new_x="LMARGIN", new_y="NEXT") 

133 

134 # spacing 

135 pdf.ln(20) 

136 

137 # --- 3. The "Dare" Description (Main Item) --- 

138 # We use a light grey box to highlight the "Dare" content 

139 pdf.set_fill_color(245, 245, 245) 

140 pdf.set_font("helvetica", "B", 12) 

141 pdf.cell( 

142 0, 

143 10, 

144 f" Item / Description (Product ID: {dare_id})", 

145 fill=True, 

146 new_x="LMARGIN", 

147 new_y="NEXT", 

148 ) 

149 

150 pdf.ln(5) # 5mm gap 

151 # Stripe amounts in cents, so we divide by 100 

152 # use ISO 4217 Style (Formal) for currency code display 

153 display_amount = f"{currency_code.name} {(amount / 100):.2f}" 

154 

155 pdf.set_font("helvetica", "", 11) 

156 # Print the description (width 0 to fill, but we stop 'NEXT' to stay on line) 

157 # We use new_x="RIGHT" to keep the cursor at the end of this cell 

158 pdf.cell(0, 10, dare_description, border="B", new_x="RIGHT", new_y="TOP") 

159 

160 # Now move the cursor back to the right margin to overlay the price on the same line 

161 # We use align="R" so the price sits at the far right of that bottom border 

162 pdf.cell(0, 10, display_amount, align="R", new_x="LMARGIN", new_y="NEXT") 

163 

164 # --- 4. Total Amount --- 

165 pdf.set_font("helvetica", "B", 16) 

166 pdf.cell(0, 15, f"TOTAL PAID: {display_amount}", align="R", border="T") 

167 

168 pdf_data = pdf.output() 

169 return bytes(pdf_data)