feat(08-10-receipt-module): phases 08-10 complete — receipt issuing from orders

Phase 08 — DB Foundation:
- 3 new tables: receipt_configs, receipts, receipt_number_counters
- company_settings extended with BDO, REGON, KRS, logo fields

Phase 09 — Receipt Config:
- CRUD for receipt configurations (Settings > Accounting)
- ReceiptConfigController + ReceiptConfigRepository

Phase 10 — Receipt Issuing:
- ReceiptRepository with atomic numbering (INSERT ON DUPLICATE KEY UPDATE)
- ReceiptController with snapshot pattern (seller/buyer/items as JSON)
- "Wystaw paragon" button in order view
- Documents tab showing both receipts and marketplace documents
- Activity log entry on receipt creation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 19:49:06 +01:00
parent 3bccc7a533
commit ed057fc304
31 changed files with 2539 additions and 39 deletions

View File

@@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\ReceiptConfigRepository;
use Throwable;
final class ReceiptController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly ReceiptRepository $receipts,
private readonly ReceiptConfigRepository $receiptConfigs,
private readonly CompanySettingsRepository $companySettings,
private readonly OrdersRepository $orders
) {
}
public function create(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
$details = $this->orders->findDetails($orderId);
if ($details === null) {
return Response::html('Not found', 404);
}
$configs = array_filter($this->receiptConfigs->listAll(), static fn(array $c): bool => (int) ($c['is_active'] ?? 0) === 1);
if ($configs === []) {
Flash::set('order.error', $this->translator->get('receipts.create.no_configs'));
return Response::redirect('/orders/' . $orderId);
}
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
$items = is_array($details['items'] ?? null) ? $details['items'] : [];
$seller = $this->companySettings->getSettings();
$totalGross = 0.0;
foreach ($items as $item) {
$qty = (float) ($item['quantity'] ?? 0);
$price = $item['original_price_with_tax'] !== null ? (float) $item['original_price_with_tax'] : 0.0;
$totalGross += $qty * $price;
}
$html = $this->template->render('orders/receipt-create', [
'title' => $this->translator->get('receipts.create.title'),
'activeMenu' => 'orders',
'activeOrders' => 'list',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'orderId' => $orderId,
'order' => $order,
'items' => $items,
'configs' => array_values($configs),
'seller' => $seller,
'totalGross' => $totalGross,
], 'layouts/app');
return Response::html($html);
}
public function store(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('order.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/orders/' . $orderId);
}
$configId = (int) $request->input('config_id', '0');
if ($configId <= 0) {
Flash::set('order.error', $this->translator->get('receipts.create.no_config_selected'));
return Response::redirect('/orders/' . $orderId . '/receipt/create');
}
$config = $this->receiptConfigs->findById($configId);
if ($config === null || (int) ($config['is_active'] ?? 0) !== 1) {
Flash::set('order.error', $this->translator->get('receipts.create.invalid_config'));
return Response::redirect('/orders/' . $orderId . '/receipt/create');
}
$details = $this->orders->findDetails($orderId);
if ($details === null) {
return Response::html('Not found', 404);
}
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
$items = is_array($details['items'] ?? null) ? $details['items'] : [];
$addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : [];
$payments = is_array($details['payments'] ?? null) ? $details['payments'] : [];
$seller = $this->companySettings->getSettings();
$sellerSnapshot = [
'company_name' => $seller['company_name'] ?? '',
'tax_number' => $seller['tax_number'] ?? '',
'street' => $seller['street'] ?? '',
'city' => $seller['city'] ?? '',
'postal_code' => $seller['postal_code'] ?? '',
'phone' => $seller['phone'] ?? '',
'email' => $seller['email'] ?? '',
'bank_account' => $seller['bank_account'] ?? '',
'bdo_number' => $seller['bdo_number'] ?? '',
'regon' => $seller['regon'] ?? '',
'court_register' => $seller['court_register'] ?? '',
];
$buyerAddress = $this->resolveBuyerAddress($addresses);
$buyerSnapshot = $buyerAddress !== null ? [
'name' => $buyerAddress['name'] ?? '',
'company_name' => $buyerAddress['company_name'] ?? '',
'tax_number' => $buyerAddress['company_tax_number'] ?? '',
'street' => trim(($buyerAddress['street_name'] ?? '') . ' ' . ($buyerAddress['street_number'] ?? '')),
'city' => $buyerAddress['city'] ?? '',
'postal_code' => $buyerAddress['zip_code'] ?? '',
'phone' => $buyerAddress['phone'] ?? '',
'email' => $buyerAddress['email'] ?? '',
] : null;
$itemsSnapshot = [];
$totalGross = 0.0;
foreach ($items as $item) {
$qty = (float) ($item['quantity'] ?? 0);
$price = $item['original_price_with_tax'] !== null ? (float) $item['original_price_with_tax'] : 0.0;
$lineTotal = $qty * $price;
$totalGross += $lineTotal;
$itemsSnapshot[] = [
'name' => $item['original_name'] ?? '',
'quantity' => $qty,
'price' => $price,
'total' => $lineTotal,
'sku' => $item['sku'] ?? '',
'ean' => $item['ean'] ?? '',
];
}
$issueDate = trim((string) $request->input('issue_date', ''));
if ($issueDate === '' || strtotime($issueDate) === false) {
$issueDate = date('Y-m-d');
}
$saleDate = $this->resolveSaleDate($config, $order, $payments, $issueDate);
$orderReference = $this->resolveOrderReference($config, $order);
try {
$receiptNumber = $this->receipts->getNextNumber(
$configId,
(string) ($config['number_format'] ?? 'PAR/%N/%M/%Y'),
(string) ($config['numbering_type'] ?? 'monthly')
);
$user = $this->auth->user();
$this->receipts->create([
'order_id' => $orderId,
'config_id' => $configId,
'receipt_number' => $receiptNumber,
'issue_date' => $issueDate,
'sale_date' => $saleDate,
'seller_data_json' => json_encode($sellerSnapshot, JSON_UNESCAPED_UNICODE),
'buyer_data_json' => $buyerSnapshot !== null ? json_encode($buyerSnapshot, JSON_UNESCAPED_UNICODE) : null,
'items_json' => json_encode($itemsSnapshot, JSON_UNESCAPED_UNICODE),
'total_net' => number_format($totalGross, 2, '.', ''),
'total_gross' => number_format($totalGross, 2, '.', ''),
'order_reference_value' => $orderReference,
'created_by' => is_array($user) ? ($user['id'] ?? null) : null,
]);
$userName = is_array($user) ? (string) ($user['username'] ?? $user['email'] ?? '') : '';
$this->orders->recordActivity(
$orderId,
'receipt_issued',
'Wystawiono paragon: ' . $receiptNumber,
['receipt_number' => $receiptNumber, 'config_id' => $configId, 'total_gross' => number_format($totalGross, 2, '.', '')],
'user',
$userName !== '' ? $userName : null
);
Flash::set('order.success', 'Paragon wystawiony: ' . $receiptNumber);
} catch (Throwable) {
Flash::set('order.error', 'Blad wystawiania paragonu');
}
return Response::redirect('/orders/' . $orderId);
}
/**
* @param list<array<string, mixed>> $addresses
* @return array<string, mixed>|null
*/
private function resolveBuyerAddress(array $addresses): ?array
{
$byType = [];
foreach ($addresses as $addr) {
$type = (string) ($addr['address_type'] ?? '');
if ($type !== '' && !isset($byType[$type])) {
$byType[$type] = $addr;
}
}
return $byType['invoice'] ?? $byType['customer'] ?? null;
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $order
* @param list<array<string, mixed>> $payments
*/
private function resolveSaleDate(array $config, array $order, array $payments, string $issueDate): string
{
$source = (string) ($config['sale_date_source'] ?? 'issue_date');
if ($source === 'order_date') {
$ordered = (string) ($order['ordered_at'] ?? '');
if ($ordered !== '') {
$ts = strtotime($ordered);
return $ts !== false ? date('Y-m-d', $ts) : $issueDate;
}
}
if ($source === 'payment_date' && $payments !== []) {
$lastPayment = $payments[0] ?? [];
$payDate = (string) ($lastPayment['payment_date'] ?? '');
if ($payDate !== '') {
$ts = strtotime($payDate);
return $ts !== false ? date('Y-m-d', $ts) : $issueDate;
}
}
return $issueDate;
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $order
*/
private function resolveOrderReference(array $config, array $order): ?string
{
$ref = (string) ($config['order_reference'] ?? 'none');
if ($ref === 'orderpro') {
return (string) ($order['internal_order_number'] ?? '');
}
if ($ref === 'integration') {
return (string) ($order['external_order_id'] ?? '');
}
return null;
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
use PDO;
final class ReceiptRepository
{
public function __construct(
private readonly PDO $pdo
) {
}
/**
* @return list<array<string, mixed>>
*/
public function findByOrderId(int $orderId): array
{
$statement = $this->pdo->prepare(
'SELECT r.*, rc.name AS config_name
FROM receipts r
LEFT JOIN receipt_configs rc ON rc.id = r.config_id
WHERE r.order_id = :order_id
ORDER BY r.created_at DESC'
);
$statement->execute(['order_id' => $orderId]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return array<string, mixed>|null
*/
public function findById(int $id): ?array
{
$statement = $this->pdo->prepare('SELECT * FROM receipts WHERE id = :id LIMIT 1');
$statement->execute(['id' => $id]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
/**
* @param array<string, mixed> $data
*/
public function create(array $data): int
{
$statement = $this->pdo->prepare(
'INSERT INTO receipts (
order_id, config_id, receipt_number, issue_date, sale_date,
seller_data_json, buyer_data_json, items_json,
total_net, total_gross, order_reference_value, created_by
) VALUES (
:order_id, :config_id, :receipt_number, :issue_date, :sale_date,
:seller_data_json, :buyer_data_json, :items_json,
:total_net, :total_gross, :order_reference_value, :created_by
)'
);
$statement->execute([
'order_id' => (int) $data['order_id'],
'config_id' => (int) $data['config_id'],
'receipt_number' => (string) $data['receipt_number'],
'issue_date' => (string) $data['issue_date'],
'sale_date' => (string) $data['sale_date'],
'seller_data_json' => (string) $data['seller_data_json'],
'buyer_data_json' => $data['buyer_data_json'],
'items_json' => (string) $data['items_json'],
'total_net' => (string) $data['total_net'],
'total_gross' => (string) $data['total_gross'],
'order_reference_value' => $data['order_reference_value'] ?? null,
'created_by' => $data['created_by'] ?? null,
]);
return (int) $this->pdo->lastInsertId();
}
public function getNextNumber(int $configId, string $numberFormat, string $numberingType): string
{
$year = (int) date('Y');
$month = $numberingType === 'yearly' ? null : (int) date('n');
if ($month === null) {
$this->pdo->prepare(
'INSERT INTO receipt_number_counters (config_id, year, month, last_number)
VALUES (:config_id, :year, NULL, 1)
ON DUPLICATE KEY UPDATE last_number = last_number + 1'
)->execute(['config_id' => $configId, 'year' => $year]);
$stmt = $this->pdo->prepare(
'SELECT last_number FROM receipt_number_counters
WHERE config_id = :config_id AND year = :year AND month IS NULL'
);
$stmt->execute(['config_id' => $configId, 'year' => $year]);
} else {
$this->pdo->prepare(
'INSERT INTO receipt_number_counters (config_id, year, month, last_number)
VALUES (:config_id, :year, :month, 1)
ON DUPLICATE KEY UPDATE last_number = last_number + 1'
)->execute(['config_id' => $configId, 'year' => $year, 'month' => $month]);
$stmt = $this->pdo->prepare(
'SELECT last_number FROM receipt_number_counters
WHERE config_id = :config_id AND year = :year AND month = :month'
);
$stmt->execute(['config_id' => $configId, 'year' => $year, 'month' => $month]);
}
$lastNumber = (int) $stmt->fetchColumn();
$number = str_replace(
['%N', '%M', '%Y'],
[str_pad((string) $lastNumber, 3, '0', STR_PAD_LEFT), str_pad((string) ($month ?? 1), 2, '0', STR_PAD_LEFT), (string) $year],
$numberFormat
);
return $number;
}
}

View File

@@ -10,7 +10,9 @@ use App\Core\Security\Csrf;
use App\Core\View\Template;
use App\Core\Support\Flash;
use App\Core\Support\StringHelper;
use App\Modules\Accounting\ReceiptRepository;
use App\Modules\Auth\AuthService;
use App\Modules\Settings\ReceiptConfigRepository;
use App\Modules\Shipments\ShipmentPackageRepository;
final class OrdersController
@@ -20,7 +22,9 @@ final class OrdersController
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly OrdersRepository $orders,
private readonly ?ShipmentPackageRepository $shipmentPackages = null
private readonly ?ShipmentPackageRepository $shipmentPackages = null,
private readonly ?ReceiptRepository $receiptRepo = null,
private readonly ?ReceiptConfigRepository $receiptConfigRepo = null
) {
}
@@ -162,6 +166,18 @@ final class OrdersController
? $this->shipmentPackages->findByOrderId($orderId)
: [];
$receipts = $this->receiptRepo !== null
? $this->receiptRepo->findByOrderId($orderId)
: [];
$activeReceiptConfigs = [];
if ($this->receiptConfigRepo !== null) {
$activeReceiptConfigs = array_filter(
$this->receiptConfigRepo->listAll(),
static fn(array $c): bool => (int) ($c['is_active'] ?? 0) === 1
);
}
$flashSuccess = (string) Flash::get('order.success', '');
$flashError = (string) Flash::get('order.error', '');
@@ -188,6 +204,8 @@ final class OrdersController
'currentStatusCode' => $statusCode,
'flashSuccess' => $flashSuccess,
'flashError' => $flashError,
'receipts' => $receipts,
'receiptConfigs' => $activeReceiptConfigs,
], 'layouts/app');
return Response::html($html);

View File

@@ -44,6 +44,10 @@ final class CompanySettingsRepository
'tax_number' => trim((string) ($row['tax_number'] ?? '')),
'bank_account' => trim((string) ($row['bank_account'] ?? '')),
'bank_owner_name' => trim((string) ($row['bank_owner_name'] ?? '')),
'bdo_number' => trim((string) ($row['bdo_number'] ?? '')),
'regon' => trim((string) ($row['regon'] ?? '')),
'court_register' => trim((string) ($row['court_register'] ?? '')),
'logo_path' => trim((string) ($row['logo_path'] ?? '')),
'default_package_length_cm' => (float) ($row['default_package_length_cm'] ?? 25.0),
'default_package_width_cm' => (float) ($row['default_package_width_cm'] ?? 20.0),
'default_package_height_cm' => (float) ($row['default_package_height_cm'] ?? 8.0),
@@ -73,6 +77,10 @@ final class CompanySettingsRepository
tax_number = :tax_number,
bank_account = :bank_account,
bank_owner_name = :bank_owner_name,
bdo_number = :bdo_number,
regon = :regon,
court_register = :court_register,
logo_path = :logo_path,
default_package_length_cm = :length,
default_package_width_cm = :width,
default_package_height_cm = :height,
@@ -95,6 +103,10 @@ final class CompanySettingsRepository
'tax_number' => StringHelper::nullableString((string) ($data['tax_number'] ?? '')),
'bank_account' => StringHelper::nullableString((string) ($data['bank_account'] ?? '')),
'bank_owner_name' => StringHelper::nullableString((string) ($data['bank_owner_name'] ?? '')),
'bdo_number' => StringHelper::nullableString((string) ($data['bdo_number'] ?? '')),
'regon' => StringHelper::nullableString((string) ($data['regon'] ?? '')),
'court_register' => StringHelper::nullableString((string) ($data['court_register'] ?? '')),
'logo_path' => StringHelper::nullableString((string) ($data['logo_path'] ?? '')),
'length' => max(0.1, (float) ($data['default_package_length_cm'] ?? 25.0)),
'width' => max(0.1, (float) ($data['default_package_width_cm'] ?? 20.0)),
'height' => max(0.1, (float) ($data['default_package_height_cm'] ?? 8.0)),
@@ -151,6 +163,10 @@ final class CompanySettingsRepository
'tax_number' => '',
'bank_account' => '',
'bank_owner_name' => '',
'bdo_number' => '',
'regon' => '',
'court_register' => '',
'logo_path' => '',
'default_package_length_cm' => 25.0,
'default_package_width_cm' => 20.0,
'default_package_height_cm' => 8.0,

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use Throwable;
final class ReceiptConfigController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly ReceiptConfigRepository $repository
) {
}
public function index(Request $request): Response
{
$t = $this->translator;
$configs = $this->repository->listAll();
$editConfig = null;
$editId = (int) $request->input('edit', '0');
if ($editId > 0) {
$editConfig = $this->repository->findById($editId);
}
$html = $this->template->render('settings/accounting', [
'title' => $t->get('settings.accounting.title'),
'activeMenu' => 'settings',
'activeSettings' => 'accounting',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'configs' => $configs,
'editConfig' => $editConfig,
'successMessage' => Flash::get('settings.accounting.success', ''),
'errorMessage' => Flash::get('settings.accounting.error', ''),
], 'layouts/app');
return Response::html($html);
}
public function save(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings.accounting.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/settings/accounting');
}
$name = trim((string) $request->input('name', ''));
$numberFormat = trim((string) $request->input('number_format', ''));
if ($name === '') {
Flash::set('settings.accounting.error', 'Nazwa konfiguracji jest wymagana');
return Response::redirect('/settings/accounting');
}
if ($numberFormat === '' || strpos($numberFormat, '%N') === false) {
Flash::set('settings.accounting.error', 'Format numeracji jest wymagany i musi zawierac %N');
return Response::redirect('/settings/accounting');
}
try {
$this->repository->save([
'id' => $request->input('id', ''),
'name' => $name,
'is_active' => $request->input('is_active', null),
'number_format' => $numberFormat,
'numbering_type' => $request->input('numbering_type', 'monthly'),
'is_named' => $request->input('is_named', null),
'sale_date_source' => $request->input('sale_date_source', 'issue_date'),
'order_reference' => $request->input('order_reference', 'none'),
]);
Flash::set('settings.accounting.success', $this->translator->get('settings.accounting.flash.saved'));
} catch (Throwable) {
Flash::set('settings.accounting.error', $this->translator->get('settings.accounting.flash.save_failed'));
}
return Response::redirect('/settings/accounting');
}
public function toggleStatus(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings.accounting.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/settings/accounting');
}
$id = (int) $request->input('id', '0');
if ($id <= 0) {
Flash::set('settings.accounting.error', 'Nieprawidlowy identyfikator konfiguracji');
return Response::redirect('/settings/accounting');
}
try {
$this->repository->toggleStatus($id);
Flash::set('settings.accounting.success', $this->translator->get('settings.accounting.flash.toggled'));
} catch (Throwable) {
Flash::set('settings.accounting.error', 'Blad zmiany statusu');
}
return Response::redirect('/settings/accounting');
}
public function delete(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings.accounting.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/settings/accounting');
}
$id = (int) $request->input('id', '0');
if ($id <= 0) {
Flash::set('settings.accounting.error', 'Nieprawidlowy identyfikator konfiguracji');
return Response::redirect('/settings/accounting');
}
try {
$this->repository->delete($id);
Flash::set('settings.accounting.success', $this->translator->get('settings.accounting.flash.deleted'));
} catch (Throwable) {
Flash::set('settings.accounting.error', $this->translator->get('settings.accounting.flash.delete_failed'));
}
return Response::redirect('/settings/accounting');
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
use Throwable;
final class ReceiptConfigRepository
{
public function __construct(
private readonly PDO $pdo
) {
}
/**
* @return list<array<string, mixed>>
*/
public function listAll(): array
{
$statement = $this->pdo->prepare('SELECT * FROM receipt_configs ORDER BY created_at DESC');
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return array<string, mixed>|null
*/
public function findById(int $id): ?array
{
$statement = $this->pdo->prepare('SELECT * FROM receipt_configs WHERE id = :id LIMIT 1');
$statement->execute(['id' => $id]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
/**
* @param array<string, mixed> $data
*/
public function save(array $data): void
{
$id = isset($data['id']) && $data['id'] !== '' ? (int) $data['id'] : null;
$params = [
'name' => trim((string) ($data['name'] ?? '')),
'is_active' => isset($data['is_active']) ? 1 : 0,
'number_format' => trim((string) ($data['number_format'] ?? 'PAR/%N/%M/%Y')),
'numbering_type' => in_array((string) ($data['numbering_type'] ?? ''), ['monthly', 'yearly'], true)
? (string) $data['numbering_type']
: 'monthly',
'is_named' => isset($data['is_named']) ? 1 : 0,
'sale_date_source' => in_array((string) ($data['sale_date_source'] ?? ''), ['order_date', 'payment_date', 'issue_date'], true)
? (string) $data['sale_date_source']
: 'issue_date',
'order_reference' => in_array((string) ($data['order_reference'] ?? ''), ['none', 'orderpro', 'integration'], true)
? (string) $data['order_reference']
: 'none',
];
if ($id !== null) {
$statement = $this->pdo->prepare(
'UPDATE receipt_configs SET
name = :name,
is_active = :is_active,
number_format = :number_format,
numbering_type = :numbering_type,
is_named = :is_named,
sale_date_source = :sale_date_source,
order_reference = :order_reference
WHERE id = :id'
);
$params['id'] = $id;
} else {
$statement = $this->pdo->prepare(
'INSERT INTO receipt_configs (name, is_active, number_format, numbering_type, is_named, sale_date_source, order_reference)
VALUES (:name, :is_active, :number_format, :numbering_type, :is_named, :sale_date_source, :order_reference)'
);
}
$statement->execute($params);
}
public function toggleStatus(int $id): void
{
$statement = $this->pdo->prepare(
'UPDATE receipt_configs SET is_active = NOT is_active WHERE id = :id'
);
$statement->execute(['id' => $id]);
}
public function delete(int $id): void
{
$statement = $this->pdo->prepare('DELETE FROM receipt_configs WHERE id = :id');
$statement->execute(['id' => $id]);
}
}