feat: úhrada za všechny jednou osobou (issue #29, SINGLE_PAYMENT)
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 is contained in:
+7
-4
@@ -86,12 +86,15 @@ app.post("/api/login", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// TODO dočasné řešení - QR se zobrazuje přes <img>, nemáme sem jak dostat token
|
||||
app.get("/api/qr", (req, res) => {
|
||||
// QR se zobrazuje přes <img>, nemáme sem jak dostat token
|
||||
app.get("/api/qr", async (req, res) => {
|
||||
if (!req.query?.login) {
|
||||
throw Error("Nebyl předán login");
|
||||
return res.status(400).json({ error: "Nebyl předán login" });
|
||||
}
|
||||
const img = getQr(req.query.login as string);
|
||||
if (!req.query?.id) {
|
||||
return res.status(400).json({ error: "Nebyl předán identifikátor QR kódu" });
|
||||
}
|
||||
const img = await getQr(req.query.login as string, req.query.id as string);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': img.length
|
||||
|
||||
+9
-6
@@ -5,6 +5,7 @@ import getStorage from "./storage";
|
||||
import { downloadPizzy } from "./chefie";
|
||||
import { getClientData, getToday, initIfNeeded } from "./service";
|
||||
import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum, PendingQr } from "../../types/gen/types.gen";
|
||||
import crypto from "crypto";
|
||||
|
||||
const storage = getStorage();
|
||||
const PENDING_QR_PREFIX = 'pending_qr';
|
||||
@@ -269,11 +270,13 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
|
||||
if (bankAccount?.length && bankAccountHolder?.length) {
|
||||
for (const order of clientData.pizzaDay.orders!) {
|
||||
if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl
|
||||
const id = crypto.randomUUID();
|
||||
let message = order.pizzaList!.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', ');
|
||||
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message);
|
||||
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message, id);
|
||||
order.hasQr = true;
|
||||
// Uložíme nevyřízený QR kód pro persistentní zobrazení
|
||||
await addPendingQr(order.customer, {
|
||||
id,
|
||||
date: today,
|
||||
creator: login,
|
||||
totalPrice: order.totalPrice,
|
||||
@@ -360,8 +363,8 @@ function getPendingQrKey(login: string): string {
|
||||
export async function addPendingQr(login: string, pendingQr: PendingQr): Promise<void> {
|
||||
const key = getPendingQrKey(login);
|
||||
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
||||
// Nepřidáváme duplicity pro stejný den
|
||||
if (!existing.some(qr => qr.date === pendingQr.date)) {
|
||||
// Deduplikace podle id (ne podle data — jeden den může mít uživatel víc QR kódů)
|
||||
if (!existing.some(qr => qr.id === pendingQr.id)) {
|
||||
existing.push(pendingQr);
|
||||
await storage.setData(key, existing);
|
||||
}
|
||||
@@ -375,11 +378,11 @@ export async function getPendingQrs(login: string): Promise<PendingQr[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Označí QR kód pro daný den jako uhrazený (odstraní ho ze seznamu nevyřízených).
|
||||
* Označí QR kód jako uhrazený (odstraní ho ze seznamu nevyřízených).
|
||||
*/
|
||||
export async function dismissPendingQr(login: string, date: string): Promise<void> {
|
||||
export async function dismissPendingQr(login: string, id: string): Promise<void> {
|
||||
const key = getPendingQrKey(login);
|
||||
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
||||
const filtered = existing.filter(qr => qr.date !== date);
|
||||
const filtered = existing.filter(qr => qr.id !== id);
|
||||
await storage.setData(key, filtered);
|
||||
}
|
||||
+26
-30
@@ -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');
|
||||
}
|
||||
|
||||
@@ -112,11 +112,11 @@ router.post("/updatePizzaFee", async (req: Request<{}, any, UpdatePizzaFeeData["
|
||||
/** Označí QR kód jako uhrazený. */
|
||||
router.post("/dismissQr", async (req: Request<{}, any, DismissQrData["body"]>, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
if (!req.body.date) {
|
||||
return res.status(400).json({ error: "Nebyl předán datum" });
|
||||
if (!req.body.id) {
|
||||
return res.status(400).json({ error: "Nebyl předán identifikátor QR kódu" });
|
||||
}
|
||||
try {
|
||||
await dismissPendingQr(login, req.body.date);
|
||||
await dismissPendingQr(login, req.body.id);
|
||||
res.status(200).json({});
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { parseToken, formatDate } from "../utils";
|
||||
import { generateQr } from "../qr";
|
||||
import { addPendingQr } from "../pizza";
|
||||
import { GenerateQrData } from "../../../types";
|
||||
import crypto from "crypto";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -44,10 +45,12 @@ router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, r
|
||||
}
|
||||
|
||||
// Vygenerovat QR kód
|
||||
await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose);
|
||||
const id = crypto.randomUUID();
|
||||
await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose, id);
|
||||
|
||||
// Uložit jako nevyřízený QR kód
|
||||
await addPendingQr(recipient.login, {
|
||||
id,
|
||||
date: today,
|
||||
creator: login,
|
||||
totalPrice: recipient.amount,
|
||||
|
||||
Reference in New Issue
Block a user