<?php
namespace App\Controller\Api;
use App\Entity\ApartmentPayment;
use App\Entity\PaymentBill;
use App\Entity\User;
use App\Helpers\ResponseCode;
use App\Repository\ApartmentDataRepository;
use App\Repository\CounterInfoRepository;
use App\Repository\PaymentBillRepository;
use App\Repository\UtilityBillItemRepository;
use App\Utils\Fondy;
use App\Utils\Mercure;
use App\Utils\Paginator;
use App\Utils\ResponseTrait;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
class UtilityBillController extends AbstractController
{
use ResponseTrait {
ResponseTrait::__construct as private traitConstruct;
}
public function __construct(ContainerInterface $container)
{
$this->traitConstruct($container);
}
/**
* @Route(path="/api/utility/dashboard", methods={"GET"})
*/
public function getDashboard(Request $request)
{
/** @var User $user */
$user = $this->getUser();
$em = $this->getDoctrine()->getManager();
$apartmentDataRepository = $em->getRepository(\App\Entity\ApartmentData::class);
$utilityBillItemRepository = $em->getRepository(\App\Entity\UtilityBillItem::class);
// Get persAcc or type filter from query parameter (optional)
// persAcc takes precedence over type for more precise filtering
$persAcc = $request->query->get('persAcc');
$type = $request->query->get('type');
// Get all apartments for user
$allApartments = $apartmentDataRepository->findAllByUser($user);
if (empty($allApartments)) {
return $this->statusOk(null, null, [
'user' => [
'name' => $user->getFullName(),
'apartment' => $user->getApartment(),
],
'apartments' => [],
'balance' => 0,
'counters' => [],
'bills' => [],
]);
}
// If persAcc is specified, find that specific apartment
$apartmentData = null;
if ($persAcc) {
$apartmentData = $apartmentDataRepository->findByPersAcc($persAcc);
// Verify it belongs to this user
if ($apartmentData && $apartmentData->getUser() !== $user) {
$apartmentData = null;
}
} elseif ($type) {
// Fallback to type filter if persAcc not provided
// Normalize "boxing" to "box" for compatibility
$normalizedType = ($type === 'boxing') ? 'box' : $type;
foreach ($allApartments as $apt) {
$aptType = $apt->getType();
// Normalize "boxing" to "box" for comparison
if ($aptType === 'boxing') {
$aptType = 'box';
}
if ($aptType === $normalizedType) {
$apartmentData = $apt;
break;
}
}
} else {
// Default to first apartment (or apartment type if available)
$apartmentData = $allApartments[0];
foreach ($allApartments as $apt) {
if ($apt->getType() === 'apartment') {
$apartmentData = $apt;
break;
}
}
}
// Format all apartments for selection screen
$apartments = [];
foreach ($allApartments as $apt) {
// Use total_balance from entity, fallback to calculated balance if not available
// Negate the value to match calculateBalance format (negative = red/debt)
$aptBalance = $apt->getTotalBalance() !== null
? -(float)$apt->getTotalBalance()
: $this->calculateBalance($apt, $utilityBillItemRepository);
$aptNumber = $apt->getNumber() ?? '';
// Format number display
$displayNumber = $aptNumber;
if (preg_match('/№\s*(\d+)/', $aptNumber, $matches)) {
$displayNumber = '№' . $matches[1];
}
$apartments[] = [
'persAcc' => $apt->getPersAcc(),
'type' => $apt->getType(),
'number' => $displayNumber,
'balance' => $aptBalance,
'fio' => $apt->getFio(),
];
}
// Get utility counters (only for apartments with counters)
$counters = [];
if ($apartmentData && $apartmentData->getType() === 'apartment') {
$counters = $this->formatCounters($apartmentData->getCounters()->toArray());
}
// Get bills grouped by month for selected apartment
$bills = [];
if ($apartmentData) {
$bills = $this->getBillsGroupedByMonth($apartmentData, $utilityBillItemRepository);
}
// Get payments for selected apartment or all apartments
$payments = [];
if ($persAcc) {
// Get payments for specific apartment
if ($apartmentData) {
$payments = $this->formatPayments($apartmentData->getPayments()->toArray());
}
} else {
// Get all payments from all apartments
foreach ($allApartments as $apt) {
$aptPayments = $this->formatPayments($apt->getPayments()->toArray());
$payments = array_merge($payments, $aptPayments);
}
// Sort by date descending
usort($payments, function($a, $b) {
return strcmp($b['paymentDate'], $a['paymentDate']);
});
}
// Get balance for selected apartment - use total_balance from entity, fallback to calculated balance
// Negate the value to match calculateBalance format (negative = red/debt)
$balance = 0;
if ($apartmentData) {
$balance = $apartmentData->getTotalBalance() !== null
? -(float)$apartmentData->getTotalBalance()
: $this->calculateBalance($apartmentData, $utilityBillItemRepository);
}
// Get user name from first apartment or user
$userName = $user->getFullName();
if (!empty($allApartments) && $allApartments[0]->getFio()) {
$userName = $allApartments[0]->getFio();
}
return $this->statusOk(null, null, [
'user' => [
'name' => $userName,
'apartment' => $apartmentData && $apartmentData->getNumber()
? $apartmentData->getNumber()
: ($user->getApartment() ? 'Апартаменти №' . $user->getApartment() : null),
],
'apartments' => $apartments,
'balance' => $balance,
'counters' => $counters,
'bills' => $bills,
'payments' => $payments,
]);
}
/**
* @Route(path="/api/utility/bills/{year}/{month}", methods={"GET"})
*/
public function getBillsForMonth(int $year, int $month, Request $request)
{
/** @var User $user */
$user = $this->getUser();
$em = $this->getDoctrine()->getManager();
$apartmentDataRepository = $em->getRepository(\App\Entity\ApartmentData::class);
$utilityBillItemRepository = $em->getRepository(\App\Entity\UtilityBillItem::class);
// Get type filter from query parameter (optional)
$type = $request->query->get('type');
// Get all apartments for user
$allApartments = $apartmentDataRepository->findAllByUser($user);
// Find apartment by type or use first one
$apartmentData = null;
if ($type) {
foreach ($allApartments as $apt) {
if ($apt->getType() === $type) {
$apartmentData = $apt;
break;
}
}
} else {
// Default to first apartment (or apartment type if available)
if (!empty($allApartments)) {
$apartmentData = $allApartments[0];
foreach ($allApartments as $apt) {
if ($apt->getType() === 'apartment') {
$apartmentData = $apt;
break;
}
}
}
}
if (!$apartmentData) {
return $this->statusOk(null, null, [
'period' => sprintf('%04d-%02d', $year, $month),
'total' => 0,
'items' => [],
]);
}
$period = new \DateTime();
$period->setDate($year, $month, 1);
$period->setTime(0, 0, 0);
$billItems = $utilityBillItemRepository->createQueryBuilder('bi')
->where('bi.apartmentData = :apartmentData')
->andWhere('bi.period = :period')
->setParameter('apartmentData', $apartmentData)
->setParameter('period', $period)
->getQuery()
->getResult();
$total = 0;
$items = [];
foreach ($billItems as $item) {
$amount = (float)$item->getAmount();
$total += $amount;
$items[] = [
'service' => $item->getService(),
'org' => $item->getOrg(),
'amount' => number_format($amount, 2, ',', ' '),
];
}
return $this->statusOk(null, null, [
'period' => sprintf('%04d-%02d', $year, $month),
'total' => number_format($total, 2, ',', ' '),
'items' => $items,
]);
}
/**
* @Route(path="/api/utility/payments", methods={"GET"})
*/
public function getPaymentHistory(Request $request, Paginator $paginator)
{
/** @var User $user */
$user = $this->getUser();
$em = $this->getDoctrine()->getManager();
$paymentBillRepository = $em->getRepository(\App\Entity\PaymentBill::class);
$query = $paymentBillRepository->getByUser($user);
$payments = $paginator->paginate(
$query,
$request->query->getInt('page', 1),
$request->query->getInt('limit', 10)
);
$formattedPayments = [];
foreach ($payments['items'] as $payment) {
$formattedPayments[] = [
'id' => $payment->getId(),
'amount' => number_format($payment->getCost(), 2, ',', ' '),
'date' => $payment->getCreatedAt()->format('d.m.Y H:i'),
'status' => $payment->getStatus(),
];
}
$payments['items'] = $formattedPayments;
return $this->statusOk(null, null, $payments);
}
/**
* @Route(path="/api/utility/payment", methods={"POST"})
*/
public function payUtilityBill(
Request $request,
TranslatorInterface $translator,
Fondy $fondy,
Mercure $mercure
) {
try {
$data = json_decode($request->getContent(), true);
/** @var User $user */
$user = $this->getUser();
$em = $this->getDoctrine()->getManager();
// Validate input
$year = isset($data['year']) ? (int)$data['year'] : null;
$month = isset($data['month']) ? (int)$data['month'] : null;
$amount = isset($data['amount']) ? (float)$data['amount'] : null;
if (!$year || !$month || !$amount || $amount <= 0) {
return $this->statusConflict(
$translator->trans('invalid_payment_data'),
ResponseCode::BILLS_NOT_PROVIDED
);
}
// Check if user has valid card token
$recToken = $user->getRecToken();
$rectokenLifetime = $user->getRectokenLifetime();
if (!$recToken || !$rectokenLifetime || $rectokenLifetime->format('Y-m-d') < date('Y-m-d')) {
return $this->statusConflict(
$translator->trans('need_card'),
ResponseCode::BILLS_NOT_PROVIDED
);
}
// Get apartment data
$apartmentDataRepository = $em->getRepository(\App\Entity\ApartmentData::class);
$apartmentData = $apartmentDataRepository->findByUser($user);
if (!$apartmentData) {
return $this->statusConflict(
$translator->trans('apartment_not_found'),
ResponseCode::BILLS_NOT_PROVIDED
);
}
// Get utility bill items for the specified period
$period = new \DateTime();
$period->setDate($year, $month, 1);
$period->setTime(0, 0, 0);
$utilityBillItemRepository = $em->getRepository(\App\Entity\UtilityBillItem::class);
$billItems = $utilityBillItemRepository->createQueryBuilder('bi')
->where('bi.apartmentData = :apartmentData')
->andWhere('bi.period = :period')
->setParameter('apartmentData', $apartmentData)
->setParameter('period', $period)
->getQuery()
->getResult();
if (empty($billItems)) {
return $this->statusConflict(
$translator->trans('bills_not_found_for_period'),
ResponseCode::BILLS_NOT_PROVIDED
);
}
// Create payment
$payment = new PaymentBill();
$payment->setUser($user);
$payment->setCost($amount);
$em->persist($payment);
$em->flush();
// Process payment through Fondy
$result = $fondy->initiatePayment(
ceil($amount * 100), // Convert to kopecks
$user->getRecToken(),
'Utility_Payment_' . $payment->getId()
);
// Update payment with Fondy response
$payment->setOrderId($result['order_id'] ?? '')
->setStatus($result['order_status'] ?? '')
->setPaymentId($result['payment_id'] ?? '');
$em->persist($payment);
$em->flush();
// Send Mercure notification
$mercure->sendMessageAboutBillPayment($payment);
return $this->statusOk(
$translator->trans('payment_done'),
null,
[
'payment_id' => $payment->getPaymentId(),
'order_id' => $payment->getOrderId(),
'status' => $payment->getStatus(),
'amount' => $amount,
]
);
} catch (\Exception $e) {
return $this->statusConflict(
'Error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine(),
ResponseCode::BILLS_NOT_PROVIDED
);
}
}
/**
* Format utility counters for mobile app
*
* @param array $counters
* @return array
*/
private function formatCounters(array $counters): array
{
$formatted = [];
$counterMap = [
'Електроенергія' => ['type' => 'light', 'label' => 'Світло'],
'Холодна вода' => ['type' => 'cold_water', 'label' => 'Холодна вода'],
'Гаряча вода' => ['type' => 'hot_water', 'label' => 'Гаряча вода'],
'Опалення' => ['type' => 'heating', 'label' => 'Опалення'],
];
foreach ($counters as $counter) {
$counterName = $counter->getCounter();
foreach ($counterMap as $key => $info) {
if (strpos($counterName, $key) !== false) {
$formatted[] = [
'type' => $info['type'],
'label' => $info['label'],
'indication' => $counter->getIndication(),
'counter' => $counterName,
];
break;
}
}
}
// Ensure all types are present
$types = ['light', 'cold_water', 'hot_water', 'heating'];
$existingTypes = array_column($formatted, 'type');
foreach ($types as $type) {
if (!in_array($type, $existingTypes)) {
$labelMap = [
'light' => 'Світло',
'cold_water' => 'Холодна вода',
'hot_water' => 'Гаряча вода',
'heating' => 'Опалення',
];
$formatted[] = [
'type' => $type,
'label' => $labelMap[$type],
'indication' => 0,
'counter' => '',
];
}
}
return $formatted;
}
/**
* Get bills grouped by month
*
* @param $apartmentData
* @param UtilityBillItemRepository $utilityBillItemRepository
* @return array
*/
private function getBillsGroupedByMonth($apartmentData, UtilityBillItemRepository $utilityBillItemRepository): array
{
$billItems = $utilityBillItemRepository->createQueryBuilder('bi')
->where('bi.apartmentData = :apartmentData')
->setParameter('apartmentData', $apartmentData)
->orderBy('bi.period', 'DESC')
->getQuery()
->getResult();
$grouped = [];
$ukrainianMonths = [
1 => 'Січень', 2 => 'Лютий', 3 => 'Березень',
4 => 'Квітень', 5 => 'Травень', 6 => 'Червень',
7 => 'Липень', 8 => 'Серпень', 9 => 'Вересень',
10 => 'Жовтень', 11 => 'Листопад', 12 => 'Грудень',
];
foreach ($billItems as $item) {
$period = $item->getPeriod();
$key = $period->format('Y-m');
$year = (int)$period->format('Y');
$month = (int)$period->format('m');
if (!isset($grouped[$key])) {
$grouped[$key] = [
'period' => $key,
'month' => $ukrainianMonths[$month] ?? $month,
'year' => $year,
'total' => 0,
'items' => [],
];
}
$amount = (float)$item->getAmount();
$grouped[$key]['total'] += $amount;
$grouped[$key]['items'][] = [
'service' => $item->getService(),
'amount' => $amount,
];
}
// Format totals and sort by period descending
$result = [];
foreach ($grouped as $key => $data) {
$result[] = [
'period' => $key,
'month' => $data['month'],
'year' => $data['year'],
'total' => number_format($data['total'], 2, ',', ' '),
'items' => array_map(function($item) {
return [
'service' => $item['service'],
'amount' => number_format($item['amount'], 2, ',', ' '),
];
}, $data['items']),
];
}
// Sort by period descending
usort($result, function($a, $b) {
return strcmp($b['period'], $a['period']);
});
return $result;
}
/**
* Map service name to short name for mobile display
*
* @param string $service
* @return string
*/
private function mapServiceToShortName(string $service): string
{
$mapping = [
'електроенергі' => 'Світло',
'холодн' => 'Холодна вода',
'гаряч' => 'Гаряча вода',
'опаленн' => 'Опалення',
];
$serviceLower = mb_strtolower($service);
foreach ($mapping as $key => $label) {
if (mb_strpos($serviceLower, $key) !== false) {
return $label;
}
}
return $service;
}
/**
* Calculate balance (negative sum of unpaid bills)
*
* @param $apartmentData
* @param UtilityBillItemRepository $utilityBillItemRepository
* @return float
*/
private function calculateBalance($apartmentData, UtilityBillItemRepository $utilityBillItemRepository): float
{
// Get all unpaid bill items
$billItems = $utilityBillItemRepository->createQueryBuilder('bi')
->where('bi.apartmentData = :apartmentData')
->setParameter('apartmentData', $apartmentData)
->getQuery()
->getResult();
$total = 0;
foreach ($billItems as $item) {
$total += (float)$item->getAmount();
}
// Return negative balance
return -$total;
}
/**
* @Route(path="/api/utility/counters", methods={"GET"})
*/
public function getCounters(Request $request)
{
/** @var User $user */
$user = $this->getUser();
$em = $this->getDoctrine()->getManager();
$apartmentDataRepository = $em->getRepository(\App\Entity\ApartmentData::class);
$counterInfoRepository = $em->getRepository(\App\Entity\CounterInfo::class);
// Get persAcc from query parameter (optional)
$persAcc = $request->query->get('persAcc');
// Get all apartments for user
$allApartments = $apartmentDataRepository->findAllByUser($user);
// Find apartment by persAcc if provided, otherwise use first apartment
$apartmentData = null;
if ($persAcc) {
$apartmentData = $apartmentDataRepository->findByPersAcc($persAcc);
// Verify it belongs to this user
if ($apartmentData && $apartmentData->getUser() !== $user) {
$apartmentData = null;
}
} else {
// Default to first apartment if no persAcc specified
if (!empty($allApartments)) {
$apartmentData = $allApartments[0];
}
}
if (!$apartmentData) {
return $this->statusOk(null, null, [
'counters' => [],
]);
}
$counterInfos = $counterInfoRepository->findByPersAcc($apartmentData->getPersAcc());
$formattedCounters = [];
foreach ($counterInfos as $counterInfo) {
$counterType = $this->getCounterType($counterInfo->getCounter());
$indications = [];
foreach ($counterInfo->getIndications() as $indication) {
$indications[] = [
'period' => $indication->getPeriod()->format('Y-m-d'),
'previous' => $indication->getIndiPrev(),
'current' => $indication->getIndiCur(),
'difference' => $indication->getIndiCur() - $indication->getIndiPrev(),
'updated' => $indication->getCreatedAt()->format('d.m.Y'),
];
}
// Sort indications by period descending
usort($indications, function($a, $b) {
return strcmp($b['period'], $a['period']);
});
// Extract meter number from counter string (e.g., "10622721/Електроенергія" -> "10622721")
$meterNumber = $this->extractMeterNumber($counterInfo->getCounter());
$formattedCounters[] = [
'type' => $counterType['type'],
'label' => $counterType['label'],
'counter' => $counterInfo->getCounter(),
'meterNumber' => $meterNumber,
'persAcc' => $counterInfo->getPersAcc(),
'dateStart' => $counterInfo->getDateStart() ? $counterInfo->getDateStart()->format('Y-m-d') : null,
'dateEnd' => $counterInfo->getDateEnd() ? $counterInfo->getDateEnd()->format('Y-m-d') : null,
'indications' => $indications,
];
}
return $this->statusOk(null, null, [
'counters' => $formattedCounters,
]);
}
/**
* @Route(path="/api/utility/counters/{type}", methods={"GET"})
*/
public function getCounterByType(string $type, Request $request)
{
/** @var User $user */
$user = $this->getUser();
$em = $this->getDoctrine()->getManager();
$apartmentDataRepository = $em->getRepository(\App\Entity\ApartmentData::class);
$counterInfoRepository = $em->getRepository(\App\Entity\CounterInfo::class);
// Get persAcc from query parameter (optional)
$persAcc = $request->query->get('persAcc');
// Get all apartments for user
$allApartments = $apartmentDataRepository->findAllByUser($user);
// Find apartment by persAcc if provided, otherwise use first apartment
$apartmentData = null;
if ($persAcc) {
$apartmentData = $apartmentDataRepository->findByPersAcc($persAcc);
// Verify it belongs to this user
if ($apartmentData && $apartmentData->getUser() !== $user) {
$apartmentData = null;
}
} else {
// Default to first apartment if no persAcc specified
if (!empty($allApartments)) {
$apartmentData = $allApartments[0];
}
}
if (!$apartmentData) {
return $this->statusOk(null, null, [
'type' => $type,
'counter' => null,
'indications' => [],
]);
}
$counterInfos = $counterInfoRepository->findByPersAcc($apartmentData->getPersAcc());
$typeMap = [
'light' => 'Електроенергія',
'cold_water' => 'Холодна вода',
'hot_water' => 'Гаряча вода',
'heating' => 'Опалення',
];
$searchKey = $typeMap[$type] ?? null;
if (!$searchKey) {
return $this->statusOk(null, null, [
'type' => $type,
'counter' => null,
'indications' => [],
]);
}
$counterInfo = null;
foreach ($counterInfos as $ci) {
if (strpos($ci->getCounter(), $searchKey) !== false) {
$counterInfo = $ci;
break;
}
}
if (!$counterInfo) {
return $this->statusOk(null, null, [
'type' => $type,
'counter' => null,
'indications' => [],
]);
}
$indications = [];
foreach ($counterInfo->getIndications() as $indication) {
$indications[] = [
'period' => $indication->getPeriod()->format('Y-m-d'),
'previous' => $indication->getIndiPrev(),
'current' => $indication->getIndiCur(),
'difference' => $indication->getIndiCur() - $indication->getIndiPrev(),
'updated' => $indication->getCreatedAt()->format('d.m.Y'),
];
}
// Sort indications by period descending
usort($indications, function($a, $b) {
return strcmp($b['period'], $a['period']);
});
$counterType = $this->getCounterType($counterInfo->getCounter());
// Extract meter number from counter string (e.g., "10622721/Електроенергія" -> "10622721")
$meterNumber = $this->extractMeterNumber($counterInfo->getCounter());
return $this->statusOk(null, null, [
'type' => $type,
'label' => $counterType['label'],
'counter' => $counterInfo->getCounter(),
'meterNumber' => $meterNumber,
'persAcc' => $counterInfo->getPersAcc(),
'dateStart' => $counterInfo->getDateStart() ? $counterInfo->getDateStart()->format('Y-m-d') : null,
'dateEnd' => $counterInfo->getDateEnd() ? $counterInfo->getDateEnd()->format('Y-m-d') : null,
'indications' => $indications,
]);
}
/**
* Get counter type from counter name
*
* @param string $counterName
* @return array
*/
private function getCounterType(string $counterName): array
{
$counterMap = [
'Електроенергія' => ['type' => 'light', 'label' => 'Світло'],
'Холодна вода' => ['type' => 'cold_water', 'label' => 'Холодна вода'],
'Гаряча вода' => ['type' => 'hot_water', 'label' => 'Гаряча вода'],
'Опалення' => ['type' => 'heating', 'label' => 'Опалення'],
];
foreach ($counterMap as $key => $info) {
if (strpos($counterName, $key) !== false) {
return $info;
}
}
return ['type' => 'unknown', 'label' => $counterName];
}
/**
* Extract meter number from counter string
* Example: "10622721/Електроенергія" -> "10622721"
*
* @param string $counter
* @return string
*/
private function extractMeterNumber(string $counter): string
{
$parts = explode('/', $counter);
return $parts[0] ?? '';
}
/**
* Format payments for mobile app
*
* @param array $payments
* @return array
*/
private function formatPayments(array $payments): array
{
$formatted = [];
foreach ($payments as $payment) {
$formatted[] = [
'paymentDate' => $payment->getPaymentDate()->format('Y-m-d\TH:i:s'),
'paymentAmount' => number_format((float)$payment->getPaymentAmount(), 2, ',', ' '),
];
}
return $formatted;
}
}