V2.2: Bank card redesign, bank statement, panel bug fixes

- bank_card: premium design (hologram, contactless, security pattern, glow, signature)
- bank_statement: new Pillow generator with transaction history table
- panel: fix duplicate custom_ids (hash-based), fix ComptePanelView loading
- panel: fix BanquierPanelView user select integration
- panel: add Releve button on account detail, add consulter-clients/info/plafond/reserve
- panel: add user display name in account view, all bank commands accessible from panel
This commit is contained in:
utilisateurqu 2026-08-19 17:49:39 +02:00
parent 48ec3a3d89
commit c37a2a0ff9
3 changed files with 802 additions and 603 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,25 +1,13 @@
import os import os
import math import math
import hashlib
from io import BytesIO from io import BytesIO
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont, ImageFilter
class BankCardGenerator: class BankCardGenerator:
WIDTH = 856 WIDTH = 856
HEIGHT = 540 HEIGHT = 540
RADIUS = 30
COLORS = {
"dark": (25, 25, 35),
"mid": (35, 40, 60),
"light": (50, 55, 80),
"accent": (0, 180, 160),
"accent_dim": (0, 120, 105),
"gold": (212, 175, 55),
"white": (255, 255, 255),
"gray": (160, 165, 180),
"dim": (90, 95, 110),
}
def __init__(self): def __init__(self):
self.font_title = ImageFont.load_default() self.font_title = ImageFont.load_default()
@ -27,6 +15,8 @@ class BankCardGenerator:
self.font_value = ImageFont.load_default() self.font_value = ImageFont.load_default()
self.font_rib = ImageFont.load_default() self.font_rib = ImageFont.load_default()
self.font_large = ImageFont.load_default() self.font_large = ImageFont.load_default()
self.font_huge = ImageFont.load_default()
self.font_small = ImageFont.load_default()
try: try:
base = os.path.join(os.path.dirname(__file__), "..", "ressources", "fonts") base = os.path.join(os.path.dirname(__file__), "..", "ressources", "fonts")
if os.path.isdir(base): if os.path.isdir(base):
@ -34,27 +24,30 @@ class BankCardGenerator:
path = os.path.join(base, name) path = os.path.join(base, name)
lower = name.lower() lower = name.lower()
if "bold" in lower: if "bold" in lower:
self.font_title = ImageFont.truetype(path, 26) self.font_title = ImageFont.truetype(path, 28)
self.font_large = ImageFont.truetype(path, 36) self.font_large = ImageFont.truetype(path, 38)
elif "mono" in lower or "regular" in lower: self.font_huge = ImageFont.truetype(path, 48)
self.font_label = ImageFont.truetype(path, 16) elif "mono" in lower or "regular" in lower or "light" in lower:
self.font_label = ImageFont.truetype(path, 15)
self.font_value = ImageFont.truetype(path, 22) self.font_value = ImageFont.truetype(path, 22)
self.font_rib = ImageFont.truetype(path, 20) self.font_rib = ImageFont.truetype(path, 24)
self.font_small = ImageFont.truetype(path, 12)
except Exception: except Exception:
pass pass
def _rounded_rect(self, draw, xy, radius, fill, outline=None, width=0): def _rounded_rect(self, draw, xy, radius, fill=None, outline=None, width=0):
x0, y0, x1, y1 = xy x0, y0, x1, y1 = xy
r = radius r = min(radius, (x1 - x0) // 2, (y1 - y0) // 2)
draw.ellipse([x0, y0, x0 + 2 * r, y0 + 2 * r], fill=fill, outline=outline, width=width) if fill:
draw.ellipse([x1 - 2 * r, y0, x1, y0 + 2 * r], fill=fill, outline=outline, width=width) draw.ellipse([x0, y0, x0 + 2 * r, y0 + 2 * r], fill=fill)
draw.ellipse([x0, y1 - 2 * r, x0 + 2 * r, y1], fill=fill, outline=outline, width=width) draw.ellipse([x1 - 2 * r, y0, x1, y0 + 2 * r], fill=fill)
draw.ellipse([x1 - 2 * r, y1 - 2 * r, x1, y1], fill=fill, outline=outline, width=width) draw.ellipse([x0, y1 - 2 * r, x0 + 2 * r, y1], fill=fill)
draw.rectangle([x0 + r, y0, x1 - r, y1], fill=fill) draw.ellipse([x1 - 2 * r, y1 - 2 * r, x1, y1], fill=fill)
draw.rectangle([x0, y0 + r, x1, y1 - r], fill=fill) draw.rectangle([x0 + r, y0, x1 - r, y1], fill=fill)
draw.rectangle([x0, y0 + r, x1, y1 - r], fill=fill)
if outline and width: if outline and width:
draw.arc([x0, y0, x0 + 2 * r, y0 + 2 * r], 180, 270, fill=outline, width=width) draw.arc([x0, y0, x0 + 2 * r, y0 + 2 * r], 180, 270, fill=outline, width=width)
draw.arc([x1 - 2 * r, y0, x1, y0 + 2 * r], 270, 0, fill=outline, width=width) draw.arc([x1 - 2 * r, y0, x1, y0 + 2 * r], 270, 360, fill=outline, width=width)
draw.arc([x0, y1 - 2 * r, x0 + 2 * r, y1], 90, 180, fill=outline, width=width) draw.arc([x0, y1 - 2 * r, x0 + 2 * r, y1], 90, 180, fill=outline, width=width)
draw.arc([x1 - 2 * r, y1 - 2 * r, x1, y1], 0, 90, fill=outline, width=width) draw.arc([x1 - 2 * r, y1 - 2 * r, x1, y1], 0, 90, fill=outline, width=width)
draw.line([x0 + r, y0, x1 - r, y0], fill=outline, width=width) draw.line([x0 + r, y0, x1 - r, y0], fill=outline, width=width)
@ -62,40 +55,65 @@ class BankCardGenerator:
draw.line([x0, y0 + r, x0, y1 - r], fill=outline, width=width) draw.line([x0, y0 + r, x0, y1 - r], fill=outline, width=width)
draw.line([x1, y0 + r, x1, y1 - r], fill=outline, width=width) draw.line([x1, y0 + r, x1, y1 - r], fill=outline, width=width)
def _draw_gradient_rect(self, draw, xy, color_start, color_end, vertical=True): def _gradient(self, draw, xy, c1, c2, vertical=True):
x0, y0, x1, y1 = xy x0, y0, x1, y1 = xy
if vertical: rng = range(y0, y1) if vertical else range(x0, x1)
for y in range(y0, y1): length = (y1 - y0) if vertical else (x1 - x0)
t = (y - y0) / max(1, y1 - y0) for i in rng:
r = int(color_start[0] + (color_end[0] - color_start[0]) * t) t = (i - (y0 if vertical else x0)) / max(1, length)
g = int(color_start[1] + (color_end[1] - color_start[1]) * t) r = int(c1[0] + (c2[0] - c1[0]) * t)
b = int(color_start[2] + (color_end[2] - color_start[2]) * t) g = int(c1[1] + (c2[1] - c1[1]) * t)
draw.line([(x0, y), (x1, y)], fill=(r, g, b)) b = int(c1[2] + (c2[2] - c1[2]) * t)
else: if vertical:
for x in range(x0, x1): draw.line([(x0, i), (x1, i)], fill=(r, g, b))
t = (x - x0) / max(1, x1 - x0) else:
r = int(color_start[0] + (color_end[0] - color_start[0]) * t) draw.line([(i, y0), (i, y1)], fill=(r, g, b))
g = int(color_start[1] + (color_end[1] - color_start[1]) * t)
b = int(color_start[2] + (color_end[2] - color_start[2]) * t)
draw.line([(x, y0), (x, y1)], fill=(r, g, b))
def _draw_chip(self, draw, x, y): def _draw_chip(self, draw, x, y):
w, h = 55, 40 w, h = 60, 45
self._rounded_rect(draw, (x, y, x + w, y + h), 6, fill=(200, 175, 50), outline=(180, 155, 40), width=2) self._rounded_rect(draw, (x, y, x + w, y + h), 8, fill=(210, 180, 55))
draw.line([(x + 8, y + h // 3), (x + w - 8, y + h // 3)], fill=(160, 135, 30), width=1) self._rounded_rect(draw, (x + 3, y + 3, x + w - 3, y + h - 3), 6, fill=(225, 195, 70))
draw.line([(x + 8, y + 2 * h // 3), (x + w - 8, y + 2 * h // 3)], fill=(160, 135, 30), width=1) for dy in [h // 3, 2 * h // 3]:
draw.line([(x + w // 3, y + 4), (x + w // 3, y + h - 4)], fill=(160, 135, 30), width=1) draw.line([(x + 8, y + dy), (x + w - 8, y + dy)], fill=(190, 160, 40), width=1)
draw.line([(x + 2 * w // 3, y + 4), (x + 2 * w // 3, y + h - 4)], fill=(160, 135, 30), width=1) for dx in [w // 3, 2 * w // 3]:
draw.line([(x + dx, y + 5), (x + dx, y + h - 5)], fill=(190, 160, 40), width=1)
draw.arc([x + w // 2 - 12, y + h // 2 - 12, x + w // 2 + 12, y + h // 2 + 12], 0, 360, fill=(190, 160, 40), width=1)
def _draw_wave(self, draw, y_base, color, amplitude=15, segments=8): def _draw_hologram(self, draw, x, y, size=50):
points = [] for i in range(5):
w = self.WIDTH offset = i * 3
for i in range(segments + 1): alpha_color = (
x = int(w * i / segments) 100 + i * 30,
y = y_base + int(amplitude * math.sin(i * 1.2)) 200 - i * 20,
points.append((x, y)) 220 - i * 15,
for i in range(len(points) - 1): )
draw.line([points[i], points[i + 1]], fill=color, width=2) draw.ellipse(
[x + offset, y + offset, x + size - offset, y + size - offset],
outline=alpha_color, width=1,
)
draw.ellipse([x + size // 2 - 5, y + size // 2 - 5, x + size // 2 + 5, y + size // 2 + 5], fill=(180, 220, 240))
def _draw_contactless(self, draw, x, y):
for i, r in enumerate([10, 16, 22]):
draw.arc([x - r, y - r, x + r, y + r], -60, 60, fill=(180, 180, 190), width=1)
def _draw_security_pattern(self, draw, width, height):
for x in range(0, width, 40):
for y in range(0, height, 40):
h = hashlib.md5(f"{x}{y}".encode()).hexdigest()
if int(h[:2], 16) < 15:
draw.point((x, y), fill=(60, 65, 85))
def _draw_glow(self, img, x, y, radius=80, color=(0, 180, 160), intensity=30):
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
od = ImageDraw.Draw(overlay)
for r in range(radius, 0, -2):
alpha = int(intensity * (r / radius))
od.ellipse(
[x - r, y - r, x + r, y + r],
fill=(*color, alpha),
)
return Image.alpha_composite(img.convert("RGBA"), overlay)
def generate( def generate(
self, self,
@ -106,51 +124,84 @@ class BankCardGenerator:
account_type: str = "particulier", account_type: str = "particulier",
tva_rate: float = 0.0, tva_rate: float = 0.0,
) -> BytesIO: ) -> BytesIO:
img = Image.new("RGB", (self.WIDTH, self.HEIGHT), self.COLORS["dark"]) dark1 = (18, 18, 28)
dark2 = (30, 32, 48)
accent = (0, 190, 170)
accent_dim = (0, 130, 115)
gold = (212, 175, 55)
white = (245, 245, 250)
gray = (150, 155, 170)
dim = (80, 85, 100)
img = Image.new("RGB", (self.WIDTH, self.HEIGHT), dark1)
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
self._draw_gradient_rect(draw, (0, 0, self.WIDTH, self.HEIGHT), self.COLORS["dark"], self.COLORS["mid"]) self._gradient(draw, (0, 0, self.WIDTH, self.HEIGHT), dark1, dark2)
self._draw_wave(draw, 120, (*self.COLORS["accent"][:3],), amplitude=20, segments=10) self._draw_security_pattern(draw, self.WIDTH, self.HEIGHT)
self._draw_wave(draw, 140, (*self.COLORS["accent_dim"][:3],), amplitude=15, segments=12)
self._draw_wave(draw, 360, (*self.COLORS["light"][:3],), amplitude=12, segments=8)
self._rounded_rect(draw, (20, 20, self.WIDTH - 20, self.HEIGHT - 20), self.RADIUS, fill=None, outline=self.COLORS["accent"], width=2) for wave_y, color, amp, seg in [
(100, (*accent[:3],), 25, 10),
(130, (*accent_dim[:3],), 18, 12),
(350, (55, 60, 85), 12, 8),
]:
pts = []
for i in range(seg + 1):
px = int(self.WIDTH * i / seg)
py = wave_y + int(amp * math.sin(i * 1.3))
pts.append((px, py))
for i in range(len(pts) - 1):
draw.line([pts[i], pts[i + 1]], fill=color, width=2)
self._draw_chip(draw, 60, 130) self._rounded_rect(draw, (18, 18, self.WIDTH - 18, self.HEIGHT - 18), 28, outline=accent, width=2)
draw.text((75, 185), "VICTORIA'S VICE", fill=self.COLORS["accent"], font=self.font_title) img = self._draw_glow(img, 85, 155, radius=70, color=accent, intensity=20)
draw.text((75, 218), bank_name.upper(), fill=self.COLORS["gray"], font=self.font_label) draw = ImageDraw.Draw(img)
self._draw_chip(draw, 55, 125)
self._draw_hologram(draw, self.WIDTH - 120, 120, size=55)
self._draw_contactless(draw, self.WIDTH - 80, 80)
draw.text((65, 182), "VICTORIA'S VICE", fill=accent, font=self.font_title)
draw.text((65, 215), bank_name.upper(), fill=gray, font=self.font_label)
type_label = "COMPTE ENTREPRISE" if account_type == "entreprise" else "COMPTE PARTICULIER" type_label = "COMPTE ENTREPRISE" if account_type == "entreprise" else "COMPTE PARTICULIER"
draw.text((self.WIDTH - 200, 45), type_label, fill=self.COLORS["dim"], font=self.font_label) draw.text((self.WIDTH - 210, 42), type_label, fill=dim, font=self.font_label)
y_rib = 280
draw.text((60, y_rib), "RIB", fill=self.COLORS["dim"], font=self.font_label)
rib_parts = []
clean_rib = rib.replace(" ", "") clean_rib = rib.replace(" ", "")
for i in range(0, len(clean_rib), 4): rib_display = " ".join(clean_rib[i:i + 4] for i in range(0, len(clean_rib), 4))
rib_parts.append(clean_rib[i:i + 4])
rib_display = " ".join(rib_parts)
draw.text((60, y_rib + 22), rib_display, fill=self.COLORS["white"], font=self.font_rib)
y_solde = 350 y_card_number = 260
draw.text((60, y_solde), "SOLDE", fill=self.COLORS["dim"], font=self.font_label) draw.text((60, y_card_number - 18), "RIB", fill=dim, font=self.font_label)
draw.text((60, y_card_number), rib_display, fill=white, font=self.font_rib)
y_solde = 340
draw.text((60, y_solde - 18), "SOLDE DISPONIBLE", fill=dim, font=self.font_label)
solde_str = f"{solde:,.2f} EUR".replace(",", " ") solde_str = f"{solde:,.2f} EUR".replace(",", " ")
draw.text((60, y_solde + 22), solde_str, fill=self.COLORS["gold"], font=self.font_large) draw.text((60, y_solde), solde_str, fill=gold, font=self.font_large)
y_bottom = self.HEIGHT - 100
draw.text((60, y_bottom), "TITULAIRE", fill=self.COLORS["dim"], font=self.font_label)
draw.text((60, y_bottom + 20), holder_name.upper(), fill=self.COLORS["white"], font=self.font_value)
if account_type == "entreprise" and tva_rate > 0: if account_type == "entreprise" and tva_rate > 0:
draw.text((self.WIDTH - 200, y_bottom), "TVA", fill=self.COLORS["dim"], font=self.font_label) draw.text((self.WIDTH - 180, y_solde - 18), "TAUX TVA", fill=dim, font=self.font_label)
draw.text((self.WIDTH - 200, y_bottom + 20), f"{tva_rate}%", fill=self.COLORS["accent"], font=self.font_value) draw.text((self.WIDTH - 180, y_solde), f"{tva_rate}%", fill=accent, font=self.font_value)
for i in range(5): y_bottom = self.HEIGHT - 110
x = self.WIDTH - 60 - i * 35 draw.line([(60, y_bottom - 10), (self.WIDTH - 60, y_bottom - 10)], fill=(60, 65, 85), width=1)
draw.ellipse([x, self.HEIGHT - 50, x + 25, self.HEIGHT - 25], fill=self.COLORS["accent"] if i < 3 else self.COLORS["dim"])
draw.text((60, y_bottom), "TITULAIRE", fill=dim, font=self.font_label)
draw.text((60, y_bottom + 18), holder_name.upper(), fill=white, font=self.font_value)
sig_y = y_bottom + 5
for i in range(40):
sx = self.WIDTH - 250 + i * 3
sy = sig_y + int(3 * math.sin(i * 0.5))
draw.point((sx, sy), fill=(120, 125, 140))
draw.point((sx, sy + 1), fill=(120, 125, 140))
for i in range(4):
cx = self.WIDTH - 65 - i * 30
draw.ellipse([cx, self.HEIGHT - 45, cx + 28, self.HEIGHT - 17], fill=accent if i < 2 else dim)
draw.text((55, self.HEIGHT - 35), "VIRTUAL CARD", fill=dim, font=self.font_small)
buf = BytesIO() buf = BytesIO()
img.save(buf, format="PNG") img.save(buf, format="PNG")

188
utils/bank_statement.py Normal file
View file

@ -0,0 +1,188 @@
import os
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont
class BankStatementGenerator:
WIDTH = 900
ROW_HEIGHT = 38
HEADER_HEIGHT = 180
FOOTER_HEIGHT = 80
def __init__(self):
self.font_title = ImageFont.load_default()
self.font_header = ImageFont.load_default()
self.font_body = ImageFont.load_default()
self.font_body_bold = ImageFont.load_default()
self.font_small = ImageFont.load_default()
self.font_amount_pos = ImageFont.load_default()
self.font_amount_neg = ImageFont.load_default()
try:
base = os.path.join(os.path.dirname(__file__), "..", "ressources", "fonts")
if os.path.isdir(base):
for name in os.listdir(base):
path = os.path.join(base, name)
lower = name.lower()
if "bold" in lower:
self.font_title = ImageFont.truetype(path, 30)
self.font_header = ImageFont.truetype(path, 16)
self.font_body_bold = ImageFont.truetype(path, 15)
self.font_amount_pos = ImageFont.truetype(path, 15)
self.font_amount_neg = ImageFont.truetype(path, 15)
elif "mono" in lower or "regular" in lower or "light" in lower:
self.font_body = ImageFont.truetype(path, 14)
self.font_small = ImageFont.truetype(path, 12)
except Exception:
pass
def _rounded_rect(self, draw, xy, radius, fill=None, outline=None, width=0):
x0, y0, x1, y1 = xy
r = min(radius, (x1 - x0) // 2, (y1 - y0) // 2)
if fill:
draw.ellipse([x0, y0, x0 + 2 * r, y0 + 2 * r], fill=fill)
draw.ellipse([x1 - 2 * r, y0, x1, y0 + 2 * r], fill=fill)
draw.ellipse([x0, y1 - 2 * r, x0 + 2 * r, y1], fill=fill)
draw.ellipse([x1 - 2 * r, y1 - 2 * r, x1, y1], fill=fill)
draw.rectangle([x0 + r, y0, x1 - r, y1], fill=fill)
draw.rectangle([x0, y0 + r, x1, y1 - r], fill=fill)
if outline and width:
draw.line([x0 + r, y0, x1 - r, y0], fill=outline, width=width)
draw.line([x0 + r, y1, x1 - r, y1], fill=outline, width=width)
draw.line([x0, y0 + r, x0, y1 - r], fill=outline, width=width)
draw.line([x1, y0 + r, x1, y1 - r], fill=outline, width=width)
def generate(
self,
bank_name: str,
holder_name: str,
rib: str,
solde_actuel: float,
transactions: list,
account_type: str = "particulier",
) -> BytesIO:
bg = (15, 15, 22)
card_bg = (22, 24, 35)
accent = (0, 190, 170)
gold = (212, 175, 55)
white = (240, 240, 245)
gray = (140, 145, 160)
dim = (70, 75, 90)
row_even = (26, 28, 40)
row_odd = (22, 24, 35)
green = (40, 200, 120)
red = (220, 70, 70)
line_color = (40, 42, 55)
n_rows = len(transactions)
total_h = self.HEADER_HEIGHT + n_rows * self.ROW_HEIGHT + self.FOOTER_HEIGHT + 60
img = Image.new("RGB", (self.WIDTH, total_h), bg)
draw = ImageDraw.Draw(img)
self._rounded_rect(draw, (20, 20, self.WIDTH - 20, 50), 10, fill=card_bg)
draw.text((35, 25), "RELEVE BANCAIRE", fill=accent, font=self.font_title)
y = 80
draw.line([(30, y), (self.WIDTH - 30, y)], fill=line_color, width=1)
y += 15
col_labels = [
(35, "DATE"),
(160, "TYPE"),
(380, "EMETTEUR / DESTINATAIRE"),
(620, "MONTANT"),
]
for cx, label in col_labels:
draw.text((cx, y), label, fill=gray, font=self.font_header)
y += 25
draw.text((35, y), f"Banque : {bank_name}", fill=white, font=self.font_body_bold)
draw.text((35, y + 20), f"Titulaire : {holder_name}", fill=white, font=self.font_body)
draw.text((35, y + 40), f"RIB : {rib}", fill=gray, font=self.font_small)
solde_label = f"Solde actuel : {solde_actuel:,.2f} EUR".replace(",", " ")
draw.text((self.WIDTH - 300, y + 10), solde_label, fill=gold, font=self.font_body_bold)
type_label = "Entreprise" if account_type == "entreprise" else "Particulier"
draw.text((self.WIDTH - 300, y + 32), f"Type : {type_label}", fill=gray, font=self.font_small)
y = self.HEADER_HEIGHT + 20
draw.line([(30, y - 5), (self.WIDTH - 30, y - 5)], fill=line_color, width=1)
for i, tx in enumerate(transactions):
ry = y + i * self.ROW_HEIGHT
row_bg = row_even if i % 2 == 0 else row_odd
draw.rectangle([(30, ry), (self.WIDTH - 30, ry + self.ROW_HEIGHT)], fill=row_bg)
date_str = tx.get("date", "N/A")
if len(date_str) > 16:
date_str = date_str[:16]
tx_type = tx.get("type", "N/A")
type_display = {
"virement": "Virement",
"depot_banquier": "Depot",
"retrait_banquier": "Retrait",
"tva_prelevee": "TVA Prelevee",
}.get(tx_type, tx_type)
emetteur_rib = tx.get("emetteur_rib", "")
destinataire_rib = tx.get("destinataire_rib", "")
montant_val = tx.get("montant", 0)
emetteur_id = tx.get("emetteur_id")
destinataire_id = tx.get("destinataire_id")
if emetteur_id:
other_ref = f"De: <@{emetteur_id}>"
elif emetteur_rib:
other_ref = f"De: ...{emetteur_rib[-6:]}" if emetteur_rib else ""
elif destinataire_id:
other_ref = f"A: <@{destinataire_id}>"
elif destinataire_rib:
other_ref = f"A: ...{destinataire_rib[-6:]}" if destinataire_rib else ""
else:
other_ref = ""
metadata = tx.get("metadata", "")
if metadata:
try:
import json
meta = json.loads(metadata)
if "banquier_id" in meta:
other_ref += f" (banquier: {meta['banquier_id']})"
except Exception:
pass
draw.text((35, ry + 10), date_str, fill=gray, font=self.font_body)
draw.text((160, ry + 10), type_display, fill=white, font=self.font_body_bold)
draw.text((380, ry + 10), other_ref, fill=gray, font=self.font_body)
is_incoming = tx_type in ("depot_banquier",) or (
tx_type == "virement" and destinataire_rib and not emetteur_rib
)
if tx_type == "virement":
is_incoming = False
if tx_type == "tva_prelevee":
amt_color = red
amt_str = f"-{montant_val:,.2f} EUR".replace(",", " ")
elif is_incoming:
amt_color = green
amt_str = f"+{montant_val:,.2f} EUR".replace(",", " ")
else:
amt_color = red if montant_val > 0 else gray
amt_str = f"-{montant_val:,.2f} EUR".replace(",", " ")
draw.text((620, ry + 10), amt_str, fill=amt_color, font=self.font_amount_pos)
draw.line([(30, ry + self.ROW_HEIGHT), (self.WIDTH - 30, ry + self.ROW_HEIGHT)], fill=line_color, width=1)
footer_y = y + n_rows * self.ROW_HEIGHT + 15
draw.line([(30, footer_y), (self.WIDTH - 30, footer_y)], fill=line_color, width=1)
footer_y += 10
draw.text((35, footer_y), f"{n_rows} transaction(s)", fill=gray, font=self.font_small)
draw.text((35, footer_y + 18), "Victoria's Vice — Systeme Bancaire V2", fill=dim, font=self.font_small)
buf = BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return buf