607 lines
24 KiB
PHP
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;
|
|
}
|
|
}
|