This commit is contained in:
Ty Clifford
2026-06-14 15:25:31 -04:00
parent 2f67ae718f
commit b7eaa81501
34 changed files with 3814 additions and 2 deletions
+221
View File
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace Budget;
use PDO;
use RuntimeException;
final class ExportService
{
private const TABLES = [
'monthly_plans', 'income_entries', 'categories', 'budget_items',
'transactions', 'dead_account_entries', 'calendar_events',
];
public function __construct(private PDO $pdo, private Settings $settings, private BudgetService $budget)
{
}
public function package(): string
{
$data = [];
foreach (self::TABLES as $table) {
$data[$table] = $this->pdo->query('SELECT * FROM ' . $table)->fetchAll();
}
$safeSettings = [];
foreach ($this->settings->all() as $key => $value) {
if (!str_starts_with($key, 'mailgun.api_key') && !str_starts_with($key, 'security.')) {
$safeSettings[$key] = $value;
}
}
$package = [
'format' => 'neon-ledger/budget-planner',
'version' => 1,
'exported_at' => date(DATE_ATOM),
'settings' => $safeSettings,
'data' => $data,
];
$json = json_encode($package, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
throw new RuntimeException('Planner package could not be encoded.');
}
return $json;
}
public function importPackage(string $json, bool $replace): array
{
$package = json_decode($json, true);
if (!is_array($package)
|| ($package['format'] ?? '') !== 'neon-ledger/budget-planner'
|| (int) ($package['version'] ?? 0) !== 1
|| !is_array($package['data'] ?? null)
) {
throw new RuntimeException('This is not a compatible Neon Ledger planner package.');
}
$inserted = [];
$this->pdo->beginTransaction();
try {
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql') {
$this->pdo->exec('SET FOREIGN_KEY_CHECKS=0');
} else {
$this->pdo->exec('PRAGMA defer_foreign_keys = ON');
}
if ($replace) {
foreach (array_reverse(self::TABLES) as $table) {
$this->pdo->exec('DELETE FROM ' . $table);
}
}
foreach (self::TABLES as $table) {
$rows = $package['data'][$table] ?? [];
$inserted[$table] = 0;
if (!is_array($rows)) {
continue;
}
foreach ($rows as $row) {
if (!is_array($row) || $row === []) {
continue;
}
$columns = array_keys($row);
$columnSql = implode(', ', array_map(
fn (string $column): string => $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql'
? '`' . $column . '`'
: '"' . $column . '"',
$columns
));
$placeholders = implode(', ', array_map(static fn (string $column): string => ':' . $column, $columns));
$verb = $replace
? ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql' ? 'REPLACE' : 'INSERT OR REPLACE')
: ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql' ? 'INSERT IGNORE' : 'INSERT OR IGNORE');
$statement = $this->pdo->prepare(sprintf('%s INTO %s (%s) VALUES (%s)', $verb, $table, $columnSql, $placeholders));
$statement->execute($row);
$inserted[$table] += $statement->rowCount();
}
}
foreach ((array) ($package['settings'] ?? []) as $key => $value) {
if (is_string($key) && !str_starts_with($key, 'mailgun.api_key') && !str_starts_with($key, 'security.')) {
$this->settings->set($key, $value);
}
}
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql') {
$this->pdo->exec('SET FOREIGN_KEY_CHECKS=1');
}
$this->pdo->commit();
} catch (\Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $exception;
}
return $inserted;
}
public function html(string $month): string
{
$summary = $this->budget->summary($month);
$items = $this->budget->budgetItems($month);
$transactions = $this->budget->transactions($month);
$symbol = (string) $this->settings->get('app.currency_symbol', '$');
$escape = static fn (mixed $value): string => htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
$money = static fn (mixed $value): string => Support::money((float) $value, $symbol);
$itemRows = '';
foreach ($items as $item) {
$itemRows .= sprintf(
'<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>',
$escape($item['name']),
$escape($item['category_name'] ?: 'Uncategorized'),
$money($item['planned_amount']),
$money($item['paid_amount']),
$money($item['remaining_amount'])
);
}
$transactionRows = '';
foreach ($transactions as $transaction) {
$transactionRows .= sprintf(
'<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>',
$escape($transaction['transacted_on']),
$escape($transaction['merchant']),
$escape($transaction['category_name'] ?: 'Uncategorized'),
$money($transaction['amount'])
);
}
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Budget report ' . $escape($month) . '</title>'
. '<style>body{font:15px/1.5 system-ui;color:#182033;max-width:1000px;margin:40px auto;padding:0 24px}h1{color:#5b36d9}'
. '.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.stat{border:1px solid #ddd;border-radius:10px;padding:14px}'
. 'table{width:100%;border-collapse:collapse;margin:20px 0}th,td{text-align:left;padding:9px;border-bottom:1px solid #ddd}th{color:#5b36d9}'
. '@media print{body{margin:0}.no-print{display:none}}</style></head><body>'
. '<button class="no-print" onclick="print()">Print / Save as PDF</button>'
. '<h1>' . $escape($this->settings->get('app.name', 'Neon Ledger')) . ' &mdash; ' . $escape(date('F Y', strtotime($month . '-01'))) . '</h1>'
. '<div class="stats"><div class="stat"><small>Resources</small><strong>' . $money($summary['gross_resources']) . '</strong></div>'
. '<div class="stat"><small>Expenses</small><strong>' . $money($summary['expenses']) . '</strong></div>'
. '<div class="stat"><small>Dormant movement</small><strong>' . $money($summary['dead_movement']) . '</strong></div>'
. '<div class="stat"><small>Remaining</small><strong>' . $money($summary['remaining']) . '</strong></div></div>'
. '<h2>Budget progress</h2><table><thead><tr><th>Item</th><th>Category</th><th>Target</th><th>Paid</th><th>Owing</th></tr></thead><tbody>'
. $itemRows . '</tbody></table><h2>Expenses</h2><table><thead><tr><th>Date</th><th>Description</th><th>Category</th><th>Amount</th></tr></thead><tbody>'
. $transactionRows . '</tbody></table><p>Exported ' . $escape(date(DATE_RFC2822)) . '</p></body></html>';
}
public function pdf(string $month): string
{
$summary = $this->budget->summary($month);
$items = $this->budget->budgetItems($month);
$symbol = (string) $this->settings->get('app.currency_symbol', '$');
$lines = [
(string) $this->settings->get('app.name', 'Neon Ledger') . ' - ' . date('F Y', strtotime($month . '-01')),
'',
'Resources: ' . Support::money($summary['gross_resources'], $symbol),
'Expenses: ' . Support::money($summary['expenses'], $symbol),
'Dormant movement: ' . Support::money($summary['dead_movement'], $symbol),
'Remaining: ' . Support::money($summary['remaining'], $symbol),
'',
'BUDGET PROGRESS',
];
foreach ($items as $item) {
$lines[] = sprintf(
'%s | target %s | paid %s | owing %s',
$item['name'],
Support::money($item['planned_amount'], $symbol),
Support::money($item['paid_amount'], $symbol),
Support::money($item['remaining_amount'], $symbol)
);
}
$lines[] = '';
$lines[] = 'Generated ' . date(DATE_RFC2822);
return $this->simplePdf($lines);
}
private function simplePdf(array $lines): string
{
$content = "BT\n/F1 11 Tf\n50 760 Td\n14 TL\n";
foreach ($lines as $index => $line) {
$line = iconv('UTF-8', 'Windows-1252//TRANSLIT', (string) $line) ?: (string) $line;
$line = str_replace(['\\', '(', ')'], ['\\\\', '\\(', '\\)'], $line);
$content .= ($index === 0 ? '' : "T*\n") . '(' . $line . ") Tj\n";
}
$content .= "ET\n";
$objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>',
'<< /Length ' . strlen($content) . " >>\nstream\n" . $content . 'endstream',
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
];
$pdf = "%PDF-1.4\n";
$offsets = [0];
foreach ($objects as $number => $object) {
$offsets[] = strlen($pdf);
$pdf .= ($number + 1) . " 0 obj\n" . $object . "\nendobj\n";
}
$xref = strlen($pdf);
$pdf .= "xref\n0 " . (count($objects) + 1) . "\n0000000000 65535 f \n";
for ($index = 1; $index <= count($objects); $index++) {
$pdf .= sprintf("%010d 00000 n \n", $offsets[$index]);
}
$pdf .= 'trailer << /Size ' . (count($objects) + 1) . " /Root 1 0 R >>\nstartxref\n" . $xref . "\n%%EOF";
return $pdf;
}
}