diff --git a/README.md b/README.md index 7258b53..b4f9904 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,12 @@ Aplikace sestává ze tří (čtyř) modulů. - [x] Implementovat Pizza day - [x] Umožnit uzamčení objednávek zakladatelem - [x] Možnost uložení čísla účtu - - [ ] Automatické generování a zobrazení QR kódů - - [ ] https://qr-platba.cz/pro-vyvojare/restful-api/ + - [x] Automatické generování a zobrazení QR kódů + - [x] https://qr-platba.cz/pro-vyvojare/restful-api/ - [ ] Zobrazovat celkovou cenu objednávky pod tabulkou objednávek - [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day - [ ] Umožnit přidat k objednávce poznámku (např. "bez oliv") + - [ ] Negenerovat QR kód pro objednávajícího - [ ] Předvyplnění poslední vybrané hodnoty občas nefunguje, viz komentář - [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není venku) - [ ] Vylepšit dokumentaci projektu @@ -59,7 +60,7 @@ Aplikace sestává ze tří (čtyř) modulů. - [ ] Nutno nejprve vyřešit předávání PHPSESSIONID cookie na pizzachefie.cz pomocí fetch() - [ ] Přesunout autentizaci na server (JWT?) - [x] Zavést .env.template a přidat .env do .gitignore -- [ ] Zkrášlit dialog pro vyplnění čísla účtu, vypadá mizerně +- [x] Zkrášlit dialog pro vyplnění čísla účtu, vypadá mizerně - [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams) - [ ] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh) - [ ] Možnost náhledu na jiné dny v týdnu (např. pomocí šipek) diff --git a/client/src/Api.ts b/client/src/Api.ts index 2c3782b..e013d2a 100644 --- a/client/src/Api.ts +++ b/client/src/Api.ts @@ -18,6 +18,10 @@ const api = { post: (url: string, body: TBody) => request(url, { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }), } +export const getQrUrl = (login: string) => { + return `${getBaseUrl()}/api/qr?login=${login}`; +} + export const getData = async () => { return await api.get('/api/data'); } @@ -50,8 +54,8 @@ export const finishOrder = async (login) => { return await api.post('/api/finishOrder', JSON.stringify({ login })); } -export const finishDelivery = async (login) => { - return await api.post('/api/finishDelivery', JSON.stringify({ login })); +export const finishDelivery = async (login, bankAccount, bankAccountHolder) => { + return await api.post('/api/finishDelivery', JSON.stringify({ login, bankAccount, bankAccountHolder })); } export const updateChoice = async (name: string, choice: number | null) => { diff --git a/client/src/App.css b/client/src/App.css index a3e12bb..2642021 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -71,4 +71,8 @@ color: rgb(0, 89, 255); cursor: pointer; margin-left: 10px; +} + +.qr-code { + text-align: center; } \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index f8cad23..7e9d3b6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,10 +1,10 @@ import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; import 'bootstrap/dist/css/bootstrap.min.css'; import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket'; -import { addPizza, createPizzaDay, deletePizzaDay, finishDelivery, finishOrder, getData, getFood, getPizzy, lockPizzaDay, removePizza, unlockPizzaDay, updateChoice } from './Api'; +import { addPizza, createPizzaDay, deletePizzaDay, finishDelivery, finishOrder, getData, getFood, getPizzy, getQrUrl, lockPizzaDay, removePizza, unlockPizzaDay, updateChoice } from './Api'; import { useAuth } from './context/auth'; import Login from './Login'; -import { Locations, ClientData, Pizza, PizzaOrder, State } from './Types'; +import { Locations, ClientData, Pizza, PizzaOrder, State, Order } from './Types'; import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap'; import Header from './components/Header'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' @@ -14,16 +14,19 @@ import 'react-select-search/style.css'; import './App.css'; import { SelectSearchOption } from 'react-select-search'; import { faTrashCan } from '@fortawesome/free-regular-svg-icons'; +import { useBank } from './context/bank'; const EVENT_CONNECT = "connect" function App() { const auth = useAuth(); + const bank = useBank(); const [isConnected, setIsConnected] = useState(false); const [data, setData] = useState(); const [food, setFood] = useState(); const [pizzy, setPizzy] = useState(); + const [myOrder, setMyOrder] = useState(); const socket = useContext(SocketContext); const choiceRef = useRef(null); @@ -76,6 +79,16 @@ function App() { } }, [auth, auth?.login, data?.choices]) + // Reference na mojí objednávku + useEffect(() => { + if (data?.pizzaDay?.orders) { + const myOrder = data.pizzaDay.orders.find(o => o.customer === auth?.login); + if (myOrder) { + setMyOrder(myOrder); + } + } + }, [data?.pizzaDay?.orders]) + const changeChoice = async (event: React.ChangeEvent) => { const index = Object.values(Locations).indexOf(event.target.value as unknown as Locations); if (auth?.login) { @@ -183,10 +196,8 @@ function App() { Poslední změny:
    -
  • Žárovka praskla a vyhodila hlavní jistič
  • -
  • Už jsem jí vyměnil
  • -
  • Dobrá práce
  • -
  • Aplikace zatím nic nového neumí
  • +
  • Nová žárovka zatím funguje
  • +
  • Funkční generování a zobrazení QR kódů pro Pizza day

