Files
yogibook_aury_new/public/class/ShopService.php
T
2026-10-14 10:21:55 +02:00

977 lines
45 KiB
PHP

<?php
/**
* ShopService
* Logica del negozio della scuola in un unico posto: catalogo, classi valide,
* posti liberi, situazione dell'allieva. Usata da:
* - shop.php (negozio dell'allieva nella webapp)
* - in seguito: checkout, API pubblica per WordPress (apiwp), app mobile
*
* Tutti i metodi ricevono $idschool esplicito: funziona anche senza sessione.
* Posti occupati = prenotazioni 'booked' + 'pending' della lezione (come BookingRules).
*/
if (!class_exists('DBHandlerSelect')) {
require_once __DIR__ . '/db-functions.php';
}
class ShopService
{
/** Giorni di calendario considerati per i posti delle classi fisse */
const LOOKAHEAD_DAYS = 120;
/** Lezioni singole/prova acquistabili nei prossimi N giorni */
const SINGLE_DAYS = 21;
/** Massimo di lezioni future lette per ogni classe */
const MAX_OCCURRENCES = 30;
const GIORNI = ['domenica', 'lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato'];
const MESI = [1 => 'gennaio', 'febbraio', 'marzo', 'aprile', 'maggio', 'giugno', 'luglio', 'agosto', 'settembre', 'ottobre', 'novembre', 'dicembre'];
/**
* Catalogo completo, pronto per json_encode (lo stesso formato servirà all'API WordPress).
*
* @param int $idschool
* @param int|null $userId allieva loggata (per prova già usata, tessera, pacchetti attivi)
* @param bool $onlineOnly true = solo prodotti "acquistabili online"
*/
public static function catalog(int $idschool, ?int $userId = null, bool $onlineOnly = true): array
{
$pdo = self::pdo();
// ---- Prodotti e opzioni
$st = $pdo->prepare(
"SELECT * FROM products WHERE idschool = ? AND active = 'Y'" . ($onlineOnly ? " AND visible_online = 'Y'" : "") . "
ORDER BY FIELD(kind, 'lessons', 'membership', 'voucher'), sort_order, title"
);
$st->execute([$idschool]);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
$variantsBy = [];
$st = $pdo->prepare("SELECT * FROM product_variants WHERE idschool = ? AND active = 'Y' ORDER BY sort_order, idvariant");
$st->execute([$idschool]);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $v) {
$variantsBy[(int) $v['idproduct']][] = $v;
}
$linksBy = [];
$st = $pdo->prepare("SELECT idproduct, idservice, idservicecategory FROM product_services WHERE idschool = ?");
$st->execute([$idschool]);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $l) {
$linksBy[(int) $l['idproduct']][] = $l;
}
$galleryBy = [];
$st = $pdo->prepare("SELECT idproduct, filename FROM product_images WHERE idschool = ? ORDER BY sort_order, idimage");
$st->execute([$idschool]);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $g) {
$galleryBy[(int) $g['idproduct']][] = $g['filename'];
}
$variantLabel = [];
$st = $pdo->prepare("SELECT v.idvariant, v.label, p.title FROM product_variants v JOIN products p ON p.idproduct = v.idproduct WHERE v.idschool = ?");
$st->execute([$idschool]);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
$variantLabel[(int) $r['idvariant']] = $r['title'] . ' · ' . $r['label'];
}
// ---- Classi della scuola
$showTeacher = SchoolSettings::get('show_teacher', 'N', $idschool) === 'Y';
$st = $pdo->prepare(
"SELECT s.idservice, s.servicename, s.day, s.time, s.category, s.maxcapacity, s.colorclass, s.classduration,
TRIM(CONCAT(COALESCE(em.nameemployee, ''), ' ', COALESCE(em.surnameemployee, ''))) AS teacher
FROM service s
LEFT JOIN employee em ON em.idemployee = s.idteacher AND em.idschool = s.idschool
WHERE s.idschool = ?"
);
$st->execute([$idschool]);
$servicesAll = [];
$byCategory = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $s) {
$servicesAll[(int) $s['idservice']] = $s;
$byCategory[(int) $s['category']][] = (int) $s['idservice'];
}
$st = $pdo->prepare("SELECT idservicecategory, namecategory FROM servicecategory WHERE idschool = ? ORDER BY namecategory");
$st->execute([$idschool]);
$catNames = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $c) {
$catNames[(int) $c['idservicecategory']] = $c['namecategory'];
}
// ---- Situazione dell'allieva
$user = self::userContext($idschool, $userId);
// ---- Costruzione prodotti
$products = [];
$usedSvcs = [];
$usedCats = [];
foreach ($rows as $p) {
$pid = (int) $p['idproduct'];
if (empty($variantsBy[$pid])) {
continue; // nessuna opzione attiva = non acquistabile
}
$svc = [];
foreach ($linksBy[$pid] ?? [] as $l) {
if (!empty($l['idservice']) && isset($servicesAll[(int) $l['idservice']])) {
$svc[] = (int) $l['idservice'];
}
if (!empty($l['idservicecategory'])) {
$svc = array_merge($svc, $byCategory[(int) $l['idservicecategory']] ?? []);
}
}
$svc = array_values(array_unique($svc));
if ($p['kind'] === 'lessons' && !$svc) {
continue; // pacchetto senza classi: non vendibile
}
$cats = [];
foreach ($svc as $sid) {
$cid = (int) $servicesAll[$sid]['category'];
if (isset($catNames[$cid])) {
$cats[$cid] = true;
}
}
$cats = array_keys($cats);
if ($p['kind'] === 'lessons') {
$usedSvcs = array_merge($usedSvcs, $svc);
$usedCats = array_merge($usedCats, $cats);
}
$variants = [];
foreach ($variantsBy[$pid] as $v) {
$variants[] = [
'id' => (int) $v['idvariant'],
'label' => $v['label'],
'lessons' => $v['lessons'] !== null ? (int) $v['lessons'] : null,
'price' => (float) $v['price'],
'compare' => $v['compare_price'] !== null ? (float) $v['compare_price'] : null,
'note' => $v['price_note'],
'vmode' => $v['validity_mode'],
'vvalue' => $v['validity_value'] !== null ? (int) $v['validity_value'] : null,
'vunit' => $v['validity_unit'],
'vstart' => $v['validity_start'],
'target' => $v['target_idvariant'] !== null ? (int) $v['target_idvariant'] : null,
'targetLabel' => $v['target_idvariant'] !== null ? ($variantLabel[(int) $v['target_idvariant']] ?? '') : '',
];
}
$mode = $p['kind'] === 'lessons' ? $p['booking_mode'] : $p['kind'];
$products[] = [
'id' => $pid,
'slug' => $p['slug'],
'kind' => $p['kind'],
'mode' => $mode, // fixed | free | single | membership | voucher
'group' => self::group($p), // per i filtri del catalogo
'slots' => (int) $p['fixed_slots'],
'weekly' => $p['weekly_limit'] !== null ? (int) $p['weekly_limit'] : null,
'maxres' => $p['max_reschedule'] !== null ? (int) $p['max_reschedule'] : null,
'once' => $p['once_per_student'] === 'Y',
'title' => $p['title'],
'subtitle' => (string) $p['subtitle'],
'badge' => (string) $p['badge'],
'cover' => (string) $p['cover_image'],
'gallery' => $galleryBy[$pid] ?? [],
'description' => (string) $p['description'], // HTML già ripulito al salvataggio
'suitable' => (string) $p['suitable_for'],
'requirements' => (string) $p['requirements'],
'pros' => self::lines($p['pros']),
'cons' => self::lines($p['cons']),
'services' => $svc,
'cats' => $cats,
'variants' => $variants,
'blocked' => ($p['once_per_student'] === 'Y' && in_array($pid, $user['usedProducts'], true)) ? 'used' : null,
];
}
// ---- Calendario e posti delle classi usate dai pacchetti
$services = self::servicesWithOccurrences($idschool, array_values(array_unique($usedSvcs)), $servicesAll, $showTeacher, $userId);
// ---- Discipline (categorie) presenti nel catalogo
$categories = [];
foreach (array_unique($usedCats) as $cid) {
$categories[] = ['id' => $cid, 'name' => $catNames[$cid]];
}
usort($categories, fn($a, $b) => strcasecmp($a['name'], $b['name']));
// ---- Date utili (tessere)
$seasonStart = max(1, min(12, (int) SchoolSettings::get('membership_season_start', 9, $idschool)));
$today = new DateTimeImmutable('today');
$y = (int) $today->format('Y');
$endYear = ((int) $today->format('n') >= $seasonStart) ? $y + 1 : $y;
$seasonEnd = (new DateTimeImmutable(sprintf('%04d-%02d-01', $endYear, $seasonStart)))->modify('-1 day');
// Prodotto tessera da proporre se serve
$user['membershipProduct'] = null;
foreach ($products as $p) {
if ($p['kind'] === 'membership') {
$user['membershipProduct'] = $p['id'];
break;
}
}
unset($user['usedProducts']);
return [
'today' => $today->format('Y-m-d'),
'yearEnd' => $y . '-12-31',
'seasonEnd' => $seasonEnd->format('Y-m-d'),
'singleDays' => self::SINGLE_DAYS,
'products' => $products,
'services' => (object) $services,
'categories' => $categories,
'user' => $user,
];
}
/** Gruppo del catalogo: single | fixed | free | membership | voucher */
public static function group(array $p): string
{
return $p['kind'] === 'lessons' ? (string) $p['booking_mode'] : (string) $p['kind'];
}
/**
* Situazione dell'allieva nella scuola: prodotti "una volta" già acquistati,
* tessera valida, pacchetti attivi.
*/
public static function userContext(int $idschool, ?int $userId): array
{
$required = SchoolSettings::get('membership_required', 'N', $idschool) === 'Y';
$ctx = [
'logged' => (bool) $userId,
'membershipRequired' => $required,
'hasMembership' => false,
'membershipUntil' => null,
'packages' => [],
'usedProducts' => [],
];
if (!$userId) {
return $ctx;
}
$pdo = self::pdo();
$st = $pdo->prepare(
"SELECT DISTINCT idproduct FROM orderbook
WHERE iduser = ? AND idschool = ? AND idproduct IS NOT NULL
AND (payment_status IS NULL OR payment_status NOT IN ('cancelled', 'refunded'))"
);
$st->execute([$userId, $idschool]);
$ctx['usedProducts'] = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
$st = $pdo->prepare(
"SELECT MAX(valid_to) FROM memberships
WHERE iduser = ? AND idschool = ? AND status = 'active' AND valid_from <= CURDATE() AND valid_to >= CURDATE()"
);
$st->execute([$userId, $idschool]);
$until = $st->fetchColumn();
$ctx['hasMembership'] = (bool) $until;
$ctx['membershipUntil'] = $until ?: null;
// Pacchetti attivi: non scaduti e con lezioni rimaste
$st = $pdo->prepare(
"SELECT ob.idorderbook, ob.product_name, ob.nticket, ob.quantityclass, ob.expireon, ob.payment_status,
s.servicename,
(SELECT COUNT(*) FROM bookingclass b
WHERE b.idorder = ob.idorderbook AND b.idschool = ob.idschool
AND b.status <> 'cancelled' AND b.is_gift = 'N') AS used
FROM orderbook ob
LEFT JOIN service s ON s.idservice = ob.idservice
WHERE ob.iduser = ? AND ob.idschool = ?
AND (ob.expireon IS NULL OR ob.expireon >= CURDATE())
AND (ob.payment_status IS NULL OR ob.payment_status NOT IN ('cancelled', 'refunded'))
ORDER BY ob.expireon IS NULL, ob.expireon"
);
$st->execute([$userId, $idschool]);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $o) {
$tot = (int) $o['nticket'] > 0 ? (int) $o['nticket'] : (int) $o['quantityclass'];
$left = $tot > 0 ? max(0, $tot - (int) $o['used']) : null;
if ($left === 0) {
continue;
}
$ctx['packages'][] = [
'title' => (string) ($o['product_name'] ?: $o['servicename'] ?: 'Pacchetto'),
'left' => $left,
'expire' => $o['expireon'],
'pending' => $o['payment_status'] === 'pending',
];
}
return $ctx;
}
/**
* Lezioni future delle classi indicate, con posti liberi.
* @return array idservice => [name, day, teacher, cap, cat, occ => [[id, date, time, label, free, mine]]]
*/
public static function servicesWithOccurrences(int $idschool, array $ids, array $servicesAll, bool $showTeacher, ?int $userId = null): array
{
$out = [];
foreach ($ids as $sid) {
if (!isset($servicesAll[$sid])) {
continue;
}
$s = $servicesAll[$sid];
$out[$sid] = [
'name' => $s['servicename'],
'day' => trim($s['day'] . ' ' . $s['time']),
'teacher' => $showTeacher ? trim((string) $s['teacher']) : '',
'cap' => ($s['maxcapacity'] !== null && (int) $s['maxcapacity'] > 0) ? (int) $s['maxcapacity'] : null,
'cat' => (int) $s['category'],
'color' => preg_match('/^#[0-9a-fA-F]{6}$/', (string) ($s['colorclass'] ?? '')) ? strtolower($s['colorclass']) : null,
'minutes' => (int) round((float) ($s['classduration'] ?? 0) * ((float) ($s['classduration'] ?? 0) <= 5 ? 60 : 1)),
'occ' => [],
];
}
if (!$out) {
return $out;
}
$ph = implode(',', array_fill(0, count($out), '?'));
$st = self::pdo()->prepare(
"SELECT ss.idserviceschedule, ss.idservice, ss.dateschedule,
(SELECT COUNT(*) FROM bookingclass b
WHERE b.idserviceschedule = ss.idserviceschedule AND b.idschool = ss.idschool
AND b.status IN ('booked', 'pending')) AS taken
" . ($userId ? ", (SELECT COUNT(*) FROM bookingclass b2
WHERE b2.idserviceschedule = ss.idserviceschedule AND b2.idschool = ss.idschool
AND b2.iduser = ? AND b2.status <> 'cancelled') AS mine" : ", 0 AS mine") . "
FROM serviceschedule ss
WHERE ss.idschool = ?
AND ss.idservice IN ($ph)
AND ss.dateschedule > NOW()
AND ss.dateschedule < CURDATE() + INTERVAL " . (int) self::LOOKAHEAD_DAYS . " DAY
AND NOT EXISTS (SELECT 1 FROM dayoff d WHERE d.idschool = ss.idschool AND d.dayoffdate = DATE(ss.dateschedule))
ORDER BY ss.dateschedule"
);
$args = $userId ? [$userId, $idschool] : [$idschool];
$st->execute(array_merge($args, array_keys($out)));
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $o) {
$sid = (int) $o['idservice'];
if (count($out[$sid]['occ']) >= self::MAX_OCCURRENCES) {
continue;
}
$ts = strtotime($o['dateschedule']);
$cap = $out[$sid]['cap'];
$out[$sid]['occ'][] = [
'id' => (int) $o['idserviceschedule'],
'date' => date('Y-m-d', $ts),
'time' => date('H:i', $ts),
'label' => self::dateLabel($ts),
'free' => $cap === null ? null : max(0, $cap - (int) $o['taken']),
'mine' => (int) $o['mine'] > 0,
];
}
return $out;
}
/** "Martedì 13 ottobre · 18:00" */
public static function dateLabel(int $ts): string
{
return ucfirst(self::GIORNI[(int) date('w', $ts)]) . ' ' . date('j', $ts) . ' ' . self::MESI[(int) date('n', $ts)] . ' · ' . date('H:i', $ts);
}
public static function lines(?string $t): array
{
return array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', (string) $t))));
}
/* ==================================================================
* CHECKOUT
* ================================================================ */
/** Metodi di pagamento attivi per la scuola */
public static function paymentMethods(int $idschool): array
{
$bank = SchoolSettings::get('bank_enabled', 'N', $idschool) === 'Y'
&& trim((string) SchoolSettings::get('bank_holder', '', $idschool)) !== ''
&& trim((string) SchoolSettings::get('bank_iban', '', $idschool)) !== '';
// La carta arriverà con il collegamento Stripe Connect della scuola
$card = SchoolSettings::get('stripe_charges_enabled', 'N', $idschool) === 'Y'
&& trim((string) SchoolSettings::get('stripe_account_id', '', $idschool)) !== '';
return ['bank' => $bank, 'card' => $card];
}
/**
* Ricontrolla lato server la scelta arrivata dal negozio.
* $in: idvariant, services (array di idservice), idschedule, recipient_name, recipient_email, recipient_message
* @return array product, variant, services, schedule, recipient
* @throws RuntimeException con messaggio per l'allieva
*/
public static function selection(int $idschool, int $userId, array $in): array
{
$pdo = self::pdo();
$st = $pdo->prepare(
"SELECT v.*, p.kind, p.title, p.booking_mode, p.fixed_slots, p.weekly_limit, p.max_reschedule,
p.once_per_student, p.active AS p_active, p.visible_online, p.slug
FROM product_variants v
JOIN products p ON p.idproduct = v.idproduct AND p.idschool = v.idschool
WHERE v.idvariant = ? AND v.idschool = ?"
);
$st->execute([(int) ($in['idvariant'] ?? 0), $idschool]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row || $row['active'] !== 'Y' || $row['p_active'] !== 'Y' || $row['visible_online'] !== 'Y') {
throw new RuntimeException('Questo prodotto non è più disponibile.');
}
$pid = (int) $row['idproduct'];
$mode = $row['kind'] === 'lessons' ? $row['booking_mode'] : $row['kind'];
if ($row['once_per_student'] === 'Y') {
$st = $pdo->prepare(
"SELECT 1 FROM orderbook WHERE iduser = ? AND idschool = ? AND idproduct = ?
AND (payment_status IS NULL OR payment_status NOT IN ('cancelled', 'refunded')) LIMIT 1"
);
$st->execute([$userId, $idschool, $pid]);
if ($st->fetchColumn()) {
throw new RuntimeException('Hai già utilizzato questo prodotto: si può acquistare una sola volta.');
}
}
$allowed = self::productServiceIds($idschool, $pid);
$sel = [
'product' => $row,
'mode' => $mode,
'variant' => $row,
'services' => [],
'schedule' => null,
'recipient' => null,
];
if ($mode === 'fixed') {
$slots = (int) $row['fixed_slots'] === 2 ? 2 : 1;
$ids = array_values(array_unique(array_filter(array_map('intval', (array) ($in['services'] ?? [])))));
if (count($ids) !== $slots) {
throw new RuntimeException($slots === 2 ? 'Scegli le tue due classi.' : 'Scegli la tua classe.');
}
$weeks = $row['lessons'] ? (int) ceil((int) $row['lessons'] / $slots) : 8;
foreach ($ids as $sid) {
if (!in_array($sid, $allowed, true)) {
throw new RuntimeException('Una delle classi scelte non è inclusa in questo prodotto.');
}
$free = self::fixedFree($idschool, $sid, $weeks);
if ($free === -1) {
throw new RuntimeException('Una delle classi scelte non ha lezioni in calendario.');
}
if ($free === 0) {
throw new RuntimeException('Una delle classi scelte è al completo. Torna indietro e scegline un\'altra.');
}
}
$sel['services'] = $ids;
}
if ($mode === 'single') {
$st = $pdo->prepare(
"SELECT ss.*, s.servicename, s.maxcapacity FROM serviceschedule ss
JOIN service s ON s.idservice = ss.idservice
WHERE ss.idserviceschedule = ? AND ss.idschool = ? AND ss.dateschedule > NOW()"
);
$st->execute([(int) ($in['idschedule'] ?? 0), $idschool]);
$sch = $st->fetch(PDO::FETCH_ASSOC);
if (!$sch || !in_array((int) $sch['idservice'], $allowed, true)) {
throw new RuntimeException('La lezione scelta non è più disponibile.');
}
$st = $pdo->prepare("SELECT 1 FROM bookingclass WHERE idserviceschedule = ? AND iduser = ? AND idschool = ? AND status <> 'cancelled' LIMIT 1");
$st->execute([(int) $sch['idserviceschedule'], $userId, $idschool]);
if ($st->fetchColumn()) {
throw new RuntimeException('Sei già iscritta a questa lezione.');
}
if ((int) $sch['maxcapacity'] > 0 && self::occupied($idschool, (int) $sch['idserviceschedule']) >= (int) $sch['maxcapacity']) {
throw new RuntimeException('La lezione scelta è al completo. Torna indietro e scegli un\'altra data.');
}
$sel['schedule'] = $sch;
}
if ($mode === 'voucher') {
$name = trim((string) ($in['recipient_name'] ?? ''));
$email = trim((string) ($in['recipient_email'] ?? ''));
if ($name === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Indica nome ed email di chi riceve il buono.');
}
if (empty($row['target_idvariant'])) {
throw new RuntimeException('Questo buono non è configurato correttamente. Contatta la scuola.');
}
$sel['recipient'] = [
'name' => mb_substr($name, 0, 191),
'email' => mb_substr($email, 0, 191),
'message' => mb_substr(trim((string) ($in['recipient_message'] ?? '')), 0, 500),
];
}
return $sel;
}
/** idservice validi per un prodotto (classi singole + categorie intere) */
public static function productServiceIds(int $idschool, int $idproduct): array
{
$st = self::pdo()->prepare(
"SELECT DISTINCT s.idservice
FROM product_services ps
JOIN service s ON s.idschool = ps.idschool
AND (s.idservice = ps.idservice OR (ps.idservicecategory IS NOT NULL AND s.category = ps.idservicecategory))
WHERE ps.idproduct = ? AND ps.idschool = ?"
);
$st->execute([$idproduct, $idschool]);
return array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
}
public static function occupied(int $idschool, int $idschedule): int
{
$st = self::pdo()->prepare(
"SELECT COUNT(*) FROM bookingclass WHERE idserviceschedule = ? AND idschool = ? AND status IN ('booked', 'pending')"
);
$st->execute([$idschedule, $idschool]);
return (int) $st->fetchColumn();
}
/**
* Posti liberi minimi sulle prossime $weeks lezioni di una classe.
* null = senza limite, -1 = nessuna lezione in calendario
*/
public static function fixedFree(int $idschool, int $idservice, int $weeks): ?int
{
$pdo = self::pdo();
$st = $pdo->prepare("SELECT maxcapacity FROM service WHERE idservice = ? AND idschool = ?");
$st->execute([$idservice, $idschool]);
$cap = (int) $st->fetchColumn();
$st = $pdo->prepare(
"SELECT (SELECT COUNT(*) FROM bookingclass b WHERE b.idserviceschedule = ss.idserviceschedule
AND b.idschool = ss.idschool AND b.status IN ('booked', 'pending')) AS taken
FROM serviceschedule ss
WHERE ss.idservice = ? AND ss.idschool = ? AND ss.dateschedule > NOW()
AND NOT EXISTS (SELECT 1 FROM dayoff d WHERE d.idschool = ss.idschool AND d.dayoffdate = DATE(ss.dateschedule))
ORDER BY ss.dateschedule
LIMIT " . max(1, $weeks)
);
$st->execute([$idservice, $idschool]);
$taken = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
if (!$taken) {
return -1;
}
return $cap > 0 ? max(0, $cap - max($taken)) : null;
}
/**
* Verifica un codice sconto per l'opzione scelta (l'utente è sempre quello della sessione).
* @return array idcoupon, code, discount
* @throws RuntimeException
*/
public static function checkCoupon(int $idschool, int $userId, string $code, array $variant): array
{
$code = strtoupper(trim($code));
$price = (float) $variant['price'];
$pdo = self::pdo();
$st = $pdo->prepare("SELECT * FROM coupons WHERE idschool = ? AND code = ? AND active = 'Y' LIMIT 1");
$st->execute([$idschool, $code]);
$c = $st->fetch(PDO::FETCH_ASSOC);
$today = date('Y-m-d');
if (!$c || ($c['valid_from'] && $today < $c['valid_from']) || ($c['valid_to'] && $today > $c['valid_to'])) {
throw new RuntimeException('Codice non valido o scaduto.');
}
$st = $pdo->prepare("SELECT idproduct, idvariant FROM coupon_products WHERE idcoupon = ? AND idschool = ?");
$st->execute([(int) $c['idcoupon'], $idschool]);
$limits = $st->fetchAll(PDO::FETCH_ASSOC);
if ($limits) {
$ok = false;
foreach ($limits as $l) {
if (
(int) $l['idvariant'] === (int) $variant['idvariant']
|| ((int) $l['idproduct'] === (int) $variant['idproduct'] && empty($l['idvariant']))
) {
$ok = true;
}
}
if (!$ok) {
throw new RuntimeException('Questo codice non vale su questo prodotto.');
}
}
if ($c['min_amount'] !== null && $price < (float) $c['min_amount']) {
throw new RuntimeException('Codice valido per acquisti da ' . number_format((float) $c['min_amount'], 2, ',', '.') . ' €.');
}
if ($c['max_uses'] !== null) {
$st = $pdo->prepare("SELECT COUNT(*) FROM coupon_redemptions WHERE idcoupon = ? AND idschool = ?");
$st->execute([(int) $c['idcoupon'], $idschool]);
if ((int) $st->fetchColumn() >= (int) $c['max_uses']) {
throw new RuntimeException('Codice esaurito.');
}
}
if ($c['max_uses_per_user'] !== null) {
$st = $pdo->prepare("SELECT COUNT(*) FROM coupon_redemptions WHERE idcoupon = ? AND idschool = ? AND iduser = ?");
$st->execute([(int) $c['idcoupon'], $idschool, $userId]);
if ((int) $st->fetchColumn() >= (int) $c['max_uses_per_user']) {
throw new RuntimeException('Hai già usato questo codice.');
}
}
if ($c['new_students_only'] === 'Y') {
$st = $pdo->prepare("SELECT COUNT(*) FROM orderbook WHERE iduser = ? AND idschool = ?");
$st->execute([$userId, $idschool]);
if ((int) $st->fetchColumn() > 0) {
throw new RuntimeException('Codice riservato alle nuove allieve.');
}
}
$value = (float) $c['discount_value'];
$discount = $c['discount_type'] === 'fixed' ? min($value, $price) : round($price * min($value, 100) / 100, 2);
return ['idcoupon' => (int) $c['idcoupon'], 'code' => $c['code'], 'discount' => $discount];
}
/** Scadenza della tessera per un'opzione (Y-m-d o null) */
public static function membershipEnd(int $idschool, array $variant, ?string $from = null): ?string
{
$from = $from ?? date('Y-m-d');
if ($variant['validity_mode'] === 'calendar_year') {
return substr($from, 0, 4) . '-12-31';
}
if ($variant['validity_mode'] === 'season') {
$start = max(1, min(12, (int) SchoolSettings::get('membership_season_start', 9, $idschool)));
$y = (int) substr($from, 0, 4);
$endYear = ((int) substr($from, 5, 2) >= $start) ? $y + 1 : $y;
return (new DateTimeImmutable(sprintf('%04d-%02d-01', $endYear, $start)))->modify('-1 day')->format('Y-m-d');
}
if (!empty($variant['validity_value'])) {
return self::addPeriod($from, (int) $variant['validity_value'], (string) $variant['validity_unit'])->modify('-1 day')->format('Y-m-d');
}
return null;
}
public static function addPeriod(string $from, int $n, string $unit): DateTimeImmutable
{
$d = new DateTimeImmutable($from);
if ($unit === 'days') {
return $d->modify('+' . $n . ' days');
}
if ($unit === 'weeks') {
return $d->modify('+' . ($n * 7) . ' days');
}
// mesi senza sforare (31 gennaio + 1 mese = fine febbraio)
$y = (int) $d->format('Y');
$m = (int) $d->format('n') + $n;
$y += intdiv($m - 1, 12);
$m = (($m - 1) % 12) + 1;
$last = (int) (new DateTimeImmutable(sprintf('%04d-%02d-01', $y, $m)))->format('t');
return $d->setDate($y, $m, min((int) $d->format('j'), $last));
}
/**
* Crea l'ordine (orderbook) e tutto ciò che ne deriva, in un'unica transazione.
* - classe fissa: orderbook_services (le prenotazioni le crea la scuola alla propagazione)
* - lezione singola: prenotazione subito (bonifico = 'pending', carta = 'booked')
* - buono regalo: gift_vouchers in attesa del pagamento
* - tessera: la riga in memberships nasce alla conferma del pagamento
* @param string $method 'bank' | 'stripe'
* @return int idorderbook
*/
public static function createOrder(int $idschool, int $userId, array $sel, string $method, ?array $coupon): int
{
$pdo = self::pdo();
$p = $sel['product'];
$v = $sel['variant'];
$mode = $sel['mode'];
$paid = $method === 'stripe';
$st = $pdo->prepare("SELECT first_name, last_name, email FROM auth_users WHERE id = ?");
$st->execute([$userId]);
$u = $st->fetch(PDO::FETCH_ASSOC) ?: ['first_name' => '', 'last_name' => '', 'email' => ''];
$price = round((float) $v['price'], 2);
$discount = $coupon ? min(round((float) $coupon['discount'], 2), $price) : 0.0;
$total = round($price - $discount, 2);
$lessons = $v['lessons'] !== null ? (int) $v['lessons'] : null;
$today = date('Y-m-d');
// Scadenza
$expire = null;
if ($mode === 'free' && $v['validity_mode'] === 'duration' && $v['validity_value'] && $v['validity_start'] === 'purchase') {
$expire = self::addPeriod($today, (int) $v['validity_value'], (string) $v['validity_unit'])->format('Y-m-d');
} elseif ($mode === 'single' && $sel['schedule']) {
$expire = substr($sel['schedule']['dateschedule'], 0, 10);
} elseif ($mode === 'membership') {
$expire = self::membershipEnd($idschool, $v, $today);
}
// Recuperi: dal prodotto, altrimenti dai parametri della scuola per quel numero di lezioni
$maxres = $p['max_reschedule'] !== null ? (int) $p['max_reschedule'] : null;
if ($maxres === null && $lessons && in_array($mode, ['fixed', 'free'], true)) {
$st = $pdo->prepare("SELECT reschedulenumber FROM expiryparameter WHERE quantityclass = ? AND idschool = ? LIMIT 1");
$st->execute([$lessons, $idschool]);
$r = $st->fetchColumn();
$maxres = $r !== false ? (int) $r : null;
}
if ($mode === 'single') {
$maxres = 0;
}
$idservice = null;
if ($mode === 'fixed') {
$idservice = $sel['services'][0];
} elseif ($mode === 'single') {
$idservice = (int) $sel['schedule']['idservice'];
}
$snapshot = [
'mode' => $mode,
'fixed_slots' => (int) $p['fixed_slots'],
'weekly_limit' => $p['weekly_limit'] !== null ? (int) $p['weekly_limit'] : null,
'max_reschedule' => $maxres,
'lessons' => $lessons,
'validity_mode' => $v['validity_mode'],
'validity_value' => $v['validity_value'] !== null ? (int) $v['validity_value'] : null,
'validity_unit' => $v['validity_unit'],
'validity_start' => $v['validity_start'],
'services' => $mode === 'free' || $mode === 'single' ? self::productServiceIds($idschool, (int) $p['idproduct']) : $sel['services'],
'price' => $price,
];
$pdo->beginTransaction();
try {
// Lezione singola: blocca la riga della lezione e ricontrolla il posto
if ($mode === 'single') {
$st = $pdo->prepare("SELECT ss.idserviceschedule, s.maxcapacity FROM serviceschedule ss JOIN service s ON s.idservice = ss.idservice WHERE ss.idserviceschedule = ? AND ss.idschool = ? FOR UPDATE");
$st->execute([(int) $sel['schedule']['idserviceschedule'], $idschool]);
$lock = $st->fetch(PDO::FETCH_ASSOC);
if (!$lock || ((int) $lock['maxcapacity'] > 0 && self::occupied($idschool, (int) $lock['idserviceschedule']) >= (int) $lock['maxcapacity'])) {
throw new RuntimeException('Nel frattempo la lezione si è riempita. Scegli un\'altra data.');
}
}
$pdo->prepare(
"INSERT INTO orderbook
(order_date_created, order_billing_email, order_total, product_name, line_total, quantity,
idservice, is_openflexy, iduser, nticket, expireon, status, quantityclass,
first_name, last_name, maxreschedule, reprogrammed, idschool,
idproduct, idvariant, booking_mode, weekly_limit, rules_snapshot, source, payment_status,
amount, discount_amount, idcoupon)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)->execute([
$today,
$u['email'],
number_format($total, 2, '.', ''),
mb_substr($p['title'] . ' · ' . $v['label'], 0, 200),
number_format($total, 2, '.', ''),
$idservice,
$mode === 'free' ? 'Y' : 'N',
$userId,
$lessons,
$expire,
$paid ? ($mode === 'fixed' ? 'pending' : 'completed') : 'on-hold',
$lessons,
$u['first_name'],
$u['last_name'],
$maxres,
$idschool,
(int) $p['idproduct'],
(int) $v['idvariant'],
in_array($mode, ['fixed', 'free', 'single'], true) ? $mode : null,
$p['weekly_limit'] !== null ? (int) $p['weekly_limit'] : null,
json_encode($snapshot, JSON_UNESCAPED_UNICODE),
$method,
$paid ? 'paid' : 'pending',
$total,
$discount > 0 ? $discount : null,
$coupon ? (int) $coupon['idcoupon'] : null,
]);
$idorder = (int) $pdo->lastInsertId();
if ($mode === 'fixed') {
$ins = $pdo->prepare("INSERT INTO orderbook_services (idorderbook, idschool, idservice, slot) VALUES (?, ?, ?, ?)");
foreach ($sel['services'] as $i => $sid) {
$ins->execute([$idorder, $idschool, $sid, $i + 1]);
}
}
if ($mode === 'single') {
$pdo->prepare(
"INSERT INTO bookingclass (idserviceschedule, bookingstart, status, idservice, iduser, idorder, cancellation_token, idschool)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
)->execute([
(int) $sel['schedule']['idserviceschedule'],
$sel['schedule']['dateschedule'],
$paid ? 'booked' : 'pending',
(int) $sel['schedule']['idservice'],
$userId,
$idorder,
bin2hex(random_bytes(16)),
$idschool,
]);
}
if ($mode === 'voucher') {
$codeSt = $pdo->prepare("SELECT 1 FROM gift_vouchers WHERE idschool = ? AND code = ?");
do {
$code = 'YB-' . self::randomCode(4) . '-' . self::randomCode(4);
$codeSt->execute([$idschool, $code]);
} while ($codeSt->fetchColumn());
$vExpire = $v['validity_value'] ? self::addPeriod($today, (int) $v['validity_value'], (string) $v['validity_unit'])->format('Y-m-d') : null;
$pdo->prepare(
"INSERT INTO gift_vouchers (idschool, code, idvariant, buyer_iduser, buyer_name, buyer_email, recipient_name, recipient_email,
message, source, amount, expires_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)->execute([
$idschool,
$code,
(int) $v['target_idvariant'],
$userId,
trim($u['first_name'] . ' ' . $u['last_name']),
$u['email'],
$sel['recipient']['name'],
$sel['recipient']['email'],
$sel['recipient']['message'] ?: null,
$method,
$total,
$vExpire,
$paid ? 'active' : 'pending',
]);
// collega il buono all'ordine (per attivarlo alla conferma del pagamento)
$pdo->prepare("UPDATE orderbook SET cod = ? WHERE idorderbook = ?")->execute([$code, $idorder]);
}
if ($mode === 'membership' && $paid) {
$pdo->prepare("INSERT INTO memberships (idschool, iduser, idorderbook, valid_from, valid_to, status) VALUES (?, ?, ?, ?, ?, 'active')")
->execute([$idschool, $userId, $idorder, $today, $expire ?? '2099-12-31']);
}
if ($coupon) {
$pdo->prepare("INSERT INTO coupon_redemptions (idcoupon, idschool, iduser, idorderbook, discount_amount) VALUES (?, ?, ?, ?, ?)")
->execute([(int) $coupon['idcoupon'], $idschool, $userId, $idorder, $discount]);
}
$pdo->commit();
return $idorder;
} catch (Throwable $ex) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $ex;
}
}
/** Dati del bonifico per un ordine: intestatario, IBAN, banca, causale, scadenza */
public static function bankInfo(int $idschool, array $order): array
{
$tpl = trim((string) SchoolSettings::get('bank_reference', '', $idschool)) ?: 'Ordine {ordine} - {nome}';
$iban = strtoupper(preg_replace('/\s+/', '', (string) SchoolSettings::get('bank_iban', '', $idschool)));
$days = max(1, (int) SchoolSettings::get('bank_due_days', 5, $idschool));
$created = $order['order_date_created'] ?: date('Y-m-d');
return [
'holder' => (string) SchoolSettings::get('bank_holder', '', $idschool),
'iban' => trim(chunk_split($iban, 4, ' ')),
'bank' => (string) SchoolSettings::get('bank_name', '', $idschool),
'reference' => mb_substr(strtr($tpl, ['{ordine}' => (string) $order['idorderbook'], '{nome}' => trim($order['first_name'] . ' ' . $order['last_name'])]), 0, 140),
'due' => date('Y-m-d', strtotime($created . ' +' . $days . ' days')),
];
}
/**
* La scuola conferma il pagamento (bonifico ricevuto) di un ordine in attesa.
* - ordine pagato
* - lezione singola: la prenotazione passa da 'pending' a 'booked'
* - tessera: nasce la riga in memberships
* - buono regalo: diventa attivo
* Idempotente: false se l'ordine non è (più) in attesa.
*/
public static function markPaid(int $idschool, int $idorder): bool
{
$pdo = self::pdo();
$pdo->beginTransaction();
try {
$st = $pdo->prepare(
"SELECT ob.*, p.kind FROM orderbook ob LEFT JOIN products p ON p.idproduct = ob.idproduct
WHERE ob.idorderbook = ? AND ob.idschool = ? FOR UPDATE"
);
$st->execute([$idorder, $idschool]);
$o = $st->fetch(PDO::FETCH_ASSOC);
if (!$o || $o['payment_status'] !== 'pending') {
$pdo->rollBack();
return false;
}
// Classe fissa: 'pending' = da propagare (stesso significato degli ordini storici)
$pdo->prepare("UPDATE orderbook SET payment_status = 'paid', status = ? WHERE idorderbook = ? AND idschool = ?")
->execute([$o['booking_mode'] === 'fixed' ? 'pending' : 'completed', $idorder, $idschool]);
if ($o['booking_mode'] === 'single') {
$pdo->prepare("UPDATE bookingclass SET status = 'booked' WHERE idorder = ? AND idschool = ? AND status = 'pending'")
->execute([$idorder, $idschool]);
}
if ($o['kind'] === 'membership') {
$pdo->prepare("INSERT INTO memberships (idschool, iduser, idorderbook, valid_from, valid_to, status) VALUES (?, ?, ?, CURDATE(), ?, 'active')")
->execute([$idschool, (int) $o['iduser'], $idorder, $o['expireon'] ?: '2099-12-31']);
}
if (!empty($o['cod'])) {
$pdo->prepare("UPDATE gift_vouchers SET status = 'active' WHERE idschool = ? AND code = ? AND status = 'pending'")
->execute([$idschool, $o['cod']]);
}
$pdo->commit();
return true;
} catch (Throwable $ex) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $ex;
}
}
/**
* Annulla un ordine in attesa di pagamento (dall'allieva o dalla scuola):
* libera il posto della lezione singola, annulla il buono e l'uso del coupon.
* $userId: se indicato, solo se l'ordine è suo.
*/
public static function cancelPending(int $idschool, int $idorder, ?int $userId = null): bool
{
$pdo = self::pdo();
$pdo->beginTransaction();
try {
$sql = "SELECT * FROM orderbook WHERE idorderbook = ? AND idschool = ?" . ($userId ? " AND iduser = ?" : "") . " FOR UPDATE";
$args = $userId ? [$idorder, $idschool, $userId] : [$idorder, $idschool];
$st = $pdo->prepare($sql);
$st->execute($args);
$o = $st->fetch(PDO::FETCH_ASSOC);
if (!$o || $o['payment_status'] !== 'pending') {
$pdo->rollBack();
return false;
}
$pdo->prepare("UPDATE orderbook SET payment_status = 'cancelled', status = 'cancelled' WHERE idorderbook = ? AND idschool = ?")
->execute([$idorder, $idschool]);
$pdo->prepare("UPDATE bookingclass SET status = 'cancelled' WHERE idorder = ? AND idschool = ? AND status <> 'cancelled'")
->execute([$idorder, $idschool]);
$pdo->prepare("DELETE FROM coupon_redemptions WHERE idorderbook = ? AND idschool = ?")->execute([$idorder, $idschool]);
if (!empty($o['cod'])) {
$pdo->prepare("UPDATE gift_vouchers SET status = 'cancelled' WHERE idschool = ? AND code = ? AND status = 'pending'")
->execute([$idschool, $o['cod']]);
}
$pdo->commit();
return true;
} catch (Throwable $ex) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $ex;
}
}
/** Ordini su cui la scuola deve agire: da pagare + da propagare (contatore in sidebar) */
public static function pendingCount(int $idschool): int
{
$st = self::pdo()->prepare(
"SELECT COUNT(*) FROM orderbook
WHERE idschool = ?
AND (payment_status = 'pending'
OR (status = 'pending' AND (payment_status IS NULL OR payment_status = 'paid')))"
);
$st->execute([$idschool]);
return (int) $st->fetchColumn();
}
private static function randomCode(int $len): string
{
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$out = '';
for ($i = 0; $i < $len; $i++) {
$out .= $chars[random_int(0, strlen($chars) - 1)];
}
return $out;
}
private static function pdo(): PDO
{
return DBHandlerSelect::getInstance()->getConnection();
}
}