feat: úhrada za všechny jednou osobou (issue #29, SINGLE_PAYMENT)
ci/woodpecker/push/workflow Pipeline was canceled

Přidává možnost, aby jeden strávník zaplatil celý účet v restauraci a ostatní
obdrželi QR kód pro refundaci.

Prerekvizita — podpora více QR kódů na (příjemce, den):
- PendingQr.id (UUID) nahrazuje deduplikaci podle data; každý QR má vlastní klíč
- QR obrázky uloženy do Redis/storage (base64) místo tmpdir — přežijí redeploy
- GET /api/qr vyžaduje ?id= parametr; dismissQr přijímá {id} místo {date}

Feature:
- Ikona 'Zaplatit za všechny' v choices-table pro každou LunchChoice (kromě
  PIZZA/NEOBEDVAM/ROZHODUJI); viditelná jen při ≥2 strávnících a vyplněném účtu
- PayForAllModal: tabulka strávníků s prefillovanými cenami z menu, příplatky
  per-diner, celkové dýško rozpočtené rovnoměrně, generování QR přes POST /api/qr/generate
- parsePriceCzk() helper pro parsing 'N Kč' → number

Co se nemění: POST /api/qr/generate API kontrakt, PizzaOrder.hasQr boolean

Co se mění v OpenAPI: PendingQr.id (required), getPizzaQr ?id param, dismissQr body

Co-Authored-By: opmrdkazkrtkaus <opmrdkazkrtkaus@melancholik.eu>
This commit was merged in pull request #53.
This commit is contained in:
2026-04-28 22:35:15 +02:00
parent e5999852b7
commit 1e1e23df80
11 changed files with 430 additions and 66 deletions
+26 -30
View File
@@ -1,19 +1,17 @@
import fs from "fs";
import axios from "axios";
import os from "os";
import path from "path";
import crypto from "crypto";
import { formatDate } from "./utils";
import getStorage from "./storage";
const QR_GENERATOR_URL = 'https://api.paylibo.com/paylibo/generator/image';
const COUNTRY_CODE = 'CZ';
const CURRENCY_CODE = 'CZK';
const QR_PIXEL_SIZE = 256;
const tmpDir = os.tmpdir();
const storage = getStorage();
/**
* Převede číslo účtu z BBAN do IBAN. Automaticky dopočítá kontrolní číslice.
*
*
* @param bankAccountNumber číslo účtu ve formátu BBAN (123456-0123456789/0100)
*/
function convertBbanToIban(bankAccountNumber: string): string {
@@ -41,26 +39,23 @@ function convertBbanToIban(bankAccountNumber: string): string {
return iban;
}
function createNameHash(customerName: string): string {
return crypto.createHash('md5').update(customerName).digest('hex');
}
function createFilePath(nameHash: string): string {
const fileName = `${formatDate(new Date())}_${nameHash}.png`;
return path.join(tmpDir, fileName);
function createStorageKey(customerName: string, id: string): string {
const nameHash = crypto.createHash('md5').update(customerName).digest('hex');
return `qr_${nameHash}_${id}`;
}
/**
* Vygeneruje, uloží a vrátí unikátní ID obrázku platebního QR kódu s danými parametry.
*
* Vygeneruje a uloží obrázek platebního QR kódu do storage (Redis/JSON).
* Data přežijí redeploy — není třeba persistentní filesystém.
*
* @param customerName jméno uživatele, pro kterého je QR kód generován
* @param bankAccountNumber číslo cílového bankovního účtu ve formátu BBAN
* @param bankAccountHolder jméno držitele cílového bankovního účtu
* @param amount částka v Kč
* @param message zpráva pro příjemce
* @returns hash, pomocí kterého lze následně získat vygenerovaný obrázek
* @param id unikátní identifikátor (UUID) tohoto QR kódu
*/
export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string): Promise<string> {
export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string, id: string): Promise<void> {
// Zpráva pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků
if (message.indexOf('*') >= 0) {
message = message.replace('*', '');
@@ -77,22 +72,23 @@ export async function generateQr(customerName: string, bankAccountNumber: string
branding: false,
compress: false,
size: QR_PIXEL_SIZE,
}
const response = await axios.get(QR_GENERATOR_URL, { responseType: 'stream', params: { ...payload } });
// Použijeme hash, abychom nemuseli řešit nepovolené znaky ve jménu uživatele
const nameHash = createNameHash(customerName);
const imgPath = createFilePath(nameHash);
response.data.pipe(fs.createWriteStream(imgPath));
return nameHash;
};
const response = await axios.get(QR_GENERATOR_URL, { responseType: 'arraybuffer', params: { ...payload } });
const base64 = Buffer.from(response.data).toString('base64');
await storage.setData(createStorageKey(customerName, id), base64);
}
/**
* Vrátí obrázek s QR kódem, pokud existuje.
*
* Vrátí obrázek s QR kódem ze storage.
*
* @param customerName jméno uživatele
* @param id unikátní identifikátor QR kódu
* @returns data obrázku
*/
export function getQr(customerName: string): Buffer {
const imgPath = createFilePath(createNameHash(customerName));
return fs.readFileSync(imgPath);
}
export async function getQr(customerName: string, id: string): Promise<Buffer> {
const base64 = await storage.getData<string>(createStorageKey(customerName, id));
if (!base64) {
throw new Error("QR kód nebyl nalezen");
}
return Buffer.from(base64, 'base64');
}