222 lines
10 KiB
PHP
222 lines
10 KiB
PHP
<?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')) . ' — ' . $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;
|
|
}
|
|
}
|