<?php
namespace ApplicationBundle\Modules\BankConnect\Controller;
use ApplicationBundle\Controller\GenericController;
use ApplicationBundle\Entity\FinancialConnector;
use ApplicationBundle\Interfaces\SessionCheckInterface;
use ApplicationBundle\Modules\Aspire\Aspire;
use ApplicationBundle\Modules\Authentication\Constants\UserConstants;
use ApplicationBundle\Modules\BankConnect\ConnectorCredentialService;
use ApplicationBundle\Modules\BankConnect\ConnectorRegistry;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* BC2 — the tenant-facing "connect a bank" UI over the unified connector registry
* ({@see FinancialConnector}). One screen for every provider: list connectors + status, add/edit
* one, pause/resume, delete, and sync-now. Credentials never leave this box in the clear —
* secrets are encrypted on save ({@see ConnectorCredentialService}) and only ever shown redacted.
*
* Aspire (adapter #1) keeps its detailed setup in the Aspire module; here it is "linked" by
* pointing a registry row at an existing AspireConnection, so the unified screen can drive it
* (sync-now → the proven Aspire::syncConnection) without duplicating its credentials.
*/
class BankConnectController extends GenericController implements SessionCheckInterface
{
private function boot(): void
{
ConnectorCredentialService::ensureKey($this->container);
}
public function indexAction(Request $request): Response
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$companyId = $request->getSession()->get(UserConstants::USER_COMPANY_ID);
$connectors = $em->getRepository('ApplicationBundle\\Entity\\FinancialConnector')
->findBy(array('companyId' => $companyId), array('id' => 'DESC'));
// Existing Aspire connections available to link (so the tenant picks, never hand-types).
$aspireConnections = $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')
->findBy(array('companyId' => $companyId), array('id' => 'DESC'));
return $this->render('@BankConnect/pages/bank_connect.html.twig', array(
'connectors' => $connectors,
'aspireConnections' => $aspireConnections,
'builtProviders' => ConnectorRegistry::BUILT,
'plannedProviders' => ConnectorRegistry::PLANNED,
'keyAvailable' => ConnectorCredentialService::keyAvailable(),
'page_title' => 'Bank Connections',
));
}
/** POST /bank-connect/save — create or update one connector. */
public function saveAction(Request $request): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$companyId = (int) $request->getSession()->get(UserConstants::USER_COMPANY_ID);
$id = (int) $request->request->get('id', 0);
$provider = strtolower(trim((string) $request->request->get('provider', '')));
$label = trim((string) $request->request->get('label', ''));
$schedule = trim((string) $request->request->get('schedule_cron', ''));
if ($provider === '') {
return new JsonResponse(array('success' => false, 'message' => 'Provider is required.'));
}
if (!in_array($provider, ConnectorRegistry::knownProviders(), true)) {
return new JsonResponse(array('success' => false, 'message' => 'Unknown provider.'));
}
if ($id) {
$c = $em->getRepository('ApplicationBundle\\Entity\\FinancialConnector')->find($id);
if (!$c || (int) $c->getCompanyId() !== $companyId) {
return new JsonResponse(array('success' => false, 'message' => 'Not found.'));
}
} else {
$c = new FinancialConnector();
$c->setCompanyId($companyId ?: 1);
$c->setStatus(FinancialConnector::STATUS_ACTIVE);
$c->setCreatedAt(new \DateTime());
}
$c->setProvider($provider);
$c->setLabel($label !== '' ? $label : ucfirst($provider));
$c->setScheduleCron($schedule !== '' ? $schedule : null);
$c->setUpdatedAt(new \DateTime());
if ($provider === 'aspire') {
// Aspire creds live on the AspireConnection — link, don't duplicate.
$linked = (int) $request->request->get('linked_aspire_connection_id', 0);
if ($linked <= 0) {
return new JsonResponse(array('success' => false, 'message' => 'Choose an Aspire connection to link.'));
}
$ac = $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($linked);
if (!$ac || (int) $ac->getCompanyId() !== $companyId) {
return new JsonResponse(array('success' => false, 'message' => 'Aspire connection not found.'));
}
$c->setAuthType(FinancialConnector::AUTH_API_KEY);
$c->setLinkedAspireConnectionId($linked);
} else {
// Other providers: creds are entered here → ENCRYPTED at rest. Refuse to store in the
// clear (fail closed) if no encryption key is configured on the box.
$credsJson = trim((string) $request->request->get('credentials', ''));
if ($credsJson !== '') {
if (!ConnectorCredentialService::keyAvailable()) {
return new JsonResponse(array('success' => false, 'message' => 'Encryption key not configured — cannot store credentials securely. Set app_encryption_key first.'));
}
$creds = json_decode($credsJson, true);
if (!is_array($creds)) {
// Accept a bare token too (e.g. Wise API token pasted directly).
$creds = array('token' => $credsJson);
}
$c->setCredentialsEnc(ConnectorCredentialService::encrypt($creds));
}
$c->setAuthType($provider === 'wise' ? FinancialConnector::AUTH_API_KEY : FinancialConnector::AUTH_OAUTH2);
}
$accountRefs = trim((string) $request->request->get('account_refs', ''));
if ($accountRefs !== '') { $c->setAccountRefs($accountRefs); }
$em->persist($c);
$em->flush();
return new JsonResponse(array('success' => true, 'id' => $c->getId()));
}
/** POST /bank-connect/{id}/toggle — pause ⇄ resume. */
public function toggleAction(Request $request, int $id): JsonResponse
{
$this->boot();
$c = $this->ownedConnector($request, $id);
if (!$c) { return new JsonResponse(array('success' => false, 'message' => 'Not found.')); }
$c->setStatus($c->getStatus() === FinancialConnector::STATUS_ACTIVE
? FinancialConnector::STATUS_PAUSED : FinancialConnector::STATUS_ACTIVE);
$c->setUpdatedAt(new \DateTime());
$this->getDoctrine()->getManager()->flush();
return new JsonResponse(array('success' => true, 'status' => $c->getStatus()));
}
/** POST /bank-connect/{id}/delete. */
public function deleteAction(Request $request, int $id): JsonResponse
{
$this->boot();
$c = $this->ownedConnector($request, $id);
if (!$c) { return new JsonResponse(array('success' => false, 'message' => 'Not found.')); }
$em = $this->getDoctrine()->getManager();
$em->remove($c);
$em->flush();
return new JsonResponse(array('success' => true));
}
/** POST /bank-connect/{id}/sync — sync one connector now (Aspire → proven syncConnection). */
public function syncNowAction(Request $request, int $id): JsonResponse
{
$this->boot();
$c = $this->ownedConnector($request, $id);
if (!$c) { return new JsonResponse(array('success' => false, 'message' => 'Not found.')); }
$em = $this->getDoctrine()->getManager();
$loginId = (int) $request->getSession()->get(UserConstants::USER_LOGIN_ID);
if (strtolower((string) $c->getProvider()) === 'aspire') {
$ac = $c->getLinkedAspireConnectionId()
? $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find((int) $c->getLinkedAspireConnectionId())
: null;
if (!$ac) { return new JsonResponse(array('success' => false, 'message' => 'Linked Aspire connection missing.')); }
$res = Aspire::syncConnection($ac, $em, $loginId, 'connect-ui');
$c->setLastSyncAt(new \DateTime());
$c->setLastSyncStatus(!empty($res['success']) ? 'ok' : 'error');
$c->setLastSyncError(!empty($res['success']) ? null : ($res['error'] ?? 'sync error'));
$em->flush();
if (empty($res['success'])) {
return new JsonResponse(array('success' => false, 'message' => $res['error'] ?? 'Sync failed.'));
}
return new JsonResponse(array('success' => true, 'detail' => sprintf('Fetched %d, new %d, posted %d.',
$res['fetched'] ?? 0, $res['new'] ?? 0, $res['auto_posted'] ?? 0)));
}
return new JsonResponse(array('success' => false, 'message' => ucfirst($c->getProvider()) . ' sync ships in a later phase (BC3/BC4).'));
}
private function ownedConnector(Request $request, int $id)
{
$companyId = (int) $request->getSession()->get(UserConstants::USER_COMPANY_ID);
$c = $this->getDoctrine()->getManager()->getRepository('ApplicationBundle\\Entity\\FinancialConnector')->find($id);
return ($c && (int) $c->getCompanyId() === $companyId) ? $c : null;
}
}