- Implemented the full Fat Bottom Grille storefront and staff dashboard.
Highlights include configurable menus, specials, cart/checkout, taxes, customer accounts with email 2FA, newsletters, analytics, refunds, PDF/CSV exports, Square/Mailgun adapters, and optional MySQL migration. See README.md for setup and production configuration. Verified PHP/JavaScript syntax, responsive layouts, registration, 2FA, checkout, dashboard workflows, settings persistence, and PDF generation. Real Square, Mailgun, and MySQL connections require credentials/server access. Square implementation follows the official Payments API and Refunds API.
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FatBottom\Services;
|
||||
|
||||
use FatBottom\Core\Database;
|
||||
|
||||
final class CartService
|
||||
{
|
||||
public function __construct(private readonly Database $db)
|
||||
{
|
||||
}
|
||||
|
||||
public function current(?int $userId = null): array
|
||||
{
|
||||
$token = $_COOKIE['fatbottom_cart'] ?? '';
|
||||
$cart = null;
|
||||
if (is_string($token) && $token !== '') {
|
||||
$cart = $this->db->fetch('SELECT * FROM carts WHERE token = ? AND status = ?', [$token, 'active']);
|
||||
}
|
||||
|
||||
if ($cart === null) {
|
||||
$token = bin2hex(random_bytes(24));
|
||||
$this->db->execute(
|
||||
'INSERT INTO carts (token, user_id, status) VALUES (?, ?, ?)',
|
||||
[$token, $userId, 'active']
|
||||
);
|
||||
setcookie('fatbottom_cart', $token, [
|
||||
'expires' => time() + 60 * 60 * 24 * 30,
|
||||
'path' => '/',
|
||||
'secure' => !empty($_SERVER['HTTPS']),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
$cart = $this->db->fetch('SELECT * FROM carts WHERE token = ?', [$token]);
|
||||
} elseif ($userId !== null && empty($cart['user_id'])) {
|
||||
$this->db->execute('UPDATE carts SET user_id = ? WHERE id = ?', [$userId, (int) $cart['id']]);
|
||||
$cart['user_id'] = $userId;
|
||||
}
|
||||
|
||||
$this->touch((int) $cart['id']);
|
||||
return $cart;
|
||||
}
|
||||
|
||||
public function add(int $menuItemId, int $quantity, string $notes, ?int $userId = null): bool
|
||||
{
|
||||
$item = $this->db->fetch('SELECT * FROM menu_items WHERE id = ? AND active = 1', [$menuItemId]);
|
||||
if ($item === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$special = $this->db->fetch(
|
||||
"SELECT price_cents FROM specials
|
||||
WHERE menu_item_id = ? AND active = 1 AND price_cents IS NOT NULL
|
||||
AND (starts_at IS NULL OR starts_at <= CURRENT_TIMESTAMP)
|
||||
AND (ends_at IS NULL OR ends_at >= CURRENT_TIMESTAMP)
|
||||
ORDER BY price_cents ASC LIMIT 1",
|
||||
[$menuItemId]
|
||||
);
|
||||
$unitPrice = $special !== null
|
||||
? min((int) $item['price_cents'], (int) $special['price_cents'])
|
||||
: (int) $item['price_cents'];
|
||||
|
||||
$cart = $this->current($userId);
|
||||
$notes = trim(substr($notes, 0, 240));
|
||||
$existing = $this->db->fetch(
|
||||
'SELECT * FROM cart_items WHERE cart_id = ? AND menu_item_id = ? AND notes = ?',
|
||||
[(int) $cart['id'], $menuItemId, $notes]
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
$this->db->execute(
|
||||
'UPDATE cart_items SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[min(20, (int) $existing['quantity'] + max(1, $quantity)), (int) $existing['id']]
|
||||
);
|
||||
} else {
|
||||
$this->db->execute(
|
||||
'INSERT INTO cart_items (cart_id, menu_item_id, quantity, unit_price_cents, notes)
|
||||
VALUES (?, ?, ?, ?, ?)',
|
||||
[(int) $cart['id'], $menuItemId, min(20, max(1, $quantity)), $unitPrice, $notes]
|
||||
);
|
||||
}
|
||||
|
||||
$this->recalculate((int) $cart['id']);
|
||||
return true;
|
||||
}
|
||||
|
||||
public function update(array $quantities, ?int $userId = null): void
|
||||
{
|
||||
$cart = $this->current($userId);
|
||||
foreach ($quantities as $itemId => $quantity) {
|
||||
$itemId = (int) $itemId;
|
||||
$quantity = (int) $quantity;
|
||||
if ($quantity <= 0) {
|
||||
$this->db->execute('DELETE FROM cart_items WHERE id = ? AND cart_id = ?', [$itemId, (int) $cart['id']]);
|
||||
} else {
|
||||
$this->db->execute(
|
||||
'UPDATE cart_items SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND cart_id = ?',
|
||||
[min(20, $quantity), $itemId, (int) $cart['id']]
|
||||
);
|
||||
}
|
||||
}
|
||||
$this->recalculate((int) $cart['id']);
|
||||
}
|
||||
|
||||
public function remove(int $cartItemId, ?int $userId = null): void
|
||||
{
|
||||
$cart = $this->current($userId);
|
||||
$this->db->execute('DELETE FROM cart_items WHERE id = ? AND cart_id = ?', [$cartItemId, (int) $cart['id']]);
|
||||
$this->recalculate((int) $cart['id']);
|
||||
}
|
||||
|
||||
public function details(?int $userId = null): array
|
||||
{
|
||||
$cart = $this->current($userId);
|
||||
$items = $this->db->fetchAll(
|
||||
'SELECT ci.*, mi.name, mi.description, mi.active
|
||||
FROM cart_items ci
|
||||
JOIN menu_items mi ON mi.id = ci.menu_item_id
|
||||
WHERE ci.cart_id = ?
|
||||
ORDER BY ci.id',
|
||||
[(int) $cart['id']]
|
||||
);
|
||||
$cart['items'] = $items;
|
||||
$cart['item_count'] = array_sum(array_map(static fn (array $item): int => (int) $item['quantity'], $items));
|
||||
return $cart;
|
||||
}
|
||||
|
||||
private function touch(int $cartId): void
|
||||
{
|
||||
$this->db->execute('UPDATE carts SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?', [$cartId]);
|
||||
}
|
||||
|
||||
private function recalculate(int $cartId): void
|
||||
{
|
||||
$subtotal = (int) $this->db->fetch(
|
||||
'SELECT COALESCE(SUM(quantity * unit_price_cents), 0) AS subtotal
|
||||
FROM cart_items WHERE cart_id = ?',
|
||||
[$cartId]
|
||||
)['subtotal'];
|
||||
$this->db->execute(
|
||||
'UPDATE carts SET subtotal_cents = ?, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[$subtotal, $cartId]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FatBottom\Services;
|
||||
|
||||
use FatBottom\Core\Config;
|
||||
use RuntimeException;
|
||||
|
||||
final class Mailer
|
||||
{
|
||||
public function __construct(private readonly Config $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function send(string $to, string $subject, string $html): bool
|
||||
{
|
||||
if (!(bool) $this->config->get('mailgun.enabled', false)) {
|
||||
$this->writeToLog($to, $subject, $html);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!function_exists('curl_init')) {
|
||||
throw new RuntimeException('The PHP cURL extension is required for Mailgun.');
|
||||
}
|
||||
|
||||
$domain = (string) $this->config->get('mailgun.domain');
|
||||
$apiKey = (string) $this->config->get('mailgun.api_key');
|
||||
if ($domain === '' || $apiKey === '') {
|
||||
throw new RuntimeException('Mailgun is enabled but its credentials are incomplete.');
|
||||
}
|
||||
|
||||
$from = sprintf(
|
||||
'%s <%s>',
|
||||
$this->config->get('mailgun.from_name', 'Fat Bottom Grille'),
|
||||
$this->config->get('mailgun.from_email')
|
||||
);
|
||||
|
||||
$curl = curl_init('https://api.mailgun.net/v3/' . rawurlencode($domain) . '/messages');
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_USERPWD => 'api:' . $apiKey,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'subject' => $subject,
|
||||
'html' => $html,
|
||||
],
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
]);
|
||||
$response = curl_exec($curl);
|
||||
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$error = curl_error($curl);
|
||||
curl_close($curl);
|
||||
|
||||
if ($response === false || $status < 200 || $status >= 300) {
|
||||
throw new RuntimeException('Mailgun delivery failed: ' . ($error ?: 'HTTP ' . $status));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function writeToLog(string $to, string $subject, string $html): void
|
||||
{
|
||||
$directory = BASE_PATH . '/storage/logs';
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0775, true);
|
||||
}
|
||||
$line = sprintf(
|
||||
"[%s] TO: %s | SUBJECT: %s\n%s\n\n",
|
||||
date('c'),
|
||||
$to,
|
||||
$subject,
|
||||
trim(strip_tags($html))
|
||||
);
|
||||
file_put_contents($directory . '/mail.log', $line, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FatBottom\Services;
|
||||
|
||||
final class MenuPdfExporter
|
||||
{
|
||||
public function output(string $businessName, array $categories): never
|
||||
{
|
||||
$pages = [];
|
||||
$lines = [];
|
||||
foreach ($categories as $category) {
|
||||
$lines[] = ['type' => 'category', 'text' => strtoupper((string) $category['name'])];
|
||||
foreach ($category['items'] as $item) {
|
||||
$lines[] = [
|
||||
'type' => 'item',
|
||||
'text' => sprintf('%s $%.2f', $item['name'], $item['price_cents'] / 100),
|
||||
];
|
||||
$lines[] = ['type' => 'description', 'text' => (string) $item['description']];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_chunk($lines, 24) as $chunk) {
|
||||
$pages[] = $this->pageStream($businessName, $chunk);
|
||||
}
|
||||
if ($pages === []) {
|
||||
$pages[] = $this->pageStream($businessName, []);
|
||||
}
|
||||
|
||||
$pdf = $this->buildPdf($pages);
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: attachment; filename="fat-bottom-grille-menu.pdf"');
|
||||
header('Content-Length: ' . strlen($pdf));
|
||||
echo $pdf;
|
||||
exit;
|
||||
}
|
||||
|
||||
private function pageStream(string $businessName, array $lines): string
|
||||
{
|
||||
$escape = static function (string $value): string {
|
||||
$ascii = function_exists('iconv')
|
||||
? (iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value) ?: $value)
|
||||
: preg_replace('/[^\x20-\x7E]/', '', $value);
|
||||
|
||||
return str_replace(
|
||||
['\\', '(', ')', "\r", "\n"],
|
||||
['\\\\', '\\(', '\\)', ' ', ' '],
|
||||
(string) $ascii
|
||||
);
|
||||
};
|
||||
|
||||
$stream = "0.035 0.071 0.102 rg\n0 0 612 792 re f\n";
|
||||
$stream .= "0.78 0.58 0.20 rg\nBT /F1 25 Tf 46 738 Td (" . $escape($businessName) . ") Tj ET\n";
|
||||
$stream .= "0.91 0.92 0.90 rg\nBT /F1 10 Tf 46 718 Td (KEYSER, WEST VIRGINIA | ONLINE MENU) Tj ET\n";
|
||||
$stream .= "0.78 0.58 0.20 RG\n46 704 m 566 704 l S\n";
|
||||
$y = 678;
|
||||
foreach ($lines as $line) {
|
||||
$text = $escape(substr((string) $line['text'], 0, 92));
|
||||
if ($line['type'] === 'category') {
|
||||
$stream .= "0.78 0.58 0.20 rg\nBT /F1 15 Tf 46 {$y} Td ({$text}) Tj ET\n";
|
||||
$y -= 24;
|
||||
} elseif ($line['type'] === 'item') {
|
||||
$stream .= "0.96 0.95 0.89 rg\nBT /F1 12 Tf 54 {$y} Td ({$text}) Tj ET\n";
|
||||
$y -= 17;
|
||||
} else {
|
||||
$stream .= "0.68 0.72 0.72 rg\nBT /F1 8 Tf 54 {$y} Td ({$text}) Tj ET\n";
|
||||
$y -= 23;
|
||||
}
|
||||
}
|
||||
$stream .= "0.55 0.60 0.62 rg\nBT /F1 8 Tf 46 35 Td (Prices and availability are subject to change.) Tj ET\n";
|
||||
return $stream;
|
||||
}
|
||||
|
||||
private function buildPdf(array $streams): string
|
||||
{
|
||||
$objects = [];
|
||||
$objects[1] = '<< /Type /Catalog /Pages 2 0 R >>';
|
||||
$pageIds = [];
|
||||
$nextId = 4;
|
||||
foreach ($streams as $index => $stream) {
|
||||
$pageId = $nextId++;
|
||||
$contentId = $nextId++;
|
||||
$pageIds[] = $pageId . ' 0 R';
|
||||
$objects[$pageId] = sprintf(
|
||||
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents %d 0 R >>',
|
||||
$contentId
|
||||
);
|
||||
$objects[$contentId] = "<< /Length " . strlen($stream) . " >>\nstream\n{$stream}endstream";
|
||||
}
|
||||
$objects[2] = '<< /Type /Pages /Kids [' . implode(' ', $pageIds) . '] /Count ' . count($pageIds) . ' >>';
|
||||
$objects[3] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>';
|
||||
ksort($objects);
|
||||
|
||||
$pdf = "%PDF-1.4\n";
|
||||
$offsets = [0];
|
||||
foreach ($objects as $id => $object) {
|
||||
$offsets[$id] = strlen($pdf);
|
||||
$pdf .= "{$id} 0 obj\n{$object}\nendobj\n";
|
||||
}
|
||||
$xref = strlen($pdf);
|
||||
$count = max(array_keys($objects)) + 1;
|
||||
$pdf .= "xref\n0 {$count}\n0000000000 65535 f \n";
|
||||
for ($id = 1; $id < $count; $id++) {
|
||||
$pdf .= sprintf("%010d 00000 n \n", $offsets[$id] ?? 0);
|
||||
}
|
||||
$pdf .= "trailer\n<< /Size {$count} /Root 1 0 R >>\nstartxref\n{$xref}\n%%EOF";
|
||||
return $pdf;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FatBottom\Services;
|
||||
|
||||
use FatBottom\Core\Config;
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class MySqlMigrator
|
||||
{
|
||||
public function __construct(private readonly Config $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function migrate(): array
|
||||
{
|
||||
$sqlitePath = BASE_PATH . '/storage/database/store.sqlite';
|
||||
if (!is_file($sqlitePath)) {
|
||||
throw new RuntimeException('The SQLite store database does not exist yet.');
|
||||
}
|
||||
if (!extension_loaded('pdo_mysql')) {
|
||||
throw new RuntimeException('The pdo_mysql PHP extension is required for migration.');
|
||||
}
|
||||
|
||||
$mysql = new PDO(
|
||||
sprintf(
|
||||
'mysql:host=%s;port=%d;charset=utf8mb4',
|
||||
$this->config->get('database.mysql_host'),
|
||||
(int) $this->config->get('database.mysql_port', 3306)
|
||||
),
|
||||
(string) $this->config->get('database.mysql_username'),
|
||||
(string) $this->config->get('database.mysql_password'),
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
$database = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $this->config->get('database.mysql_database'));
|
||||
if ($database === '') {
|
||||
throw new RuntimeException('Enter a valid MySQL database name.');
|
||||
}
|
||||
$mysql->exec("CREATE DATABASE IF NOT EXISTS `{$database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||
$mysql->exec("USE `{$database}`");
|
||||
|
||||
$sqlite = new PDO('sqlite:' . $sqlitePath, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
$tables = $sqlite->query(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
)->fetchAll(PDO::FETCH_COLUMN);
|
||||
$rowCount = 0;
|
||||
|
||||
$mysql->exec('SET FOREIGN_KEY_CHECKS = 0');
|
||||
foreach ($tables as $table) {
|
||||
$columns = $sqlite->query("PRAGMA table_info(`{$table}`)")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$definitions = [];
|
||||
$primary = [];
|
||||
foreach ($columns as $column) {
|
||||
$type = strtoupper((string) $column['type']);
|
||||
$mysqlType = str_contains($type, 'INT') ? 'BIGINT'
|
||||
: (str_contains($type, 'REAL') || str_contains($type, 'FLOA') ? 'DOUBLE' : 'TEXT');
|
||||
if ((int) $column['pk'] === 1 && $column['name'] === 'id') {
|
||||
$definitions[] = '`id` BIGINT NOT NULL AUTO_INCREMENT';
|
||||
$primary[] = '`id`';
|
||||
} else {
|
||||
$definitions[] = sprintf(
|
||||
'`%s` %s %s',
|
||||
str_replace('`', '', (string) $column['name']),
|
||||
$mysqlType,
|
||||
(int) $column['notnull'] === 1 ? 'NOT NULL' : 'NULL'
|
||||
);
|
||||
if ((int) $column['pk'] === 1) {
|
||||
$primary[] = '`' . str_replace('`', '', (string) $column['name']) . '`';
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($primary !== []) {
|
||||
$definitions[] = 'PRIMARY KEY (' . implode(', ', $primary) . ')';
|
||||
}
|
||||
$mysql->exec("DROP TABLE IF EXISTS `{$table}`");
|
||||
$mysql->exec("CREATE TABLE `{$table}` (" . implode(', ', $definitions) . ') ENGINE=InnoDB');
|
||||
|
||||
$rows = $sqlite->query("SELECT * FROM `{$table}`")->fetchAll(PDO::FETCH_ASSOC);
|
||||
if ($rows !== []) {
|
||||
$names = array_keys($rows[0]);
|
||||
$placeholders = implode(', ', array_fill(0, count($names), '?'));
|
||||
$quotedNames = implode(', ', array_map(static fn (string $name): string => "`{$name}`", $names));
|
||||
$insert = $mysql->prepare("INSERT INTO `{$table}` ({$quotedNames}) VALUES ({$placeholders})");
|
||||
foreach ($rows as $row) {
|
||||
$insert->execute(array_values($row));
|
||||
$rowCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
$mysql->exec('SET FOREIGN_KEY_CHECKS = 1');
|
||||
|
||||
return ['tables' => count($tables), 'rows' => $rowCount];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FatBottom\Services;
|
||||
|
||||
use FatBottom\Core\Database;
|
||||
use RuntimeException;
|
||||
|
||||
final class OrderService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Database $db,
|
||||
private readonly PaymentGateway $payments
|
||||
) {
|
||||
}
|
||||
|
||||
public function totals(array $cart): array
|
||||
{
|
||||
$subtotal = (int) $cart['subtotal_cents'];
|
||||
$taxRate = (int) $this->db->fetch(
|
||||
'SELECT COALESCE(SUM(rate_basis_points), 0) AS total_rate FROM tax_rates WHERE active = 1'
|
||||
)['total_rate'];
|
||||
$tax = (int) round($subtotal * $taxRate / 10000);
|
||||
return [
|
||||
'subtotal_cents' => $subtotal,
|
||||
'tax_rate_basis_points' => $taxRate,
|
||||
'tax_cents' => $tax,
|
||||
'total_cents' => $subtotal + $tax,
|
||||
];
|
||||
}
|
||||
|
||||
public function place(array $cart, array $customer, ?int $userId, string $sourceId): array
|
||||
{
|
||||
if (empty($cart['items'])) {
|
||||
throw new RuntimeException('Your cart is empty.');
|
||||
}
|
||||
|
||||
$totals = $this->totals($cart);
|
||||
$orderNumber = 'FBG-' . date('ymd') . '-'
|
||||
. strtoupper(substr(hash('sha256', (string) $cart['token']), 0, 6));
|
||||
$payment = $this->payments->charge($sourceId, $totals['total_cents'], $orderNumber);
|
||||
|
||||
$pdo = $this->db->pdo();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$statement = $pdo->prepare(
|
||||
'INSERT INTO orders
|
||||
(order_number, user_id, cart_id, customer_name, customer_email, customer_phone,
|
||||
order_type, status, payment_status, payment_provider, payment_reference,
|
||||
subtotal_cents, tax_cents, total_cents, special_instructions, pickup_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$statement->execute([
|
||||
$orderNumber,
|
||||
$userId,
|
||||
(int) $cart['id'],
|
||||
$customer['customer_name'],
|
||||
$customer['customer_email'],
|
||||
$customer['customer_phone'],
|
||||
'pickup',
|
||||
'paid',
|
||||
'paid',
|
||||
$payment['provider'],
|
||||
$payment['reference'],
|
||||
$totals['subtotal_cents'],
|
||||
$totals['tax_cents'],
|
||||
$totals['total_cents'],
|
||||
$customer['special_instructions'],
|
||||
$customer['pickup_time'] ?: null,
|
||||
]);
|
||||
$orderId = (int) $pdo->lastInsertId();
|
||||
|
||||
$itemStatement = $pdo->prepare(
|
||||
'INSERT INTO order_items
|
||||
(order_id, menu_item_id, item_name, quantity, unit_price_cents, line_total_cents, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
foreach ($cart['items'] as $item) {
|
||||
$itemStatement->execute([
|
||||
$orderId,
|
||||
(int) $item['menu_item_id'],
|
||||
$item['name'],
|
||||
(int) $item['quantity'],
|
||||
(int) $item['unit_price_cents'],
|
||||
(int) $item['quantity'] * (int) $item['unit_price_cents'],
|
||||
$item['notes'],
|
||||
]);
|
||||
}
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO order_events (order_id, user_id, event_type, details) VALUES (?, ?, ?, ?)'
|
||||
)->execute([$orderId, $userId, 'order_placed', 'Online pickup order paid successfully.']);
|
||||
$pdo->prepare(
|
||||
"UPDATE carts SET status = 'converted', converted_at = CURRENT_TIMESTAMP WHERE id = ?"
|
||||
)->execute([(int) $cart['id']]);
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $error) {
|
||||
$pdo->rollBack();
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return $this->db->fetch('SELECT * FROM orders WHERE id = ?', [$orderId]) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FatBottom\Services;
|
||||
|
||||
use FatBottom\Core\Config;
|
||||
use RuntimeException;
|
||||
|
||||
final class PaymentGateway
|
||||
{
|
||||
public function __construct(private readonly Config $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function charge(string $sourceId, int $amountCents, string $orderNumber): array
|
||||
{
|
||||
if (!(bool) $this->config->get('square.enabled', false)) {
|
||||
return [
|
||||
'success' => true,
|
||||
'provider' => 'demo',
|
||||
'reference' => 'DEMO-' . strtoupper(bin2hex(random_bytes(5))),
|
||||
'status' => 'COMPLETED',
|
||||
];
|
||||
}
|
||||
|
||||
if ($sourceId === '') {
|
||||
throw new RuntimeException('Card details are required.');
|
||||
}
|
||||
if (!function_exists('curl_init')) {
|
||||
throw new RuntimeException('The PHP cURL extension is required for Square payments.');
|
||||
}
|
||||
|
||||
$environment = (string) $this->config->get('square.environment', 'sandbox');
|
||||
$baseUrl = $environment === 'production'
|
||||
? 'https://connect.squareup.com'
|
||||
: 'https://connect.squareupsandbox.com';
|
||||
$accessToken = (string) $this->config->get('square.access_token');
|
||||
$locationId = (string) $this->config->get('square.location_id');
|
||||
|
||||
if ($accessToken === '' || $locationId === '') {
|
||||
throw new RuntimeException('Square is enabled but its server credentials are incomplete.');
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'source_id' => $sourceId,
|
||||
'idempotency_key' => substr(hash('sha256', 'fatbottom-payment|' . $orderNumber), 0, 45),
|
||||
'amount_money' => [
|
||||
'amount' => $amountCents,
|
||||
'currency' => 'USD',
|
||||
],
|
||||
'location_id' => $locationId,
|
||||
'reference_id' => $orderNumber,
|
||||
'note' => 'Online pickup order ' . $orderNumber,
|
||||
'autocomplete' => true,
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
$curl = curl_init($baseUrl . '/v2/payments');
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $accessToken,
|
||||
'Content-Type: application/json',
|
||||
'Square-Version: 2026-05-20',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$response = curl_exec($curl);
|
||||
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$error = curl_error($curl);
|
||||
curl_close($curl);
|
||||
|
||||
$decoded = is_string($response) ? json_decode($response, true) : null;
|
||||
if ($response === false || $status < 200 || $status >= 300 || !isset($decoded['payment'])) {
|
||||
$message = $decoded['errors'][0]['detail'] ?? $error ?: 'Payment was declined.';
|
||||
throw new RuntimeException((string) $message);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'provider' => 'square',
|
||||
'reference' => (string) $decoded['payment']['id'],
|
||||
'status' => (string) $decoded['payment']['status'],
|
||||
];
|
||||
}
|
||||
|
||||
public function refund(string $paymentReference, int $amountCents, string $reason): array
|
||||
{
|
||||
if (!(bool) $this->config->get('square.enabled', false) || str_starts_with($paymentReference, 'DEMO-')) {
|
||||
return ['success' => true, 'reference' => 'REF-DEMO-' . strtoupper(bin2hex(random_bytes(4)))];
|
||||
}
|
||||
|
||||
if (!function_exists('curl_init')) {
|
||||
throw new RuntimeException('The PHP cURL extension is required for Square refunds.');
|
||||
}
|
||||
|
||||
$environment = (string) $this->config->get('square.environment', 'sandbox');
|
||||
$baseUrl = $environment === 'production'
|
||||
? 'https://connect.squareup.com'
|
||||
: 'https://connect.squareupsandbox.com';
|
||||
$payload = json_encode([
|
||||
'idempotency_key' => bin2hex(random_bytes(16)),
|
||||
'payment_id' => $paymentReference,
|
||||
'amount_money' => ['amount' => $amountCents, 'currency' => 'USD'],
|
||||
'reason' => substr($reason, 0, 190),
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
$curl = curl_init($baseUrl . '/v2/refunds');
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $this->config->get('square.access_token'),
|
||||
'Content-Type: application/json',
|
||||
'Square-Version: 2026-05-20',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$response = curl_exec($curl);
|
||||
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($curl);
|
||||
$decoded = is_string($response) ? json_decode($response, true) : null;
|
||||
if ($status < 200 || $status >= 300 || !isset($decoded['refund'])) {
|
||||
throw new RuntimeException((string) ($decoded['errors'][0]['detail'] ?? 'Refund failed.'));
|
||||
}
|
||||
|
||||
return ['success' => true, 'reference' => (string) $decoded['refund']['id']];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FatBottom\Services;
|
||||
|
||||
use FatBottom\Core\Database;
|
||||
|
||||
final class StatsService
|
||||
{
|
||||
public function __construct(private readonly Database $db)
|
||||
{
|
||||
}
|
||||
|
||||
public function record(string $route, ?int $userId): void
|
||||
{
|
||||
if (str_starts_with($route, '/assets/')) {
|
||||
return;
|
||||
}
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$this->db->execute(
|
||||
'INSERT INTO visitor_events (session_id, user_id, route, referrer, user_agent, ip_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
session_id(),
|
||||
$userId,
|
||||
substr($route, 0, 255),
|
||||
substr((string) ($_SERVER['HTTP_REFERER'] ?? ''), 0, 500),
|
||||
substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500),
|
||||
hash('sha256', $ip . date('Y-m-d')),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function dashboard(): array
|
||||
{
|
||||
if (!$this->db->isSqlite()) {
|
||||
return [
|
||||
'today_orders' => (int) $this->db->fetch(
|
||||
'SELECT COUNT(*) AS value FROM orders WHERE DATE(placed_at) = CURRENT_DATE'
|
||||
)['value'],
|
||||
'today_sales' => (int) $this->db->fetch(
|
||||
"SELECT COALESCE(SUM(o.total_cents), 0) - COALESCE(SUM(r.refunded_cents), 0) AS value
|
||||
FROM orders o
|
||||
LEFT JOIN (
|
||||
SELECT order_id, SUM(amount_cents) AS refunded_cents FROM refunds GROUP BY order_id
|
||||
) r ON r.order_id = o.id
|
||||
WHERE o.payment_status IN ('paid', 'partially_refunded', 'refunded')
|
||||
AND DATE(o.placed_at) = CURRENT_DATE"
|
||||
)['value'],
|
||||
'open_orders' => (int) $this->db->fetch(
|
||||
"SELECT COUNT(*) AS value FROM orders WHERE status IN ('paid', 'preparing', 'ready')"
|
||||
)['value'],
|
||||
'abandoned_carts' => (int) $this->db->fetch(
|
||||
"SELECT COUNT(*) AS value FROM carts
|
||||
WHERE status = 'active' AND subtotal_cents > 0
|
||||
AND last_seen_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE)"
|
||||
)['value'],
|
||||
'today_visitors' => (int) $this->db->fetch(
|
||||
'SELECT COUNT(DISTINCT session_id) AS value FROM visitor_events
|
||||
WHERE DATE(created_at) = CURRENT_DATE'
|
||||
)['value'],
|
||||
'newsletter_members' => (int) $this->db->fetch(
|
||||
'SELECT COUNT(*) AS value FROM users WHERE newsletter_opt_in = 1 AND active = 1'
|
||||
)['value'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'today_orders' => (int) $this->db->fetch(
|
||||
"SELECT COUNT(*) AS value FROM orders
|
||||
WHERE date(placed_at, 'localtime') = date('now', 'localtime')"
|
||||
)['value'],
|
||||
'today_sales' => (int) $this->db->fetch(
|
||||
"SELECT COALESCE(SUM(o.total_cents), 0) - COALESCE(SUM(r.refunded_cents), 0) AS value
|
||||
FROM orders o
|
||||
LEFT JOIN (
|
||||
SELECT order_id, SUM(amount_cents) AS refunded_cents FROM refunds GROUP BY order_id
|
||||
) r ON r.order_id = o.id
|
||||
WHERE o.payment_status IN ('paid', 'partially_refunded', 'refunded')
|
||||
AND date(o.placed_at, 'localtime') = date('now', 'localtime')"
|
||||
)['value'],
|
||||
'open_orders' => (int) $this->db->fetch(
|
||||
"SELECT COUNT(*) AS value FROM orders WHERE status IN ('paid', 'preparing', 'ready')"
|
||||
)['value'],
|
||||
'abandoned_carts' => (int) $this->db->fetch(
|
||||
"SELECT COUNT(*) AS value FROM carts
|
||||
WHERE status = 'active' AND subtotal_cents > 0
|
||||
AND last_seen_at < datetime('now', '-30 minutes')"
|
||||
)['value'],
|
||||
'today_visitors' => (int) $this->db->fetch(
|
||||
"SELECT COUNT(DISTINCT session_id) AS value FROM visitor_events
|
||||
WHERE date(created_at, 'localtime') = date('now', 'localtime')"
|
||||
)['value'],
|
||||
'newsletter_members' => (int) $this->db->fetch(
|
||||
'SELECT COUNT(*) AS value FROM users WHERE newsletter_opt_in = 1 AND active = 1'
|
||||
)['value'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FatBottom\Services;
|
||||
|
||||
use FatBottom\Core\Config;
|
||||
use FatBottom\Core\Database;
|
||||
|
||||
final class TwoFactorService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Database $db,
|
||||
private readonly Mailer $mailer,
|
||||
private readonly Config $config
|
||||
) {
|
||||
}
|
||||
|
||||
public function issue(array $user, string $purpose = 'login'): ?string
|
||||
{
|
||||
$code = (string) random_int(100000, 999999);
|
||||
$expiresAt = gmdate('Y-m-d H:i:s', time() + 600);
|
||||
|
||||
$this->db->execute(
|
||||
'INSERT INTO login_codes (user_id, code_hash, purpose, expires_at) VALUES (?, ?, ?, ?)',
|
||||
[(int) $user['id'], password_hash($code, PASSWORD_DEFAULT), $purpose, $expiresAt]
|
||||
);
|
||||
|
||||
$name = htmlspecialchars((string) $user['full_name'], ENT_QUOTES, 'UTF-8');
|
||||
$this->mailer->send(
|
||||
(string) $user['email'],
|
||||
'Your Fat Bottom Grille verification code',
|
||||
"<p>Hi {$name},</p><p>Your verification code is <strong>{$code}</strong>.</p><p>It expires in 10 minutes.</p>"
|
||||
);
|
||||
|
||||
return (bool) $this->config->get('mailgun.enabled', false) ? null : $code;
|
||||
}
|
||||
|
||||
public function verify(int $userId, string $code, string $purpose = 'login'): bool
|
||||
{
|
||||
$record = $this->db->fetch(
|
||||
'SELECT * FROM login_codes
|
||||
WHERE user_id = ? AND purpose = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY id DESC LIMIT 1',
|
||||
[$userId, $purpose]
|
||||
);
|
||||
if ($record === null || !password_verify($code, (string) $record['code_hash'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->db->execute('UPDATE login_codes SET used_at = CURRENT_TIMESTAMP WHERE id = ?', [(int) $record['id']]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user