Files
yogibook_aury_new/public/class/PlatformBilling.php
T
2026-10-08 20:20:39 +02:00

607 lines
24 KiB
PHP

<?php
/**
* PlatformBilling
* Abbonamento delle scuole al portale YogiBook.
*
* - Impostazioni del superadmin (tabella platform_settings, chiave/valore)
* - Piani (platform_plans) e stato dell'abbonamento di una scuola
* - Ordini di rinnovo (platform_orders): creazione, pagamento, annullo
* - Stripe Checkout (chiamate API dirette, senza librerie) e verifica webhook
*
* Le chiavi Stripe stanno SOLO nel .env:
* STRIPE_SECRET_KEY=sk_test_... (o sk_live_...)
* STRIPE_WEBHOOK_SECRET=whsec_...
* Funziona anche senza sessione (webhook): passare sempre gli ID espliciti.
*/
if (!class_exists('DBHandlerSelect')) {
require_once __DIR__ . '/db-functions.php';
}
class PlatformBilling
{
/** Impostazioni ammesse e valori predefiniti */
const DEFAULTS = [
// Dati del fornitore (per ricevute e riepiloghi)
'company_name' => '',
'company_vat' => '',
'company_address' => '',
'company_email' => '',
// Regole
'vat_rate' => '22',
'trial_days' => '30',
'reminder_days' => '15',
'grace_days' => '7',
// Carta
'card_enabled' => 'N',
// Bonifico
'bank_enabled' => 'N',
'bank_holder' => '',
'bank_iban' => '',
'bank_name' => '',
'bank_reference' => 'Abbonamento YogiBook {ordine} - {scuola}',
'bank_due_days' => '7',
];
const CYCLES = ['month' => 'Mensile', 'year' => 'Annuale'];
const METHODS = ['stripe' => 'Carta', 'bank' => 'Bonifico', 'manual' => 'Manuale'];
const STATUSES = ['pending' => 'In attesa', 'paid' => 'Pagato', 'cancelled' => 'Annullato', 'refunded' => 'Rimborsato'];
private static $settings = null;
private static $env = null;
/* ==================================================================
* Impostazioni ed env
* ================================================================ */
public static function pdo(): PDO
{
return DBHandlerSelect::getInstance()->getConnection();
}
/** Valore dal .env (o dalle variabili d'ambiente). Stringa vuota se assente. */
public static function env(string $key): string
{
$v = getenv($key);
if ($v !== false && $v !== '') {
return (string) $v;
}
if (!empty($_ENV[$key])) {
return (string) $_ENV[$key];
}
if (!empty($_SERVER[$key]) && is_string($_SERVER[$key])) {
return (string) $_SERVER[$key];
}
if (self::$env === null) {
self::$env = [];
foreach ([dirname(__DIR__, 2) . '/.env', dirname(__DIR__) . '/.env'] as $file) {
if (!is_readable($file)) {
continue;
}
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#' || strpos($line, '=') === false) {
continue;
}
[$k, $val] = array_map('trim', explode('=', $line, 2));
if (strlen($val) >= 2 && ($val[0] === '"' || $val[0] === "'") && substr($val, -1) === $val[0]) {
$val = substr($val, 1, -1);
}
self::$env[$k] = $val;
}
break;
}
}
return (string) (self::$env[$key] ?? '');
}
public static function all(): array
{
if (self::$settings === null) {
self::$settings = self::DEFAULTS;
$rows = self::pdo()->query("SELECT setting_key, setting_value FROM platform_settings")->fetchAll(PDO::FETCH_KEY_PAIR);
foreach ($rows as $k => $v) {
if (array_key_exists($k, self::DEFAULTS)) {
self::$settings[$k] = (string) $v;
}
}
}
return self::$settings;
}
public static function get(string $key, $default = null)
{
$all = self::all();
return $all[$key] ?? $default;
}
/** Salva solo le chiavi ammesse */
public static function save(array $data): void
{
$st = self::pdo()->prepare(
"INSERT INTO platform_settings (setting_key, setting_value) VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)"
);
foreach ($data as $k => $v) {
if (array_key_exists($k, self::DEFAULTS)) {
$st->execute([$k, $v === null ? null : (string) $v]);
}
}
self::$settings = null;
}
public static function appUrl(): string
{
return rtrim(self::env('APP_URL'), '/');
}
/** IBAN normalizzato se valido (controllo mod 97), altrimenti null */
public static function iban(?string $v): ?string
{
$v = strtoupper(preg_replace('/\s+/', '', (string) $v));
if (!preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/', $v)) {
return null;
}
$num = '';
foreach (str_split(substr($v, 4) . substr($v, 0, 4)) as $ch) {
$num .= ctype_alpha($ch) ? (string) (ord($ch) - 55) : $ch;
}
$rest = 0;
foreach (str_split($num, 7) as $chunk) {
$rest = (int) (($rest . $chunk) % 97);
}
return $rest === 1 ? $v : null;
}
public static function ibanFormat(?string $v): string
{
return trim(chunk_split((string) $v, 4, ' '));
}
public static function money($v): string
{
return number_format((float) $v, 2, ',', '.') . ' €';
}
/* ==================================================================
* Metodi di pagamento disponibili
* ================================================================ */
public static function stripeSecret(): string
{
return self::env('STRIPE_SECRET_KEY');
}
/** 'test', 'live' oppure '' se la chiave manca */
public static function stripeMode(): string
{
$k = self::stripeSecret();
if (strpos($k, 'sk_live_') === 0 || strpos($k, 'rk_live_') === 0) {
return 'live';
}
if (strpos($k, 'sk_test_') === 0 || strpos($k, 'rk_test_') === 0) {
return 'test';
}
return '';
}
public static function cardAvailable(): bool
{
return self::get('card_enabled') === 'Y' && self::stripeMode() !== '';
}
public static function bankAvailable(): bool
{
return self::get('bank_enabled') === 'Y' && self::get('bank_holder') !== '' && self::iban(self::get('bank_iban')) !== null;
}
/* ==================================================================
* Piani
* ================================================================ */
public static function plans(bool $activeOnly = true): array
{
$sql = "SELECT * FROM platform_plans" . ($activeOnly ? " WHERE active = 'Y'" : "") . " ORDER BY sort_order, name";
return self::pdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}
public static function plan(int $idplan): ?array
{
$st = self::pdo()->prepare("SELECT * FROM platform_plans WHERE idplan = ?");
$st->execute([$idplan]);
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
}
/** Prezzo IVA esclusa del piano per il ciclo, null se non previsto */
public static function price(array $plan, string $cycle): ?float
{
$v = $cycle === 'month' ? $plan['price_month'] : $plan['price_year'];
return $v === null ? null : (float) $v;
}
/** @return array [net, vat_rate, vat_amount, total] */
public static function quote(float $net): array
{
$rate = max(0, (float) self::get('vat_rate', 22));
$net = round($net, 2);
$vat = round($net * $rate / 100, 2);
return [$net, $rate, $vat, round($net + $vat, 2)];
}
/* ==================================================================
* Stato dell'abbonamento di una scuola
* ================================================================ */
public static function school(int $idschool): ?array
{
$st = self::pdo()->prepare(
"SELECT s.*, p.name AS plan_name, p.price_month, p.price_year, p.max_students
FROM schools s
LEFT JOIN platform_plans p ON p.idplan = s.idplan
WHERE s.idschool = ?"
);
$st->execute([$idschool]);
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
}
private static function days(string $from, string $to): int
{
return (int) (new DateTimeImmutable($from))->diff(new DateTimeImmutable($to))->format('%r%a');
}
/**
* Stato calcolato.
* code: exempt | active | expiring | trial | grace | expired | none
* @return array ['code','label','tone','ends_on','days']
* days = giorni rimasti (active/expiring/trial), di tolleranza rimasti (grace), da quanto è scaduto (expired)
*/
public static function state(array $school, ?string $today = null): array
{
$today = $today ?? date('Y-m-d');
$paid = $school['paid_until'] ?: null;
$trial = $school['trial_until'] ?: null;
$remind = max(0, (int) self::get('reminder_days', 15));
$grace = max(0, (int) self::get('grace_days', 7));
if (($school['billing_exempt'] ?? 'N') === 'Y') {
return ['code' => 'exempt', 'label' => 'Gratuito', 'tone' => 'ok', 'ends_on' => null, 'days' => null];
}
if ($paid && $paid >= $today) {
$d = self::days($today, $paid);
return $d <= $remind
? ['code' => 'expiring', 'label' => 'In scadenza', 'tone' => 'warn', 'ends_on' => $paid, 'days' => $d]
: ['code' => 'active', 'label' => 'Attivo', 'tone' => 'ok', 'ends_on' => $paid, 'days' => $d];
}
if ($trial && $trial >= $today) {
return ['code' => 'trial', 'label' => 'In prova', 'tone' => 'info', 'ends_on' => $trial, 'days' => self::days($today, $trial)];
}
if (!$paid && !$trial) {
return ['code' => 'none', 'label' => 'Non attivo', 'tone' => 'muted', 'ends_on' => null, 'days' => null];
}
$end = max((string) $paid, (string) $trial);
$graceEnd = (new DateTimeImmutable($end))->modify('+' . $grace . ' days')->format('Y-m-d');
if ($today <= $graceEnd) {
return ['code' => 'grace', 'label' => 'Scaduto', 'tone' => 'danger', 'ends_on' => $end, 'days' => self::days($today, $graceEnd)];
}
return ['code' => 'expired', 'label' => 'Scaduto', 'tone' => 'danger', 'ends_on' => $end, 'days' => self::days($end, $today)];
}
/** Aggiunge mesi senza "sforare" (31 gennaio + 1 mese = 28/29 febbraio) */
private static function addMonths(DateTimeImmutable $d, int $months): DateTimeImmutable
{
$y = (int) $d->format('Y');
$m = (int) $d->format('n') + $months;
$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));
}
/**
* Prossimo periodo pagato: parte dal giorno dopo la scadenza attuale
* (abbonamento o prova) se non è ancora passata, altrimenti da oggi.
* @return array [from, to] (Y-m-d)
*/
public static function nextPeriod(array $school, string $cycle, ?string $today = null): array
{
$today = $today ?? date('Y-m-d');
$lastEnd = max((string) ($school['paid_until'] ?? ''), (string) ($school['trial_until'] ?? ''));
$from = ($lastEnd !== '' && $lastEnd >= $today)
? (new DateTimeImmutable($lastEnd))->modify('+1 day')
: new DateTimeImmutable($today);
$to = self::addMonths($from, $cycle === 'month' ? 1 : 12)->modify('-1 day');
return [$from->format('Y-m-d'), $to->format('Y-m-d')];
}
/* ==================================================================
* Ordini
* ================================================================ */
public static function order(int $idorder, ?int $idschool = null): ?array
{
$sql = "SELECT o.*, s.name AS school_name FROM platform_orders o JOIN schools s ON s.idschool = o.idschool WHERE o.idorder = ?";
$args = [$idorder];
if ($idschool !== null) {
$sql .= " AND o.idschool = ?";
$args[] = $idschool;
}
$st = self::pdo()->prepare($sql);
$st->execute($args);
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* Crea un ordine "in attesa".
* $opt: amount_net (importo IVA esclusa diverso dal listino), notes, allow_inactive (solo superadmin)
* @throws RuntimeException con messaggio leggibile
*/
public static function createOrder(int $idschool, int $idplan, string $cycle, string $method, ?int $userId, array $opt = []): array
{
if (!isset(self::CYCLES[$cycle]) || !isset(self::METHODS[$method])) {
throw new RuntimeException('Dati dell\'ordine non validi.');
}
$school = self::school($idschool);
$plan = self::plan($idplan);
if (!$school || !$plan || ($plan['active'] !== 'Y' && empty($opt['allow_inactive']))) {
throw new RuntimeException('Piano non disponibile.');
}
$net = isset($opt['amount_net']) ? (float) $opt['amount_net'] : self::price($plan, $cycle);
if ($net === null || $net < 0) {
throw new RuntimeException('Questo piano non prevede il pagamento ' . strtolower(self::CYCLES[$cycle]) . '.');
}
[$net, $rate, $vat, $total] = self::quote($net);
[$from, $to] = self::nextPeriod($school, $cycle);
$snapshot = [];
foreach (['name', 'legal_name', 'vat_number', 'tax_code', 'sdi_code', 'pec', 'email', 'billing_email', 'address', 'postcode', 'city', 'province', 'country'] as $k) {
$snapshot[$k] = $school[$k] ?? null;
}
$pdo = self::pdo();
$pdo->prepare(
"INSERT INTO platform_orders
(idschool, idplan, plan_name, cycle, period_from, period_to, amount_net, vat_rate, vat_amount, total,
currency, method, status, due_date, billing_snapshot, notes, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'EUR', ?, 'pending', ?, ?, ?, ?)"
)->execute([
$idschool,
$idplan,
$plan['name'],
$cycle,
$from,
$to,
$net,
$rate,
$vat,
$total,
$method,
$method === 'bank' ? date('Y-m-d', strtotime('+' . max(1, (int) self::get('bank_due_days', 7)) . ' days')) : null,
json_encode($snapshot, JSON_UNESCAPED_UNICODE),
isset($opt['notes']) && trim((string) $opt['notes']) !== '' ? trim((string) $opt['notes']) : null,
$userId ?: null,
]);
$id = (int) $pdo->lastInsertId();
$number = sprintf('YB-%s-%05d', date('Y'), $id);
$reference = null;
if ($method === 'bank') {
$tpl = trim((string) self::get('bank_reference')) ?: self::DEFAULTS['bank_reference'];
$reference = mb_substr(strtr($tpl, ['{ordine}' => $number, '{scuola}' => (string) $school['name']]), 0, 140);
}
$pdo->prepare("UPDATE platform_orders SET number = ?, bank_reference = ? WHERE idorder = ?")
->execute([$number, $reference, $id]);
return self::order($id);
}
/**
* Segna l'ordine come pagato ed estende l'abbonamento della scuola.
* Idempotente: se l'ordine non è più "in attesa" non fa nulla e restituisce false.
*/
public static function markPaid(int $idorder, ?int $confirmedBy = null, ?string $paymentIntent = null): bool
{
$pdo = self::pdo();
$pdo->beginTransaction();
try {
$st = $pdo->prepare("SELECT * FROM platform_orders WHERE idorder = ? FOR UPDATE");
$st->execute([$idorder]);
$o = $st->fetch(PDO::FETCH_ASSOC);
if (!$o || $o['status'] !== 'pending') {
$pdo->rollBack();
return false;
}
$st = $pdo->prepare("SELECT * FROM schools WHERE idschool = ? FOR UPDATE");
$st->execute([(int) $o['idschool']]);
$school = $st->fetch(PDO::FETCH_ASSOC);
// Il periodo definitivo si calcola adesso (un bonifico può arrivare dopo giorni)
[$from, $to] = self::nextPeriod($school, $o['cycle']);
$pdo->prepare(
"UPDATE platform_orders
SET status = 'paid', paid_at = NOW(), period_from = ?, period_to = ?, confirmed_by = ?,
stripe_payment_intent = COALESCE(?, stripe_payment_intent)
WHERE idorder = ?"
)->execute([$from, $to, $confirmedBy ?: null, $paymentIntent, $idorder]);
$pdo->prepare(
"UPDATE schools
SET paid_until = ?, idplan = COALESCE(?, idplan), billing_cycle = ?,
status = IF(status IN ('trial', 'suspended'), 'active', status)
WHERE idschool = ?"
)->execute([$to, $o['idplan'], $o['cycle'], (int) $o['idschool']]);
$pdo->commit();
return true;
} catch (Throwable $ex) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $ex;
}
}
/** Annulla un ordine in attesa (eventualmente solo se della scuola indicata) */
public static function cancelOrder(int $idorder, ?int $idschool = null): bool
{
$sql = "UPDATE platform_orders SET status = 'cancelled' WHERE idorder = ? AND status = 'pending'";
$args = [$idorder];
if ($idschool !== null) {
$sql .= " AND idschool = ?";
$args[] = $idschool;
}
$st = self::pdo()->prepare($sql);
$st->execute($args);
return $st->rowCount() > 0;
}
/* ==================================================================
* Stripe (API REST diretta)
* ================================================================ */
/** @throws RuntimeException */
public static function stripeRequest(string $method, string $path, array $params = [], array $headers = []): array
{
$secret = self::stripeSecret();
if ($secret === '') {
throw new RuntimeException('Pagamento con carta non configurato.');
}
$url = 'https://api.stripe.com/v1/' . ltrim($path, '/');
$ch = curl_init();
$opt = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERPWD => $secret . ':',
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => $headers,
];
if ($method === 'GET') {
$opt[CURLOPT_URL] = $url . ($params ? '?' . http_build_query($params) : '');
} else {
$opt[CURLOPT_URL] = $url;
$opt[CURLOPT_POST] = true;
$opt[CURLOPT_POSTFIELDS] = http_build_query($params);
}
curl_setopt_array($ch, $opt);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($body === false) {
error_log('[PlatformBilling] Stripe curl: ' . $err);
throw new RuntimeException('Impossibile contattare il servizio di pagamento. Riprova tra poco.');
}
$data = json_decode((string) $body, true);
if ($code >= 400 || !is_array($data)) {
error_log('[PlatformBilling] Stripe ' . $code . ': ' . substr((string) $body, 0, 500));
throw new RuntimeException('Il servizio di pagamento ha rifiutato la richiesta. Riprova o scegli il bonifico.');
}
return $data;
}
/** Crea la sessione Stripe Checkout per un ordine e restituisce l'URL di pagamento */
public static function stripeCheckout(array $order, array $school): string
{
$base = self::appUrl();
$params = [
'mode' => 'payment',
'client_reference_id' => (string) $order['idorder'],
'success_url' => $base . '/subscription.php?checkout=done&o=' . (int) $order['idorder'],
'cancel_url' => $base . '/subscription.php?checkout=cancel&o=' . (int) $order['idorder'],
'line_items' => [[
'quantity' => 1,
'price_data' => [
'currency' => 'eur',
'unit_amount' => (int) round((float) $order['total'] * 100),
'product_data' => [
'name' => 'YogiBook · ' . $order['plan_name'] . ' (' . strtolower(self::CYCLES[$order['cycle']] ?? '') . ')',
'description' => 'Ordine ' . $order['number'] . ' · ' . $school['name'],
],
],
]],
'metadata' => ['idorder' => (string) $order['idorder'], 'idschool' => (string) $order['idschool'], 'number' => (string) $order['number']],
'payment_intent_data' => ['metadata' => ['idorder' => (string) $order['idorder'], 'number' => (string) $order['number']]],
];
$email = $school['billing_email'] ?: $school['email'];
if ($email && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$params['customer_email'] = $email;
}
$session = self::stripeRequest('POST', 'checkout/sessions', $params, ['Idempotency-Key: yb-platform-order-' . (int) $order['idorder']]);
if (empty($session['id']) || empty($session['url'])) {
throw new RuntimeException('Risposta del servizio di pagamento non valida.');
}
self::pdo()->prepare("UPDATE platform_orders SET stripe_session_id = ? WHERE idorder = ?")
->execute([$session['id'], (int) $order['idorder']]);
return $session['url'];
}
/**
* Controlla una sessione Checkout e, se pagata e coerente con l'ordine, lo segna pagato.
* Usata dal webhook e al ritorno dalla pagina di pagamento (utile anche in locale senza webhook).
*/
public static function applyStripeSession(array $session): bool
{
$idorder = (int) ($session['metadata']['idorder'] ?? 0);
if ($idorder <= 0 || ($session['payment_status'] ?? '') !== 'paid') {
return false;
}
$o = self::order($idorder);
if (!$o || $o['method'] !== 'stripe') {
return false;
}
if ($o['stripe_session_id'] && $o['stripe_session_id'] !== ($session['id'] ?? '')) {
error_log('[PlatformBilling] sessione Stripe diversa per ordine ' . $idorder);
return false;
}
$expected = (int) round((float) $o['total'] * 100);
if ((int) ($session['amount_total'] ?? -1) !== $expected || strtolower((string) ($session['currency'] ?? '')) !== 'eur') {
error_log('[PlatformBilling] importo Stripe non coerente per ordine ' . $idorder);
return false;
}
$pi = $session['payment_intent'] ?? null;
return self::markPaid($idorder, null, is_string($pi) ? $pi : null);
}
public static function stripeSync(array $order): bool
{
if ($order['status'] !== 'pending' || $order['method'] !== 'stripe' || empty($order['stripe_session_id'])) {
return false;
}
$session = self::stripeRequest('GET', 'checkout/sessions/' . rawurlencode($order['stripe_session_id']));
return self::applyStripeSession($session);
}
/** Verifica la firma "Stripe-Signature" di un webhook */
public static function stripeVerify(string $payload, string $header, string $secret, int $tolerance = 300): bool
{
if ($secret === '' || $header === '') {
return false;
}
$t = null;
$sigs = [];
foreach (explode(',', $header) as $part) {
$kv = explode('=', trim($part), 2);
if (count($kv) !== 2) {
continue;
}
if ($kv[0] === 't') {
$t = (int) $kv[1];
} elseif ($kv[0] === 'v1') {
$sigs[] = $kv[1];
}
}
if (!$t || !$sigs || abs(time() - $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $payload, $secret);
foreach ($sigs as $s) {
if (hash_equals($expected, $s)) {
return true;
}
}
return false;
}
}