Dnes je {data.date}

@@ -288,7 +299,7 @@ function App() { await lockPizzaDay(auth.login); }}>Vrátit do "uzamčeno" } @@ -297,7 +308,7 @@ function App() { { data.pizzaDay.state === State.DELIVERED &&
-

Pizzy byly doručeny.

+

Pizzy byly doručeny. Objednávku můžete uhradit pomocí QR kódu níže.

} @@ -309,6 +320,15 @@ function App() { disabled={data.pizzaDay.state !== State.CREATED} /> + { + data.pizzaDay.state === State.DELIVERED && myOrder && +
+

QR platba

+
Částka: {myOrder.totalPrice} Kč
+ QR kód +

Generování QR kódů je v experimentální fázi - doporučujeme si překontrolovat údaje před odesláním platby.

+
+ } } diff --git a/server/src/chefie.ts b/server/src/chefie.ts index 059976e..a9932c0 100644 --- a/server/src/chefie.ts +++ b/server/src/chefie.ts @@ -3,6 +3,7 @@ import os from 'os'; import path from 'path'; import fs from 'fs'; import axios from 'axios'; +import { formatDate } from './utils'; type PizzaSize = { varId: number, @@ -85,10 +86,7 @@ const downloadPizzy = async () => { export const fetchPizzy = async (): Promise => { const tmpDir = os.tmpdir(); const date_ob = new Date(); - const date = ("0" + date_ob.getDate()).slice(-2); - const month = ("0" + (date_ob.getMonth() + 1)).slice(-2); - const year = date_ob.getFullYear(); - const dateStr = year + "-" + month + "-" + date; + const dateStr = formatDate(date_ob); const dataPath = path.join(tmpDir, `chefie-${dateStr}.json`); if (fs.existsSync(dataPath)) { diff --git a/server/src/index.ts b/server/src/index.ts index 37e74c6..03ef1cb 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -7,6 +7,7 @@ import { addPizzaOrder, createPizzaDay, deletePizzaDay, finishPizzaDelivery, fin import dotenv from 'dotenv'; import path from 'path'; import { fetchMenus } from "./restaurants"; +import { getQr } from "./qr"; const ENVIRONMENT = process.env.NODE_ENV || 'production' dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); @@ -134,7 +135,7 @@ app.post("/api/finishDelivery", (req, res) => { if (!req.body?.login) { throw Error("Nebyl předán login"); } - const data = finishPizzaDelivery(req.body.login); + const data = finishPizzaDelivery(req.body.login, req.body.bankAccount, req.body.bankAccountHolder); io.emit("message", data); res.status(200).json({}); }); @@ -148,6 +149,18 @@ app.post("/api/updateChoice", (req, res) => { res.status(200).json(data); }); +app.get("/api/qr", (req, res) => { + if (!req.query?.login || typeof req.query.login !== 'string') { + throw Error("Nebyl předán login"); + } + const img = getQr(req.query.login); + res.writeHead(200, { + 'Content-Type': 'image/png', + 'Content-Length': img.length + }); + res.end(img); +}); + io.on("connection", (socket) => { console.log(`New client connected: ${socket.id}`); diff --git a/server/src/qr.ts b/server/src/qr.ts new file mode 100644 index 0000000..2493bc1 --- /dev/null +++ b/server/src/qr.ts @@ -0,0 +1,98 @@ +import fs from "fs"; +import axios from "axios"; +import os from "os"; +import path from "path"; +import crypto from "crypto"; +import { formatDate } from "./utils"; + +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(); + +/** + * 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 { + // TODO validovat číslo účtu stejně jako na klientovi, pro případ že sem někdo pošle nesmysl + let prefix: string = ''; + let accountNumber: string = bankAccountNumber; + if (bankAccountNumber.indexOf('-') >= 0) { + const split = bankAccountNumber.split('-'); + prefix = split[0]; + accountNumber = split[1]; + } + prefix = prefix.padStart(6, '0'); + const split = accountNumber.split('/'); + accountNumber = split[0].padStart(10, '0'); + const bankCode = split[1].padStart(4, '0'); + let iban = `${bankCode}${prefix}${accountNumber}${COUNTRY_CODE}00`; + // Zatím napevno, nemá smysl řešit nic jiného než CZ + iban = iban.replace('C', '12').replace('Z', '35'); + const remainder = BigInt(iban) % BigInt(97); + const checkDigits = BigInt(98) - remainder; + iban = `${COUNTRY_CODE}${checkDigits.toString()}${bankCode}${prefix}${accountNumber}`; + if (iban.length !== 24) { + throw Error("Neplatná délka sestaveného IBAN: " + iban.length + ", očekáváno 24"); + } + 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); +} + +/** + * Vygeneruje, uloží a vrátí unikátní ID obrázku platebního QR kódu s danými parametry. + * + * @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 + */ +export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string): Promise { + // Zpráva pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků + if (message.indexOf('*') >= 0) { + message = message.replace('*', ''); + } + if (message.length > 60) { + message = message.substring(0, 60); + } + const payload = { + iban: convertBbanToIban(bankAccountNumber), + amount, + currency: CURRENCY_CODE, + message, + recipientName: bankAccountHolder, + 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; +} + +/** + * Vrátí obrázek s QR kódem, pokud existuje. + * + * @param customerName jméno uživatele + * @returns data obrázku + */ +export function getQr(customerName: string): Buffer { + const imgPath = createFilePath(createNameHash(customerName)); + return fs.readFileSync(imgPath); +} \ No newline at end of file diff --git a/server/src/service.ts b/server/src/service.ts index 921cb12..88ed6b3 100644 --- a/server/src/service.ts +++ b/server/src/service.ts @@ -1,7 +1,8 @@ -import {ClientData, Locations, Order, Pizza, PizzaDayState, PizzaOrder, PizzaSize, UdalostEnum} from "./types"; -import {db} from "./database"; -import {formatDate, getHumanDate, getIsWeekend} from "./utils"; -import {callNotifikace} from "./notifikace"; +import { ClientData, Locations, Order, Pizza, PizzaDayState, PizzaOrder, PizzaSize, UdalostEnum } from "./types"; +import { db } from "./database"; +import { formatDate, getHumanDate, getIsWeekend } from "./utils"; +import { callNotifikace } from "./notifikace"; +import { generateQr } from "./qr"; /** Vrátí dnešní datum, případně fiktivní datum pro účely vývoje a testování. */ function getToday(): Date { @@ -191,17 +192,18 @@ export function finishPizzaOrder(login: string) { } clientData.pizzaDay.state = PizzaDayState.ORDERED; db.set(today, clientData); - callNotifikace({input:{udalost:UdalostEnum.OBJEDNANA_PIZZA,user:clientData?.pizzaDay?.creator}}) + callNotifikace({ input: { udalost: UdalostEnum.OBJEDNANA_PIZZA, user: clientData?.pizzaDay?.creator } }) return clientData; } /** * Nastaví stav pizza day na "pizzy doručeny". + * Vygeneruje QR kódy pro všechny objednatele, pokud objednávající má vyplněné číslo účtu a jméno. * * @param login login uživatele * @returns aktuální data pro uživatele */ -export function finishPizzaDelivery(login: string) { +export function finishPizzaDelivery(login: string, bankAccount?: string, bankAccountHolder?: string) { const today = formatDate(getToday()); const clientData: ClientData = db.get(today); if (!clientData.pizzaDay) { @@ -214,6 +216,15 @@ export function finishPizzaDelivery(login: string) { throw Error("Pizza day není ve stavu " + PizzaDayState.ORDERED); } clientData.pizzaDay.state = PizzaDayState.DELIVERED; + + // Vygenerujeme QR kód, pokud k tomu máme data + if (bankAccount?.length && bankAccountHolder?.length) { + for (const order of clientData.pizzaDay.orders) { + let message = order.pizzaList.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', '); + const price = order.pizzaList.map(pizza => pizza.price).reduce((partial, a) => partial + a, 0); + generateQr(order.customer, bankAccount, bankAccountHolder, price, message).then(() => order.hasQr = true); + } + } db.set(today, clientData); return clientData; } diff --git a/server/src/types.ts b/server/src/types.ts index 0671901..2bbc844 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -33,6 +33,7 @@ export interface Order { customer: string, // jméno objednatele pizzaList: PizzaOrder[], // seznam objednaných pizz totalPrice: number, // celková cena všech objednaných pizz a krabic + hasQr?: boolean, // true, pokud je k objednávce vygenerován QR kód pro platbu } /** Stav pizza dne */