from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.lib.units import cm
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, HRFlowable
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_CENTER
from io import BytesIO
from datetime import datetime
from xml.sax.saxutils import escape
def generer_recu(data):
"""
Génère un reçu de remboursement au format PDF
Args:
data: dict contenant toutes les informations nécessaires
Returns:
BytesIO: Buffer contenant le PDF (ou None en cas d'erreur)
"""
# === 1. LOGS ET VALIDATION ===
# Pas de numero_recu/trans_id/montants en clair ici : ces logs finissent
# dans les logs du Space Hugging Face, visibles par quiconque y a acces.
print("=== DÉBUT GÉNÉRATION REÇU ===")
# Vérification des données minimales
if not data.get('numero_recu'):
print("ERREUR : numero_recu manquant")
return None
if not data.get('client'):
print("ERREUR : données client manquantes")
return None
if not data.get('paiement'):
print("ERREUR : données paiement manquantes")
return None
# === 2. GÉNÉRATION PDF ===
try:
# Création du buffer
buffer = BytesIO()
# Configuration du document
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=2*cm,
leftMargin=2*cm,
topMargin=2*cm,
bottomMargin=2*cm
)
# Container pour les éléments
elements = []
# Styles
styles = getSampleStyleSheet()
# Style personnalisé pour le titre
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
fontSize=20,
textColor=colors.HexColor('#58a6ff'),
spaceAfter=12,
alignment=TA_CENTER,
fontName='Helvetica-Bold'
)
# Style pour les sous-titres
subtitle_style = ParagraphStyle(
'CustomSubtitle',
parent=styles['Heading2'],
fontSize=14,
textColor=colors.HexColor('#8b949e'),
spaceAfter=10,
spaceBefore=15,
fontName='Helvetica-Bold'
)
# Style pour le texte normal
normal_style = ParagraphStyle(
'CustomNormal',
parent=styles['Normal'],
fontSize=10,
textColor=colors.black,
spaceAfter=6
)
# Style spécifique pour forcer le retour à la ligne dans les cellules du tableau
table_val_style = ParagraphStyle(
'TableVal',
parent=styles['Normal'],
fontName='Helvetica',
fontSize=10,
textColor=colors.black,
leading=12
)
# Style pour les montants importants
amount_style = ParagraphStyle(
'AmountStyle',
parent=styles['Normal'],
fontSize=16,
textColor=colors.HexColor('#54bd4b'),
alignment=TA_CENTER,
fontName='Helvetica-Bold',
spaceAfter=10
)
# === EN-TÊTE ===
elements.append(Paragraph("REÇU DE REMBOURSEMENT", title_style))
elements.append(Spacer(1, 0.3*cm))
# Numéro de reçu et date
date_str = data['date_paiement'].strftime('%d/%m/%Y') if hasattr(data['date_paiement'], 'strftime') else str(data['date_paiement'])
header_data = [
[f"N° {data['numero_recu']}", f"Date: {date_str}"]
]
header_table = Table(header_data, colWidths=[8*cm, 8*cm])
header_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 11),
('TEXTCOLOR', (0, 0), (0, 0), colors.HexColor('#58a6ff')),
('TEXTCOLOR', (1, 0), (1, 0), colors.HexColor('#8b949e')),
('ALIGN', (0, 0), (0, 0), 'LEFT'),
('ALIGN', (1, 0), (1, 0), 'RIGHT'),
]))
elements.append(header_table)
elements.append(Spacer(1, 0.5*cm))
# Ligne de séparation
elements.append(HRFlowable(width="100%", thickness=2, color=colors.HexColor('#58a6ff')))
elements.append(Spacer(1, 0.5*cm))
# === INFORMATIONS CLIENT ===
elements.append(Paragraph("INFORMATIONS CLIENT", subtitle_style))
client = data['client']
# On enveloppe l'adresse dans un objet Paragraph pour forcer le retour à la ligne
# Paragraph interprete un mini-balisage XML : une adresse saisie par le client
# contenant , ,
... ferait echouer toute la generation du recu.
adresse_formatee = Paragraph(escape(str(client.get('Adresse') or 'N/A')), table_val_style)
client_data = [
['Nom Complet:', client.get('Nom_Complet', 'N/A')],
['N° Client:', client.get('ID_Client', 'N/A')],
['Téléphone:', str(client.get('Telephone', 'N/A'))],
['Adresse:', adresse_formatee]
]
client_table = Table(client_data, colWidths=[4*cm, 12*cm])
client_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTNAME', (1, 0), (1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#8b949e')),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
]))
elements.append(client_table)
elements.append(Spacer(1, 0.5*cm))
# === INFORMATIONS PRÊT ===
elements.append(Paragraph("INFORMATIONS PRÊT", subtitle_style))
loan = data['loan']
loan_data = [
['N° Prêt:', loan.get('ID_Pret', 'N/A')],
['Type de Prêt:', loan.get('Type_Pret', 'N/A')],
['Capital Initial:', f"{loan.get('Montant_Capital', 0):,.0f} XOF"],
['Montant Total Dû:', f"{loan.get('Montant_Total', 0):,.0f} XOF"]
]
loan_table = Table(loan_data, colWidths=[4*cm, 12*cm])
loan_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTNAME', (1, 0), (1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#8b949e')),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
]))
elements.append(loan_table)
elements.append(Spacer(1, 0.5*cm))
# === DÉTAILS DU PAIEMENT ===
elements.append(Paragraph("DÉTAILS DU PAIEMENT", subtitle_style))
paiement = data['paiement']
# Gestion sûre de la date d'échéance
date_echeance = paiement.get('date_echeance_prevue', 'N/A')
if hasattr(date_echeance, 'strftime'):
date_echeance_str = date_echeance.strftime('%d/%m/%Y')
else:
date_echeance_str = str(date_echeance)
payment_data = [
['Échéance:', str(paiement.get('numero_echeance', 'N/A'))],
['Date Échéance Prévue:', date_echeance_str],
['Retard (jours):', str(paiement.get('jours_retard', 0))],
['Statut:', paiement.get('statut_paiement', 'N/A')]
]
payment_table = Table(payment_data, colWidths=[4*cm, 12*cm])
payment_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTNAME', (1, 0), (1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#8b949e')),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
]))
elements.append(payment_table)
elements.append(Spacer(1, 0.5*cm))
# === DÉCOMPOSITION DU MONTANT ===
elements.append(Paragraph("DÉCOMPOSITION DU MONTANT", subtitle_style))
decomp_data = [
['Principal remboursé', f"{paiement.get('montant_principal', 0):,.0f} XOF"],
['Intérêts payés', f"{paiement.get('montant_interets', 0):,.0f} XOF"],
['Pénalités de retard', f"{paiement.get('penalites_retard', 0):,.0f} XOF"]
]
decomp_table = Table(decomp_data, colWidths=[10*cm, 6*cm])
decomp_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#8b949e')),
('ALIGN', (1, 0), (1, -1), 'RIGHT'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LINEBELOW', (0, 0), (-1, -2), 0.5, colors.grey),
('LEFTPADDING', (0, 0), (-1, -1), 5),
('RIGHTPADDING', (0, 0), (-1, -1), 5),
]))
elements.append(decomp_table)
elements.append(Spacer(1, 0.3*cm))
# === MONTANT TOTAL VERSÉ ===
montant_verse = paiement.get('montant_verse',
paiement.get('montant_principal', 0) +
paiement.get('montant_interets', 0) +
paiement.get('penalites_retard', 0))
total_data = [
['MONTANT TOTAL VERSÉ', f"{montant_verse:,.0f} XOF"]
]
total_table = Table(total_data, colWidths=[10*cm, 6*cm])
total_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 12),
('TEXTCOLOR', (0, 0), (0, -1), colors.black),
('TEXTCOLOR', (1, 0), (1, -1), colors.HexColor('#54bd4b')),
('ALIGN', (1, 0), (1, -1), 'RIGHT'),
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#e8f5e9')),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LINEABOVE', (0, 0), (-1, 0), 2, colors.HexColor('#54bd4b')),
('LEFTPADDING', (0, 0), (-1, -1), 5),
('RIGHTPADDING', (0, 0), (-1, -1), 5),
('TOPPADDING', (0, 0), (-1, -1), 8),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
]))
elements.append(total_table)
elements.append(Spacer(1, 0.5*cm))
# === SOLDES ===
elements.append(Paragraph("SOLDES", subtitle_style))
soldes_data = [
['Solde avant paiement', f"{paiement.get('solde_avant', 0):,.0f} XOF"],
['Solde après paiement', f"{paiement.get('solde_apres', 0):,.0f} XOF"]
]
soldes_table = Table(soldes_data, colWidths=[10*cm, 6*cm])
soldes_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#8b949e')),
('ALIGN', (1, 0), (1, -1), 'RIGHT'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 5),
('RIGHTPADDING', (0, 0), (-1, -1), 5),
]))
elements.append(soldes_table)
elements.append(Spacer(1, 0.5*cm))
# === MOYEN DE PAIEMENT ===
elements.append(Paragraph("MOYEN DE PAIEMENT", subtitle_style))
moyen_data = [
['Mode de paiement:', data.get('moyen', 'N/A')],
['Référence externe:', data.get('reference', 'N/A')],
['Transaction ID:', data.get('trans_id', 'N/A')]
]
moyen_table = Table(moyen_data, colWidths=[4*cm, 12*cm])
moyen_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTNAME', (1, 0), (1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#8b949e')),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
]))
elements.append(moyen_table)
elements.append(Spacer(1, 1*cm))
# === FOOTER ===
elements.append(HRFlowable(width="100%", thickness=1, color=colors.grey))
elements.append(Spacer(1, 0.3*cm))
footer_text = f"Reçu généré le {datetime.now().strftime('%d/%m/%Y à %H:%M')}"
footer_style = ParagraphStyle(
'Footer',
parent=styles['Normal'],
fontSize=8,
textColor=colors.grey,
alignment=TA_CENTER
)
elements.append(Paragraph(footer_text, footer_style))
# Construction du PDF
doc.build(elements)
# Retour du buffer
buffer.seek(0)
print("=== PDF GÉNÉRÉ AVEC SUCCÈS ===")
return buffer
except Exception as e:
print(f"[ECHEC] ERREUR LORS DE LA GÉNÉRATION : {e}")
import traceback
traceback.print_exc()
return None