src/ApplicationBundle/Modules/Aspire/Controller/AspireController.php line 39

Open in your IDE?
  1. <?php
  2. namespace ApplicationBundle\Modules\Aspire\Controller;
  3. use ApplicationBundle\Controller\GenericController;
  4. use ApplicationBundle\Entity\AccTransactionDetails;
  5. use ApplicationBundle\Entity\AccTransactions;
  6. use ApplicationBundle\Entity\AspireAccount;
  7. use ApplicationBundle\Entity\AspireConnection;
  8. use ApplicationBundle\Entity\AspirePayout;
  9. use ApplicationBundle\Entity\AspirePayoutItem;
  10. use ApplicationBundle\Entity\AspirePostRule;
  11. use ApplicationBundle\Entity\AspireTransaction;
  12. use ApplicationBundle\Helper\Crypt;
  13. use ApplicationBundle\Interfaces\SessionCheckInterface;
  14. use ApplicationBundle\Modules\Aspire\Aspire;
  15. use ApplicationBundle\Modules\Authentication\Constants\UserConstants;
  16. use Symfony\Component\HttpFoundation\JsonResponse;
  17. use Symfony\Component\HttpFoundation\Request;
  18. use Symfony\Component\HttpFoundation\Response;
  19. class AspireController extends GenericController implements SessionCheckInterface
  20. {
  21.     private function boot(): void
  22.     {
  23.         $key '';
  24.         try {
  25.             $key $this->getParameter('app_encryption_key');
  26.         } catch (\Exception $e) {}
  27.         if ($key) {
  28.             Crypt::setKey($key);
  29.         }
  30.     }
  31.     // =========================================================================
  32.     // CONNECTIONS
  33.     // =========================================================================
  34.     public function connectionListAction(Request $request): Response
  35.     {
  36.         $this->boot();
  37.         $em      $this->getDoctrine()->getManager();
  38.         $session $request->getSession();
  39.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  40.         $connections $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')
  41.             ->findBy(['companyId' => $companyId], ['id' => 'DESC']);
  42.         return $this->render('@Aspire/pages/list/list_aspire_connections.html.twig', [
  43.             'connections' => $connections,
  44.             'page_title'  => 'Aspire Connect — Connections',
  45.         ]);
  46.     }
  47.     /**
  48.      * Aspire Accounts ⇄ ERP bank-account (GL head) mapping. Each Aspire account
  49.      * (multi-currency) maps to a local bank_accounts row whose accounts_head_id is
  50.      * the GL head used when posting that account's transactions. A tenant default
  51.      * (acc_setting aspire_default_bank_account_id) is the fallback.
  52.      */
  53.     public function accountListAction(Request $request): Response
  54.     {
  55.         $this->boot();
  56.         $em        $this->getDoctrine()->getManager();
  57.         $conn      $em->getConnection();
  58.         $companyId  $request->getSession()->get(UserConstants::USER_COMPANY_ID);
  59.         $accounts $conn->fetchAllAssociative(
  60.             "SELECT aa.id, aa.aspire_account_id, aa.account_type, aa.currency_code, aa.available_balance, aa.bank_account_id,
  61.                     ba.account_holder_name AS bank_name, ba.account_number, ba.accounts_head_id,
  62.                     ah.name AS gl_head_name
  63.              FROM aspire_account aa
  64.              LEFT JOIN bank_accounts ba ON ba.id = aa.bank_account_id
  65.              LEFT JOIN acc_accounts_head ah ON ah.accounts_head_id = ba.accounts_head_id
  66.              WHERE aa.company_id = ? ORDER BY aa.currency_code, aa.aspire_account_id",
  67.             [$companyId]
  68.         );
  69.         $bankAccounts $conn->fetchAllAssociative(
  70.             "SELECT ba.id, ba.account_holder_name, ba.account_number, ba.currency_code, ba.accounts_head_id, ah.name AS gl_head_name
  71.              FROM bank_accounts ba LEFT JOIN acc_accounts_head ah ON ah.accounts_head_id = ba.accounts_head_id
  72.              ORDER BY ba.account_holder_name"
  73.         );
  74.         $defaultBankId = (int) $conn->fetchOne("SELECT data FROM acc_setting WHERE name = 'aspire_default_bank_account_id' LIMIT 1");
  75.         return $this->render('@Aspire/pages/list/aspire_accounts.html.twig', [
  76.             'accounts'      => $accounts,
  77.             'bankAccounts'  => $bankAccounts,
  78.             'defaultBankId' => $defaultBankId,
  79.             'page_title'    => 'Aspire Connect — Accounts',
  80.         ]);
  81.     }
  82.     /** POST /aspire/accounts/map — set an account's bank mapping, or the default. */
  83.     public function accountMapAction(Request $request): JsonResponse
  84.     {
  85.         $this->boot();
  86.         $em        $this->getDoctrine()->getManager();
  87.         $conn      $em->getConnection();
  88.         $companyId  = (int) $request->getSession()->get(UserConstants::USER_COMPANY_ID);
  89.         // set tenant default fallback
  90.         if ($request->request->get('set_default') !== null) {
  91.             $bankId = (int) $request->request->get('bankAccountId'0);
  92.             $conn->executeStatement("DELETE FROM acc_setting WHERE name = 'aspire_default_bank_account_id'");
  93.             if ($bankId 0) {
  94.                 $conn->executeStatement(
  95.                     "INSERT INTO acc_setting (name, data, company_id, created_at) VALUES ('aspire_default_bank_account_id', ?, ?, NOW())",
  96.                     [(string) $bankId$companyId]
  97.                 );
  98.             }
  99.             return new JsonResponse(['success' => true]);
  100.         }
  101.         // map a specific Aspire account → bank account (+ backfill its txns)
  102.         $accId  = (int) $request->request->get('id'0);
  103.         $bankId = (int) $request->request->get('bankAccountId'0);
  104.         $acct $accId $em->getRepository('ApplicationBundle\\Entity\\AspireAccount')->find($accId) : null;
  105.         if (!$acct || $acct->getCompanyId() != $companyId) {
  106.             return new JsonResponse(['success' => false'message' => 'Account not found.']);
  107.         }
  108.         $acct->setBankAccountId($bankId ?: null);
  109.         $em->flush();
  110.         // backfill un-posted transactions of this Aspire account with the mapped wallet
  111.         $backfilled $conn->executeStatement(
  112.             "UPDATE aspire_transaction SET local_bank_account_id = ?
  113.              WHERE company_id = ? AND aspire_account_id = ? AND status NOT IN ('posted','auto_posted','matched')",
  114.             [$bankId ?: 0$companyId$acct->getAspireAccountId()]
  115.         );
  116.         return new JsonResponse(['success' => true'backfilled' => $backfilled]);
  117.     }
  118.     public function connectionFormAction(Request $requestint $id 0): Response
  119.     {
  120.         $this->boot();
  121.         $em      $this->getDoctrine()->getManager();
  122.         $session $request->getSession();
  123.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  124.         $conn $id $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($id) : null;
  125.         if ($conn && $conn->getCompanyId() != $companyId) {
  126.             return $this->redirectToRoute('aspire_connection_list');
  127.         }
  128.         $bankAccounts $em->getRepository('ApplicationBundle\\Entity\\BankAccounts')
  129.             ->createQueryBuilder('b')
  130.             ->where('b.docBookedFlag != 1 OR b.docBookedFlag IS NULL')
  131.             ->getQuery()->getResult();
  132.         return $this->render('@Aspire/pages/input_forms/aspire_connection.html.twig', [
  133.             'conn'         => $conn,
  134.             'bankAccounts' => $bankAccounts,
  135.             'page_title'   => $conn 'Edit Connection' 'New Aspire Connection',
  136.         ]);
  137.     }
  138.     public function connectionSaveAction(Request $request): JsonResponse
  139.     {
  140.         $this->boot();
  141.         $em      $this->getDoctrine()->getManager();
  142.         $session $request->getSession();
  143.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  144.         $loginId   $session->get(UserConstants::USER_LOGIN_ID);
  145.         $id    = (int)$request->request->get('id'0);
  146.         $label trim($request->request->get('label'''));
  147.         $clientId     trim($request->request->get('client_id'''));
  148.         $clientSecret trim($request->request->get('client_secret'''));
  149.         $defaultBankAccountId = (int)$request->request->get('default_bank_account_id'0) ?: null;
  150.         $payoutClientId     trim($request->request->get('payout_client_id''')) ?: null;
  151.         $payoutClientSecret trim($request->request->get('payout_client_secret''')) ?: null;
  152.         $initialSyncDateStr trim($request->request->get('initial_sync_date'''));
  153.         $initialSyncDate $initialSyncDateStr ? new \DateTime($initialSyncDateStr) : null;
  154.         if (!$label || !$clientId) {
  155.             return new JsonResponse(['success' => false'message' => 'Label and Client ID are required.']);
  156.         }
  157.         if ($id) {
  158.             $conn $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($id);
  159.             if (!$conn || $conn->getCompanyId() != $companyId) {
  160.                 return new JsonResponse(['success' => false'message' => 'Not found.']);
  161.             }
  162.             $conn->setEditLoginId($loginId);
  163.         } else {
  164.             $conn = new AspireConnection();
  165.             $conn->setCompanyId($companyId);
  166.             $conn->setCreateLoginId($loginId);
  167.             $conn->setStatus('active');
  168.         }
  169.         $conn->setLabel($label);
  170.         $conn->setClientId($clientId);
  171.         if ($clientSecret) {
  172.             $conn->setClientSecret(Crypt::encrypt($clientSecret));
  173.         }
  174.         $conn->setDefaultBankAccountId($defaultBankAccountId);
  175.         $conn->setInitialSyncDate($initialSyncDate);
  176.         // Payout credentials (optional, separate Aspire API key for transfers)
  177.         if ($payoutClientId !== null) {
  178.             $conn->setPayoutClientId($payoutClientId ?: null);
  179.         }
  180.         if ($payoutClientSecret) {
  181.             $conn->setPayoutClientSecret(Crypt::encrypt($payoutClientSecret));
  182.         } elseif ($payoutClientId === '') {
  183.             // Clearing payout client id also clears the secret
  184.             $conn->setPayoutClientSecret(null);
  185.         }
  186.         $em->persist($conn);
  187.         $em->flush();
  188.         return new JsonResponse(['success' => true'id' => $conn->getId()]);
  189.     }
  190.     public function connectionTestAction(Request $requestint $id): JsonResponse
  191.     {
  192.         $this->boot();
  193.         $em      $this->getDoctrine()->getManager();
  194.         $session $request->getSession();
  195.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  196.         $conn $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($id);
  197.         if (!$conn || $conn->getCompanyId() != $companyId) {
  198.             return new JsonResponse(['success' => false'message' => 'Not found.']);
  199.         }
  200.         // Force token refresh
  201.         $conn->setTokenExpiresAt(0);
  202.         $token Aspire::getValidAccessToken($conn$em);
  203.         if (!$token) {
  204.             $detail $conn->getLastSyncError() ?: 'No error detail stored.';
  205.             return new JsonResponse(['success' => false'message' => 'Invalid credentials — could not obtain token. Detail: ' $detail]);
  206.         }
  207.         $resp Aspire::makeRequest(Aspire::BASE_URL '/accounts'$token);
  208.         if (!empty($resp['error']) || ($resp['http_code'] ?? 200) >= 400) {
  209.             return new JsonResponse(['success' => false'message' => 'Credentials valid but /accounts returned an error.''detail' => $resp]);
  210.         }
  211.         $count count($resp['data'] ?? []);
  212.         return new JsonResponse(['success' => true'message' => "Connected. Found {$count} Aspire account(s)."]);
  213.     }
  214.     public function connectionDisableAction(Request $requestint $id): JsonResponse
  215.     {
  216.         $this->boot();
  217.         $em      $this->getDoctrine()->getManager();
  218.         $session $request->getSession();
  219.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  220.         $conn $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($id);
  221.         if (!$conn || $conn->getCompanyId() != $companyId) {
  222.             return new JsonResponse(['success' => false]);
  223.         }
  224.         $conn->setStatus($conn->getStatus() === 'disabled' 'active' 'disabled');
  225.         $em->flush();
  226.         return new JsonResponse(['success' => true'status' => $conn->getStatus()]);
  227.     }
  228.     public function connectionDeleteAction(Request $requestint $id): JsonResponse
  229.     {
  230.         $this->boot();
  231.         $em      $this->getDoctrine()->getManager();
  232.         $session $request->getSession();
  233.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  234.         $conn $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($id);
  235.         if (!$conn || $conn->getCompanyId() != $companyId) {
  236.             return new JsonResponse(['success' => false]);
  237.         }
  238.         $conn->setStatus('deleted');
  239.         $em->flush();
  240.         return new JsonResponse(['success' => true]);
  241.     }
  242.     // =========================================================================
  243.     // ACCOUNT MAPPING
  244.     // =========================================================================
  245.     public function accountMapSaveAction(Request $request): JsonResponse
  246.     {
  247.         $this->boot();
  248.         $em        $this->getDoctrine()->getManager();
  249.         $session   $request->getSession();
  250.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  251.         $aspireAccountEntityId = (int)$request->request->get('aspire_account_entity_id');
  252.         $bankAccountId         = (int)$request->request->get('bank_account_id') ?: null;
  253.         $acct $em->getRepository('ApplicationBundle\\Entity\\AspireAccount')->find($aspireAccountEntityId);
  254.         if (!$acct || $acct->getCompanyId() != $companyId) {
  255.             return new JsonResponse(['success' => false]);
  256.         }
  257.         $acct->setBankAccountId($bankAccountId);
  258.         $em->flush();
  259.         return new JsonResponse(['success' => true]);
  260.     }
  261.     // =========================================================================
  262.     // TRANSACTIONS (staging inbox)
  263.     // =========================================================================
  264.     public function transactionListAction(Request $request): Response
  265.     {
  266.         $em        $this->getDoctrine()->getManager();
  267.         $session   $request->getSession();
  268.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  269.         $status       $request->query->get('status''');
  270.         $connectionId = (int)$request->query->get('connection_id'0);
  271.         $qb $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')
  272.             ->createQueryBuilder('t')
  273.             ->where('t.companyId = :cid')
  274.             ->setParameter('cid'$companyId)
  275.             ->orderBy('t.postedAt''DESC')
  276.             ->setMaxResults(500);
  277.         if ($status) {
  278.             $qb->andWhere('t.status = :st')->setParameter('st'$status);
  279.         }
  280.         if ($connectionId) {
  281.             $qb->andWhere('t.connectionId = :conn')->setParameter('conn'$connectionId);
  282.         }
  283.         $transactions $qb->getQuery()->getResult();
  284.         $connections $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')
  285.             ->findBy(['companyId' => $companyId]);
  286.         return $this->render('@Aspire/pages/list/list_aspire_transactions.html.twig', [
  287.             'transactions' => $transactions,
  288.             'connections'  => $connections,
  289.             'status'       => $status,
  290.             'connectionId' => $connectionId,
  291.             'page_title'   => 'Aspire Connect — Transactions',
  292.         ]);
  293.     }
  294.     public function transactionViewAction(Request $requestint $id): Response
  295.     {
  296.         $em        $this->getDoctrine()->getManager();
  297.         $session   $request->getSession();
  298.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  299.         $txn $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find($id);
  300.         if (!$txn || $txn->getCompanyId() != $companyId) {
  301.             return $this->redirectToRoute('aspire_txn_list');
  302.         }
  303.         // Functional currency + the live engine rate, so the view can show the
  304.         // FX-rate override only for foreign txns and pre-fill the auto rate.
  305.         $fqp      '\ApplicationBundle\Modules\Accounts\Service\FxRateProvider';
  306.         $baseCode strtoupper((string) ($fqp::resolveBaseCurrency($em, (int) $companyId) ?: ''));
  307.         $currCode strtoupper((string) $txn->getCurrencyCode());
  308.         $autoRate null;
  309.         if ($currCode && $baseCode && $currCode !== $baseCode) {
  310.             $d $txn->getPostedAt() ?: new \DateTime();
  311.             // getRate() throws if no rate row exists — that's fine, just show no auto rate.
  312.             try { $r = (float) $fqp::getRate($em$currCode$baseCode$d'MID'); if ($r 0) { $autoRate $r; } }
  313.             catch (\Throwable $e) {}
  314.         }
  315.         return $this->render('@Aspire/pages/view/view_aspire_transaction.html.twig', [
  316.             'txn'                => $txn,
  317.             'page_title'         => 'Transaction #' $txn->getId(),
  318.             'base_currency_code' => $baseCode,
  319.             'auto_rate'          => $autoRate,
  320.         ]);
  321.     }
  322.     public function transactionPostNowAction(Request $requestint $id): JsonResponse
  323.     {
  324.         $this->boot();
  325.         $em        $this->getDoctrine()->getManager();
  326.         $session   $request->getSession();
  327.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  328.         $loginId   $session->get(UserConstants::USER_LOGIN_ID);
  329.         $txn $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find($id);
  330.         if (!$txn || $txn->getCompanyId() != $companyId) {
  331.             return new JsonResponse(['success' => false'message' => 'Not found.']);
  332.         }
  333.         if (in_array($txn->getStatus(), ['posted''auto_posted'])) {
  334.             return new JsonResponse(['success' => false'message' => 'Already posted.']);
  335.         }
  336.         $debitHead  = (int)$request->request->get('debit_account_head_id');
  337.         $creditHead = (int)$request->request->get('credit_account_head_id');
  338.         $notes      $request->request->get('notes''');
  339.         if (!$debitHead || !$creditHead) {
  340.             return new JsonResponse(['success' => false'message' => 'Select both debit and credit accounts.']);
  341.         }
  342.         $rule = new AspirePostRule();
  343.         $rule->setDebitAccountHeadId($debitHead);
  344.         $rule->setCreditAccountHeadId($creditHead);
  345.         $rule->setAutoPost(true);
  346.         $rule->setEnabled(true);
  347.         $txn->setNotes($notes);
  348.         // Optional per-leg FX rate overrides from the inbox (blank => engine default).
  349.         $opts   = [];
  350.         $drRate $request->request->get('dr_rate''');
  351.         $crRate $request->request->get('cr_rate''');
  352.         if ($drRate !== '' && (float) $drRate 0) { $opts['drRate'] = (float) $drRate; }
  353.         if ($crRate !== '' && (float) $crRate 0) { $opts['crRate'] = (float) $crRate; }
  354.         $ok Aspire::postTransactionToGL($txn$rule$em$loginId$opts);
  355.         if ($ok) {
  356.             $txn->setStatus('posted');
  357.             $em->flush();
  358.             // Audit-Ready: the moment cash posts, check whether this party head is already documented
  359.             // (tagged to an invoice) or needs a source document → open/close the case automatically.
  360.             // Advisory only — never allowed to affect the GL post.
  361.             try {
  362.                 (new \ApplicationBundle\Modules\Accounts\Service\AuditReadyReconciliationService($em))
  363.                     ->evaluateAfterPosting([$debitHead$creditHead], null, (int) $loginId);
  364.             } catch (\Throwable $e) { /* audit-ready is advisory — never break a GL post */ }
  365.             // Build pre-filled suggest_rule payload for the "save as rule?" modal
  366.             $matchVal '';
  367.             if ($txn->getDescription()) {
  368.                 $words array_slice(preg_split('/\s+/'trim($txn->getDescription())), 04);
  369.                 $matchVal implode(' '$words);
  370.             }
  371.             $suggestRule = [
  372.                 'name'                  => substr($txn->getDescription() ?: $txn->getReference() ?: ''080),
  373.                 'match_type'            => $txn->getCounterparty() ? 'counterparty_equals' 'description_contains',
  374.                 'match_value'           => $txn->getCounterparty() ?: $matchVal,
  375.                 'currency_code'         => $txn->getCurrencyCode(),
  376.                 'direction_filter'      => $txn->getDirection() ?: 'both',
  377.                 'debit_account_head_id' => $debitHead,
  378.                 'credit_account_head_id'=> $creditHead,
  379.             ];
  380.             return new JsonResponse(['success' => true'suggest_rule' => $suggestRule]);
  381.         }
  382.         return new JsonResponse(['success' => false'message' => 'GL posting failed — check account head configuration.']);
  383.     }
  384.     /**
  385.      * Undo a post / match so the bank transaction can be re-linked.
  386.      *  - posted / auto_posted: Aspire CREATED a standalone GL voucher → reverse + delete
  387.      *    it (DeleteDocument::AccTransactions un-hits the ledger), drop the feed overlay.
  388.      *  - matched: linked to a PRE-EXISTING voucher → only UNLINK + drop our feed overlay;
  389.      *    never delete that voucher (it wasn't ours).
  390.      * The transaction returns to 'new' so it re-appears in the inbox for re-posting/re-linking.
  391.      * POST /aspire/transaction/undo/{id}
  392.      */
  393.     public function transactionUndoAction(Request $requestint $id): JsonResponse
  394.     {
  395.         $this->boot();
  396.         $em        $this->getDoctrine()->getManager();
  397.         $session   $request->getSession();
  398.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  399.         $txn $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find($id);
  400.         if (!$txn || $txn->getCompanyId() != $companyId) {
  401.             return new JsonResponse(['success' => false'message' => 'Not found.']);
  402.         }
  403.         $status    $txn->getStatus();
  404.         $voucherId = (int) $txn->getAccTransactionsId();
  405.         if (!in_array($status, ['posted''auto_posted''matched'])) {
  406.             return new JsonResponse(['success' => false'message' => 'Nothing to undo — this transaction is not posted or matched.']);
  407.         }
  408.         // An FX conversion posts BOTH legs onto ONE voucher — collect every Aspire leg
  409.         // linked to the same voucher so they all reset together (otherwise the paired leg
  410.         // is left orphaned, pointing at a voucher that's been deleted → "can't undo").
  411.         $repo     $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction');
  412.         $siblings $voucherId
  413.             $repo->findBy(['accTransactionsId' => $voucherId'companyId' => $companyId])
  414.             : [];
  415.         if (empty($siblings)) { $siblings = [$txn]; }
  416.         try {
  417.             if ($status === 'matched') {
  418.                 // Linked to an existing voucher — unlink only, never delete it.
  419.                 if ($voucherId) { \ApplicationBundle\Modules\Aspire\Aspire::unmarkReconciledFromFeed($em$voucherId); }
  420.                 $message $voucherId
  421.                     ? ('Unlinked from voucher #' $voucherId ' — it can now be re-linked or posted.')
  422.                     : 'Unlinked — it can now be re-linked or posted.';
  423.             } else {
  424.                 // We created this voucher — reverse the GL hit by deleting it (once). Tolerate
  425.                 // an already-deleted voucher (e.g. undoing the orphaned second leg).
  426.                 if ($voucherId) {
  427.                     \ApplicationBundle\Modules\Aspire\Aspire::unmarkReconciledFromFeed($em$voucherId);
  428.                     $stillExists $em->getConnection()->fetchOne(
  429.                         'SELECT transaction_id FROM acc_transactions WHERE transaction_id = ?', [$voucherId]
  430.                     );
  431.                     if ($stillExists) {
  432.                         \ApplicationBundle\Modules\System\DeleteDocument::AccTransactions($em$voucherId1);
  433.                     }
  434.                 }
  435.                 $legNote = (count($siblings) > 1) ? (' (' count($siblings) . ' legs reset)') : '';
  436.                 $message $voucherId
  437.                     ? ('GL post reversed (voucher #' $voucherId ' deleted)' $legNote ' — it can now be re-posted or linked.')
  438.                     : 'Post reversed — it can now be re-posted or linked.';
  439.             }
  440.             $stamp = (new \DateTime())->format('Y-m-d H:i');
  441.             foreach ($siblings as $leg) {
  442.                 $leg->setAccTransactionsId(null);
  443.                 $leg->setMatchedRuleId(null);
  444.                 $leg->setPostedAtErp(null);
  445.                 $leg->setPostedLoginId(null);
  446.                 $leg->setStatus('new');
  447.                 $note trim((string) $leg->getNotes());
  448.                 $leg->setNotes(trim($note ' [post/link undone ' $stamp ']'));
  449.             }
  450.             $em->flush();
  451.         } catch (\Throwable $e) {
  452.             return new JsonResponse(['success' => false'message' => 'Undo failed: ' $e->getMessage()]);
  453.         }
  454.         return new JsonResponse(['success' => true'message' => $message]);
  455.     }
  456.     public function transactionIgnoreAction(Request $requestint $id): JsonResponse
  457.     {
  458.         $em        $this->getDoctrine()->getManager();
  459.         $session   $request->getSession();
  460.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  461.         $txn $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find($id);
  462.         if (!$txn || $txn->getCompanyId() != $companyId) {
  463.             return new JsonResponse(['success' => false]);
  464.         }
  465.         $txn->setStatus('ignored');
  466.         $em->flush();
  467.         return new JsonResponse(['success' => true]);
  468.     }
  469.     /**
  470.      * Candidate existing vouchers to match this bank transaction against — by amount
  471.      * (±tolerance) within a date window, excluding vouchers already linked to another
  472.      * Aspire transaction. GET /aspire/transaction/match-candidates/{id}
  473.      */
  474.     public function transactionMatchCandidatesAction(Request $requestint $id): JsonResponse
  475.     {
  476.         $em        $this->getDoctrine()->getManager();
  477.         $companyId $request->getSession()->get(UserConstants::USER_COMPANY_ID);
  478.         $conn      $em->getConnection();
  479.         $txn $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find($id);
  480.         if (!$txn || $txn->getCompanyId() != $companyId) {
  481.             return new JsonResponse(['success' => false'message' => 'Not found.']);
  482.         }
  483.         // The bank amount is signed (negative for outgoing) but voucher leg amounts
  484.         // are stored positive — match on the ABSOLUTE value.
  485.         $amount abs((float) $txn->getAmount());
  486.         $tol    max(0.5$amount 0.01);                 // 1% or 50c, whichever larger
  487.         $date   $txn->getPostedAt() ?: new \DateTime();
  488.         $from   = (clone $date)->modify('-31 days');        // bank value-date vs posting-date drift
  489.         $to     = (clone $date)->modify('+31 days');
  490.         // Match against a transaction LINE (the leg that hit the bank), not the header
  491.         // sum — a multi-leg voucher's header total rarely equals a single bank line.
  492.         $rows $conn->fetchAllAssociative(
  493.             "SELECT t.transaction_id AS id, t.document_hash AS hash,
  494.                     t.transaction_amount AS amount, t.transaction_date AS date,
  495.                     MIN(ABS(d.amount - ?)) AS leg_diff
  496.                FROM acc_transactions t
  497.                JOIN acc_transaction_details d ON d.transaction_id = t.transaction_id
  498.               WHERE ABS(d.amount - ?) <= ?
  499.                 AND t.transaction_date BETWEEN ? AND ?
  500.                 AND (t.delete_flag = 0 OR t.delete_flag IS NULL)
  501.                 AND t.transaction_id NOT IN (
  502.                       SELECT acc_transactions_id FROM aspire_transaction WHERE acc_transactions_id IS NOT NULL
  503.                 )
  504.               GROUP BY t.transaction_id, t.document_hash, t.transaction_amount, t.transaction_date
  505.               ORDER BY leg_diff ASC, ABS(DATEDIFF(t.transaction_date, ?)) ASC, t.transaction_id DESC
  506.               LIMIT 20",
  507.             [$amount$amount$tol$from->format('Y-m-d 00:00:00'), $to->format('Y-m-d 23:59:59'), $date->format('Y-m-d')]
  508.         );
  509.         return new JsonResponse(['success' => true'txn_amount' => $amount'candidates' => $rows]);
  510.     }
  511.     /**
  512.      * Link this bank transaction to an EXISTING voucher instead of posting a new one
  513.      * (dedupe). No GL entry is created — we just mark the bank txn matched & linked, so
  514.      * the reconciliation overlay ties them together. POST /aspire/transaction/match/{id}
  515.      */
  516.     public function transactionMatchAction(Request $requestint $id): JsonResponse
  517.     {
  518.         $em        $this->getDoctrine()->getManager();
  519.         $companyId $request->getSession()->get(UserConstants::USER_COMPANY_ID);
  520.         $conn      $em->getConnection();
  521.         $txn $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find($id);
  522.         if (!$txn || $txn->getCompanyId() != $companyId) {
  523.             return new JsonResponse(['success' => false'message' => 'Not found.']);
  524.         }
  525.         if (in_array($txn->getStatus(), ['posted''auto_posted''matched'])) {
  526.             return new JsonResponse(['success' => false'message' => 'Already posted or matched.']);
  527.         }
  528.         $voucherId = (int) $request->request->get('transaction_id');
  529.         if (!$voucherId) {
  530.             return new JsonResponse(['success' => false'message' => 'Select a voucher to match.']);
  531.         }
  532.         $exists $conn->fetchOne('SELECT transaction_id FROM acc_transactions WHERE transaction_id = ?', [$voucherId]);
  533.         if (!$exists) {
  534.             return new JsonResponse(['success' => false'message' => 'Voucher not found.']);
  535.         }
  536.         // Guard: that voucher must not already be linked to another bank txn.
  537.         $taken $conn->fetchOne('SELECT id FROM aspire_transaction WHERE acc_transactions_id = ? AND id <> ?', [$voucherId$id]);
  538.         if ($taken) {
  539.             return new JsonResponse(['success' => false'message' => 'That voucher is already matched to another transaction.']);
  540.         }
  541.         $txn->setAccTransactionsId($voucherId);
  542.         $txn->setStatus('matched');
  543.         $txn->setPostedAtErp(new \DateTime());
  544.         $txn->setNotes(trim((string) $txn->getNotes() . ' [matched to existing voucher #' $voucherId ']'));
  545.         $em->flush();
  546.         // The bank feed now confirms this existing voucher — mark it reconciled.
  547.         \ApplicationBundle\Modules\Aspire\Aspire::markReconciledFromFeed($em$voucherId$txn, (int) $request->getSession()->get(UserConstants::USER_LOGIN_ID));
  548.         return new JsonResponse(['success' => true'message' => 'Linked to voucher #' $voucherId ' — no new entry posted.']);
  549.     }
  550.     /**
  551.      * Counterpart candidates for a wallet-to-wallet FX conversion — other un-posted
  552.      * Aspire txns in the OPPOSITE direction (the other leg of the exchange).
  553.      * GET /aspire/transaction/conversion-candidates/{id}
  554.      */
  555.     public function transactionConversionCandidatesAction(Request $requestint $id): JsonResponse
  556.     {
  557.         $em        $this->getDoctrine()->getManager();
  558.         $companyId $request->getSession()->get(UserConstants::USER_COMPANY_ID);
  559.         $txn $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find($id);
  560.         if (!$txn || $txn->getCompanyId() != $companyId) {
  561.             return new JsonResponse(['success' => false'message' => 'Not found.']);
  562.         }
  563.         $oppositeDir = ($txn->getDirection() === 'debit') ? 'credit' 'debit';
  564.         // Counterpart legs within ±7 days of this txn (a conversion's two legs settle close
  565.         // together) — much shorter than listing every un-posted transaction.
  566.         $anchorDate = ($txn->getPostedAt() ?: new \DateTime())->format('Y-m-d');
  567.         $rows $em->getConnection()->fetchAllAssociative(
  568.             "SELECT id, aspire_txn_id, amount, currency_code, direction, description, posted_at
  569.                FROM aspire_transaction
  570.               WHERE company_id = ? AND id <> ? AND status = 'new' AND direction = ?
  571.                 AND posted_at IS NOT NULL
  572.                 AND ABS(DATEDIFF(posted_at, ?)) <= 7
  573.               ORDER BY ABS(DATEDIFF(posted_at, ?)) ASC, id DESC
  574.               LIMIT 25",
  575.             [$companyId$id$oppositeDir$anchorDate$anchorDate]
  576.         );
  577.         return new JsonResponse(['success' => true'candidates' => $rows]);
  578.     }
  579.     /**
  580.      * Post a wallet-to-wallet FX conversion: pair THIS txn with a counterpart leg,
  581.      * post one balanced conversion journal (both wallets + realized FX) via
  582.      * FxConversionService, and mark BOTH Aspire txns posted & cross-linked.
  583.      * POST /aspire/transaction/post-conversion/{id} { counterpart_id, applied_rate }
  584.      */
  585.     public function transactionPostConversionAction(Request $requestint $id): JsonResponse
  586.     {
  587.         $this->boot();
  588.         $em        $this->getDoctrine()->getManager();
  589.         $companyId $request->getSession()->get(UserConstants::USER_COMPANY_ID);
  590.         $loginId   $request->getSession()->get(UserConstants::USER_LOGIN_ID);
  591.         $repo $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction');
  592.         $txn  $repo->find($id);
  593.         $cpId = (int) $request->request->get('counterpart_id');
  594.         $cp   $cpId $repo->find($cpId) : null;
  595.         if (!$txn || $txn->getCompanyId() != $companyId) { return new JsonResponse(['success' => false'message' => 'Transaction not found.']); }
  596.         if (!$cp   || $cp->getCompanyId()  != $companyId) { return new JsonResponse(['success' => false'message' => 'Select the counterpart transaction.']); }
  597.         if ($cp->getId() == $txn->getId())                { return new JsonResponse(['success' => false'message' => 'Counterpart must be a different transaction.']); }
  598.         foreach ([$txn$cp] as $t) {
  599.             if (in_array($t->getStatus(), ['posted''auto_posted''matched'])) {
  600.                 return new JsonResponse(['success' => false'message' => 'One of the legs is already posted/matched.']);
  601.             }
  602.         }
  603.         // Source = outgoing (debit) leg; destination = incoming (credit) leg.
  604.         $debit  = ($txn->getDirection() === 'debit') ? $txn $cp;
  605.         $credit = ($txn->getDirection() === 'debit') ? $cp  $txn;
  606.         if ($debit->getDirection() !== 'debit' || $credit->getDirection() !== 'credit') {
  607.             return new JsonResponse(['success' => false'message' => 'A conversion needs one outgoing and one incoming leg.']);
  608.         }
  609.         $srcBank = \ApplicationBundle\Modules\Aspire\Aspire::resolveTxnBankAccount($em$debit);
  610.         $dstBank = \ApplicationBundle\Modules\Aspire\Aspire::resolveTxnBankAccount($em$credit);
  611.         if (!$srcBank || !$dstBank) {
  612.             return new JsonResponse(['success' => false'message' => 'Could not resolve the wallet for one of the legs — map the Aspire account to a bank account under Aspire → Accounts (or set a default).']);
  613.         }
  614.         $srcAmount   abs((float) $debit->getAmount());
  615.         $dstAmount   abs((float) $credit->getAmount());
  616.         $appliedRate = (float) $request->request->get('applied_rate'0);
  617.         if ($appliedRate <= && $srcAmount 0) { $appliedRate $dstAmount $srcAmount; }   // derive src→dst
  618.         // Optional manual rate of 1 source-currency unit to the book/functional currency.
  619.         // Blank → the service auto-resolves (exact-date → nearest → cross-derive from the legs).
  620.         $bookRate trim((string) $request->request->get('book_rate'''));
  621.         try {
  622.             $res = \ApplicationBundle\Modules\Accounts\Service\FxConversionService::post(
  623.                 $em, (int) $companyId, (int) $loginId,
  624.                 $srcBank$dstBank,
  625.                 $srcAmountstrtoupper((string) $debit->getCurrencyCode()),
  626.                 $dstAmountstrtoupper((string) $credit->getCurrencyCode()),
  627.                 $appliedRatenull'0'null,
  628.                 $debit->getAspireTxnId(),
  629.                 ($debit->getPostedAt() ?: new \DateTime()),
  630.                 'aspire'$debit->getAspireTxnId(),
  631.                 'FX conversion ' $debit->getCurrencyCode() . '→' $credit->getCurrencyCode(),
  632.                 $bookRate !== '' $bookRate null
  633.             );
  634.         } catch (\Throwable $e) {
  635.             return new JsonResponse(['success' => false'message' => 'Conversion post failed: ' $e->getMessage()]);
  636.         }
  637.         $txnId = (int) ($res['transactionId'] ?? 0);
  638.         foreach ([$debit$credit] as $t) {
  639.             $other = ($t === $debit) ? $credit $debit;
  640.             $t->setStatus('posted');
  641.             $t->setAccTransactionsId($txnId);
  642.             $t->setPostedAtErp(new \DateTime());
  643.             $t->setPostedLoginId($loginId ?: null);
  644.             $t->setNotes(trim((string) $t->getNotes() . ' [FX conversion, paired #' $other->getId() . ', GL #' $txnId ']'));
  645.         }
  646.         $em->flush();
  647.         // Both legs came off the bank feed — mark the posted conversion voucher reconciled.
  648.         \ApplicationBundle\Modules\Aspire\Aspire::markReconciledFromFeed($em$txnId$debit, (int) $loginId);
  649.         return new JsonResponse(['success' => true'message' => 'Conversion posted (GL #' $txnId '); both legs linked.']);
  650.     }
  651.     /**
  652.      * Suggest an FX rate for the rate fields: internal /tax/fx-rates first, then the
  653.      * external provider as a fallback. GET /aspire/fx-rate-lookup?from=USD&to=SGD&date=YYYY-MM-DD
  654.      */
  655.     public function fxRateLookupAction(Request $request): JsonResponse
  656.     {
  657.         $em   $this->getDoctrine()->getManager();
  658.         $from strtoupper(trim((string) $request->query->get('from''')));
  659.         $to   strtoupper(trim((string) $request->query->get('to''')));
  660.         $dStr = (string) $request->query->get('date''');
  661.         if ($from === '' || $to === '') { return new JsonResponse(['success' => false]); }
  662.         if ($from === $to)              { return new JsonResponse(['success' => true'rate' => 1'source' => 'same']); }
  663.         try { $date $dStr ? new \DateTime($dStr) : new \DateTime(); } catch (\Throwable $e) { $date = new \DateTime(); }
  664.         // Internal rate table wins.
  665.         $fqp '\ApplicationBundle\Modules\Accounts\Service\FxRateProvider';
  666.         try {
  667.             $r = (float) $fqp::getRate($em$from$to$date'MID');
  668.             if ($r 0) { return new JsonResponse(['success' => true'rate' => $r'source' => 'internal']); }
  669.         } catch (\Throwable $e) { /* none — fall through */ }
  670.         // External provider fallback — OPT-IN only (it makes an outbound call to a
  671.         // third-party rate API). Enable per tenant: acc_setting 'external_fx_rate_enabled' = '1'.
  672.         $extEnabled $em->getConnection()->fetchOne("SELECT data FROM acc_setting WHERE name = 'external_fx_rate_enabled' LIMIT 1");
  673.         if ($extEnabled === '1') {
  674.             $r = \ApplicationBundle\Modules\Accounts\Service\ExternalFxRateService::fetchRate($from$to$date);
  675.             if ($r) { return new JsonResponse(['success' => true'rate' => $r'source' => 'external']); }
  676.         }
  677.         return new JsonResponse(['success' => false'message' => 'No internal rate — enter manually.']);
  678.     }
  679.     public function transactionBulkPostAction(Request $request): JsonResponse
  680.     {
  681.         $this->boot();
  682.         $em        $this->getDoctrine()->getManager();
  683.         $session   $request->getSession();
  684.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  685.         $loginId   $session->get(UserConstants::USER_LOGIN_ID);
  686.         $ids        $request->request->get('ids', []);
  687.         $debitHead  = (int)$request->request->get('debit_account_head_id');
  688.         $creditHead = (int)$request->request->get('credit_account_head_id');
  689.         if (!$debitHead || !$creditHead || empty($ids)) {
  690.             return new JsonResponse(['success' => false'message' => 'Missing parameters.']);
  691.         }
  692.         $rule = new AspirePostRule();
  693.         $rule->setDebitAccountHeadId($debitHead);
  694.         $rule->setCreditAccountHeadId($creditHead);
  695.         $rule->setAutoPost(true);
  696.         $rule->setEnabled(true);
  697.         $posted $failed 0;
  698.         foreach ((array)$ids as $txnId) {
  699.             $txn $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find((int)$txnId);
  700.             if (!$txn || $txn->getCompanyId() != $companyId) {
  701.                 continue;
  702.             }
  703.             if (in_array($txn->getStatus(), ['posted''auto_posted'])) {
  704.                 continue;
  705.             }
  706.             $ok Aspire::postTransactionToGL($txn$rule$em$loginId);
  707.             $ok $posted++ : $failed++;
  708.         }
  709.         $em->flush();
  710.         return new JsonResponse(['success' => true'posted' => $posted'failed' => $failed]);
  711.     }
  712.     // =========================================================================
  713.     // AUTO-POST RULES
  714.     // =========================================================================
  715.     public function ruleListAction(Request $request): Response
  716.     {
  717.         $em        $this->getDoctrine()->getManager();
  718.         $session   $request->getSession();
  719.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  720.         $rules $em->getRepository('ApplicationBundle\\Entity\\AspirePostRule')
  721.             ->createQueryBuilder('r')
  722.             ->where('r.companyId = :cid AND (r.docBookedFlag != 1 OR r.docBookedFlag IS NULL)')
  723.             ->setParameter('cid'$companyId)
  724.             ->orderBy('r.priority''ASC')
  725.             ->getQuery()->getResult();
  726.         return $this->render('@Aspire/pages/list/list_aspire_post_rules.html.twig', [
  727.             'rules'      => $rules,
  728.             'page_title' => 'Aspire Connect — Auto-Post Rules',
  729.         ]);
  730.     }
  731.     public function ruleFormAction(Request $requestint $id 0): Response
  732.     {
  733.         $em        $this->getDoctrine()->getManager();
  734.         $session   $request->getSession();
  735.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  736.         $rule $id $em->getRepository('ApplicationBundle\\Entity\\AspirePostRule')->find($id) : null;
  737.         if ($rule && $rule->getCompanyId() != $companyId) {
  738.             return $this->redirectToRoute('aspire_rule_list');
  739.         }
  740.         $connections $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')
  741.             ->findBy(['companyId' => $companyId'status' => 'active']);
  742.         return $this->render('@Aspire/pages/input_forms/aspire_post_rule.html.twig', [
  743.             'rule'        => $rule,
  744.             'connections' => $connections,
  745.             'page_title'  => $rule 'Edit Auto-Post Rule' 'New Auto-Post Rule',
  746.         ]);
  747.     }
  748.     public function ruleSaveAction(Request $request): JsonResponse
  749.     {
  750.         $em        $this->getDoctrine()->getManager();
  751.         $session   $request->getSession();
  752.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  753.         $loginId   $session->get(UserConstants::USER_LOGIN_ID);
  754.         $id = (int)$request->request->get('id'0);
  755.         if ($id) {
  756.             $rule $em->getRepository('ApplicationBundle\\Entity\\AspirePostRule')->find($id);
  757.             if (!$rule || $rule->getCompanyId() != $companyId) {
  758.                 return new JsonResponse(['success' => false]);
  759.             }
  760.             $rule->setEditLoginId($loginId);
  761.         } else {
  762.             $rule = new AspirePostRule();
  763.             $rule->setCompanyId($companyId);
  764.             $rule->setCreateLoginId($loginId);
  765.         }
  766.         $rule->setName($request->request->get('name'''));
  767.         $rule->setPriority((int)$request->request->get('priority'100));
  768.         $rule->setConnectionId((int)$request->request->get('connection_id'0) ?: null);
  769.         $rule->setMatchType($request->request->get('match_type''description_contains'));
  770.         $rule->setMatchValue($request->request->get('match_value''') ?: null);
  771.         $rule->setMinAmount($request->request->get('min_amount''') !== '' ? (float)$request->request->get('min_amount') : null);
  772.         $rule->setMaxAmount($request->request->get('max_amount''') !== '' ? (float)$request->request->get('max_amount') : null);
  773.         $rule->setDirectionFilter($request->request->get('direction_filter''both'));
  774.         $rule->setCurrencyCode(strtoupper(trim($request->request->get('currency_code'''))) ?: null);
  775.         $rule->setDebitAccountHeadId((int)$request->request->get('debit_account_head_id') ?: null);
  776.         $rule->setCreditAccountHeadId((int)$request->request->get('credit_account_head_id') ?: null);
  777.         $rule->setTaxRate($request->request->get('tax_rate''') !== '' ? (float)$request->request->get('tax_rate') : null);
  778.         $rule->setAutoPost((bool)(int)$request->request->get('auto_post'1));
  779.         $rule->setEnabled((bool)(int)$request->request->get('enabled'1));
  780.         $em->persist($rule);
  781.         $em->flush();
  782.         return new JsonResponse(['success' => true'id' => $rule->getId()]);
  783.     }
  784.     public function ruleDeleteAction(Request $requestint $id): JsonResponse
  785.     {
  786.         $em        $this->getDoctrine()->getManager();
  787.         $session   $request->getSession();
  788.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  789.         $rule $em->getRepository('ApplicationBundle\\Entity\\AspirePostRule')->find($id);
  790.         if (!$rule || $rule->getCompanyId() != $companyId) {
  791.             return new JsonResponse(['success' => false]);
  792.         }
  793.         $rule->setDocBookedFlag(1);
  794.         $em->flush();
  795.         return new JsonResponse(['success' => true]);
  796.     }
  797.     public function ruleTestAction(Request $request): JsonResponse
  798.     {
  799.         $em        $this->getDoctrine()->getManager();
  800.         $session   $request->getSession();
  801.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  802.         $ruleId = (int)$request->request->get('rule_id');
  803.         $rule   $em->getRepository('ApplicationBundle\\Entity\\AspirePostRule')->find($ruleId);
  804.         if (!$rule || $rule->getCompanyId() != $companyId) {
  805.             return new JsonResponse(['success' => false]);
  806.         }
  807.         $stagingRows $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')
  808.             ->createQueryBuilder('t')
  809.             ->where('t.companyId = :cid AND t.status = :st')
  810.             ->setParameter('cid'$companyId)
  811.             ->setParameter('st''new')
  812.             ->setMaxResults(200)
  813.             ->getQuery()->getResult();
  814.         $matches = [];
  815.         foreach ($stagingRows as $txn) {
  816.             if (Aspire::evaluateRules($txn, [$rule])) {
  817.                 $matches[] = [
  818.                     'id'          => $txn->getId(),
  819.                     'aspire_txn'  => $txn->getAspireTxnId(),
  820.                     'date'        => $txn->getPostedAt() ? $txn->getPostedAt()->format('Y-m-d') : '',
  821.                     'amount'      => $txn->getAmount(),
  822.                     'direction'   => $txn->getDirection(),
  823.                     'description' => $txn->getDescription(),
  824.                     'counterparty'=> $txn->getCounterparty(),
  825.                 ];
  826.             }
  827.         }
  828.         return new JsonResponse(['success' => true'matches' => $matches]);
  829.     }
  830.     public function suggestHeadsAction(Request $requestint $id): JsonResponse
  831.     {
  832.         $this->boot();
  833.         $em        $this->getDoctrine()->getManager();
  834.         $session   $request->getSession();
  835.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  836.         $txn $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find($id);
  837.         if (!$txn || $txn->getCompanyId() != $companyId) {
  838.             return new JsonResponse(['success' => false]);
  839.         }
  840.         $suggestion Aspire::suggestHeads($txn$em);
  841.         return new JsonResponse(['success' => true'suggestion' => $suggestion]);
  842.     }
  843.     // =========================================================================
  844.     // PAYOUTS
  845.     // =========================================================================
  846.     public function payoutListAction(Request $request): Response
  847.     {
  848.         $em        $this->getDoctrine()->getManager();
  849.         $session   $request->getSession();
  850.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  851.         $payouts $em->getRepository('ApplicationBundle\\Entity\\AspirePayout')
  852.             ->createQueryBuilder('p')
  853.             ->where('p.companyId = :cid AND (p.docBookedFlag != 1 OR p.docBookedFlag IS NULL)')
  854.             ->setParameter('cid'$companyId)
  855.             ->orderBy('p.id''DESC')
  856.             ->setMaxResults(300)
  857.             ->getQuery()->getResult();
  858.         $connections $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')
  859.             ->findBy(['companyId' => $companyId'status' => 'active']);
  860.         return $this->render('@Aspire/pages/list/list_aspire_payouts.html.twig', [
  861.             'payouts'     => $payouts,
  862.             'connections' => $connections,
  863.             'page_title'  => 'Aspire Connect — Payouts',
  864.         ]);
  865.     }
  866.     public function payoutFormAction(Request $requestint $id 0): Response
  867.     {
  868.         $this->boot();
  869.         $em        $this->getDoctrine()->getManager();
  870.         $session   $request->getSession();
  871.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  872.         $payout $id $em->getRepository('ApplicationBundle\\Entity\\AspirePayout')->find($id) : null;
  873.         if ($payout && $payout->getCompanyId() != $companyId) {
  874.             return $this->redirectToRoute('aspire_payout_list');
  875.         }
  876.         $connections $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')
  877.             ->findBy(['companyId' => $companyId'status' => 'active']);
  878.         $aspireAccounts $em->getRepository('ApplicationBundle\\Entity\\AspireAccount')
  879.             ->findBy(['companyId' => $companyId]);
  880.         $items $payout $em->getRepository('ApplicationBundle\\Entity\\AspirePayoutItem')
  881.             ->findBy(['payoutId' => $payout->getId()]) : [];
  882.         return $this->render('@Aspire/pages/input_forms/aspire_payout.html.twig', [
  883.             'payout'         => $payout,
  884.             'connections'    => $connections,
  885.             'aspireAccounts' => $aspireAccounts,
  886.             'items'          => $items,
  887.             'page_title'     => $payout 'Edit Payout Draft' 'New Payout',
  888.         ]);
  889.     }
  890.     public function payoutViewAction(Request $requestint $id): Response
  891.     {
  892.         $em        $this->getDoctrine()->getManager();
  893.         $session   $request->getSession();
  894.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  895.         $payout $em->getRepository('ApplicationBundle\\Entity\\AspirePayout')->find($id);
  896.         if (!$payout || $payout->getCompanyId() != $companyId) {
  897.             return $this->redirectToRoute('aspire_payout_list');
  898.         }
  899.         $items $em->getRepository('ApplicationBundle\\Entity\\AspirePayoutItem')
  900.             ->findBy(['payoutId' => $id]);
  901.         return $this->render('@Aspire/pages/view/view_aspire_payout.html.twig', [
  902.             'payout'     => $payout,
  903.             'items'      => $items,
  904.             'page_title' => 'Payout #' $payout->getId(),
  905.         ]);
  906.     }
  907.     public function payoutSaveDraftAction(Request $request): JsonResponse
  908.     {
  909.         $this->boot();
  910.         $em        $this->getDoctrine()->getManager();
  911.         $session   $request->getSession();
  912.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  913.         $loginId   $session->get(UserConstants::USER_LOGIN_ID);
  914.         $id         = (int)$request->request->get('id'0);
  915.         $type       $request->request->get('payout_type''single');
  916.         $connId     = (int)$request->request->get('connection_id');
  917.         $sourceAcct $request->request->get('source_aspire_account_id''');
  918.         $srcCur     $request->request->get('source_currency''SGD');
  919.         $dstCur     $request->request->get('destination_currency''SGD');
  920.         $method     $request->request->get('payment_method''LOCAL');
  921.         $clearing   $request->request->get('clearing_system''FAST');
  922.         $reference  $request->request->get('reference''');
  923.         $conn $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($connId);
  924.         if (!$conn || $conn->getCompanyId() != $companyId) {
  925.             return new JsonResponse(['success' => false'message' => 'Connection not found.']);
  926.         }
  927.         if ($id) {
  928.             $payout $em->getRepository('ApplicationBundle\\Entity\\AspirePayout')->find($id);
  929.             if (!$payout || $payout->getCompanyId() != $companyId) {
  930.                 return new JsonResponse(['success' => false]);
  931.             }
  932.             if (!in_array($payout->getStatus(), ['draft'])) {
  933.                 return new JsonResponse(['success' => false'message' => 'Cannot edit a payout that is already submitted.']);
  934.             }
  935.             $payout->setEditLoginId($loginId);
  936.             // Remove old items
  937.             $oldItems $em->getRepository('ApplicationBundle\\Entity\\AspirePayoutItem')
  938.                 ->findBy(['payoutId' => $id]);
  939.             foreach ($oldItems as $oi) {
  940.                 $em->remove($oi);
  941.             }
  942.         } else {
  943.             $payout = new AspirePayout();
  944.             $payout->setCompanyId($companyId);
  945.             $payout->setConnectionId($connId);
  946.             $payout->setStatus('draft');
  947.             $payout->setCreateLoginId($loginId);
  948.         }
  949.         $payout->setPayoutType($type);
  950.         $payout->setSourceAspireAccountId($sourceAcct);
  951.         $payout->setSourceCurrency($srcCur);
  952.         $payout->setDestinationCurrency($dstCur);
  953.         $payout->setPaymentMethod($method);
  954.         $payout->setClearingSystem($clearing);
  955.         $payout->setReference($reference);
  956.         $em->persist($payout);
  957.         $em->flush();
  958.         // Save line items
  959.         $itemsData $request->request->get('items', []);
  960.         $total 0;
  961.         foreach ((array)$itemsData as $itemRow) {
  962.             $item = new AspirePayoutItem();
  963.             $item->setPayoutId($payout->getId());
  964.             $item->setBankCountryCode($itemRow['bank_country_code'] ?? 'SG');
  965.             $item->setBankCode($itemRow['bank_code'] ?? null);
  966.             $item->setBankAccountNumber($itemRow['bank_account_number'] ?? null);
  967.             $item->setAccountHolderName($itemRow['account_holder_name'] ?? null);
  968.             $item->setEntityType($itemRow['entity_type'] ?? 'INDIVIDUAL');
  969.             $item->setAmount(isset($itemRow['amount']) ? (float)$itemRow['amount'] : null);
  970.             $item->setReference($itemRow['reference'] ?? null);
  971.             $item->setBeneficiaryEmployeeId(!empty($itemRow['employee_id']) ? (int)$itemRow['employee_id'] : null);
  972.             $item->setBeneficiarySupplierId(!empty($itemRow['supplier_id']) ? (int)$itemRow['supplier_id'] : null);
  973.             $item->setItemStatus('pending');
  974.             $em->persist($item);
  975.             $total += (float)($itemRow['amount'] ?? 0);
  976.         }
  977.         $payout->setTotalAmount($total);
  978.         $em->flush();
  979.         return new JsonResponse(['success' => true'id' => $payout->getId()]);
  980.     }
  981.     public function payoutRequestOtpAction(Request $requestint $id): JsonResponse
  982.     {
  983.         $this->boot();
  984.         $em        $this->getDoctrine()->getManager();
  985.         $session   $request->getSession();
  986.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  987.         $payout $em->getRepository('ApplicationBundle\\Entity\\AspirePayout')->find($id);
  988.         if (!$payout || $payout->getCompanyId() != $companyId) {
  989.             return new JsonResponse(['success' => false'message' => 'Not found.']);
  990.         }
  991.         if ($payout->getStatus() !== 'draft') {
  992.             return new JsonResponse(['success' => false'message' => 'Payout is not in draft state.']);
  993.         }
  994.         $conn  $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($payout->getConnectionId());
  995.         $items $em->getRepository('ApplicationBundle\\Entity\\AspirePayoutItem')->findBy(['payoutId' => $id]);
  996.         if ($payout->getPayoutType() === 'single') {
  997.             $item $items[0] ?? null;
  998.             if (!$item) {
  999.                 return new JsonResponse(['success' => false'message' => 'No beneficiary found.']);
  1000.             }
  1001.             $payload = [
  1002.                 'source_currency'      => $payout->getSourceCurrency(),
  1003.                 'source_account_id'    => $payout->getSourceAspireAccountId(),
  1004.                 'destination_currency' => $payout->getDestinationCurrency(),
  1005.                 'bank_country_code'    => $item->getBankCountryCode(),
  1006.                 'payment_method'       => $payout->getPaymentMethod(),
  1007.                 'clearing_system'      => $payout->getClearingSystem(),
  1008.                 'entity_type'          => $item->getEntityType(),
  1009.                 'bank_code'            => $item->getBankCode(),
  1010.                 'bank_account_number'  => $item->getBankAccountNumber(),
  1011.                 'account_holder_name'  => $item->getAccountHolderName(),
  1012.                 'amount'               => (float)$item->getAmount(),
  1013.                 'reference'            => $item->getReference() ?: $payout->getReference(),
  1014.             ];
  1015.             $resp Aspire::createSingleTransfer($conn$payload$em);
  1016.         } else {
  1017.             $beneficiaries = [];
  1018.             foreach ($items as $item) {
  1019.                 $beneficiaries[] = [
  1020.                     'bank_code'            => $item->getBankCode(),
  1021.                     'bank_account_number'  => $item->getBankAccountNumber(),
  1022.                     'account_holder_name'  => $item->getAccountHolderName(),
  1023.                     'entity_type'          => $item->getEntityType(),
  1024.                     'bank_country_code'    => $item->getBankCountryCode(),
  1025.                     'amount'               => (float)$item->getAmount(),
  1026.                     'reference'            => $item->getReference() ?: $payout->getReference(),
  1027.                 ];
  1028.             }
  1029.             $payload = [
  1030.                 'source_account_id'    => $payout->getSourceAspireAccountId(),
  1031.                 'source_currency'      => $payout->getSourceCurrency(),
  1032.                 'destination_currency' => $payout->getDestinationCurrency(),
  1033.                 'bank_country_code'    => $items[0]->getBankCountryCode() ?? 'SG',
  1034.                 'payment_method'       => $payout->getPaymentMethod(),
  1035.                 'clearing_system'      => $payout->getClearingSystem(),
  1036.                 'beneficiaries'        => $beneficiaries,
  1037.                 'total_transfer_amount'=> (float)$payout->getTotalAmount(),
  1038.                 'reference'            => $payout->getReference(),
  1039.             ];
  1040.             $resp Aspire::createBulkTransfer($conn$payload$em);
  1041.         }
  1042.         $payout->setRawRequest(json_encode($payload));
  1043.         $payout->setRawResponse(json_encode($resp));
  1044.         if (!empty($resp['error']) || ($resp['http_code'] ?? 200) >= 400) {
  1045.             $payout->setStatus('error');
  1046.             $payout->setErrorMessage(json_encode($resp));
  1047.             $em->flush();
  1048.             return new JsonResponse(['success' => false'message' => 'Aspire API error.''detail' => $resp]);
  1049.         }
  1050.         $transferId $resp['transfer_id'] ?? ($resp['bulk_transfer_id'] ?? ($resp['id'] ?? null));
  1051.         if ($transferId) {
  1052.             $payout->setAspireTransferId($transferId);
  1053.         }
  1054.         $payout->setStatus('pending_otp');
  1055.         $em->flush();
  1056.         return new JsonResponse(['success' => true'message' => 'OTP sent to your registered mobile number. Enter it below to confirm.''transfer_id' => $transferId]);
  1057.     }
  1058.     public function payoutSubmitAction(Request $requestint $id): JsonResponse
  1059.     {
  1060.         $this->boot();
  1061.         $em        $this->getDoctrine()->getManager();
  1062.         $session   $request->getSession();
  1063.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  1064.         $loginId   $session->get(UserConstants::USER_LOGIN_ID);
  1065.         $payout $em->getRepository('ApplicationBundle\\Entity\\AspirePayout')->find($id);
  1066.         if (!$payout || $payout->getCompanyId() != $companyId) {
  1067.             return new JsonResponse(['success' => false]);
  1068.         }
  1069.         if ($payout->getStatus() !== 'pending_otp') {
  1070.             return new JsonResponse(['success' => false'message' => 'Payout is not awaiting OTP.']);
  1071.         }
  1072.         // OTP confirmation: Aspire's exact endpoint for OTP confirm is TBD (sandbox testing needed).
  1073.         // For now we mark as submitted — the /transfer/{id}/status poll will confirm completion.
  1074.         $payout->setStatus('submitted');
  1075.         $payout->setSubmittedLoginId($loginId);
  1076.         $payout->setSubmittedAt(new \DateTime());
  1077.         $em->flush();
  1078.         return new JsonResponse(['success' => true'message' => 'Payout submitted. Refreshing status…']);
  1079.     }
  1080.     public function payoutRefreshStatusAction(Request $requestint $id): JsonResponse
  1081.     {
  1082.         $this->boot();
  1083.         $em        $this->getDoctrine()->getManager();
  1084.         $session   $request->getSession();
  1085.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  1086.         $payout $em->getRepository('ApplicationBundle\\Entity\\AspirePayout')->find($id);
  1087.         if (!$payout || $payout->getCompanyId() != $companyId) {
  1088.             return new JsonResponse(['success' => false]);
  1089.         }
  1090.         if (!$payout->getAspireTransferId()) {
  1091.             return new JsonResponse(['success' => false'message' => 'No Aspire transfer ID recorded.']);
  1092.         }
  1093.         $conn $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($payout->getConnectionId());
  1094.         $resp $payout->getPayoutType() === 'bulk'
  1095.             Aspire::getBulkTransferStatus($conn$payout->getAspireTransferId(), $em)
  1096.             : Aspire::getTransferStatus($conn$payout->getAspireTransferId(), $em);
  1097.         $aspireStatus $resp['status'] ?? ($resp['transfer_status'] ?? null);
  1098.         $payout->setRawResponse(json_encode($resp));
  1099.         $payout->setLastStatusCheckAt(new \DateTime());
  1100.         if ($aspireStatus) {
  1101.             $map = [
  1102.                 'Completed'  => 'completed',
  1103.                 'Rejected'   => 'rejected',
  1104.                 'In Progress'=> 'submitted',
  1105.             ];
  1106.             $mapped $map[$aspireStatus] ?? null;
  1107.             if ($mapped) {
  1108.                 $payout->setStatus($mapped);
  1109.             }
  1110.         }
  1111.         $em->flush();
  1112.         return new JsonResponse(['success' => true'aspire_status' => $aspireStatus'status' => $payout->getStatus()]);
  1113.     }
  1114.     public function payoutFxQuoteAction(Request $request): JsonResponse
  1115.     {
  1116.         $this->boot();
  1117.         $em        $this->getDoctrine()->getManager();
  1118.         $session   $request->getSession();
  1119.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  1120.         $connId = (int)$request->request->get('connection_id');
  1121.         $conn $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($connId);
  1122.         if (!$conn || $conn->getCompanyId() != $companyId) {
  1123.             return new JsonResponse(['success' => false]);
  1124.         }
  1125.         $params = [
  1126.             'source_currency'      => $request->request->get('source_currency'),
  1127.             'destination_currency' => $request->request->get('destination_currency'),
  1128.             'amount'               => (float)$request->request->get('amount'),
  1129.         ];
  1130.         $resp Aspire::getFxQuote($conn$params$em);
  1131.         return new JsonResponse($resp);
  1132.     }
  1133.     // =========================================================================
  1134.     // SYNC
  1135.     // =========================================================================
  1136.     public function syncNowAction(Request $requestint $connectionId): JsonResponse
  1137.     {
  1138.         $this->boot();
  1139.         $em        $this->getDoctrine()->getManager();
  1140.         $session   $request->getSession();
  1141.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  1142.         $loginId   $session->get(UserConstants::USER_LOGIN_ID);
  1143.         $conn $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($connectionId);
  1144.         if (!$conn || $conn->getCompanyId() != $companyId) {
  1145.             return new JsonResponse(['success' => false'error' => 'Connection not found.']);
  1146.         }
  1147.         try {
  1148.             $result Aspire::syncConnection($conn$em$loginId'manual');
  1149.         } catch (\Exception $e) {
  1150.             return new JsonResponse(['success' => false'error' => $e->getMessage()]);
  1151.         }
  1152.         // Attach lastSyncError detail if sync failed on token
  1153.         if (!($result['success'] ?? true) && $conn->getLastSyncError()) {
  1154.             $result['detail'] = $conn->getLastSyncError();
  1155.         }
  1156.         return new JsonResponse($result);
  1157.     }
  1158.     public function syncLogListAction(Request $request): Response
  1159.     {
  1160.         $em        $this->getDoctrine()->getManager();
  1161.         $session   $request->getSession();
  1162.         $companyId $session->get(UserConstants::USER_COMPANY_ID);
  1163.         $logs $em->getRepository('ApplicationBundle\\Entity\\AspireSyncLog')
  1164.             ->createQueryBuilder('l')
  1165.             ->where('l.companyId = :cid')
  1166.             ->setParameter('cid'$companyId)
  1167.             ->orderBy('l.startedAt''DESC')
  1168.             ->setMaxResults(200)
  1169.             ->getQuery()->getResult();
  1170.         return $this->render('@Aspire/pages/list/list_aspire_connections.html.twig', [
  1171.             'connections' => [],
  1172.             'syncLogs'    => $logs,
  1173.             'showLogs'    => true,
  1174.             'page_title'  => 'Aspire Connect — Sync Log',
  1175.         ]);
  1176.     }
  1177. }