<?php
namespace ApplicationBundle\Modules\Aspire\Controller;
use ApplicationBundle\Controller\GenericController;
use ApplicationBundle\Entity\AccTransactionDetails;
use ApplicationBundle\Entity\AccTransactions;
use ApplicationBundle\Entity\AspireAccount;
use ApplicationBundle\Entity\AspireConnection;
use ApplicationBundle\Entity\AspirePayout;
use ApplicationBundle\Entity\AspirePayoutItem;
use ApplicationBundle\Entity\AspirePostRule;
use ApplicationBundle\Entity\AspireTransaction;
use ApplicationBundle\Helper\Crypt;
use ApplicationBundle\Interfaces\SessionCheckInterface;
use ApplicationBundle\Modules\Aspire\Aspire;
use ApplicationBundle\Modules\Authentication\Constants\UserConstants;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class AspireController extends GenericController implements SessionCheckInterface
{
private function boot(): void
{
$key = '';
try {
$key = $this->getParameter('app_encryption_key');
} catch (\Exception $e) {}
if ($key) {
Crypt::setKey($key);
}
}
// =========================================================================
// CONNECTIONS
// =========================================================================
public function connectionListAction(Request $request): Response
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$connections = $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')
->findBy(['companyId' => $companyId], ['id' => 'DESC']);
return $this->render('@Aspire/pages/list/list_aspire_connections.html.twig', [
'connections' => $connections,
'page_title' => 'Aspire Connect — Connections',
]);
}
/**
* Aspire Accounts ⇄ ERP bank-account (GL head) mapping. Each Aspire account
* (multi-currency) maps to a local bank_accounts row whose accounts_head_id is
* the GL head used when posting that account's transactions. A tenant default
* (acc_setting aspire_default_bank_account_id) is the fallback.
*/
public function accountListAction(Request $request): Response
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$conn = $em->getConnection();
$companyId = $request->getSession()->get(UserConstants::USER_COMPANY_ID);
$accounts = $conn->fetchAllAssociative(
"SELECT aa.id, aa.aspire_account_id, aa.account_type, aa.currency_code, aa.available_balance, aa.bank_account_id,
ba.account_holder_name AS bank_name, ba.account_number, ba.accounts_head_id,
ah.name AS gl_head_name
FROM aspire_account aa
LEFT JOIN bank_accounts ba ON ba.id = aa.bank_account_id
LEFT JOIN acc_accounts_head ah ON ah.accounts_head_id = ba.accounts_head_id
WHERE aa.company_id = ? ORDER BY aa.currency_code, aa.aspire_account_id",
[$companyId]
);
$bankAccounts = $conn->fetchAllAssociative(
"SELECT ba.id, ba.account_holder_name, ba.account_number, ba.currency_code, ba.accounts_head_id, ah.name AS gl_head_name
FROM bank_accounts ba LEFT JOIN acc_accounts_head ah ON ah.accounts_head_id = ba.accounts_head_id
ORDER BY ba.account_holder_name"
);
$defaultBankId = (int) $conn->fetchOne("SELECT data FROM acc_setting WHERE name = 'aspire_default_bank_account_id' LIMIT 1");
return $this->render('@Aspire/pages/list/aspire_accounts.html.twig', [
'accounts' => $accounts,
'bankAccounts' => $bankAccounts,
'defaultBankId' => $defaultBankId,
'page_title' => 'Aspire Connect — Accounts',
]);
}
/** POST /aspire/accounts/map — set an account's bank mapping, or the default. */
public function accountMapAction(Request $request): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$conn = $em->getConnection();
$companyId = (int) $request->getSession()->get(UserConstants::USER_COMPANY_ID);
// set tenant default fallback
if ($request->request->get('set_default') !== null) {
$bankId = (int) $request->request->get('bankAccountId', 0);
$conn->executeStatement("DELETE FROM acc_setting WHERE name = 'aspire_default_bank_account_id'");
if ($bankId > 0) {
$conn->executeStatement(
"INSERT INTO acc_setting (name, data, company_id, created_at) VALUES ('aspire_default_bank_account_id', ?, ?, NOW())",
[(string) $bankId, $companyId]
);
}
return new JsonResponse(['success' => true]);
}
// map a specific Aspire account → bank account (+ backfill its txns)
$accId = (int) $request->request->get('id', 0);
$bankId = (int) $request->request->get('bankAccountId', 0);
$acct = $accId ? $em->getRepository('ApplicationBundle\\Entity\\AspireAccount')->find($accId) : null;
if (!$acct || $acct->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false, 'message' => 'Account not found.']);
}
$acct->setBankAccountId($bankId ?: null);
$em->flush();
// backfill un-posted transactions of this Aspire account with the mapped wallet
$backfilled = $conn->executeStatement(
"UPDATE aspire_transaction SET local_bank_account_id = ?
WHERE company_id = ? AND aspire_account_id = ? AND status NOT IN ('posted','auto_posted','matched')",
[$bankId ?: 0, $companyId, $acct->getAspireAccountId()]
);
return new JsonResponse(['success' => true, 'backfilled' => $backfilled]);
}
public function connectionFormAction(Request $request, int $id = 0): Response
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$conn = $id ? $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($id) : null;
if ($conn && $conn->getCompanyId() != $companyId) {
return $this->redirectToRoute('aspire_connection_list');
}
$bankAccounts = $em->getRepository('ApplicationBundle\\Entity\\BankAccounts')
->createQueryBuilder('b')
->where('b.docBookedFlag != 1 OR b.docBookedFlag IS NULL')
->getQuery()->getResult();
return $this->render('@Aspire/pages/input_forms/aspire_connection.html.twig', [
'conn' => $conn,
'bankAccounts' => $bankAccounts,
'page_title' => $conn ? 'Edit Connection' : 'New Aspire Connection',
]);
}
public function connectionSaveAction(Request $request): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$loginId = $session->get(UserConstants::USER_LOGIN_ID);
$id = (int)$request->request->get('id', 0);
$label = trim($request->request->get('label', ''));
$clientId = trim($request->request->get('client_id', ''));
$clientSecret = trim($request->request->get('client_secret', ''));
$defaultBankAccountId = (int)$request->request->get('default_bank_account_id', 0) ?: null;
$payoutClientId = trim($request->request->get('payout_client_id', '')) ?: null;
$payoutClientSecret = trim($request->request->get('payout_client_secret', '')) ?: null;
$initialSyncDateStr = trim($request->request->get('initial_sync_date', ''));
$initialSyncDate = $initialSyncDateStr ? new \DateTime($initialSyncDateStr) : null;
if (!$label || !$clientId) {
return new JsonResponse(['success' => false, 'message' => 'Label and Client ID are required.']);
}
if ($id) {
$conn = $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($id);
if (!$conn || $conn->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false, 'message' => 'Not found.']);
}
$conn->setEditLoginId($loginId);
} else {
$conn = new AspireConnection();
$conn->setCompanyId($companyId);
$conn->setCreateLoginId($loginId);
$conn->setStatus('active');
}
$conn->setLabel($label);
$conn->setClientId($clientId);
if ($clientSecret) {
$conn->setClientSecret(Crypt::encrypt($clientSecret));
}
$conn->setDefaultBankAccountId($defaultBankAccountId);
$conn->setInitialSyncDate($initialSyncDate);
// Payout credentials (optional, separate Aspire API key for transfers)
if ($payoutClientId !== null) {
$conn->setPayoutClientId($payoutClientId ?: null);
}
if ($payoutClientSecret) {
$conn->setPayoutClientSecret(Crypt::encrypt($payoutClientSecret));
} elseif ($payoutClientId === '') {
// Clearing payout client id also clears the secret
$conn->setPayoutClientSecret(null);
}
$em->persist($conn);
$em->flush();
return new JsonResponse(['success' => true, 'id' => $conn->getId()]);
}
public function connectionTestAction(Request $request, int $id): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$conn = $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($id);
if (!$conn || $conn->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false, 'message' => 'Not found.']);
}
// Force token refresh
$conn->setTokenExpiresAt(0);
$token = Aspire::getValidAccessToken($conn, $em);
if (!$token) {
$detail = $conn->getLastSyncError() ?: 'No error detail stored.';
return new JsonResponse(['success' => false, 'message' => 'Invalid credentials — could not obtain token. Detail: ' . $detail]);
}
$resp = Aspire::makeRequest(Aspire::BASE_URL . '/accounts', $token);
if (!empty($resp['error']) || ($resp['http_code'] ?? 200) >= 400) {
return new JsonResponse(['success' => false, 'message' => 'Credentials valid but /accounts returned an error.', 'detail' => $resp]);
}
$count = count($resp['data'] ?? []);
return new JsonResponse(['success' => true, 'message' => "Connected. Found {$count} Aspire account(s)."]);
}
public function connectionDisableAction(Request $request, int $id): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$conn = $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($id);
if (!$conn || $conn->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false]);
}
$conn->setStatus($conn->getStatus() === 'disabled' ? 'active' : 'disabled');
$em->flush();
return new JsonResponse(['success' => true, 'status' => $conn->getStatus()]);
}
public function connectionDeleteAction(Request $request, int $id): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$conn = $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($id);
if (!$conn || $conn->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false]);
}
$conn->setStatus('deleted');
$em->flush();
return new JsonResponse(['success' => true]);
}
// =========================================================================
// ACCOUNT MAPPING
// =========================================================================
public function accountMapSaveAction(Request $request): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$aspireAccountEntityId = (int)$request->request->get('aspire_account_entity_id');
$bankAccountId = (int)$request->request->get('bank_account_id') ?: null;
$acct = $em->getRepository('ApplicationBundle\\Entity\\AspireAccount')->find($aspireAccountEntityId);
if (!$acct || $acct->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false]);
}
$acct->setBankAccountId($bankAccountId);
$em->flush();
return new JsonResponse(['success' => true]);
}
// =========================================================================
// TRANSACTIONS (staging inbox)
// =========================================================================
public function transactionListAction(Request $request): Response
{
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$status = $request->query->get('status', '');
$connectionId = (int)$request->query->get('connection_id', 0);
$qb = $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')
->createQueryBuilder('t')
->where('t.companyId = :cid')
->setParameter('cid', $companyId)
->orderBy('t.postedAt', 'DESC')
->setMaxResults(500);
if ($status) {
$qb->andWhere('t.status = :st')->setParameter('st', $status);
}
if ($connectionId) {
$qb->andWhere('t.connectionId = :conn')->setParameter('conn', $connectionId);
}
$transactions = $qb->getQuery()->getResult();
$connections = $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')
->findBy(['companyId' => $companyId]);
return $this->render('@Aspire/pages/list/list_aspire_transactions.html.twig', [
'transactions' => $transactions,
'connections' => $connections,
'status' => $status,
'connectionId' => $connectionId,
'page_title' => 'Aspire Connect — Transactions',
]);
}
public function transactionViewAction(Request $request, int $id): Response
{
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$txn = $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find($id);
if (!$txn || $txn->getCompanyId() != $companyId) {
return $this->redirectToRoute('aspire_txn_list');
}
// Functional currency + the live engine rate, so the view can show the
// FX-rate override only for foreign txns and pre-fill the auto rate.
$fqp = '\ApplicationBundle\Modules\Accounts\Service\FxRateProvider';
$baseCode = strtoupper((string) ($fqp::resolveBaseCurrency($em, (int) $companyId) ?: ''));
$currCode = strtoupper((string) $txn->getCurrencyCode());
$autoRate = null;
if ($currCode && $baseCode && $currCode !== $baseCode) {
$d = $txn->getPostedAt() ?: new \DateTime();
// getRate() throws if no rate row exists — that's fine, just show no auto rate.
try { $r = (float) $fqp::getRate($em, $currCode, $baseCode, $d, 'MID'); if ($r > 0) { $autoRate = $r; } }
catch (\Throwable $e) {}
}
return $this->render('@Aspire/pages/view/view_aspire_transaction.html.twig', [
'txn' => $txn,
'page_title' => 'Transaction #' . $txn->getId(),
'base_currency_code' => $baseCode,
'auto_rate' => $autoRate,
]);
}
public function transactionPostNowAction(Request $request, int $id): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$loginId = $session->get(UserConstants::USER_LOGIN_ID);
$txn = $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find($id);
if (!$txn || $txn->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false, 'message' => 'Not found.']);
}
if (in_array($txn->getStatus(), ['posted', 'auto_posted'])) {
return new JsonResponse(['success' => false, 'message' => 'Already posted.']);
}
$debitHead = (int)$request->request->get('debit_account_head_id');
$creditHead = (int)$request->request->get('credit_account_head_id');
$notes = $request->request->get('notes', '');
if (!$debitHead || !$creditHead) {
return new JsonResponse(['success' => false, 'message' => 'Select both debit and credit accounts.']);
}
$rule = new AspirePostRule();
$rule->setDebitAccountHeadId($debitHead);
$rule->setCreditAccountHeadId($creditHead);
$rule->setAutoPost(true);
$rule->setEnabled(true);
$txn->setNotes($notes);
// Optional per-leg FX rate overrides from the inbox (blank => engine default).
$opts = [];
$drRate = $request->request->get('dr_rate', '');
$crRate = $request->request->get('cr_rate', '');
if ($drRate !== '' && (float) $drRate > 0) { $opts['drRate'] = (float) $drRate; }
if ($crRate !== '' && (float) $crRate > 0) { $opts['crRate'] = (float) $crRate; }
$ok = Aspire::postTransactionToGL($txn, $rule, $em, $loginId, $opts);
if ($ok) {
$txn->setStatus('posted');
$em->flush();
// Audit-Ready: the moment cash posts, check whether this party head is already documented
// (tagged to an invoice) or needs a source document → open/close the case automatically.
// Advisory only — never allowed to affect the GL post.
try {
(new \ApplicationBundle\Modules\Accounts\Service\AuditReadyReconciliationService($em))
->evaluateAfterPosting([$debitHead, $creditHead], null, (int) $loginId);
} catch (\Throwable $e) { /* audit-ready is advisory — never break a GL post */ }
// Build pre-filled suggest_rule payload for the "save as rule?" modal
$matchVal = '';
if ($txn->getDescription()) {
$words = array_slice(preg_split('/\s+/', trim($txn->getDescription())), 0, 4);
$matchVal = implode(' ', $words);
}
$suggestRule = [
'name' => substr($txn->getDescription() ?: $txn->getReference() ?: '', 0, 80),
'match_type' => $txn->getCounterparty() ? 'counterparty_equals' : 'description_contains',
'match_value' => $txn->getCounterparty() ?: $matchVal,
'currency_code' => $txn->getCurrencyCode(),
'direction_filter' => $txn->getDirection() ?: 'both',
'debit_account_head_id' => $debitHead,
'credit_account_head_id'=> $creditHead,
];
return new JsonResponse(['success' => true, 'suggest_rule' => $suggestRule]);
}
return new JsonResponse(['success' => false, 'message' => 'GL posting failed — check account head configuration.']);
}
/**
* Undo a post / match so the bank transaction can be re-linked.
* - posted / auto_posted: Aspire CREATED a standalone GL voucher → reverse + delete
* it (DeleteDocument::AccTransactions un-hits the ledger), drop the feed overlay.
* - matched: linked to a PRE-EXISTING voucher → only UNLINK + drop our feed overlay;
* never delete that voucher (it wasn't ours).
* The transaction returns to 'new' so it re-appears in the inbox for re-posting/re-linking.
* POST /aspire/transaction/undo/{id}
*/
public function transactionUndoAction(Request $request, int $id): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$txn = $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find($id);
if (!$txn || $txn->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false, 'message' => 'Not found.']);
}
$status = $txn->getStatus();
$voucherId = (int) $txn->getAccTransactionsId();
if (!in_array($status, ['posted', 'auto_posted', 'matched'])) {
return new JsonResponse(['success' => false, 'message' => 'Nothing to undo — this transaction is not posted or matched.']);
}
// An FX conversion posts BOTH legs onto ONE voucher — collect every Aspire leg
// linked to the same voucher so they all reset together (otherwise the paired leg
// is left orphaned, pointing at a voucher that's been deleted → "can't undo").
$repo = $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction');
$siblings = $voucherId
? $repo->findBy(['accTransactionsId' => $voucherId, 'companyId' => $companyId])
: [];
if (empty($siblings)) { $siblings = [$txn]; }
try {
if ($status === 'matched') {
// Linked to an existing voucher — unlink only, never delete it.
if ($voucherId) { \ApplicationBundle\Modules\Aspire\Aspire::unmarkReconciledFromFeed($em, $voucherId); }
$message = $voucherId
? ('Unlinked from voucher #' . $voucherId . ' — it can now be re-linked or posted.')
: 'Unlinked — it can now be re-linked or posted.';
} else {
// We created this voucher — reverse the GL hit by deleting it (once). Tolerate
// an already-deleted voucher (e.g. undoing the orphaned second leg).
if ($voucherId) {
\ApplicationBundle\Modules\Aspire\Aspire::unmarkReconciledFromFeed($em, $voucherId);
$stillExists = $em->getConnection()->fetchOne(
'SELECT transaction_id FROM acc_transactions WHERE transaction_id = ?', [$voucherId]
);
if ($stillExists) {
\ApplicationBundle\Modules\System\DeleteDocument::AccTransactions($em, $voucherId, 1);
}
}
$legNote = (count($siblings) > 1) ? (' (' . count($siblings) . ' legs reset)') : '';
$message = $voucherId
? ('GL post reversed (voucher #' . $voucherId . ' deleted)' . $legNote . ' — it can now be re-posted or linked.')
: 'Post reversed — it can now be re-posted or linked.';
}
$stamp = (new \DateTime())->format('Y-m-d H:i');
foreach ($siblings as $leg) {
$leg->setAccTransactionsId(null);
$leg->setMatchedRuleId(null);
$leg->setPostedAtErp(null);
$leg->setPostedLoginId(null);
$leg->setStatus('new');
$note = trim((string) $leg->getNotes());
$leg->setNotes(trim($note . ' [post/link undone ' . $stamp . ']'));
}
$em->flush();
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => 'Undo failed: ' . $e->getMessage()]);
}
return new JsonResponse(['success' => true, 'message' => $message]);
}
public function transactionIgnoreAction(Request $request, int $id): JsonResponse
{
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$txn = $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find($id);
if (!$txn || $txn->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false]);
}
$txn->setStatus('ignored');
$em->flush();
return new JsonResponse(['success' => true]);
}
/**
* Candidate existing vouchers to match this bank transaction against — by amount
* (±tolerance) within a date window, excluding vouchers already linked to another
* Aspire transaction. GET /aspire/transaction/match-candidates/{id}
*/
public function transactionMatchCandidatesAction(Request $request, int $id): JsonResponse
{
$em = $this->getDoctrine()->getManager();
$companyId = $request->getSession()->get(UserConstants::USER_COMPANY_ID);
$conn = $em->getConnection();
$txn = $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find($id);
if (!$txn || $txn->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false, 'message' => 'Not found.']);
}
// The bank amount is signed (negative for outgoing) but voucher leg amounts
// are stored positive — match on the ABSOLUTE value.
$amount = abs((float) $txn->getAmount());
$tol = max(0.5, $amount * 0.01); // 1% or 50c, whichever larger
$date = $txn->getPostedAt() ?: new \DateTime();
$from = (clone $date)->modify('-31 days'); // bank value-date vs posting-date drift
$to = (clone $date)->modify('+31 days');
// Match against a transaction LINE (the leg that hit the bank), not the header
// sum — a multi-leg voucher's header total rarely equals a single bank line.
$rows = $conn->fetchAllAssociative(
"SELECT t.transaction_id AS id, t.document_hash AS hash,
t.transaction_amount AS amount, t.transaction_date AS date,
MIN(ABS(d.amount - ?)) AS leg_diff
FROM acc_transactions t
JOIN acc_transaction_details d ON d.transaction_id = t.transaction_id
WHERE ABS(d.amount - ?) <= ?
AND t.transaction_date BETWEEN ? AND ?
AND (t.delete_flag = 0 OR t.delete_flag IS NULL)
AND t.transaction_id NOT IN (
SELECT acc_transactions_id FROM aspire_transaction WHERE acc_transactions_id IS NOT NULL
)
GROUP BY t.transaction_id, t.document_hash, t.transaction_amount, t.transaction_date
ORDER BY leg_diff ASC, ABS(DATEDIFF(t.transaction_date, ?)) ASC, t.transaction_id DESC
LIMIT 20",
[$amount, $amount, $tol, $from->format('Y-m-d 00:00:00'), $to->format('Y-m-d 23:59:59'), $date->format('Y-m-d')]
);
return new JsonResponse(['success' => true, 'txn_amount' => $amount, 'candidates' => $rows]);
}
/**
* Link this bank transaction to an EXISTING voucher instead of posting a new one
* (dedupe). No GL entry is created — we just mark the bank txn matched & linked, so
* the reconciliation overlay ties them together. POST /aspire/transaction/match/{id}
*/
public function transactionMatchAction(Request $request, int $id): JsonResponse
{
$em = $this->getDoctrine()->getManager();
$companyId = $request->getSession()->get(UserConstants::USER_COMPANY_ID);
$conn = $em->getConnection();
$txn = $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find($id);
if (!$txn || $txn->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false, 'message' => 'Not found.']);
}
if (in_array($txn->getStatus(), ['posted', 'auto_posted', 'matched'])) {
return new JsonResponse(['success' => false, 'message' => 'Already posted or matched.']);
}
$voucherId = (int) $request->request->get('transaction_id');
if (!$voucherId) {
return new JsonResponse(['success' => false, 'message' => 'Select a voucher to match.']);
}
$exists = $conn->fetchOne('SELECT transaction_id FROM acc_transactions WHERE transaction_id = ?', [$voucherId]);
if (!$exists) {
return new JsonResponse(['success' => false, 'message' => 'Voucher not found.']);
}
// Guard: that voucher must not already be linked to another bank txn.
$taken = $conn->fetchOne('SELECT id FROM aspire_transaction WHERE acc_transactions_id = ? AND id <> ?', [$voucherId, $id]);
if ($taken) {
return new JsonResponse(['success' => false, 'message' => 'That voucher is already matched to another transaction.']);
}
$txn->setAccTransactionsId($voucherId);
$txn->setStatus('matched');
$txn->setPostedAtErp(new \DateTime());
$txn->setNotes(trim((string) $txn->getNotes() . ' [matched to existing voucher #' . $voucherId . ']'));
$em->flush();
// The bank feed now confirms this existing voucher — mark it reconciled.
\ApplicationBundle\Modules\Aspire\Aspire::markReconciledFromFeed($em, $voucherId, $txn, (int) $request->getSession()->get(UserConstants::USER_LOGIN_ID));
return new JsonResponse(['success' => true, 'message' => 'Linked to voucher #' . $voucherId . ' — no new entry posted.']);
}
/**
* Counterpart candidates for a wallet-to-wallet FX conversion — other un-posted
* Aspire txns in the OPPOSITE direction (the other leg of the exchange).
* GET /aspire/transaction/conversion-candidates/{id}
*/
public function transactionConversionCandidatesAction(Request $request, int $id): JsonResponse
{
$em = $this->getDoctrine()->getManager();
$companyId = $request->getSession()->get(UserConstants::USER_COMPANY_ID);
$txn = $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find($id);
if (!$txn || $txn->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false, 'message' => 'Not found.']);
}
$oppositeDir = ($txn->getDirection() === 'debit') ? 'credit' : 'debit';
// Counterpart legs within ±7 days of this txn (a conversion's two legs settle close
// together) — much shorter than listing every un-posted transaction.
$anchorDate = ($txn->getPostedAt() ?: new \DateTime())->format('Y-m-d');
$rows = $em->getConnection()->fetchAllAssociative(
"SELECT id, aspire_txn_id, amount, currency_code, direction, description, posted_at
FROM aspire_transaction
WHERE company_id = ? AND id <> ? AND status = 'new' AND direction = ?
AND posted_at IS NOT NULL
AND ABS(DATEDIFF(posted_at, ?)) <= 7
ORDER BY ABS(DATEDIFF(posted_at, ?)) ASC, id DESC
LIMIT 25",
[$companyId, $id, $oppositeDir, $anchorDate, $anchorDate]
);
return new JsonResponse(['success' => true, 'candidates' => $rows]);
}
/**
* Post a wallet-to-wallet FX conversion: pair THIS txn with a counterpart leg,
* post one balanced conversion journal (both wallets + realized FX) via
* FxConversionService, and mark BOTH Aspire txns posted & cross-linked.
* POST /aspire/transaction/post-conversion/{id} { counterpart_id, applied_rate }
*/
public function transactionPostConversionAction(Request $request, int $id): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$companyId = $request->getSession()->get(UserConstants::USER_COMPANY_ID);
$loginId = $request->getSession()->get(UserConstants::USER_LOGIN_ID);
$repo = $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction');
$txn = $repo->find($id);
$cpId = (int) $request->request->get('counterpart_id');
$cp = $cpId ? $repo->find($cpId) : null;
if (!$txn || $txn->getCompanyId() != $companyId) { return new JsonResponse(['success' => false, 'message' => 'Transaction not found.']); }
if (!$cp || $cp->getCompanyId() != $companyId) { return new JsonResponse(['success' => false, 'message' => 'Select the counterpart transaction.']); }
if ($cp->getId() == $txn->getId()) { return new JsonResponse(['success' => false, 'message' => 'Counterpart must be a different transaction.']); }
foreach ([$txn, $cp] as $t) {
if (in_array($t->getStatus(), ['posted', 'auto_posted', 'matched'])) {
return new JsonResponse(['success' => false, 'message' => 'One of the legs is already posted/matched.']);
}
}
// Source = outgoing (debit) leg; destination = incoming (credit) leg.
$debit = ($txn->getDirection() === 'debit') ? $txn : $cp;
$credit = ($txn->getDirection() === 'debit') ? $cp : $txn;
if ($debit->getDirection() !== 'debit' || $credit->getDirection() !== 'credit') {
return new JsonResponse(['success' => false, 'message' => 'A conversion needs one outgoing and one incoming leg.']);
}
$srcBank = \ApplicationBundle\Modules\Aspire\Aspire::resolveTxnBankAccount($em, $debit);
$dstBank = \ApplicationBundle\Modules\Aspire\Aspire::resolveTxnBankAccount($em, $credit);
if (!$srcBank || !$dstBank) {
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).']);
}
$srcAmount = abs((float) $debit->getAmount());
$dstAmount = abs((float) $credit->getAmount());
$appliedRate = (float) $request->request->get('applied_rate', 0);
if ($appliedRate <= 0 && $srcAmount > 0) { $appliedRate = $dstAmount / $srcAmount; } // derive src→dst
// Optional manual rate of 1 source-currency unit to the book/functional currency.
// Blank → the service auto-resolves (exact-date → nearest → cross-derive from the legs).
$bookRate = trim((string) $request->request->get('book_rate', ''));
try {
$res = \ApplicationBundle\Modules\Accounts\Service\FxConversionService::post(
$em, (int) $companyId, (int) $loginId,
$srcBank, $dstBank,
$srcAmount, strtoupper((string) $debit->getCurrencyCode()),
$dstAmount, strtoupper((string) $credit->getCurrencyCode()),
$appliedRate, null, '0', null,
$debit->getAspireTxnId(),
($debit->getPostedAt() ?: new \DateTime()),
'aspire', $debit->getAspireTxnId(),
'FX conversion ' . $debit->getCurrencyCode() . '→' . $credit->getCurrencyCode(),
$bookRate !== '' ? $bookRate : null
);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => 'Conversion post failed: ' . $e->getMessage()]);
}
$txnId = (int) ($res['transactionId'] ?? 0);
foreach ([$debit, $credit] as $t) {
$other = ($t === $debit) ? $credit : $debit;
$t->setStatus('posted');
$t->setAccTransactionsId($txnId);
$t->setPostedAtErp(new \DateTime());
$t->setPostedLoginId($loginId ?: null);
$t->setNotes(trim((string) $t->getNotes() . ' [FX conversion, paired #' . $other->getId() . ', GL #' . $txnId . ']'));
}
$em->flush();
// Both legs came off the bank feed — mark the posted conversion voucher reconciled.
\ApplicationBundle\Modules\Aspire\Aspire::markReconciledFromFeed($em, $txnId, $debit, (int) $loginId);
return new JsonResponse(['success' => true, 'message' => 'Conversion posted (GL #' . $txnId . '); both legs linked.']);
}
/**
* Suggest an FX rate for the rate fields: internal /tax/fx-rates first, then the
* external provider as a fallback. GET /aspire/fx-rate-lookup?from=USD&to=SGD&date=YYYY-MM-DD
*/
public function fxRateLookupAction(Request $request): JsonResponse
{
$em = $this->getDoctrine()->getManager();
$from = strtoupper(trim((string) $request->query->get('from', '')));
$to = strtoupper(trim((string) $request->query->get('to', '')));
$dStr = (string) $request->query->get('date', '');
if ($from === '' || $to === '') { return new JsonResponse(['success' => false]); }
if ($from === $to) { return new JsonResponse(['success' => true, 'rate' => 1, 'source' => 'same']); }
try { $date = $dStr ? new \DateTime($dStr) : new \DateTime(); } catch (\Throwable $e) { $date = new \DateTime(); }
// Internal rate table wins.
$fqp = '\ApplicationBundle\Modules\Accounts\Service\FxRateProvider';
try {
$r = (float) $fqp::getRate($em, $from, $to, $date, 'MID');
if ($r > 0) { return new JsonResponse(['success' => true, 'rate' => $r, 'source' => 'internal']); }
} catch (\Throwable $e) { /* none — fall through */ }
// External provider fallback — OPT-IN only (it makes an outbound call to a
// third-party rate API). Enable per tenant: acc_setting 'external_fx_rate_enabled' = '1'.
$extEnabled = $em->getConnection()->fetchOne("SELECT data FROM acc_setting WHERE name = 'external_fx_rate_enabled' LIMIT 1");
if ($extEnabled === '1') {
$r = \ApplicationBundle\Modules\Accounts\Service\ExternalFxRateService::fetchRate($from, $to, $date);
if ($r) { return new JsonResponse(['success' => true, 'rate' => $r, 'source' => 'external']); }
}
return new JsonResponse(['success' => false, 'message' => 'No internal rate — enter manually.']);
}
public function transactionBulkPostAction(Request $request): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$loginId = $session->get(UserConstants::USER_LOGIN_ID);
$ids = $request->request->get('ids', []);
$debitHead = (int)$request->request->get('debit_account_head_id');
$creditHead = (int)$request->request->get('credit_account_head_id');
if (!$debitHead || !$creditHead || empty($ids)) {
return new JsonResponse(['success' => false, 'message' => 'Missing parameters.']);
}
$rule = new AspirePostRule();
$rule->setDebitAccountHeadId($debitHead);
$rule->setCreditAccountHeadId($creditHead);
$rule->setAutoPost(true);
$rule->setEnabled(true);
$posted = $failed = 0;
foreach ((array)$ids as $txnId) {
$txn = $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find((int)$txnId);
if (!$txn || $txn->getCompanyId() != $companyId) {
continue;
}
if (in_array($txn->getStatus(), ['posted', 'auto_posted'])) {
continue;
}
$ok = Aspire::postTransactionToGL($txn, $rule, $em, $loginId);
$ok ? $posted++ : $failed++;
}
$em->flush();
return new JsonResponse(['success' => true, 'posted' => $posted, 'failed' => $failed]);
}
// =========================================================================
// AUTO-POST RULES
// =========================================================================
public function ruleListAction(Request $request): Response
{
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$rules = $em->getRepository('ApplicationBundle\\Entity\\AspirePostRule')
->createQueryBuilder('r')
->where('r.companyId = :cid AND (r.docBookedFlag != 1 OR r.docBookedFlag IS NULL)')
->setParameter('cid', $companyId)
->orderBy('r.priority', 'ASC')
->getQuery()->getResult();
return $this->render('@Aspire/pages/list/list_aspire_post_rules.html.twig', [
'rules' => $rules,
'page_title' => 'Aspire Connect — Auto-Post Rules',
]);
}
public function ruleFormAction(Request $request, int $id = 0): Response
{
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$rule = $id ? $em->getRepository('ApplicationBundle\\Entity\\AspirePostRule')->find($id) : null;
if ($rule && $rule->getCompanyId() != $companyId) {
return $this->redirectToRoute('aspire_rule_list');
}
$connections = $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')
->findBy(['companyId' => $companyId, 'status' => 'active']);
return $this->render('@Aspire/pages/input_forms/aspire_post_rule.html.twig', [
'rule' => $rule,
'connections' => $connections,
'page_title' => $rule ? 'Edit Auto-Post Rule' : 'New Auto-Post Rule',
]);
}
public function ruleSaveAction(Request $request): JsonResponse
{
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$loginId = $session->get(UserConstants::USER_LOGIN_ID);
$id = (int)$request->request->get('id', 0);
if ($id) {
$rule = $em->getRepository('ApplicationBundle\\Entity\\AspirePostRule')->find($id);
if (!$rule || $rule->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false]);
}
$rule->setEditLoginId($loginId);
} else {
$rule = new AspirePostRule();
$rule->setCompanyId($companyId);
$rule->setCreateLoginId($loginId);
}
$rule->setName($request->request->get('name', ''));
$rule->setPriority((int)$request->request->get('priority', 100));
$rule->setConnectionId((int)$request->request->get('connection_id', 0) ?: null);
$rule->setMatchType($request->request->get('match_type', 'description_contains'));
$rule->setMatchValue($request->request->get('match_value', '') ?: null);
$rule->setMinAmount($request->request->get('min_amount', '') !== '' ? (float)$request->request->get('min_amount') : null);
$rule->setMaxAmount($request->request->get('max_amount', '') !== '' ? (float)$request->request->get('max_amount') : null);
$rule->setDirectionFilter($request->request->get('direction_filter', 'both'));
$rule->setCurrencyCode(strtoupper(trim($request->request->get('currency_code', ''))) ?: null);
$rule->setDebitAccountHeadId((int)$request->request->get('debit_account_head_id') ?: null);
$rule->setCreditAccountHeadId((int)$request->request->get('credit_account_head_id') ?: null);
$rule->setTaxRate($request->request->get('tax_rate', '') !== '' ? (float)$request->request->get('tax_rate') : null);
$rule->setAutoPost((bool)(int)$request->request->get('auto_post', 1));
$rule->setEnabled((bool)(int)$request->request->get('enabled', 1));
$em->persist($rule);
$em->flush();
return new JsonResponse(['success' => true, 'id' => $rule->getId()]);
}
public function ruleDeleteAction(Request $request, int $id): JsonResponse
{
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$rule = $em->getRepository('ApplicationBundle\\Entity\\AspirePostRule')->find($id);
if (!$rule || $rule->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false]);
}
$rule->setDocBookedFlag(1);
$em->flush();
return new JsonResponse(['success' => true]);
}
public function ruleTestAction(Request $request): JsonResponse
{
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$ruleId = (int)$request->request->get('rule_id');
$rule = $em->getRepository('ApplicationBundle\\Entity\\AspirePostRule')->find($ruleId);
if (!$rule || $rule->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false]);
}
$stagingRows = $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')
->createQueryBuilder('t')
->where('t.companyId = :cid AND t.status = :st')
->setParameter('cid', $companyId)
->setParameter('st', 'new')
->setMaxResults(200)
->getQuery()->getResult();
$matches = [];
foreach ($stagingRows as $txn) {
if (Aspire::evaluateRules($txn, [$rule])) {
$matches[] = [
'id' => $txn->getId(),
'aspire_txn' => $txn->getAspireTxnId(),
'date' => $txn->getPostedAt() ? $txn->getPostedAt()->format('Y-m-d') : '',
'amount' => $txn->getAmount(),
'direction' => $txn->getDirection(),
'description' => $txn->getDescription(),
'counterparty'=> $txn->getCounterparty(),
];
}
}
return new JsonResponse(['success' => true, 'matches' => $matches]);
}
public function suggestHeadsAction(Request $request, int $id): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$txn = $em->getRepository('ApplicationBundle\\Entity\\AspireTransaction')->find($id);
if (!$txn || $txn->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false]);
}
$suggestion = Aspire::suggestHeads($txn, $em);
return new JsonResponse(['success' => true, 'suggestion' => $suggestion]);
}
// =========================================================================
// PAYOUTS
// =========================================================================
public function payoutListAction(Request $request): Response
{
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$payouts = $em->getRepository('ApplicationBundle\\Entity\\AspirePayout')
->createQueryBuilder('p')
->where('p.companyId = :cid AND (p.docBookedFlag != 1 OR p.docBookedFlag IS NULL)')
->setParameter('cid', $companyId)
->orderBy('p.id', 'DESC')
->setMaxResults(300)
->getQuery()->getResult();
$connections = $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')
->findBy(['companyId' => $companyId, 'status' => 'active']);
return $this->render('@Aspire/pages/list/list_aspire_payouts.html.twig', [
'payouts' => $payouts,
'connections' => $connections,
'page_title' => 'Aspire Connect — Payouts',
]);
}
public function payoutFormAction(Request $request, int $id = 0): Response
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$payout = $id ? $em->getRepository('ApplicationBundle\\Entity\\AspirePayout')->find($id) : null;
if ($payout && $payout->getCompanyId() != $companyId) {
return $this->redirectToRoute('aspire_payout_list');
}
$connections = $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')
->findBy(['companyId' => $companyId, 'status' => 'active']);
$aspireAccounts = $em->getRepository('ApplicationBundle\\Entity\\AspireAccount')
->findBy(['companyId' => $companyId]);
$items = $payout ? $em->getRepository('ApplicationBundle\\Entity\\AspirePayoutItem')
->findBy(['payoutId' => $payout->getId()]) : [];
return $this->render('@Aspire/pages/input_forms/aspire_payout.html.twig', [
'payout' => $payout,
'connections' => $connections,
'aspireAccounts' => $aspireAccounts,
'items' => $items,
'page_title' => $payout ? 'Edit Payout Draft' : 'New Payout',
]);
}
public function payoutViewAction(Request $request, int $id): Response
{
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$payout = $em->getRepository('ApplicationBundle\\Entity\\AspirePayout')->find($id);
if (!$payout || $payout->getCompanyId() != $companyId) {
return $this->redirectToRoute('aspire_payout_list');
}
$items = $em->getRepository('ApplicationBundle\\Entity\\AspirePayoutItem')
->findBy(['payoutId' => $id]);
return $this->render('@Aspire/pages/view/view_aspire_payout.html.twig', [
'payout' => $payout,
'items' => $items,
'page_title' => 'Payout #' . $payout->getId(),
]);
}
public function payoutSaveDraftAction(Request $request): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$loginId = $session->get(UserConstants::USER_LOGIN_ID);
$id = (int)$request->request->get('id', 0);
$type = $request->request->get('payout_type', 'single');
$connId = (int)$request->request->get('connection_id');
$sourceAcct = $request->request->get('source_aspire_account_id', '');
$srcCur = $request->request->get('source_currency', 'SGD');
$dstCur = $request->request->get('destination_currency', 'SGD');
$method = $request->request->get('payment_method', 'LOCAL');
$clearing = $request->request->get('clearing_system', 'FAST');
$reference = $request->request->get('reference', '');
$conn = $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($connId);
if (!$conn || $conn->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false, 'message' => 'Connection not found.']);
}
if ($id) {
$payout = $em->getRepository('ApplicationBundle\\Entity\\AspirePayout')->find($id);
if (!$payout || $payout->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false]);
}
if (!in_array($payout->getStatus(), ['draft'])) {
return new JsonResponse(['success' => false, 'message' => 'Cannot edit a payout that is already submitted.']);
}
$payout->setEditLoginId($loginId);
// Remove old items
$oldItems = $em->getRepository('ApplicationBundle\\Entity\\AspirePayoutItem')
->findBy(['payoutId' => $id]);
foreach ($oldItems as $oi) {
$em->remove($oi);
}
} else {
$payout = new AspirePayout();
$payout->setCompanyId($companyId);
$payout->setConnectionId($connId);
$payout->setStatus('draft');
$payout->setCreateLoginId($loginId);
}
$payout->setPayoutType($type);
$payout->setSourceAspireAccountId($sourceAcct);
$payout->setSourceCurrency($srcCur);
$payout->setDestinationCurrency($dstCur);
$payout->setPaymentMethod($method);
$payout->setClearingSystem($clearing);
$payout->setReference($reference);
$em->persist($payout);
$em->flush();
// Save line items
$itemsData = $request->request->get('items', []);
$total = 0;
foreach ((array)$itemsData as $itemRow) {
$item = new AspirePayoutItem();
$item->setPayoutId($payout->getId());
$item->setBankCountryCode($itemRow['bank_country_code'] ?? 'SG');
$item->setBankCode($itemRow['bank_code'] ?? null);
$item->setBankAccountNumber($itemRow['bank_account_number'] ?? null);
$item->setAccountHolderName($itemRow['account_holder_name'] ?? null);
$item->setEntityType($itemRow['entity_type'] ?? 'INDIVIDUAL');
$item->setAmount(isset($itemRow['amount']) ? (float)$itemRow['amount'] : null);
$item->setReference($itemRow['reference'] ?? null);
$item->setBeneficiaryEmployeeId(!empty($itemRow['employee_id']) ? (int)$itemRow['employee_id'] : null);
$item->setBeneficiarySupplierId(!empty($itemRow['supplier_id']) ? (int)$itemRow['supplier_id'] : null);
$item->setItemStatus('pending');
$em->persist($item);
$total += (float)($itemRow['amount'] ?? 0);
}
$payout->setTotalAmount($total);
$em->flush();
return new JsonResponse(['success' => true, 'id' => $payout->getId()]);
}
public function payoutRequestOtpAction(Request $request, int $id): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$payout = $em->getRepository('ApplicationBundle\\Entity\\AspirePayout')->find($id);
if (!$payout || $payout->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false, 'message' => 'Not found.']);
}
if ($payout->getStatus() !== 'draft') {
return new JsonResponse(['success' => false, 'message' => 'Payout is not in draft state.']);
}
$conn = $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($payout->getConnectionId());
$items = $em->getRepository('ApplicationBundle\\Entity\\AspirePayoutItem')->findBy(['payoutId' => $id]);
if ($payout->getPayoutType() === 'single') {
$item = $items[0] ?? null;
if (!$item) {
return new JsonResponse(['success' => false, 'message' => 'No beneficiary found.']);
}
$payload = [
'source_currency' => $payout->getSourceCurrency(),
'source_account_id' => $payout->getSourceAspireAccountId(),
'destination_currency' => $payout->getDestinationCurrency(),
'bank_country_code' => $item->getBankCountryCode(),
'payment_method' => $payout->getPaymentMethod(),
'clearing_system' => $payout->getClearingSystem(),
'entity_type' => $item->getEntityType(),
'bank_code' => $item->getBankCode(),
'bank_account_number' => $item->getBankAccountNumber(),
'account_holder_name' => $item->getAccountHolderName(),
'amount' => (float)$item->getAmount(),
'reference' => $item->getReference() ?: $payout->getReference(),
];
$resp = Aspire::createSingleTransfer($conn, $payload, $em);
} else {
$beneficiaries = [];
foreach ($items as $item) {
$beneficiaries[] = [
'bank_code' => $item->getBankCode(),
'bank_account_number' => $item->getBankAccountNumber(),
'account_holder_name' => $item->getAccountHolderName(),
'entity_type' => $item->getEntityType(),
'bank_country_code' => $item->getBankCountryCode(),
'amount' => (float)$item->getAmount(),
'reference' => $item->getReference() ?: $payout->getReference(),
];
}
$payload = [
'source_account_id' => $payout->getSourceAspireAccountId(),
'source_currency' => $payout->getSourceCurrency(),
'destination_currency' => $payout->getDestinationCurrency(),
'bank_country_code' => $items[0]->getBankCountryCode() ?? 'SG',
'payment_method' => $payout->getPaymentMethod(),
'clearing_system' => $payout->getClearingSystem(),
'beneficiaries' => $beneficiaries,
'total_transfer_amount'=> (float)$payout->getTotalAmount(),
'reference' => $payout->getReference(),
];
$resp = Aspire::createBulkTransfer($conn, $payload, $em);
}
$payout->setRawRequest(json_encode($payload));
$payout->setRawResponse(json_encode($resp));
if (!empty($resp['error']) || ($resp['http_code'] ?? 200) >= 400) {
$payout->setStatus('error');
$payout->setErrorMessage(json_encode($resp));
$em->flush();
return new JsonResponse(['success' => false, 'message' => 'Aspire API error.', 'detail' => $resp]);
}
$transferId = $resp['transfer_id'] ?? ($resp['bulk_transfer_id'] ?? ($resp['id'] ?? null));
if ($transferId) {
$payout->setAspireTransferId($transferId);
}
$payout->setStatus('pending_otp');
$em->flush();
return new JsonResponse(['success' => true, 'message' => 'OTP sent to your registered mobile number. Enter it below to confirm.', 'transfer_id' => $transferId]);
}
public function payoutSubmitAction(Request $request, int $id): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$loginId = $session->get(UserConstants::USER_LOGIN_ID);
$payout = $em->getRepository('ApplicationBundle\\Entity\\AspirePayout')->find($id);
if (!$payout || $payout->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false]);
}
if ($payout->getStatus() !== 'pending_otp') {
return new JsonResponse(['success' => false, 'message' => 'Payout is not awaiting OTP.']);
}
// OTP confirmation: Aspire's exact endpoint for OTP confirm is TBD (sandbox testing needed).
// For now we mark as submitted — the /transfer/{id}/status poll will confirm completion.
$payout->setStatus('submitted');
$payout->setSubmittedLoginId($loginId);
$payout->setSubmittedAt(new \DateTime());
$em->flush();
return new JsonResponse(['success' => true, 'message' => 'Payout submitted. Refreshing status…']);
}
public function payoutRefreshStatusAction(Request $request, int $id): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$payout = $em->getRepository('ApplicationBundle\\Entity\\AspirePayout')->find($id);
if (!$payout || $payout->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false]);
}
if (!$payout->getAspireTransferId()) {
return new JsonResponse(['success' => false, 'message' => 'No Aspire transfer ID recorded.']);
}
$conn = $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($payout->getConnectionId());
$resp = $payout->getPayoutType() === 'bulk'
? Aspire::getBulkTransferStatus($conn, $payout->getAspireTransferId(), $em)
: Aspire::getTransferStatus($conn, $payout->getAspireTransferId(), $em);
$aspireStatus = $resp['status'] ?? ($resp['transfer_status'] ?? null);
$payout->setRawResponse(json_encode($resp));
$payout->setLastStatusCheckAt(new \DateTime());
if ($aspireStatus) {
$map = [
'Completed' => 'completed',
'Rejected' => 'rejected',
'In Progress'=> 'submitted',
];
$mapped = $map[$aspireStatus] ?? null;
if ($mapped) {
$payout->setStatus($mapped);
}
}
$em->flush();
return new JsonResponse(['success' => true, 'aspire_status' => $aspireStatus, 'status' => $payout->getStatus()]);
}
public function payoutFxQuoteAction(Request $request): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$connId = (int)$request->request->get('connection_id');
$conn = $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($connId);
if (!$conn || $conn->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false]);
}
$params = [
'source_currency' => $request->request->get('source_currency'),
'destination_currency' => $request->request->get('destination_currency'),
'amount' => (float)$request->request->get('amount'),
];
$resp = Aspire::getFxQuote($conn, $params, $em);
return new JsonResponse($resp);
}
// =========================================================================
// SYNC
// =========================================================================
public function syncNowAction(Request $request, int $connectionId): JsonResponse
{
$this->boot();
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$loginId = $session->get(UserConstants::USER_LOGIN_ID);
$conn = $em->getRepository('ApplicationBundle\\Entity\\AspireConnection')->find($connectionId);
if (!$conn || $conn->getCompanyId() != $companyId) {
return new JsonResponse(['success' => false, 'error' => 'Connection not found.']);
}
try {
$result = Aspire::syncConnection($conn, $em, $loginId, 'manual');
} catch (\Exception $e) {
return new JsonResponse(['success' => false, 'error' => $e->getMessage()]);
}
// Attach lastSyncError detail if sync failed on token
if (!($result['success'] ?? true) && $conn->getLastSyncError()) {
$result['detail'] = $conn->getLastSyncError();
}
return new JsonResponse($result);
}
public function syncLogListAction(Request $request): Response
{
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$companyId = $session->get(UserConstants::USER_COMPANY_ID);
$logs = $em->getRepository('ApplicationBundle\\Entity\\AspireSyncLog')
->createQueryBuilder('l')
->where('l.companyId = :cid')
->setParameter('cid', $companyId)
->orderBy('l.startedAt', 'DESC')
->setMaxResults(200)
->getQuery()->getResult();
return $this->render('@Aspire/pages/list/list_aspire_connections.html.twig', [
'connections' => [],
'syncLogs' => $logs,
'showLogs' => true,
'page_title' => 'Aspire Connect — Sync Log',
]);
}
}