changelog.title
changelog.subtitle
Protest fees now split into provider and internal, priced from the contract
The excessive-chargeback (protest) fee a merchant pays is now defined on their contract as progressive tiers, and each charge is split into the acquirer's portion and EPaySe's own share, with EPaySe's share being whatever remains after the provider's cut — which may be negative, and is left so rather than hidden. The tiers are read as they stood at the time of the original transaction, not as they stand today, so a dispute is priced against the terms that were in force. Contracts that have not configured protest tiers keep charging exactly as before, so nothing changes for existing merchants until an admin sets them up. A missing provider cost no longer aborts the charge; the whole fee simply falls to EPaySe. The margin scan that watches the other fee types for a negative EPaySe margin now watches protest too, so a contract tier priced below the provider's is surfaced rather than quietly absorbed. Admins configure the tiers on the contract form, where the fee amounts are capped to two decimals so the split always reconciles to the total, tiers must be contiguous, and a warning is raised — without blocking the save — when the contract tiers do not line up with the provider's.
A merchant's contract can waive the fee on failed attempts
The fixed processing fee charged on a failed payment attempt can now be turned off per merchant through their contract. It stays on by default, so nothing changes for merchants who have not negotiated otherwise and for those with no contract at all. When it is turned off the fee is not booked to the ledger, and the fee snapshot recorded against the attempt is zeroed to match — so the two records of what was charged never disagree for that attempt. The toggle lives on the contract form; the flag it sets is validated and persisted on both creating and editing a contract, and each change is recorded to the merchant's activity log with its old and new value, because turning the fee off is a change to revenue and the contract table keeps no history of its own.
The negative-balance warning now actually reaches admins
The warning that tells an admin a refund will overdraw a merchant existed but did not reach them. Started from the transactions list it never appeared at all, because the merchant id it needs was not passed through — it only worked from a transaction's detail page. When the balance lookup failed it treated the merchant as solvent and stayed quiet, and that lookup fails for admins who can issue refunds but cannot view fund flows, since those are separate permissions. And it compared the balance against the transaction's full refundable amount rather than the amount being refunded, so it also fired on refunds the merchant could comfortably afford. The confirmation now shows the balance the merchant is left with rather than an abstract deficit, says so plainly when the balance could not be read, and appears on both paths. Approving a refund that was held for balance also re-checks it — the figure that held it may be days old — and raises an alert if the approval really does take the account negative.
Refunds held for balance now raise an alert
A refund held because the merchant's available balance is short now raises a Telegram alert carrying the shortfall, so someone can decide whether to approve it or wait. Nothing moves a refund out of that state except an admin opening the queue — there is no sweep that retries once the merchant is funded again — so without the alert a held refund could sit indefinitely while the buyer waited. The alert is sent after the transaction commits, because it posts over HTTP and an open money transaction is no place for a network call. The balance check itself is unchanged and still uses available funds only: reserve exists to cover refunds and chargebacks, so spending it to fund a refund would defeat its purpose. It now reads that figure directly instead of computing five balances to use one of them.
Settlement moves to T+7 on every tier
Settlement now runs T+7 on every tier. Enterprise was on T+0 and the rest on T+1; the distinction between tiers is price, not payout speed, and 7 days is the standard term. Settlements already in flight are unaffected — the release date is written onto the transaction and its ledger entry when the payment is booked, and the release sweep reads those stored values, so nobody waits longer for money already on its way. Historical rate snapshots are left alone, as reversals must price against the terms in force at the time of the original transaction.
Rolling reserve moves to 180 days
Rolling reserve now runs 180 days instead of 90, the common term for high-risk processing. Reserves already being held are untouched: the release date is written as a concrete date when the reserve is created and nothing recalculates it, so no merchant's existing hold is extended. Historical rate snapshots are also left alone — refunds and disputes are priced against the terms in force when the original transaction was taken, and rewriting those rows would price past reversals against terms that never existed. Separately, a reserve created without a resolved fee configuration used to fall back to a 30-day term that matched nothing else in the system, quietly releasing funds two months early; it now uses the standard term and records a warning, because that path only runs when fee resolution has already gone wrong.
A missing exchange rate now pages someone
When an exchange rate cannot be resolved, the payment is still booked to the ledger — that was fixed earlier — but the resulting gap needed a person, not a log line. The transaction is left without settlement currency, rate, or amount, and somebody has to backfill them by hand. The failure now raises a Telegram alert carrying the transaction id, the presentment currency and the amount paid, so operations learn about it while it is still one transaction rather than a reconciliation surprise. The alert call is itself wrapped, so a messaging outage can never undo the booking it is reporting on.
Fee rounding no longer creates money out of nothing
Every fee component used to be truncated to two decimals independently, so on any payment whose amount was not a round figure the ledger's immediate entries no longer summed to zero — net plus fees fell one to three cents short of the amount paid, and the gap was credited to the merchant's available balance as funds EPaySe had never received. Round amounts such as 100.00 produced no gap, which is why the defect stayed invisible to the existing tests. Third-party fees and the merchant's reserve are now rounded on their own merits, the merchant's net is floored so rounding can never favour it, and EPaySe's own fee carries the remainder so the accounting identity closes exactly. Fund flow entries below one cent are also no longer discarded: only an exact zero is skipped, as the database constraint requires.
A missing exchange rate can no longer skip the ledger
A payment the PSP had already collected could end up with no ledger entry at all. Currency conversion ran before the fund flows were written, and it throws when no exchange rate can be resolved — so a missing rate skipped the booking entirely. On the hosted confirmation path the exception was then swallowed while the surrounding transaction committed, leaving the attempt marked successful, the webhook token spent, and the ledger empty; because the token was gone, the provider's retry was rejected as a duplicate and the payment could not recover. The ledger is now written first and conversion failures are caught and logged, since settlement figures are reporting metadata that can be backfilled while a missing ledger entry cannot. The confirmation path no longer swallows failures, restoring the roll-back-everything behaviour its surrounding transaction was written to provide.
Exchange-rate freshness monitor no longer false-alarms on daily providers
The scheduled exchange-rate freshness check now measures the time since the last successful upstream FETCH instead of the last rate VALUE change. Because our primary provider publishes once per day, unchanged values were being misread as "stale" for hours, firing a warning, a Cron Monitor failure, and an unhandled-exception alert every 15 minutes. The command is now a self-contained monitor that emits a single cooldown-guarded alert only on a genuine fetch outage and exits cleanly otherwise.
Per-merchant contract fees
Merchants can now be placed on a negotiated contract fee — copied from a tier template then adjusted per merchant — that overrides tier pricing. EPaySe's internal fee is derived at runtime (contract minus provider minus affiliate) and may be negative for deliberate loss-leader deals; admin margin reports surface this by design. Contract fees are versioned so historical refunds and disputes always price against the contract in effect at the time of the original transaction.
Fee-tier admin management hidden in favor of contract fees
The standalone fee-tier admin screens and per-merchant tier assignment are now hidden behind a configuration flag (off by default), as per-merchant contract fees supersede them operationally. Fee tiers still function behind the scenes — contracts fall back to tier rates for any currency they do not cover, and a tier is still used as the starting template when creating a contract — so existing pricing is unaffected. The screens can be re-enabled from configuration if needed.
Stop fraud velocity checks from blocking local end-to-end test transactions
The IP/email/phone velocity and cross-match fraud checks count all traffic by source. In local development and end-to-end tests every request originates from a single test-runner address at high volume, which legitimately tripped these checks and blocked essentially every test transaction with a high risk score. These volume checks are now skipped in the local environment only (configurable), while production, staging and the automated test suite keep full fraud coverage.
Speed up admin dashboard system-balance under concurrent load
The system-balance calculation checked its cache only after acquiring a single-flight lock, so every dashboard load waited for the lock even when the value was already cached — several admins opening the dashboard at once could each wait up to five seconds. The cache is now read before the lock, so a warm cache serves instantly and only a genuine cache miss contends for the lock; the direct-compute fallback also warms the cache so later readers stay on the fast path.
Fix 500 error on webhook and payment-account pages when access is denied
When a merchant user without the right permission opened the Webhooks list, a webhook's events, the Payment Accounts list, or a payment account's detail, the page returned a 500 error instead of a clean 'not authorized' page. The controllers declared an Inertia-only return type, but the authorization helper returns a plain HTTP response to stamp the 403/404 status, causing a type error. The affected methods now accept both response types, so denied access shows the proper 403 (or 404 for a specific record) as intended.
Fix admin dashboard 500 under load from an uncaught balance lock timeout
The system-balance calculation guards a single-flight cache lock and is meant to fall back to a direct computation when the lock is contended. A missing import made the catch clause reference a non-existent class, so the fallback never triggered: under concurrent load (e.g. several admins opening the dashboard at once) the lock timeout propagated as an unhandled error and returned a 500 on the admin dashboard. The timeout is now caught correctly and the balance is computed directly instead.
True zero-downtime deploys: graceful QUIT shutdown + deploy-window probe gate
Deploys no longer drop in-flight requests: nginx and PHP-FPM now shut down gracefully (QUIT) with budgets sized to let running requests and PSP webhooks finish, background workers stop cleanly at shutdown start, and production queue workers receive signals correctly (previously hard-killed mid-job on every deploy). Every deploy is now measured by an in-pipeline probe that can block the pipeline when any real request fails during cutover.
Clearer settings labels and admin infrastructure section naming
The merchant settings menu now shows a short plain-language description under technical integration items (API Keys, Webhooks, IP Whitelist, API Logs) in both English and Vietnamese, with the full text as a hover tooltip, so non-technical merchants understand what each is for while the industry-standard names stay intact. Separately, the admin 'Ops Hub' section is renamed to 'Infrastructure Tools' to match its actual contents (Horizon, log viewer, server monitoring) instead of implying an operations work-queue.
Highlight the correct sidebar item on merchant create pages
On merchant create pages such as New Withdrawal or New Invoice, no sidebar menu item was highlighted, so it was unclear which section you were in. Create pages now highlight their parent list item, matching how detail and edit pages already behave and matching the admin sidebar.
Fix payment-link create form offering unsupported currencies and leaking raw error text
The create-payment-link dialog listed currencies from the merchant's fee-tier configuration, which could include currencies the merchant cannot actually accept, so the form defaulted to a currency the server then rejected on the first submit. The dialog now offers only the currencies the merchant's payment methods support — the same list the server validates against. Separately, the payment-link validation messages referenced translation keys that did not exist, so a rejected amount, currency, or expiry date showed a raw key like 'validation.payment_link.currency_invalid'; those messages are now translated in English and Vietnamese.
Fix admin merchant settings not saving for merchants without an email
On the admin merchant settings page, the email field was marked required in both the form and server validation, even though a merchant can legitimately have no email. For such a merchant the browser silently blocked the Save button (native required validation), so an admin could not toggle payment links, invoices, server-to-server, or fee tier at all. Email is now optional: feature toggles save regardless of email, a valid email is still accepted and saved, and an invalid email is still rejected.
Fix admins unable to create or manage PSPs (missing permission)
Creating, updating, or managing payment service providers in the admin portal checked a 'manage:admin.psps' permission that was never defined in the permission config, so it was never seeded. The authorization check then failed for every administrator — including super admins — returning a 403 and blocking PSP management entirely. The permission is now defined and generated on the admin guard, and super admins receive it automatically.
Fix 500 error on settlement statistics when no date filter is applied
The merchant settlement statistics endpoint read validated `from_date`/`to_date`/`currency` keys that Laravel omits from the validated array when the request has no such query parameters, so loading the settlements page without a date filter threw an undefined-array-key error and returned a 500. The endpoint now treats absent optional filters as empty, returning statistics normally while still rejecting an invalid date.
Fix merchant balance inflating by the amount of unreleased gated credits
The running-balance ledger let a newly finalised entry chain onto an admin-gated reserve or settlement release that was still pending — pulling that not-yet-released credit into the balance. So whenever a merchant had a gated release awaiting admin confirmation and any new ledger entry was written (a payment, refund, dispute, adjustment) or the nightly balance recompute ran, the available balance would jump by the unreleased amount. Both the live write path and the recompute now skip pending gated releases, so a gated credit only enters the balance once an admin actually confirms it.
Prevent the dashboard analytics cache warmer from running on every server at once
The scheduled task that pre-warms dashboard analytics caches every few minutes was missing the guard that pins a task to a single server, so on a multi-instance deployment every server would run it simultaneously — duplicating heavy aggregate queries and risking lock contention. It now runs on one server only, matching every other scheduled task.
Let admins enable app-based two-factor authentication
Administrators can now turn on authenticator-app (TOTP) two-factor authentication for their own account from Account Security, scan a QR code, confirm with a 6-digit code, and save one-time recovery codes. Two-factor is opt-in and not activated until confirmed. Enabling, disabling, or regenerating recovery codes each require re-entering the current password, so a hijacked session cannot silently weaken the second factor. Once enabled, the existing admin login challenge prompts for the code on the next sign-in.
Add password reset for the admin portal
Administrators can now recover access to the operate portal without a super-admin resetting them manually. A "Forgot password?" link on the admin login page requests a reset email, and the emailed link opens an admin-scoped reset page. The flow uses the admin guard and its own reset-token store, isolated from merchant accounts: a merchant reset link can never change an admin password even when the email matches, and the endpoint does not reveal whether an email belongs to an admin. Reset requests are rate-limited and CAPTCHA-protected like admin login.
Fix balance divergence check crashing every night in staging/production
A previous fix suspended debug tracing during the nightly balance divergence sweep by calling into a package that is only installed in local development, crashing the job with a class-not-found error on every environment where it matters. The call is now guarded to a no-op when that package isn't present, matching how the rest of the app already handles it being optional.
Stop the documentation catch-all page from crashing on malformed paths
A request with an encoded double slash could reach the docs catch-all route without a page parameter bound, crashing with an unhandled error instead of returning a normal 404. The route now degrades gracefully for any unmatched path.
Fix out-of-memory crash in the nightly balance divergence check
The nightly job that independently recomputes merchant balances to detect drift was being killed by the OS for using too much memory partway through, silently skipping the night's check. Query/model activity tracing is now suspended for the duration of the job so it can stream through every merchant's ledger without accumulating memory.
Fix landing page header overflowing horizontally at md/lg breakpoints
The desktop nav and login/register buttons switched on at 768px but needed ~1240px with the Vietnamese labels, wrapping and pushing the header past the viewport width on every page using the landing layout. Split the nav into 4 primary links plus a "More" dropdown that switches on at 1024px instead. Also fixed a JSON nesting bug that showed raw i18n keys on the hero stats, moved the blog reading-time label into the eagerly-loaded common namespace so it renders correctly when embedded on the landing page, and corrected missing Vietnamese diacritics in the payment methods marquee.
Fix admin dashboard 504 timeout
Cache heavy platform-wide analytics with single-flight locking, remove dead deferred queries, lazy-load analytics cards on scroll, and pre-warm the cache on a schedule.
Respect feature entitlement in the account menu and fix a dead Card Rules link
The top-right account menu did not honour per-merchant feature rollout the way the sidebar does: it always showed the Transaction Settings entries (Retry Limits, Expiration Rules) even for merchants without the transaction-config feature. The submenu is now hidden until at least one of its items is available. Its Card Rules link also pointed at a route that no longer exists and raised an error when the menu was opened; it now links to the correct payment-card page and only appears when the fraud feature is enabled and the user may view payment cards. The Team Management entry, previously hidden from everyone because of a broken permission check, now displays for users who can manage the team.
Lock the running-balance chain head on successful payments
Concurrent successful-payment processing for the same merchant and currency (a real webhook racing the recovery inquiry, or manual/sandbox paths) could read the same stale balance and fork the running-balance ledger, making the available balance silently wrong. The attempt-success path now takes a row lock on the balance chain head before computing balances, matching the other money paths.
Sweep and re-inquire refunds stuck awaiting a PSP webhook
When a refund is sent to a PSP the merchant balance is held until the PSP confirms via webhook. If that webhook never arrived, the hold previously froze the balance forever. An hourly sweep now detects refunds stuck in this state, re-inquires the PSP for the real status, and either finalises them or raises an alert for manual review — so a lost webhook can no longer freeze funds indefinitely.
Make the balance-divergence sweep memory-safe on large merchants
The nightly balance:check-divergence command loaded a merchant's entire fund-flow history into memory at once, which exhausted memory (and failed the scheduled job) on high-volume merchants in production-scale data. The recompute now streams the ledger with a database cursor (constant memory), and each merchant chain is checked in isolation so a single malformed chain is logged and skipped instead of aborting the whole sweep.
Lock the PSP-freeze reserve when releasing or voiding it
PSP-freeze reserve release/void validated the can-release and remaining-amount against an in-memory model that could be stale, so two admins (or a retried request) acting on the same reserve could each create a release fund flow and credit the merchant twice. Both paths now re-read the reserve under a row lock and re-validate against the fresh state. PSP_FREEZE_RELEASE is not covered by the duplicate-fund-flow unique index (partial releases are legitimate), so this lock is the only guard against the double credit.
Add an independent balance-divergence oracle
New balance:check-divergence command independently recomputes each merchant's available balance from raw fund-flow amounts and alerts when the stored running balance has drifted. It catches the stored-balance drift class — including the case the existing balance-sum check misses, where the drift lands on the available entry but the chain tail self-corrects. Scheduled nightly at 00:45, before the recalc that would heal the drift.
Add unique-index backstops against duplicate fee and refund records
Companion to the fund-flow duplicate backstop: the same recovery-path race could also duplicate the per-attempt TransactionFee row or record the same PSP refund twice. Added a unique index on transaction_fees(payment_attempt_id) (one fee row per attempt) and a partial unique index on refunds(transaction_id, provider_id) where provider_id is not null (a given PSP refund recorded at most once per transaction; partial refunds with distinct provider_ids and pending refunds with a null provider_id remain allowed). Verified no existing rows violate either constraint before adding.
Add a unique-index backstop against duplicate success fund-flow entries
Attempt-success entries (PAYMENT, processing fee, MDR, settlement, reserve) and refund entries (REFUND, REFUND_FEE, REFUND_HOLD, REFUND_HOLD_RELEASE) are created exactly once per payment attempt / refund. A recovery-path race — the inquiry job and a PSP webhook resolving the same attempt concurrently — could insert a second set and double-credit the merchant. Added a PostgreSQL partial unique index on fund_flows(flowable_id, flowable_type, type) scoped to those one-per-flowable types, so the duplicate insert fails instead of double-crediting. Types that legitimately repeat per flowable (e.g. PARTNER_COMMISSION_REVERSAL partial clawbacks) are excluded. Verified no existing rows violate the constraint before adding it.
Add automatic deadlock retry to dispute resolution fund-flow writes
Added a WithTransactionRetry trait (runInTransaction() wrapping DB::transaction with a retry count) so money-path transactions survive a PostgreSQL deadlock (40P01) or serialization failure (40001) under concurrent webhook/inquiry/withdrawal load instead of surfacing a 500. Applied it to DisputeResolutionService::resolveDispute, whose closure is idempotent (re-reads state, idempotency-guarded fund-flow writes). The existing test-only DatabaseRetryServiceProvider never wrapped these transactions, so production had no deadlock retry on this path. Rollout to the remaining money paths (attempt-success fund flows, withdrawals) is paired with the per-site lock-ordering work.
Add per-provider cooldown to BIN lookup so a rate-limited API is skipped
BinLookupService tried BinList.net then BinLookup.io on every miss; when a provider returned 429 (rate limited) it was retried again on the very next lookup of a different BIN, hammering an API that had just throttled us. A provider that returns 429 or 503 now enters a 1-hour cooldown (cache key bin_api_cooldown:{provider}) and is skipped while cooling down, so traffic shifts to the other provider; if both are cooling down the lookup degrades to null, which routing already handles gracefully. Existing single-flight cache, negative cache, rate limits and failure-streak alerting are unchanged.
Fix lost dispute debiting the merchant principal twice
DISPUTE_HOLD freezes the full disputed amount when a dispute opens. On a WON dispute the hold is released (DISPUTE_WIN_RELEASE), but on a LOST dispute the hold was never released while a separate DISPUTE_LOSS was also created — so the merchant principal was debited twice (the orphaned hold plus the loss). For a paid 100 / refunded 30 transaction a lost dispute netted -190 instead of the correct -90. The hold is now treated as a provisional freeze: a lost dispute creates DISPUTE_HOLD_RELEASE to reverse the hold and records the actual loss via DISPUTE_LOSS once (the hold is released even when the chargeback is 0, e.g. already fully refunded, so it is never orphaned). The dispute fee remains non-refundable.
Require amount + fee when approving a withdrawal to prevent fee overdraft
Approving a withdrawal debits both the amount (PAYOUT) and the processing fee (OTHER_FEE), but the admin approval balance gate only required the amount. When the available balance covered the amount but not the fee — e.g. a refund or dispute landed between request and approval and dropped the balance — approval succeeded and pushed the merchant balance negative by the fee. The gate now requires available >= amount + total_fee (bcmath), rejecting approvals that would overdraw by the fee.
Fix refund hold release under-restoring when the refund is capped or partial
On the PSP refund webhook path, BaseGateway::processRefund caps/partials the refund amount and overwrites $refund->amount before releasing the REFUND_HOLD. releaseRefundHold() credited that mutated amount, so when the completed/capped refund (e.g. 250) was smaller than the originally-held amount (e.g. 300), the hold release restored only the smaller value — permanently over-debiting the merchant by the difference even though only the smaller amount was actually refunded (and fully stranding the held funds on a failed refund). releaseRefundHold() now restores the exact amount recorded on the REFUND_HOLD ledger row (negated), so the hold cycle always nets to zero regardless of later changes to $refund->amount.
Prevent a late 3DS callback from overwriting an already-finalized transaction
A transaction can have multiple payment attempts (cascade/retry). When one attempt finalized the transaction (a sibling attempt succeeded, or a PSP webhook landed) while an earlier attempt was still in its 3DS round-trip, the late callback for that earlier attempt overwrote the transaction status with its own result — e.g. downgrading a settled SUCCESS to FAIL and emitting a spurious cascade/failure webhook. ThreeDSecureController::callback now locks and re-reads the transaction inside its DB transaction and skips the transaction-status update (and cascade) when the transaction is already terminal; the attempt still records its own outcome. Mirrors the existing guard in SweepStuckAuthenticatingTransactionsJob and PaymentController::confirmation.
Fix admin manual fund-flow adjustments failing with a foreign key error
fund_flows.adjusted_by referenced users(id), but the admin fund-flow endpoints (manual adjustment, void, correction) write the acting admin's id under the admin guard. Admin ids live in the admins table, so every admin manual adjustment/void/correction failed with a foreign key violation (HTTP 500) in production — the feature was effectively unusable. The foreign key now references admins(id) ON DELETE SET NULL, matching the sibling protest_fees.adjusted_by. The migration first nulls any adjusted_by value that is not a valid admin id so the new constraint applies cleanly to existing data.
Fix back-dated manual adjustment being dropped from merchant available balance
A back-dated manual fund-flow adjustment (released_at up to 90 days in the past) lands mid-chain, leaving every later entry's stored running balance stale. Because available balance reads the last released entry's stored balance rather than re-summing, the adjustment never reached the merchant's available balance. The admin create endpoint now rebuilds the full running-balance chain via recalculateBalanceForMerchant() whenever a released_at is supplied (a non-back-dated adjustment is the chain tail and is unaffected).
Fix merchant available balance inflating after editing a markup fee with later fund flows
FundFlowService::updateMarkupFeeFundFlow() reseeded the edited entry's running balance from the merchant's LATEST balance plus the amount delta, then rebuilt only the subsequent entries from that wrong seed. When any fund flow existed after the markup fee (e.g. a settlement release), this inflated the merchant's available balance by the sum of every later entry's amount — a phantom withdrawable credit. The edit now rebuilds the entire running-balance chain from genesis via recalculateBalanceForMerchant() (the same correct path voidFundFlow uses), so available balance reflects the true post-edit total. Added a regression test asserting available balance with a fund flow created after the markup fee.
Fix non-functional "remember me" on merchant and admin login
The "remember me" checkbox on both the merchant portal and the admin (operate) login had no effect: AppServiceProvider pinned the recaller cookie duration (auth.remember_duration) to 480 minutes, exactly equal to SESSION_LIFETIME, so the remember cookie expired together with the session and never kept the user signed in any longer. Raised the default remember duration to 1440 minutes (1 day) so it outlives the session, and documented that auth.remember_duration MUST exceed session.lifetime. Added RememberMeTest asserting the recaller cookie is issued with the correct longer lifetime for both the web and admin guards.
Filter admin transactions by alert severity and type
The admin transactions list now exposes 'Alerts' (severity) and 'Alert Type' (Fraud / Website Mismatch / Source Whitelist) filters in the desktop toolbar, and the CSV/Excel export honors them so the exported file matches exactly the filtered page. Previously these alert filters were reachable only via the mobile sheet or a URL parameter and were silently dropped from the export.
Admin transaction export matches the filtered list (N=N)
The admin transactions export now contains exactly the rows the filtered page shows. Previously the export ignored the merchant, PSP, API-key, PSP-freeze and alert filters and the default last-7-days window, so a list filtered to N transactions could export far more. The index, the export record count and the export file now all apply one shared filter (AdminTransactionQueryFilter), so applying a filter that shows 50 rows on the page exports exactly 50 rows.
Source-site whitelist monitoring on transaction alerts
The admin transactions Alerts column now flags when a transaction's source site is not one of the merchant's registered/whitelisted domains. It checks the merchant-declared redirectUrl (critical) and websiteUrl (high) at create time across hosted, iframe and S2S flows, and the browser-observed referrer (low), iframe top/ancestor origin (high) and embedded Sec-Fetch context (medium) at checkout time. Each signal carries its own severity and appears as a 'Source Whitelist' category in the alert dialog. Monitor-only — transactions are never blocked. Also fixes foundational issues in the existing website-mismatch system: getRegisteredDomains now matches APPROVED websites + verified domains, domain matching is one-directional and normalized (closing a confusion/empty bypass), tracking rows are unique per transaction, and the registered-domain cache is invalidated on website changes.
Per-merchant feature rollout gating
Added an entitlement layer that hides in-progress merchant-portal features until they are rolled out per merchant. A feature defined as 'testing' in config/features.php is hidden from the nav, the team permission picker (and role badges), and returns 404 on its routes/API for non-entitled merchants — enforced by the EnsureFeatureEntitled (feature.gate) middleware on MERCHANT entitlement (Laravel Pennant scope), backed by a route-coverage CI invariant test. Owners cannot grant a role carrying a gated permission. Operators roll features out with 'php artisan feature:rollout <feature> --merchant=ID' (or --all/--off/--list); effect is immediate and reversible. First gated feature: fraud-v2 (group 'fraud').
Partner commission reversals are now idempotent per refund/dispute
A duplicate refund or dispute webhook, or a payout job retried after an interrupted run, could previously reverse the same partner commission more than once and over-claw the partner balance. Each commission is now reversed exactly once per triggering refund or dispute. Dispute loss and dispute-win fund-flow entries also guard against duplicate creation on a replayed resolution.
Status page no longer reports healthy when its own check fails
The public status API returned 'operational' whenever its underlying health check threw an error (for example, the database being unreachable) — claiming all systems healthy precisely during an outage. It now degrades honestly instead. The 90-day uptime calculation also no longer mislabels a day as 'no data' based on a single trailing component's metrics.
Unified webhook event names across payment flows
Admin-recorded manual payments emitted 'payment.succeeded'/'payment.failed' while the normal PSP flow emitted 'payment.paid'/'payment_attempt.failed'. Because webhook subscriptions match the event name exactly, a merchant subscribed to one set silently missed the other. Manual payments now emit the same canonical event names as the standard flow.
Corrected webhook and SDK documentation
Developer docs taught webhook event names ('payment.succeeded/failed/processing') and a server-to-server endpoint path that did not match the live API. Docs now reflect the real event names ('payment.paid', 'payment_attempt.failed', 'payment.requires_action') and the correct '/v1/transaction/create-s2s' path; the unpublished PHP SDK install command is marked as coming soon.
Admins notified of new contact form submissions
Contact form submissions were stored but no one was alerted, so a lead could be missed despite the 'we will get back to you' confirmation shown to the visitor. Each submission now broadcasts an in-app notification to admins, linking to the submission.
Partner commission not reversed on refund or dispute loss
When a transaction was fully or partially refunded, or lost as a dispute/chargeback, the partner's referral commission was left untouched, so partners kept commission on revenue that was clawed back. Commissions are now reversed pro-rata: pending commissions are voided and credited commissions are clawed back from the partner balance via a PARTNER_COMMISSION_REVERSAL entry (cumulative across partial refunds).
Unify payment amount to dollars across S2S, hosted, HPP and iframe
Server-to-server payments previously accepted and stored the amount in cents while the hosted, HPP and iframe flows used dollars, so S2S transactions displayed 100x the real amount (e.g. $100 for a $1 payment) on dashboards, exports, merchant webhooks, redirect payloads and emails. The S2S API now accepts the amount in dollars like the other flows, stores it consistently, and converts to minor units only when calling the underlying PSP.
Sandbox gateway crashed on inbound webhook delivery
The sandbox gateway skipped its parent constructor, leaving the fee calculator uninitialized, so every inbound webhook to the sandbox PSP failed with a 500 error. The constructor now initializes the base gateway correctly.
PSP reconciliation never marked refunds and disputes as matched
Reconciliation wrote match/discrepancy status onto refunds and disputes through a guarded update that silently dropped the fields, so those records stayed unreconciled. Status is now persisted correctly.
Email Try Again button linked to an unsigned checkout URL (403)
The customer payment-failed email built its retry link from the main app URL without a signature, so clicking Try Again hit the signed checkout route and failed with 403 Invalid signature. The link is now a temporary signed checkout URL on the gateway domain, and email templates only render the button when a signed URL is present.
Fraud-block handler returned 500 instead of 403
Fixed an enum typo (CANCELLED vs CANCEL) in all three fraud-BLOCK branches of transaction creation. Blocked transactions now return a clean 403 FRAUD_BLOCKED response and are correctly marked as cancelled.
Daily settlement generation missed mid-day settlements
Settlement date-range queries compared full timestamps against date strings, so settlement records with a time-of-day component (all real PSP data) were silently skipped. Ranges now cover the full day for settlements, refunds, and disputes.
Sandbox inbound webhook endpoint crashed on delivery
The sandbox gateway parsed inbound webhooks with an outdated DTO signature, causing a 500 error on /webhook-receiver. Parsing now matches the current WebhookDTO contract.
Merchants export dialog with column picker
Replace direct-download export on /operate/merchants with EnhancedExportDialog — supports column selection, CSV/Excel format, and async email mode. Skips unused correlated subqueries for faster exports.
Simplify cascade configuration — smart priority order and remove stub toggle
The Cascade Configuration card no longer duplicates the Payment Methods drag order. When no custom cascade order is set, the service automatically follows the Payment Methods card order (api_key_method.priority). Admins can enable a custom override via toggle. The non-functional Notify on Cascade toggle has been removed.
Redirect proxy identity guard — CAT cookie and IP matching
Adds two opt-in identity checks to the redirect proxy to ensure only the buyer who opened checkout can resolve the redirect URL. The Checkout Access Token (CAT) is an encrypted HttpOnly cookie issued when checkout loads; the IP check validates the resolve request comes from the same IP stored in payment_attempts. Both checks use OR logic: passing either grants access; both failing returns 403. A per-transaction proxy_protected flag enables retroactive protection even after merchant proxy config is disabled. Includes admin toggles per transaction and a blocked-count metric per merchant config.
Fix missing browserDetails in S2SPaymentRequestDTO constructor call
ArgumentCountError when submitting checkout-field form — $browserDetailsDto was built but not passed to S2SPaymentRequestDTO constructor (argument #26).
Fix admin merchants page 340× faster balance queries
Resolved a PostgreSQL type mismatch between fund_flows.currency (varchar) and merchants.default_currency (char(3)/bpchar) that caused currency to be a post-scan Filter instead of an Index Cond on idx_fund_flows_completed. Fix: CAST(default_currency AS varchar) collapses per-page balance batch from 2,965ms to ~9ms and stats query from 48,263ms to 581ms (verified via EXPLAIN ANALYZE on staging with 5.7M rows). Also caches fee tiers, removes redundant withCount, pre-warms stats cache every 15 minutes.
Upgrade laravel/horizon to v5.47.2 to fix queue worker crash-loop
laravel/framework v13.13.0 made the queue WorkCommand read the new --stop-when-empty-for option unconditionally, but laravel/horizon v5.45.4's horizon:work command signature did not define it, so every Horizon worker crashed on boot. Upgraded laravel/horizon to v5.47.2 (within the existing ^5.45 constraint), whose horizon:work signature includes --stop-when-empty-for, restoring queue processing.
Suppress chargeback ratio alerts below minimum transaction volume
A chargeback ratio computed from a tiny denominator is statistically meaningless (e.g. 2 disputes / 3 transactions = 66%). ChargebackRatioMonitorService now suppresses alerts when a merchant has fewer than 50 successful transactions in the rolling 30-day window, matching how card-scheme programs (Visa VAMP / Mastercard ECM) only evaluate above a minimum volume. The real ratio is still reported; only the alert level is floored to 'normal'.
Stop muted/acknowledged alerts from re-firing on a ~24h cycle
The hourly alert:auto-resolve sweep treated suppressed alerts as 'stale' because muting/acknowledging freezes last_alerted_at. It auto-resolved them, which cleared is_muted and let the next monitor run re-arm the alert — so a muted chargeback alert kept coming back roughly every 24h. AlertState::scopeStale now only matches genuinely-unsuppressed NEW alerts (excludes is_muted=true and ACKNOWLEDGED), so operator-silenced alerts stay silenced.
Fix privacy consent API — wrong named parameters and missing getAllowedCollection()
BehaviorLogController.recordPrivacyConsent() called PrivacyConsentService::recordConsent() with named parameter transaction: (passing a Transaction model) but the service expects transactionId: string. PHP 8 named argument mismatch caused 500 on POST /api/fraud/privacy-consent. Also: getConsentStatus() called getAllowedCollection() which did not exist on the service. Fixed by correcting the call to use the actual service signature and adding getAllowedCollection(string) to PrivacyConsentService.
Surface container startup migration errors (Dokploy deploy debuggability)
Container entrypoint script was silently exiting in ~1.3s when Laravel migrate:status failed (e.g. phpdotenv parse error from unquoted .env values), because the `MIGRATE_STATUS=$(...)` assignment under `set -e` killed bash before the error-handler block could print the captured output. Wrapped the capture in `set +e`/`set -e` so failures now surface in container logs with the most common causes documented (phpdotenv parse error / DB unreachable / missing APP_KEY). Eliminates a major class of black-box Dokploy rollbacks.
Document ALERT_TELEGRAM_WEBHOOK_SECRET in env templates
Both .env.staging.example and .env.production.example were missing the ALERT_TELEGRAM_WEBHOOK_SECRET placeholder needed for interactive alert buttons (Snooze/Mute/Acknowledge). Anyone provisioning a fresh VPS following the template silently ended up with non-functional buttons. Added the placeholder with inline instructions for generating the secret and registering the webhook.
Fix interactive Telegram alert buttons (Snooze/Mute/Acknowledge) not persisting
Chargeback Telegram alerts kept re-firing every hour even after ops clicked the inline keyboard buttons because Telegram was never delivering callback_query updates. Two compounding bugs: (1) merchant bot webhook subscribed only to `message` updates when registered before the interactive alert feature shipped, so Telegram silently dropped button clicks; (2) telegram:set-alert-webhook printed SUCCESS in same-token mode without re-registering anything, hiding the misconfig. Fix: command now delegates to TelegramBotService::setWebhook in same-token mode so callback_query is subscribed when alerts.interactive.enabled=true; new telegram:webhook-status read-only health command surfaces the misconfig with non-zero exit; webhook controller now logs each callback_query received.
Fix stale merchant balance shown in admin, reports and AI assistant
Merchant balance is now always derived from fund_flows (the single source of truth) instead of a deprecated, never-updated merchants.balance column. The admin merchant list/detail/analytics, KYB review, the daily Telegram report, the AI balance tool and the merchants export previously showed a stale or zero balance; they now reflect the real available balance per the merchant's default currency. The legacy column has been dropped.
Fix retry-limits settings Save doing nothing
The Transaction Config → Retry Limits form posted to a non-existent route, so clicking Save Changes silently failed and merchants could not change their max retry attempts or cooldown period. The form now targets the correct route and saves persist.
Unify card brand detection to single canonical service
Introduced CardBrandDetector as single source of truth for card brand detection, replacing 5 parallel implementations with divergent bugs. Fixes two production issues: (1) CardBrandEnum::UNKNOWN fatal Error at PaymentController:2446 causing runtime crash for JCB/Discover/UnionPay/Diners cards; (2) 'union-pay' vs 'unionpay' mismatch in BaseGateway causing UnionPay transactions to be stored as 'other'. Detection now covers all 7 brands with correct ISO 7812 BIN ranges including Mastercard 2-series (2221–2720, excluding Mir), JCB legacy BINs (2131xx, 1800xx), and CUP/Discover co-brand resolution via Discover rails.
Fix BroadcastException when Reverb is not deployed on dedicated host
TransactionCreated and TransactionUpdated events were failing as queue jobs on staging because REVERB_HOST pointed to the web app domain. Added broadcastIf() guard: skips broadcasting when REVERB_HOST equals the app hostname, preventing BroadcastException and queue job failures until Reverb is deployed on its own server.
Fix SecurityMonitoring TypeError when User-Agent header is absent
isSimilarUserAgent() declared strict string parameters but $request->userAgent() returns ?string. Automated tools (curl, scanners) sending requests with no UA header caused TypeError. Fixed by adding null guards in detectUserAgentChange(): skip comparison and skip cache update when currentUserAgent is null. Added 3 regression tests covering null UA, cache preservation, and first-request UA caching.
Fix Merchant\WebsiteController::store() 500 + scope api_key_id to current merchant
Phase 4 plural endpoint POST /websites was missing api_key_id in inline $request->validate() rules, causing NOT NULL DB constraint to throw QueryException as unhandled 500 on every call (3× repeat alerts on 2026-05-19 from manual SSRF probe). Added required + Rule::exists('api_keys','id')->where('merchant_id', $merchant->id) validation — closes cross-merchant API key binding gap as defense-in-depth. Also wrapped WebsiteService::addDomain() call in try-catch (mirroring sibling removeDomain pattern) so the 5-changes-per-30-days rate-limit raw \Exception now renders as field error via back()->withErrors() instead of bubbling to exception reporter.
Tier-based fee configuration replaces per-(merchant, payment_method) JSON fees
BREAKING: Replaces per-(merchant × PM) JSON fee config with discrete tier templates (Standard/Premium/HighVolume/Enterprise) + per-(PM × currency) provider costs. Onboarding picks 1 tier instead of 5 JSON columns × N PMs. Schema drops merchant_method.{payment,refund,dispute,claim,protest}_fees, api_key_method.{payment,refund,dispute,protest}_fees, payment_methods.default_*_fees, global_fee_configs table. Adds merchant_fee_tiers, merchant_fee_tier_rates, payment_method_provider_costs (+ history tables for Decision #5 Option 3). FeeConfigResolver service replaces WithMerchantMethodFee trait. Decision #6 Option B fallback to tier code='standard' when merchant.tier_id NULL. New routes: /operate/fee-tiers CRUD, /operate/merchants/{id}/fee-tier (audit log). 14 commits, net -2329 lines.
Fix 5 admin UI bugs — i18n, MobileCardList, currency, CaptchaConfig, error pages
CI-045: Contact form submit button was hardcoded English, now uses i18n key. CI-040: MobileCardList on 5 admin pages (Collusion, FraudSignals, DeviceFingerprints, Customers, FundFlows) was receiving :data= instead of :items= causing blank mobile cards. CI-034: Marketing Costs table displayed all amounts as USD instead of the row currency. CI-041: CaptchaConfig admin pages had 22 wrong i18n key paths (missing 18 translation keys); DataTable was missing selection-change emit. CI-038: 403/404 error pages redesigned — full dark mode, ApplicationLogo, DarkModeToggle, permission detail block, i18n, matching gradient theme.
Rename X-Client-ID HMAC header to X-Api-Key-Id
BREAKING: The HMAC authentication header X-Client-ID has been renamed to X-Api-Key-Id across all environments (middleware, signature service, simulator, docs pages, E2E tests). Also completes the ApiClient→ApiKey rename refactor: renamed test directories (ApiClients/→ApiKeys/, ApiClientTable/→ApiKeyTable/), fixed cache keys (api_client_last_used→api_key_last_used, cascade_config:api_client→cascade_config:api_key), updated query param (api_client_filter→api_key_filter), and fixed a silent cache invalidation bug in CascadingPaymentService where the forget() path used the old key name.
Fix KYB matrix mode — wire document upload, nav buttons, and review display end-to-end
4 bugs fixed in /kyb/create when a Business Category (e.g. Adult Content) is selected: (1) KybController::storeDocuments now processes dynamic industry document slots from DocumentMatrixService — previously all matrix-mode uploads were silently dropped; (2) Navigation buttons (Save Draft / Complete Application / Previous) now render in both matrix and legacy mode — they were inside the v-else block so invisible when matrix mode was active; (3) Step 5 Review now shows Country and Business Category names — fixed missing eager load + camelCase key mismatch (businessCategory → business_category); (4) Sidebar document progress is now matrix-aware via new useKybDocumentMatrixProgress composable that groups sections by personal identity + required/optional industry slots. Also: form correctly restores uploaded matrix files on Step 4 revisit (buildKybDocs extended); document_type_code column populated for admin/reporting queries; Zod schema + transformValuesForSubmit extended for dynamic slots; PERSONAL_IDENTITY_CODES const exposed as single source-of-truth from PHP via Inertia prop.
Fix Docker production build — switch to debian:bookworm + packages.sury.org
Launchpad PPA (ppa.launchpadcontent.net:443) is blocked by RackNerd VPS provider, causing build:docker to timeout after 400s when installing PHP 8.4 packages. Fixed by switching Dockerfile.production base from ubuntu:24.04 to debian:bookworm-slim and PHP source from the blocked Launchpad PPA to packages.sury.org/php/bookworm (Ondrej Surý's own server, confirmed accessible). PostgreSQL repo updated to bookworm-pgdg. GPG key saved directly (binary format, no gpg --dearmor). Build time: 594s end-to-end.
Eliminate 30s Redis exception window on Swarm rolling deploy
5 coordinated fixes eliminating 5-15 RedisException Telegram alerts per deploy: (1) Wait-for-Redis gate in entrypoint reads .env via grep (Dokploy shell env is empty) and blocks supervisord from starting horizon/reverb/scheduler until Redis overlay DNS converges; (2) Fix reverb.php REDIS_TIMEOUT cast bug (60→2.0s); (3) Add retry_interval=100ms to phpredis connections for sub-second blip absorption; (4) 3-attempt retry in CheckRedisHealth middleware; (5) Graduated readiness endpoint returns 200+degraded for Redis-only failures (prevents Swarm false-positive rollback) with database-backed counter alerting at 5 consecutive failures.
Unify KYB Business Category select with shadcn Select pattern (Years/Turnover)
Migrated /kyb/create Step 1 Business Category from custom Popover+Command to shadcn <Select>, matching Years in Business / Annual Turnover styling (h-12, border-2, rounded-xl, focus:border-blue-500, full dark mode). Added Required + Locked badges and proper vee-validate componentField binding. Side-fixes 2 hidden bugs: (1) kyb.field_locked i18n key was missing from en/vi settings.json — 5 KYB fields rendered literal 'settings.kyb.field_locked' text in refill mode because vue-i18n v9 t(key, fallback) does NOT accept a fallback string; (2) KybController eager-loads businessCategory relation, so props.kyb.business_category arrives as an OBJECT not a code string — the old select compared object to string and silently failed to restore the selected category after reload. New resetMerchantKybState() helper in kyb-helpers.js resets ACTIVE merchant fixture to REGISTERED + clears Kyb in beforeEach. E2E suite kyb-industry-category.spec.js: 5/5 PASS.
Fix 500 error on KYB pages for newly registered merchants
Fixed enum vs string comparison in SettingController (statusColor always returned null), added null-safe operator for firstRegisteredEmail, fixed optional chaining in Index.vue. Added idempotent recovery migration to apply missed April 19 KYB structures if business_categories table is missing on staging. Added BusinessCategorySeeder to FoundationSeeder so reference data is always seeded.
Remove redundant sandbox merchant test pages
Removed /sandbox/test-payment and /sandbox/testing merchant portal pages. These pages duplicated functionality already available through the checkout sandbox flow with the Sandbox PSP.
Cascade deferred items: timeline, health dashboard, dispute attribution, QA seeder
U10: decline_code select in cascade error rule forms. D14: CascadeChainSeeder with 3 exemplar chains for QA. F5/X3: disputes.payment_attempt_id FK for cascade-aware chargeback attribution. U11: CascadeAttemptsTimeline Vue component in merchant transaction detail. O9: /operate/cascade/health admin dashboard with PSP pickup rates, exhaustion merchants, and hourly volume chart.
Webhook concurrent race: lockForUpdate returns fresh Transaction
claimWebhookToken() now returns the freshly-locked Transaction from inside DB::transaction(), preventing stale in-memory reads on status, paid_amount, and refunded_amount after acquiring the row lock. All three callers (processPaymentAttempt, processRefund, processDispute) now reassign $transaction to the fresh instance, closing the concurrent webhook double-processing gap.
Cascade contract CI enforcement for all gateway implementations
AllGatewaysImplementCascadeContractTest auto-discovers every concrete gateway via glob() and uses PHP Reflection to verify mapDeclineCode() and getCascadeCapabilities() are overridden — not just inherited from BaseGateway. Fail prevents any gateway from deploying without explicit cascade mappings.
Cascade gateway capability gating and PSP rate protection
CascadeCapabilities DTO now derives gateway capability (S2S, hosted, async-webhook-only) from existing interface contracts — single source of truth, no array drift. RoutingValidator::canHandleCascadeContext blocks S2S-only PSPs from hosted cascade and async-webhook-only PSPs from S2S cascade. System-level kill switches (CASCADE_ENABLED, CASCADE_HOSTED_ENABLED, CASCADE_S2S_ENABLED) gate all cascade flows at shouldCascade() for instant ops rollback. PSP outbound rate bucket (Laravel RateLimiter, 1s decay, configurable per-PSP RPS cap) prevents cascade storms from self-DDoS-ing backup PSPs.
S2S idempotency key middleware (X-Idempotency-Key)
Merchants can now safely retry POST /api/v1/transaction/create-s2s by sending an X-Idempotency-Key header (32-64 chars). The middleware caches the first response in Redis (30-min TTL, namespaced per merchant) and replays it byte-identical on retry. A request fingerprint (amount + currency + ref + card_last4) guards against key reuse with mutated payload (returns 422 IDEMPOTENCY_KEY_CONFLICT). PENDING_3DS responses are not cached. Cache failures fail closed with HTTP 503. The key is persisted on the hop-0 payment_attempt and forwarded along the cascade chain by the orchestrator. Gated behind payment.cascade.idempotency_enabled (default ON).
Geographic restrictions for payment methods (admin-only)
Admins can now define allowed/blocked countries per payment method with allow/block mode toggle. Routing engine enforces country at method level using billing country. BIN country enrichment (8-digit) and IP geolocation add additional fraud signals. Routing rule builder hidden from merchant portal — managed centrally by admin team. Cascade and fallback paths both enforce country restrictions.
BIN country enrichment for routing context
RoutingContext now includes bin_country sourced from BIN lookup (8-digit BIN support for Visa/MC 2022 rollout). Disagreement between billing, IP, and BIN country sources emits COUNTRY_SOURCE_MISMATCH fraud signal asynchronously via queue. Prepaid cards and sandbox PSP skip the signal to avoid false positives. Single-flight Cache::lock prevents external API stampede on cache miss.
Cache invalidation back-fill for payment method config updates
Back-fill Cache::forget('payment_method:{id}') into updateCardBrands() (pre-existing bug — 1h stale config after admin update). Optimistic locking (last_updated_at check) prevents concurrent admin overwrite with 409 Conflict response. CountryCodeNormalizer added for Kosovo/disputed territory ISO code normalization.
Sweeper for stuck 3DS authenticating transactions
A scheduled job runs every 5 minutes to mark transactions stuck in AUTHENTICATING status (no 3DS callback received within 15 minutes) as failed and fire the merchant failure webhook. The 3DS callback handler now silently no-ops if the attempt has already been swept, preventing split-brain races. Gated behind payment.cascade.sweeper_enabled.
S2S synchronous cascade orchestrator (Phase 3)
Server-to-server payments now cascade synchronously across configured PSP fallbacks within a single HTTP request. The orchestrator enforces a 25-second wall-time budget, marks the prior attempt FAILED before each hop so late webhooks are rejected as stale, performs lineage and event writes in a single short DB transaction, and decorates the response with opaque per-hop psp_codes and a cascade_group_id only when more than one hop ran. Single-attempt requests keep the legacy response shape (additive-only). Gated behind payment.cascade.enabled and payment.cascade.s2s_enabled.
Redirect Proxy ops alert on Redis fallback
When Redis is unavailable and the proxy token generator fails, EPaySe now raises a Telegram alert in addition to the error log so operators learn about proxy degradation immediately. The alert is dispatched after the surrounding DB::commit() via DB::afterCommit() so the HTTP call never holds an open database transaction. Payment flow continues uninterrupted via the existing fail-open fallback.
Redirect Proxy — psp_domain exposed to gateways
Added BaseGateway::getMerchantDomain() helper so PSP adapters that support a merchant_domain field can read the merchant's configured PSP Domain. Admin UI labels updated to reflect that the field is opt-in per PSP.
DataTable component — opt-in default sort column
The shared DataTable.vue component previously hardcoded { id: 'created_at', desc: true } as its initial TanStack sorting state, causing '[Table] Column with id created_at does not exist' console warnings on tables that use a different timestamp column (e.g. updated_at). DataTable now accepts a defaultSort prop; the Redirect Proxy admin list opts in with updated_at. Backward compatible — existing consumers keep the created_at default.
Industry-driven KYB form fully activated
Removed feature flag and enabled industry-specific document requirements for all merchants. Fixed security gap where industry form branch lacked file validation.
Merchant self-service cascade error rules
Merchants can now create, edit, and manage error pattern rules that control PSP cascade behavior from the Transaction Config dashboard. Merchant rules take highest priority in the evaluation chain: merchant-specific → admin PSP-specific → admin global. Regex match type is restricted to admin-only for security.
Redesigned KYB wizard with resume-by-email
KYB onboarding is now a 5-step wizard with real-time progress save, email magic-link resume, mobile camera capture for documents, and a review page with e-signature attestation before final submission.
Industry-specific KYB document requirements
KYB form now adapts to your business category — only relevant documents are requested, reducing onboarding time for low-risk industries. High-risk industries receive clear guidance on required compliance documents.
Financial precision: withdrawal fee calculation — bcmath chain (session 14)
WithdrawalController::store() computed withdrawals.total_fee using native PHP arithmetic operators (+, *, /) on amounts from DB (string numeric) and config (integer), causing silent float coercion before DB write to numeric(36,18) column. Fix: cast $amount to string at extraction point, replace all fee arithmetic with bcmul/bcdiv/bcadd chain, replace round($totalFee, 2) with bcadd($total, '0', 2). Also added explicit (string) cast for fee_amount DB write. Final comprehensive scan of all app/ directories (Traits, Observers, Listeners, Jobs, Commands, Models) confirmed 0 additional CRITICAL violations — CAST-AS-TEXT audit exhaustively complete across 14 sessions.
Financial precision: dispute fees, BuyerShield, manual payments, refund guards (session 8)
Extended CAST-AS-TEXT audit from DB-level sums to float parameter types and native arithmetic operators (+, -, *, /). 19 CRITICAL violations across 9 files: (1) DisputeCreationService::calculateReversalFees() — 7 native float ops → bcmath; chargeback_amount round() → bcsub; correct bcmath implementation already existed in PaymentFeeCalculator. (2) BuyerClaimResolutionService::calculateReversalFees() — same pattern, 7 float ops + (float) cast on resolved_amount. (3) FundFlowService::createCorrectionEntry() — float $correctedAmount → string; FundFlowController (float) cast → (string). (4) MarkupFeeService chargeMarkupFee/updateMarkupFee — float $amount → string; abs() < 0.01 → bccomp abs. (5) ReconciliationFundFlowService — abs((float) $pendingFlow->amount) → bcmath; float $amount → string. (6) ManualPaymentAttemptService — 4 methods, floatval() → (string), feeds paid_amount DB column. (7) BuyerClaimService — ?float $amount → ?string for claim_amount DB column. (8) BuyerClaimController (string) cast added. (9) BaseGateway::processRefund() — float $refundAmount → string.
Financial precision: AI tool settlement sums (session 7)
GetSettlementData AI tool — two DB-level sums on settlements.net_amount and gross_amount fed into bcadd/bcdiv currency conversion chain. Replaced with CAST(COALESCE(SUM(amount), 0) AS TEXT) pattern. Exhaustive scan of all remaining ->sum() calls in app/ completed — remaining MEDIUM violations are display-only (number_format, analytics charts) and do not feed bcmath chains.
Fix CI-035: reserves export 504 timeout — correlated subquery → LEFT JOIN
ReservesExport::buildOptimizedQuery() had a correlated subquery in the SELECT clause: (SELECT t.ref FROM transactions t WHERE t.id = mr.transaction_id LIMIT 1). With 218,592+ reserve rows, this executed once per row (N×1 queries), causing Gateway Timeout. Fixed by replacing with LEFT JOIN transactions t ON t.id = mr.transaction_id + t.ref as transaction_ref. The buildSearchCondition() EXISTS subquery (for search-only, not per-row) remains unchanged. 31/31 ReserveControllerTest pass.
Fix CI-036: buyer claims export DISTINCT/ORDER BY PostgreSQL error
BuyerClaimsExport::buildOptimizedQuery() used SELECT DISTINCT without bc.created_at in the column list, but BaseExport::applySorting() adds ORDER BY bc.created_at DESC. PostgreSQL strict: all ORDER BY expressions must appear in SELECT list when using DISTINCT (unlike MySQL). Fixed by adding bc.created_at to the SELECT DISTINCT column list.
Fix EG-021: self-host Inter/Figtree fonts via @fontsource, remove external CDN
Removed external font CDN references (fonts.bunny.net) from app.blade.php and admin.blade.php. Fonts are now bundled via @fontsource/inter and @fontsource/figtree npm packages imported in app.js and admin.js. Fixes: (1) CSP font-src violations when CDN is blocked, (2) Playwright browser_navigate timeout from 7-12s per-file CDN latency, (3) external dependency for font loading.
Fix EG-021b: add localhost:5173 to font-src CSP in Nginx conf for 3 subdomains
docs.epayse.local, gateway.epayse.local, and status.epayse.local use hardcoded Nginx-level CSP that bypassed Laravel middleware updates. Added http://localhost:5173 to font-src directive in all 3 Nginx conf files to allow Vite dev server fonts.
Fix CI-033: StatsCarousel wrong prop name (:items → :stats) in Invoice/Index.vue
Invoice/Index.vue was passing :items='statsCards' to StatsCarousel component, but the component expects :stats prop. Fixed prop name.
Fix CI-034: InvoiceTable default sort column created_at → invoice_number
InvoiceTable/DataTable.vue had initial sort { id: 'created_at', desc: true } but created_at is not a valid accessorKey in Invoice column definitions. Also Invoice/Index.vue had defaultField: 'created_at'. Both changed to invoice_number (valid accessorKey and allowed sort column in InvoiceController). Prevents TanStack Table sort error.
Financial precision: 7 additional sum() violations fixed in API, Telegram, and AI tools
Session 5 CAST-AS-TEXT audit. Schema correction: transactions.amount and refunds.amount are numeric(36,18), not integer — all monetary columns in this codebase use numeric(36,18). Fixed 7 CRITICAL violations where DB-level float sums fed bcmath: (1) MerchantSettingsController — fund_flows SUM for API balance response feeds bcadd currency conversion chain. (2) SettlementCurrencyService — fund_flows SUM for getMerchantBalanceByCurrency feeds getMerchantTotalBalance bcadd. (3) SendTelegramDailyReport — refunds.amount sum feeds bccomp/bcadd in report; transaction CASE-SUM for revenue feeds bcadd. (4) ComparePeriodsData AI tool — two transactions.amount sums feed full bccomp/bcdiv/bcmul chain for period-over-period comparison. (5) GetRevenueData AI tool — transactions SUM feeds bcadd/bcdiv per-currency metrics. (6) GetPartnerData AI tool — FIXED wrong column (amount → commission_amount) AND added CAST AS TEXT; wrong column caused silent PostgreSQL errors. All 26 SettlementCurrencyServiceTest pass.
Financial precision: 11 additional sum() violations fixed across reports, verifier, and refund guards
Extended CAST-AS-TEXT audit to all 58 numeric(36,18) columns across 15 tables. Fixed 11 HIGH violations where DB-level float sums fed bcmath chains: (1) PartnerCommissionService — commission_amount sum guards monthly cap via bcsub (cap bypass risk). (2) PlatformPnLService — total_internal_fee, total_provider_fee, and fund_flows.amount sums (with explicit (float) cast) feed entire P&L calculation chain. (3) PspPerformanceService — paid_amount and total_internal_fee sums feed bcadd-based metrics. (4) FinancialIntegrityVerifier:1295,1323,1546 — verifier used float sums as reference values for bcsub-based discrepancy detection (defeating its own precision checks). (5) RefundService, ProcessClaimRefund, StoreRefundRequest — pending refunds sum guards over-refund prevention via bcsub. Also fixed 4 TreasuryController fund_flows display sums. All tests pass.
Financial precision: 5 DB-level sum() violations fixed across financial services
Continued CAST-AS-TEXT audit on numeric(36,18) financial columns. Fixed 5 DB-level ->sum('amount') calls that returned PHP float, losing precision on 35B+ totals: (1) FinancialIntegrityVerifier.php:757 — platform_float balance check feeds bcadd. (2) ReconciliationService.php:154 — balance validation with bccomp(..., 18) — 18 decimal places of comparison requires full precision. (3) GetPlatformFinancialsData.php:67 — fund_flows sum for AI P&L reporting. (4) MarkupFeeService.php:182 — markup_fees.amount is also numeric(36,18) (same DB schema as fund_flows). Return type changed float→string. (5) FundFlowReportService.php:120 — opening balance sum feeds bcadd(opening, netChange, 18). All 123 related tests pass (359 assertions).
Financial integrity verifier — 3 additional false-positive checks fixed, 30 tests
Continued audit of FinancialIntegrityVerifier staging false positives: (1) amount_consistency (additional fix): REFUND query lacked psp_reference filter — seeder-created refunds had r.amount stored in dollars instead of cents. Added whereExists subquery through r.transaction_id → payment_attempts → psp_reference. (2) fund_flow_vs_transaction_fee (HIGH, 200): seeder creates TransactionFees without fund flows — added whereNotNull('pa.psp_reference'). (3) transaction_fee_completeness (HIGH, 200): seeder creates SUCCESS payment_attempts without TransactionFee records — added whereNotNull('pa.psp_reference'). Total false positives eliminated: 2400+ HIGH/CRITICAL → 0. Added 2 new tests (total: 30 tests, 81 assertions). Remaining 25 issues in staging are known seeder data quality artifacts.
Financial integrity verifier — 4 staging false-positive checks fixed
FinancialIntegrityVerifier was generating ~1500+ false-positive alerts on staging: (1) amount_consistency (CRITICAL, 200): verifier compared fund_flows.amount [dollars] with refunds.amount [cents] directly — 100x unit mismatch. Fixed by dividing r.amount by 100.0 in SQL and bcdiv(..., '100', 18) in PHP. (2) fund_flow_completeness (HIGH, 973): 297,848 seeder-created payment_attempts have no fund flows (StagingSeeder bypasses service layer). Added whereNotNull('pa.psp_reference') filter — real PSP-processed attempts always have psp_reference. Zero real transactions are missing fund flows. (3) settlement_timing_accuracy (HIGH, 500): seeder sets transaction_fees.settlement_at to fixed date, fund_flows.released_at to arbitrary value — gaps up to 71 days. Same psp_reference filter applied. (4) reserve_release_timing (HIGH, 500): same seeder artifact root cause, same fix.
Financial integrity verifier false positives eliminated — REFUND, PROCESSING_FEE, MDR
FinancialIntegrityVerifier was triggering hundreds of false HIGH alerts per day. Root causes: (1) morphMap added 'refund'/'dispute' aliases without data migration — verifier queries using FQCN missed 30K rows with aliases. Fixed with whereIn(both formats) + chunked migration (FOR UPDATE SKIP LOCKED). (2) Zero-fee merchants have no PROCESSING_FEE/MDR fund flows (DB constraint prevents zero-amount entries). Fixed by joining transaction_fees and conditionally requiring fee flows only when total_fees > 0. (3) TransactionFactory::withPartialRefund/withFullRefund bypassed service layer — fixed to call FundFlowService::createRefundCompleteEntries after factory creation.
Platform float precision loss fixed — CAST(SUM AS TEXT) replaces ->sum('amount')
FundFlowService::getPendingSettlement, getReserveBalance, getSystemBalance, and getPspFreezeReserveBalance all used Eloquent ->sum('amount') which PHP cast to float, losing precision on amounts > 10 billion (IEEE 754 double only has 15-17 significant digits). FinancialIntegrityVerifier::verifyPlatformFloat also affected. Fixed all 6 locations with selectRaw('CAST(COALESCE(SUM(amount), 0) AS TEXT) as precise_sum')->first()?->precise_sum. Also replaced abs() with bcmul($n, '-1', 18) for consistency.
S2S paid_amount zero — fund flows and refund validation now correct
S2S (server-to-server) payments were not setting paid_amount on payment_attempts or transactions after PSP response. ProcessSandboxWebhook read paid_amount=0, creating zero-amount fund flows. RefundService compared dollars against cents (10000 vs 80.00), allowing over-refunds silently. Fixed by storing paid_amount in dollars (cents÷100) at S2S response time.
Double KYB notification — removed duplicate call from controller
KYB approve/reject controller explicitly called notifyKybApproved/Rejected AND the KybObserver also fired on model update, sending two notifications. Removed the explicit controller calls; observer is now the single source of notification side effects.
Float arithmetic in dispute chargeback calculation replaced with bcmath
DisputeResolutionService used round($paid - $refunded, 2) for chargeback amount — vulnerable to IEEE 754 precision loss on large amounts. Replaced with bcsub() and bccomp() for PCI-safe precision.
browserDetails fields renamed to camelCase and all required
S2S API browserDetails nested field names changed from snake_case to camelCase (e.g., user_agent → userAgent, screen_width → screenWidth). All 12 fields are now required. New Browser Details JS SDK available at /js/epayse-browser.js for easy collection.
Refund Approval Workflow & Admin Queue
Added admin refund approval queue with approve/reject/retry actions, auto-refund toggle per merchant (for PSPs without refund API), REFUND_HOLD fund flow for balance reservation, Telegram notifications for refund/chargeback events, balance-insufficient handling with admin intervention, and per-merchant refund configuration in admin settings.
Merchant self-serve PSP onboarding requests
Merchants can now submit access requests for additional payment service providers from their dashboard. Admins review, approve (auto-assigning the PSP), or reject requests with notes. Includes full status workflow: pending → under_review → approved/rejected.
Fix 7 financial accuracy bugs in settlement, fees, and fund flow
Centralized fee calculation into PaymentFeeCalculator (was duplicated in 4 places with float bugs), fixed GlobalFeeConfig to use bcmath, added disputes.amount column enabling DISPUTE_HOLD/WIN_RELEASE, fixed balance inflation (total doubled after T+N), fixed DailyCalculation to use disputes.amount, added lazy PENDING→COMPLETED transition for settlement release, optimized getSystemBalance from O(N) to O(1) with DISTINCT ON + Redis cache.
Add financial operations monitoring and management tools
Settlement-ledger auto-reconciliation (nightly), chargeback ratio monitoring with Visa VAMP/Mastercard ECM threshold alerts, manual payout creation for admin, treasury dashboard with platform-wide balance overview, admin audit log viewer, and cross-merchant P&L report with monthly breakdown.
Ops Hub - Centralized DevOps Dashboard
Added Ops Hub page in admin portal (System > Ops Hub) with categorized links to all monitoring and developer tools (Horizon, Log Viewer, Dozzle, Uptime Kuma, Beszel, GlitchTip, Dokploy, Telescope, Mailpit). Features environment-aware URL resolution, live system health panel, and access type badges.
Provider-agnostic AI Chat with pre-fetch data pattern
Refactored AI Chat features (KYB AI Chat, Platform AI Chat) to use provider-agnostic pre-fetch pattern. Claude CLI is primary (flat-rate subscription), with Gemini/OpenAI/Anthropic API as fallback. ContextProvider interface pre-fetches data from DB, injects into prompt, eliminating tool-calling dependency. Includes AbortController for SSE cleanup, typewriter effect for UX, unique delimiters for prompt security, partial tool failure resilience, and claude-cli-prefetch-pattern skill.
Fix AI Ops hardcoded /operate URL prefix and feedback route issues
Replaced hardcoded /operate/ URLs in 4 AiOps Vue components with Ziggy route() helper for subdomain routing compatibility. Fixed KYB Intelligence feedback controller to return RedirectResponse instead of JsonResponse for Inertia compatibility. Fixed ScopeValidator rejecting plural keywords (merchants, transactions) by adding admin-specific keyword list.
Fix AI Chat Widget server hang on close/re-open
Added AbortController to useStreamingChat composable. Closing chat now aborts in-flight SSE fetch requests, preventing PHP worker exhaustion (504). Added 90s timeout on fallback axios calls.
Restructure seeders into 3-tier architecture with fund flow production parity
DatabaseSeeder now lean (~37s, foundation + accounts only). StagingSeeder provides full demo data with 7 new feature seeders. MassTransactionSeeder generates 7 fund flow types per transaction matching production FundFlowService, creates merchant_reserves records, uses bcmath scale 18. 15 unit tests with 252 assertions verify balance integrity.
AI Operations Platform — Fraud Intelligence + Unified Dashboard
Phase 3: Fraud Intelligence with FraudIntelligenceReport model, FraudIntelligenceService (risk analysis, rule suggestions), GetFraudIntelligenceData AI tool integrated into AdminAnalyticsAgent. Phase 4: Unified AI Ops Dashboard with cross-domain stats (KYB, Fraud, Website, Learnings), activity feed, Learnings management page, and Fraud Intelligence index/detail pages. Admin sidebar AI Operations section with 4 sub-items. 15 PHPUnit + 53 Vitest + 8 E2E tests.
Add Telegram AI merchant support bot
AI-powered Telegram bot for per-merchant group support. Hybrid LLM (Gemini for data queries + Claude CLI for integration docs), 15 AI tools, 2-phase comfort response, /sandbox command with email credentials delivery, webhook security with dual verification, per-group rate limiting, and docs extraction pipeline.
CI/CD pipeline Telegram notifications
Real-time Telegram alerts for every CI/CD pipeline stage (deploy start/success/fail, smoke test results) with multi-channel architecture supporting separate channels for CI/CD, app alerts, and monitoring
Comprehensive system error Telegram alerting
Fix broken telegram logging channel, add global exception and queue failure handlers, PSP gateway error alerts, fraud BLOCK/REVIEW notifications, DDoS detection alerts, security monitoring Telegram channel, fund flow discrepancy alerts, and scheduled task failure monitoring — coverage from 7 scenarios to near 100% critical paths
Fix route cache duplicate names breaking staging deploy
Renamed sandbox.* route names to gateway.sandbox.*, main.sandbox.*, merchant.sandbox.* to prevent artisan route:cache failure on staging/production
Fix merchant statements page crash on empty currency config
Fixed dead code toArray() ?? fallback and added null safety guard for merchants without currency configurations
Fix CollectStatusMetrics missing Request parameter
Status metrics collection command failed after health monitoring auth was added, now creates internal authenticated request
Add 43 production readiness regression tests
19 PHPUnit tests verifying security config, bcmath enforcement, HTTP timeouts, onOneServer scheduling + 24 Playwright E2E staging tests for Sections 37-41
AI Agent Phase 4 — Social Autopilot, sub-agent orchestration & weekly learning aggregation
SocialAutopilotAgent with 4 capabilities (analyze, generate social content, approve content, generate report). ApproveContentHandler delegates to SocialContentService::approve(). SubAgentCallHandler enables parent-child agent session spawning via AgentOrchestratorService. GenerateAgentLearningsJob runs weekly to aggregate cross-session learnings (send times, content patterns, channel effectiveness). All 6 agents + 17 handlers fully registered. 8 new tests.
AI Agent Phase 3 — Churn Prevention, Performance Optimizer & auto-trigger
ChurnPreventionAgent (7 capabilities) and PerformanceOptimizerAgent (5 capabilities). 4 new handlers: DetectChurnHandler, AdjustBudgetHandler, PauseCampaignHandler, SendCampaignHandler — all delegating to existing services. AutoTriggerChurnPreventionJob runs daily at 09:00 to detect at-risk merchants and auto-start retention campaigns. 20 new tests.
AI Agent Phase 2 — Content Creator & Lead Nurturing agents
ContentCreatorAgent (6 capabilities) and LeadNurturingAgent (6 capabilities). 4 new action handlers: GenerateSocialContentHandler, CreateDripCampaignHandler, EnrollMerchantsHandler, ScoreLeadsHandler — all delegating to existing SocialContentService, DripCampaignService, and LeadScoringService. 18 new tests.
AI Agent Phase 1.5 — critical infrastructure bug fixes
Fix WebSocket event name mismatch (broadcastAs vs composable listen). Wire up MarketingAgentServiceProvider to register agents at boot. Dispatch AgentAutoApprovalTimeoutJob for review-level actions. Close learning feedback loop via recordSessionLearnings() on session completion. Remove dead Create.vue page. 14 new tests.
AI Agent Marketing Automation — Core Framework & Campaign Orchestrator
Autonomous campaign orchestration with 3-tier approval matrix (Auto/Review/Critical), real-time WebSocket timeline via Laravel Reverb, 7 action handlers (analyze, generate, create template/campaign/experiment, schedule, report), human-in-the-loop approval flow, and CampaignOrchestratorAgent as first concrete agent. 3 database tables, 6 enums, 6 core services, 3 Vue pages with live action timeline, useAgentSession composable. 89 BE + 65 FE tests.
Marketing Intelligence Phase 8 — batch operations, notifications, snooze & performance
Batch accept/dismiss for multiple recommendations with floating toolbar and confirmation dialog. Snooze action extends expires_at by 7 days. NewRecommendationsNotification dispatched after daily generation. Weekly marketing digest email job scheduled Monday 9AM. Stats caching (5 min TTL) with cache-busting on mutations. Composite database indexes on marketing_recommendations (status+created_at, status+expires_at). Select-all checkbox with ring highlight on selected cards. 20 BE + 7 FE new tests.
Marketing Intelligence Phase 7 — Claude CLI integration & AI content generator
Add ClaudeCodeService wrapping Claude Code CLI for flat-rate AI generation (Claude Max). Refactor AiInsightService with dual provider support (claude_cli vs anthropic_api). New AiContentGeneratorService with 4 methods: email subjects, email body, landing copy, ad copy — all bilingual EN/VI with JSON parsing and code block extraction. Content Generator page with dynamic forms, copy-to-clipboard, and loading states. 70 BE + 15 FE Vitest tests.
Marketing Intelligence Phase 6 — action automation, audit trail & detail page
Implement 3 missing apply methods (CreateDripCampaign, AdjustScoring, SendReEngagement) in CampaignOptimizerService. Add RecommendationAuditLog model with ULID prefix ral_ for tracking accept/dismiss/apply/rate actions. New Intelligence Show page with badges, star rating, analysis/outcome data, activity timeline, related recommendations and dismiss dialog. 35 Vitest + 16 PHPUnit tests.
Sync Intelligence page UI/UX with Admin Portal conventions
Responsive header with mobile breakpoints matching 110+ admin pages. Replace manual pagination loop with standard Pagination component (supports ellipsis, Go to page, filter preservation). Mobile-optimized recommendation cards and touch targets. Fix Playwright MCP Chrome session conflict with --isolated flag and session-start cleanup hook.
Buyer Shield — claim fees & claim_reference removal
Remove human-readable claim_reference (RSLV-XXXXXX) in favor of standard ULIDs. Add configurable claim fees per merchant per payment method (mirroring dispute fees). Fee charged on claim resolution (5 paths). CLAIM_FEE fund flow entries. Settlement NET formula updated to 7 components. Admin UI fee config tab. 34 new tests (18 BE + 16 FE).
Buyer Shield — plan gap completion (evidence upload, bulk ops UI, email i18n)
Evidence upload routes for buyer and merchant with file validation and IDOR protection. Admin bulk operations UI with checkbox row selection, floating action bar, assign/arbitrate dialogs. Bilingual Vietnamese sections in buyer-facing email templates. E2E buyer claim lifecycle tests (12 tests). Evidence upload feature tests (10 tests).
Buyer Shield Phase 4E — bulk operations & claim rate analytics
Admin bulk assign (escalated claims to reviewer) and bulk arbitrate (approve/deny multiple claims). Merchant claim rate analytics with risk level badge (Low/Medium/High) and 30-day trend detection on admin merchant detail page. 11 new tests.
Buyer Shield Phase 4D — i18n & merchant response templates
Full i18n for 6 Resolve Vue pages and ResolveLayout (~180 translation keys, EN + VI). Config-based merchant response templates (6 presets: full refund, partial, reject) with quick-apply UI in claim response form. 3 new response template tests.
Buyer Shield Phase 4 — auto-resolution & webhook events
Auto-resolution engine for claims below merchant's auto_refund_threshold (synchronous check at filing time, async PSP refund via ProcessClaimRefund). Webhook event system dispatching claim.filed, claim.auto_resolved, claim.escalated, and claim.resolved events via ProcessSendClaimWebhookToMerchant job with exponential backoff. WebhookPayloadBuilder extended with buildClaimObject() for Stripe-like event payloads. 19 new tests (8 auto-resolution + 11 webhook) across 2 test files.
EPaySe Buyer Protection (Buyer Shield) Phase 1 MVP
Buyer-facing dispute resolution portal with OTP/magic link verification, claim filing, and status tracking. Includes admin claim management with export, merchant dashboard claims view, SHA-256 token hashing, HKDF-derived session keys, TOCTOU protection, rate limiting, and IDOR prevention. 88 tests across 6 test files (161 total with related tests).
Marketing analytics dashboard & lead scoring (Phase 5)
Analytics dashboard with marketing funnel visualization, lead scoring engine (100-point algorithm across 7 factors), churn detection via volume decline analysis, campaign performance tracking, and score distribution. Includes daily scheduled jobs for funnel snapshots and lead score recalculation, Inertia deferred props for fast page load, and comprehensive test coverage (28 backend + 6 frontend tests).
Landing pages, case studies & lead capture (Phase 4)
Extends Article model with type field (ARTICLE, LANDING_PAGE, CASE_STUDY) for SEO-optimized landing pages and case studies. Includes public landing page rendering with embedded lead capture forms, lightweight lead capture endpoint with UTM tracking, admin lead management (list, detail, status workflow, assignment), newsletter auto-subscribe on consent, and blog admin type selector. 62 tests (42 backend + 20 frontend).
PSP match notification & smart re-engagement (Phase 3)
Smart re-engagement system that matches merchants with new PSPs based on their payment interests. Includes merchant payment interest widget (dashboard), PSP match scoring engine (method groups, industry, volume), admin preview and bulk notification, email notifications via PspMatchMail, anti-spam protection, and KYB auto-enrichment. 54 tests (32 backend + 22 frontend).
Lifecycle drip campaigns & email templates (Phase 2)
Automated lifecycle drip campaigns with multi-step email sequences triggered by merchant status changes. Includes drip campaign CRUD with step editor, email template management with variable substitution, merchant enrollment lifecycle (active/paused/cancelled/completed), conditional step execution (skip_if_status/require_status), campaign metrics and completion rates, scheduled daily processing, and auto-enrollment via MerchantStatusChanged event. 85 tests (50 backend + 35 frontend).
Email marketing & newsletter system (Phase 1)
Complete email marketing platform with newsletter subscribers, email campaigns, email templates, and campaign recipient tracking. Includes admin CRUD for campaigns/subscribers, public newsletter subscription with double opt-in, unsubscribe flow, preference center, blog widget integration, and comprehensive test coverage (98 backend + 22 frontend + 55 E2E tests).
AI Assistant quick questions marquee animation
Replaced JS step-by-step carousel (setInterval + drag/swipe) with CSS marquee animation for Quick Questions in AI chat widget. Smooth continuous scroll, hover-to-pause, edge fade effect, prefers-reduced-motion support.
Merchants must configure own AI API key
Removed system default LLM fallback for merchant portal. Merchants now must configure their own AI API key (OpenAI, Gemini, or Claude) to use the AI Assistant. Widget shows clear setup guide with CTA when unconfigured. Admin portal unchanged (uses system provider).
AI-powered fraud analysis tools for chat assistants
Added 6 fraud analysis tools to AI agents: 3 merchant tools (fraud summary with decision breakdown/risk distribution/daily trends, blacklist overview, rule effectiveness with trigger analysis) and 3 admin tools (platform-wide fraud overview, global fraud signal intelligence, learned rule performance). Merchant agent now has 10 tools, admin agent has 7. Includes ScopeValidator fraud keyword support. 28 new tests.
SSE streaming responses for AI assistants
Added Server-Sent Events (SSE) streaming to both merchant and admin AI chat endpoints. Responses now appear progressively in real-time instead of waiting 3-10 seconds for blocking completion. Cache hits return JSON instantly. Includes new useStreamingChat composable with debounced markdown rendering, fallback to blocking on stream failure, and blinking cursor UX during streaming. 15 backend + 20 frontend tests.
Agent + Tool Calling architecture for AI assistants
Replaced Two-Pass LLM architecture (parameter extraction → context building → answer generation) with Laravel AI SDK Agent + Tool Calling. Merchant agent has 8 tools (revenue, transactions, refunds, disputes, settlements, fraud, balance, compare), admin agent has 4 tools (platform overview, merchant ranking, PSP performance, financials). Includes 3 middleware (input sanitization, prompt leakage protection, token tracking). All 12 providers use agent path. 34 new tests with 103 assertions.
Replace LlmProviderFactory with Laravel AI SDK-based service
Replaced manual HTTP provider calls (752 LOC) with LlmSdkProviderService using Laravel AI SDK. All 12 LLM providers route through the SDK's unified API, including Together AI and Perplexity via OpenAI-compatible driver. CircuitBreaker integration preserved.
Remove Two-Pass legacy code and unsupported providers
Removed HuggingFace and AWS Bedrock providers (no SDK support), Two-Pass LLM architecture (parameter extraction + context building + answer generation), and all related code: LlmProviderFactory, MerchantDataContextBuilder, AdminDataContextBuilder, ParameterValidator, prompt template system. Reduced from 14 to 12 providers, all using Agent + Tool Calling. ~1,500 LOC removed.
Add conversion rate and total transactions to Payment Methods API
Payment Methods API now returns conversion_rate (success percentage) and total_transactions for each payment method, calculated from the merchant's last 30 days of payment attempt data. Cached for 5 minutes per merchant to minimize DB load.
Add preferredPaymentMethod parameter to transaction creation APIs
All 3 transaction creation endpoints (standard, iframe, S2S) now accept an optional preferredPaymentMethod parameter. When specified, the preferred method is tried first in the cascade order. For S2S, silently falls back to default gateway if the preferred method doesn't support S2S. Includes audit trail via payload_checkout.
Transaction alert indicators with detail dialog and severity filter
Added alert indicator column (first column) to admin transactions DataTable showing fraud and website mismatch warnings per transaction. Clicking an indicator opens a dialog with risk score bar, decision badges, and alerts grouped by category (geographic, website, fraud rules, risk score, blacklist, velocity, card testing, AML, browser, manual review). Added severity-based filter (Has Alerts, Critical, High, Medium, No Alerts) with URL persistence. Includes 31 backend tests, 23 frontend tests, and 10 E2E tests.
Deferred fraud scoring and Redis-based frequency counting
Two-phase fraud evaluation defers 8 scoring-only checks for clean transactions (~95%) via Laravel defer(), reducing latency by 40-80ms. Redis atomic counters replace DB COUNT queries for calendar-window frequency rules, cutting per-rule check time from 10-30ms to 1-5ms. Falls back to DB automatically when Redis is unavailable.
Cache fraud detection and payment routing for faster transactions
Added configurable caching layer across 6 services (FrequencyChecker, BlacklistChecker, FraudDetectionService, TransactionExpirationService, CascadingPaymentService, PaymentRoutingService) reducing DB queries from 31-53 to ~10-20 per transaction. Added FRAUD_CACHE_STORE config, early exit on blacklist block, batch country validation, and DB indexes for fraud queries.
Fix alert service bugs and add comprehensive unit tests
Fixed 3 production bugs in alert notification pipeline: undefined array key access in Telegram/Slack services, void return type preventing sendTestAlert from returning results, and deduplication never recording fingerprints. Added 44 unit tests for TelegramAlertService, SlackAlertService, and AlertDispatcher.
Country-dependent billing state combobox and postal code hints
Billing state field now shows a searchable combobox with states/provinces for supported countries. Users can select from the list or type custom values. Postal code placeholder dynamically shows format hints based on selected country.
Amount-based velocity limits
Frequency rules can now limit by total transaction amount (e.g., block if IP exceeds $10,000/day) in addition to transaction count
Calendar-based time windows
Added calendar hour/day/week/month windows that reset at natural boundaries (midnight, start of week/month) instead of rolling periods
Fix false-positive financial integrity alerts for missing REFUND fund flows
FinancialIntegrityVerifier was generating false HIGH alerts for 15,000+ completed refunds due to morph map mismatch: the Relation::morphMap short alias ('refund') was added without a data migration, so getMorphClass() returned 'refund' while existing fund_flows rows stored 'App\\Models\\Refund'. Fixed verifier to accept both formats via whereIn. Added chunked migration (SKIP LOCKED) to normalize non-Transaction rows. Fixed TransactionFactory::withPartialRefund()/withFullRefund() to create fund flows via FundFlowService. Added backfill:refund-fund-flows command for future gaps.
Admin real-time updates now authorize correctly
Admin broadcasting channels (transaction monitoring and AI-agent session updates) resolved against the merchant web guard instead of the admin guard, so an authenticated operator was silently rejected and never received live updates. The admin channels now resolve the admin guard, restoring real-time updates on the operator portal.
Payment Links, browser anti-spoofing, and developer experience improvements
Payment Links
Merchants can create shareable payment links for no-code payment collection with 6 customer fields and customer selector
Browser details collection & anti-spoofing detection
Collect browser fingerprint details and detect spoofed user agents for fraud prevention
Vue MCP debugging integration
Integrated vite-plugin-vue-mcp for AI-assisted Vue component debugging
Setup command improvements
Improved /setup command with APP_SERVICE check, seeder fallback, and hosts validation
Exchange rate markups, rolling reserves, and infrastructure modernization
Exchange rate markup management
Configure exchange rate markups for currency conversion with merchant-specific rates
Rolling reserve management
Complete rolling reserve system with merchant filters, export, and integration tests
Landing page redesign
Redesigned landing page with enhanced visuals, animations, and live checkout component
A/B testing system
Checkout optimization through A/B testing with variant support and i18n
Status page system
Public status page with admin management, live indicators, and API documentation
Docker service modernization
Renamed Docker service from laravel.test to epayse.app with auto-detect LAN IP
Mobile scroll-snap blog slider
Horizontal scroll-snap slider for blog cards on mobile with sticky stack cards
FX Markups mobile tab overlap
Resolved mobile tab overlap on FX Markups page and DataTable pagination
Issue reports, admin permissions overhaul, and partner program foundation
Issue reports
Merchants can report errors directly from the dashboard with admin notification
Partner program foundation
Multi-tier referral system with commission tracking, tier-based rates, and downline management
Redis operation service
Standardized cache layer with RedisOperationService for consistent cache operations
Admin permission mismatches
Resolved 4 admin permission mismatches causing 403 errors
PSP management, fund flows, and manual payment reconciliation
Documentation portal, customer management, and API improvements
S2S payment API, security hardening, and checkout improvements
Sandbox system, dispute management, and blog platform
Initial release of EPaySe Payment Gateway Platform
