diff --git a/backend/migrations/20260424000001_add_pending_payments_polling_index.js b/backend/migrations/20260424000001_add_pending_payments_polling_index.js new file mode 100644 index 00000000..930c82c0 --- /dev/null +++ b/backend/migrations/20260424000001_add_pending_payments_polling_index.js @@ -0,0 +1,20 @@ +/** + * Add a partial index for the Ledger Monitor pending-payment scan. + * + * The poller filters on status + created_at and excludes soft-deleted rows, + * so this partial index lets Postgres satisfy that query without scanning the + * broader payments indexes. + */ + +export async function up(knex) { + await knex.raw( + "CREATE INDEX IF NOT EXISTS payments_pending_created_idx ON payments(status, created_at ASC) WHERE deleted_at IS NULL", + ); + + console.log("✓ Added payments_pending_created_idx for Ledger Monitor polling"); +} + +export async function down(knex) { + await knex.raw("DROP INDEX IF EXISTS payments_pending_created_idx"); + console.log("✓ Removed payments_pending_created_idx"); +} \ No newline at end of file diff --git a/backend/src/lib/horizon-poller.js b/backend/src/lib/horizon-poller.js index 9f217e82..0bf29fbb 100644 --- a/backend/src/lib/horizon-poller.js +++ b/backend/src/lib/horizon-poller.js @@ -47,6 +47,14 @@ import { const POLL_INTERVAL_MS = 15_000; // 15 seconds between normal cycles const BATCH_SIZE = 50; // max pending payments per cycle const MAX_AGE_HOURS = 24; // ignore payments older than 24 h (likely abandoned) +const MERCHANT_NOTIFICATION_FIELDS = [ + "webhook_secret", + "webhook_version", + "notification_email", + "email", + "business_name", + "webhook_custom_headers", +].join(", "); /** Back-off schedule (ms) applied after consecutive Horizon fetch failures. */ const BACKOFF_DELAYS_MS = [5_000, 15_000, 30_000, 60_000]; @@ -152,10 +160,11 @@ async function pollPendingPayments() { const { data: pending, error } = await supabase .from("payments") - .select("id, amount, asset, asset_issuer, recipient, memo, memo_type, webhook_url, created_at, merchant_id, merchants(webhook_secret, webhook_version, notification_email, email, business_name, webhook_custom_headers)") + .select("id, amount, asset, asset_issuer, recipient, memo, memo_type, webhook_url, created_at, merchant_id, metadata") .eq("status", "pending") .is("deleted_at", null) .gte("created_at", cutoff) + .order("created_at", { ascending: true }) .limit(BATCH_SIZE); if (error) { @@ -489,7 +498,7 @@ async function checkPayment(payment) { } // Webhook - const merchant = payment.merchants; + const merchant = await loadMerchantNotificationConfig(payment.merchant_id); if (merchant) { const webhookPayload = getPayloadForVersion( merchant.webhook_version || "v1", @@ -544,3 +553,25 @@ async function checkPayment(payment) { function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } + +async function loadMerchantNotificationConfig(merchantId) { + if (!merchantId) { + return null; + } + + const { data, error } = await supabase + .from("merchants") + .select(MERCHANT_NOTIFICATION_FIELDS) + .eq("id", merchantId) + .maybeSingle(); + + if (error) { + logger.warn( + { err: error, merchantId }, + "Horizon poller: failed to load merchant notification config", + ); + return null; + } + + return data ?? null; +} diff --git a/backend/src/lib/horizon-poller.test.js b/backend/src/lib/horizon-poller.test.js index d7763d61..5c9c8f23 100644 --- a/backend/src/lib/horizon-poller.test.js +++ b/backend/src/lib/horizon-poller.test.js @@ -117,14 +117,18 @@ function makePayment(overrides = {}) { created_at: new Date(Date.now() - 5_000).toISOString(), merchant_id: "merchant-001", metadata: {}, - merchants: { - webhook_secret: "secret", - webhook_version: "v1", - notification_email: "merchant@example.com", - email: "merchant@example.com", - business_name: "Test Merchant", - webhook_custom_headers: {}, - }, + ...overrides, + }; +} + +function makeMerchant(overrides = {}) { + return { + webhook_secret: "secret", + webhook_version: "v1", + notification_email: "merchant@example.com", + email: "merchant@example.com", + business_name: "Test Merchant", + webhook_custom_headers: {}, ...overrides, }; } @@ -181,9 +185,10 @@ describe("Ledger Monitor — error recovery (Issue #627)", () => { const payment = makePayment(); // The poller makes 3 calls to supabase.from("payments"): - // 1. Fetch pending payments (select + limit) + // 1. Fetch pending payments (select + order + limit) // 2. Duplicate-tx guard (select + neq + maybeSingle → null) // 3. Atomic update (update + maybeSingle → { id }) + // 4. Merchant notification config lookup let fromCallCount = 0; mockSupabaseFrom.mockImplementation(() => { fromCallCount += 1; @@ -194,6 +199,7 @@ describe("Ledger Monitor — error recovery (Issue #627)", () => { eq: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), gte: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue({ data: [payment], error: null }), }; } @@ -207,14 +213,21 @@ describe("Ledger Monitor — error recovery (Issue #627)", () => { maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), }; } - // Atomic update — success + if (fromCallCount === 3) { + // Atomic update — success + return { + update: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnThis(), + is: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ data: { id: payment.id }, error: null }), + }), + }; + } return { - update: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnThis(), - is: vi.fn().mockReturnThis(), - select: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: { id: payment.id }, error: null }), - }), + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ data: makeMerchant(), error: null }), }; }); @@ -256,6 +269,7 @@ describe("Ledger Monitor — error recovery (Issue #627)", () => { eq: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), gte: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue({ data: [payment], error: null }), })); @@ -287,6 +301,7 @@ describe("Ledger Monitor — error recovery (Issue #627)", () => { eq: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), gte: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue({ data: [payment], error: null }), })); @@ -314,6 +329,7 @@ describe("Ledger Monitor — error recovery (Issue #627)", () => { eq: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), gte: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue({ data: [payment], error: null }), })); @@ -356,6 +372,7 @@ describe("Ledger Monitor — error recovery (Issue #627)", () => { eq: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), gte: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue({ data: null, error: { message: "DB down" } }), })); @@ -379,6 +396,7 @@ describe("Ledger Monitor — error recovery (Issue #627)", () => { eq: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), gte: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), limit: vi.fn().mockImplementation(() => { callCount += 1; if (callCount === 1) { @@ -422,6 +440,7 @@ describe("Ledger Monitor — error recovery (Issue #627)", () => { eq: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), gte: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue({ data: [payment], error: null }), update: updateMock, })); @@ -466,6 +485,7 @@ describe("Ledger Monitor — error recovery (Issue #627)", () => { eq: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), gte: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue({ data: [payment], error: null }), update: updateMock, })); @@ -503,6 +523,7 @@ describe("Ledger Monitor — error recovery (Issue #627)", () => { eq: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), gte: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue({ data: [payment], error: null }), })); @@ -519,6 +540,7 @@ describe("Ledger Monitor — error recovery (Issue #627)", () => { eq: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), gte: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue({ data: [payment], error: null }), })); @@ -540,6 +562,7 @@ describe("Ledger Monitor — error recovery (Issue #627)", () => { neq: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), gte: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue({ data: [payment], error: null }), // Duplicate check returns an existing payment maybeSingle: vi.fn().mockResolvedValue({ data: { id: "other-pay" }, error: null }), @@ -574,9 +597,73 @@ describe("Ledger Monitor — error recovery (Issue #627)", () => { eq: vi.fn().mockReturnThis(), is: vi.fn().mockReturnThis(), gte: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue({ data: [], error: null }), })); + describe("merchant notification lookup", () => { + it("continues confirmation when merchant notification config lookup fails", async () => { + const payment = makePayment(); + + let fromCallCount = 0; + mockSupabaseFrom.mockImplementation(() => { + fromCallCount += 1; + if (fromCallCount === 1) { + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + is: vi.fn().mockReturnThis(), + gte: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue({ data: [payment], error: null }), + }; + } + if (fromCallCount === 2) { + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + neq: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), + }; + } + if (fromCallCount === 3) { + return { + update: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnThis(), + is: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ data: { id: payment.id }, error: null }), + }), + }; + } + + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ data: null, error: { message: "merchant lookup failed" } }), + }; + }); + + mockFindMatchingPayment.mockResolvedValue({ + id: "op-1", + transaction_hash: "tx-abc", + received_amount: "10.0000000", + }); + mockVerifyTransactionSignature.mockResolvedValue({ + valid: true, + reason: "ok", + isMultiSig: false, + signatureCount: 1, + thresholdMet: true, + }); + + await pollOnce(); + + expect(mockPaymentConfirmedCounter.inc).toHaveBeenCalledWith({ asset: payment.asset }); + expect(mockSendWebhook).not.toHaveBeenCalled(); + }); + }); + await pollOnce(); expect(mockFindMatchingPayment).not.toHaveBeenCalled(); diff --git a/backend/src/lib/rate-limit.js b/backend/src/lib/rate-limit.js index cfb7f5c5..fd0ddac0 100644 --- a/backend/src/lib/rate-limit.js +++ b/backend/src/lib/rate-limit.js @@ -1,7 +1,10 @@ -import rateLimit from "express-rate-limit"; +import { createHash } from "node:crypto"; +import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import { RedisStore } from "rate-limit-redis"; export const RATE_LIMIT_REDIS_PREFIX = "rl:"; +export const VERIFY_PAYMENT_RATE_LIMIT_WINDOW_MS = 60 * 1000; +export const VERIFY_PAYMENT_RATE_LIMIT_MAX = 30; function setStandardRateLimitHeaders(res, rateLimitState) { if (!res || !rateLimitState) { @@ -34,18 +37,40 @@ export function createRedisRateLimitStore({ }); } +export function getVerifyPaymentRateLimitKey(req) { + const paymentId = + typeof req?.params?.id === "string" && req.params.id.length > 0 + ? req.params.id + : "unknown-payment"; + const merchantId = + typeof req?.merchant?.id === "string" && req.merchant.id.length > 0 + ? `merchant:${req.merchant.id}` + : null; + const apiKey = + typeof req?.headers?.["x-api-key"] === "string" && + req.headers["x-api-key"].length > 0 + ? `api:${createHash("sha256").update(req.headers["x-api-key"]).digest("hex")}` + : null; + const ipKey = ipKeyGenerator(req?.ip ?? req?.socket?.remoteAddress ?? "unknown-ip"); + const actor = merchantId ?? apiKey ?? `ip:${ipKey}`; + + return `${paymentId}:${actor}`; +} + export function createVerifyPaymentRateLimit({ store, rateLimitFactory = rateLimit, } = {}) { return rateLimitFactory({ - windowMs: 15 * 60 * 1000, - max: 10, + windowMs: VERIFY_PAYMENT_RATE_LIMIT_WINDOW_MS, + max: VERIFY_PAYMENT_RATE_LIMIT_MAX, message: { error: "Too many verification requests, please try again later.", }, standardHeaders: true, legacyHeaders: false, + validate: { ip: false }, + keyGenerator: getVerifyPaymentRateLimitKey, requestWasSuccessful: (req, res) => { setStandardRateLimitHeaders(res, req.rateLimit); return res.statusCode < 400; diff --git a/backend/src/lib/rate-limit.test.js b/backend/src/lib/rate-limit.test.js index f3ff7387..62d50f51 100644 --- a/backend/src/lib/rate-limit.test.js +++ b/backend/src/lib/rate-limit.test.js @@ -1,8 +1,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("rate-limit-redis", () => ({ + RedisStore: vi.fn(), +})); + +vi.mock("redis", () => ({ + createClient: vi.fn(), +})); + import { createRedisRateLimitStore, createVerifyPaymentRateLimit, + getVerifyPaymentRateLimitKey, RATE_LIMIT_REDIS_PREFIX, + VERIFY_PAYMENT_RATE_LIMIT_MAX, + VERIFY_PAYMENT_RATE_LIMIT_WINDOW_MS, } from "./rate-limit.js"; import { connectRedisClient, @@ -41,17 +53,53 @@ describe("createVerifyPaymentRateLimit", () => { expect(result).toBe(middleware); expect(rateLimitFactory).toHaveBeenCalledWith({ - windowMs: 15 * 60 * 1000, - max: 10, + windowMs: VERIFY_PAYMENT_RATE_LIMIT_WINDOW_MS, + max: VERIFY_PAYMENT_RATE_LIMIT_MAX, message: { error: "Too many verification requests, please try again later." }, standardHeaders: true, legacyHeaders: false, + validate: { ip: false }, + keyGenerator: expect.any(Function), requestWasSuccessful: expect.any(Function), store, }); }); }); +describe("getVerifyPaymentRateLimitKey", () => { + it("keys by payment id and merchant when merchant auth is present", () => { + expect( + getVerifyPaymentRateLimitKey({ + params: { id: "payment-123" }, + merchant: { id: "merchant-789" }, + headers: {}, + ip: "127.0.0.1", + }), + ).toBe("payment-123:merchant:merchant-789"); + }); + + it("hashes api keys instead of storing them in limiter keys", () => { + const key = getVerifyPaymentRateLimitKey({ + params: { id: "payment-123" }, + headers: { "x-api-key": "secret-api-key" }, + ip: "127.0.0.1", + }); + + expect(key).toMatch(/^payment-123:api:[a-f0-9]{64}$/); + expect(key).not.toContain("secret-api-key"); + }); + + it("falls back to ip-based keys when no merchant or api key is available", () => { + expect( + getVerifyPaymentRateLimitKey({ + params: { id: "payment-123" }, + headers: {}, + ip: "203.0.113.10", + }), + ).toBe("payment-123:ip:203.0.113.10"); + }); +}); + describe("redis client helpers", () => { beforeEach(() => { resetRedisClientForTests(); diff --git a/backend/src/routes/payments.js b/backend/src/routes/payments.js index a49eeb0a..58ba743f 100644 --- a/backend/src/routes/payments.js +++ b/backend/src/routes/payments.js @@ -2,7 +2,6 @@ import "dotenv/config"; import { randomUUID } from "node:crypto"; import { logger } from "../lib/logger.js"; import express from "express"; -import rateLimit from "express-rate-limit"; import { paymentService } from "../services/paymentService.js"; import { validateUuidParam } from "../lib/validate-uuid.js"; import { @@ -13,6 +12,7 @@ import { } from "../lib/request-schemas.js"; import { validateRequest } from "../lib/validation.js"; import { createCreatePaymentRateLimit } from "../lib/create-payment-rate-limit.js"; +import { createVerifyPaymentRateLimit } from "../lib/rate-limit.js"; import { recaptchaMiddleware } from "../lib/recaptcha.js"; import { sendWebhook, isEventSubscribed } from "../lib/webhooks.js"; import { sendReceiptEmail } from "../lib/email.js"; @@ -46,13 +46,7 @@ import { const createPaymentRateLimit = createCreatePaymentRateLimit(); -const defaultVerifyPaymentRateLimit = rateLimit({ - windowMs: 60 * 1000, // 1 minute window - max: 30, // 30 requests per minute per IP (covers 10s polling) - message: { error: "Too many verification requests, please try again later." }, - standardHeaders: true, - legacyHeaders: false, -}); +const defaultVerifyPaymentRateLimit = createVerifyPaymentRateLimit(); diff --git a/frontend/src/app/(authenticated)/payment-history/page.tsx b/frontend/src/app/(authenticated)/payment-history/page.tsx index c5ef716c..136f6f47 100644 --- a/frontend/src/app/(authenticated)/payment-history/page.tsx +++ b/frontend/src/app/(authenticated)/payment-history/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useReducer, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useLocale, useTranslations } from "next-intl"; import Skeleton from "react-loading-skeleton"; @@ -15,6 +15,14 @@ import { useMerchantApiKey, useMerchantId, } from "@/lib/merchant-store"; +import { + buildPaymentHistorySearchParams, + DEFAULT_PAYMENT_HISTORY_FILTERS, + filtersFromSearchParams, + hasActivePaymentHistoryFilters, + type PaymentHistoryFilterKey, + paymentHistoryFiltersReducer, +} from "@/lib/payment-history-filters"; import { usePaymentSocket } from "@/lib/usePaymentSocket"; interface Payment { @@ -31,14 +39,6 @@ interface PaginatedResponse { total_count: number; } -interface FilterState { - search: string; - status: string; - asset: string; - dateFrom: string; - dateTo: string; -} - const LIMIT = 50; const STATUS_OPTIONS = [ "all", @@ -48,40 +48,11 @@ const STATUS_OPTIONS = [ "refunded", ] as const; const ASSET_OPTIONS = ["all", "XLM", "USDC"] as const; -const DEFAULT_FILTERS: FilterState = { - search: "", - status: "all", - asset: "all", - dateFrom: "", - dateTo: "", -}; function toStatusLabel(t: ReturnType, status: string) { return t.has(`statuses.${status}`) ? t(`statuses.${status}`) : status; } -function filtersFromSearchParams(searchParams: URLSearchParams): FilterState { - return { - search: searchParams.get("search") ?? "", - status: searchParams.get("status") ?? "all", - asset: searchParams.get("asset") ?? "all", - dateFrom: searchParams.get("date_from") ?? "", - dateTo: searchParams.get("date_to") ?? "", - }; -} - -function buildSearchParams(filters: FilterState): URLSearchParams { - const params = new URLSearchParams(); - - if (filters.search) params.set("search", filters.search); - if (filters.status !== "all") params.set("status", filters.status); - if (filters.asset !== "all") params.set("asset", filters.asset); - if (filters.dateFrom) params.set("date_from", filters.dateFrom); - if (filters.dateTo) params.set("date_to", filters.dateTo); - - return params; -} - export default function PaymentHistoryPage() { const t = useTranslations("recentPayments"); const locale = localeToLanguageTag(useLocale()); @@ -93,16 +64,15 @@ export default function PaymentHistoryPage() { useHydrateMerchantStore(); - const filters = useMemo( + const activeFilters = useMemo( () => filtersFromSearchParams(searchParams), [searchParams], ); - const hasActiveFilters = - filters.search || - filters.status !== "all" || - filters.asset !== "all" || - filters.dateFrom || - filters.dateTo; + const [draftFilters, dispatchDraftFilters] = useReducer( + paymentHistoryFiltersReducer, + activeFilters, + ); + const hasActiveFilters = hasActivePaymentHistoryFilters(activeFilters); const [payments, setPayments] = useState([]); const [loading, setLoading] = useState(true); @@ -115,6 +85,10 @@ export default function PaymentHistoryPage() { const [isSheetOpen, setIsSheetOpen] = useState(false); const [flashedIds, setFlashedIds] = useState>(new Set()); + useEffect(() => { + dispatchDraftFilters({ type: "sync", filters: activeFilters }); + }, [activeFilters]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Check for Cmd+C (Mac) or Ctrl+C (Windows/Linux) @@ -135,8 +109,8 @@ export default function PaymentHistoryPage() { }, [hoveredPayment, t]); const updateFilters = useCallback( - (nextFilters: FilterState) => { - const params = buildSearchParams(nextFilters); + (nextFilters: typeof activeFilters) => { + const params = buildPaymentHistorySearchParams(nextFilters); const query = params.toString(); router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false, @@ -145,25 +119,47 @@ export default function PaymentHistoryPage() { [pathname, router], ); + useEffect(() => { + const timer = window.setTimeout(() => { + if (draftFilters.search !== activeFilters.search) { + updateFilters({ ...draftFilters }); + } + }, 300); + + return () => window.clearTimeout(timer); + }, [activeFilters.search, draftFilters, updateFilters]); + const handleFilterChange = useCallback( - (key: keyof FilterState, value: string) => { - updateFilters({ ...filters, [key]: value }); + (key: PaymentHistoryFilterKey, value: string) => { + const nextFilters = paymentHistoryFiltersReducer(draftFilters, { + type: "set", + key, + value, + }); + dispatchDraftFilters({ type: "set", key, value }); + + if (key !== "search") { + updateFilters(nextFilters); + } }, - [filters, updateFilters], + [draftFilters, updateFilters], ); const clearFilter = useCallback( - (key: keyof FilterState) => { - updateFilters({ - ...filters, - [key]: key === "status" || key === "asset" ? "all" : "", + (key: PaymentHistoryFilterKey) => { + const nextFilters = paymentHistoryFiltersReducer(draftFilters, { + type: "clear", + key, }); + dispatchDraftFilters({ type: "clear", key }); + updateFilters(nextFilters); }, - [filters, updateFilters], + [draftFilters, updateFilters], ); const clearAllFilters = useCallback(() => { - updateFilters(DEFAULT_FILTERS); + dispatchDraftFilters({ type: "reset" }); + updateFilters(DEFAULT_PAYMENT_HISTORY_FILTERS); }, [updateFilters]); const handleConfirmed = useCallback( @@ -215,7 +211,7 @@ export default function PaymentHistoryPage() { const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"; - const params = buildSearchParams(filters); + const params = buildPaymentHistorySearchParams(activeFilters); params.set("page", page.toString()); params.set("limit", LIMIT.toString()); @@ -249,7 +245,7 @@ export default function PaymentHistoryPage() { fetchPayments(); return () => controller.abort(); - }, [apiKey, filters, t]); + }, [activeFilters, apiKey, t]); const handlePaymentClick = (paymentId: string) => { setSelectedPayment(paymentId); @@ -604,7 +600,7 @@ export default function PaymentHistoryPage() { handleFilterChange("search", event.target.value) } @@ -637,7 +633,7 @@ export default function PaymentHistoryPage() { handleFilterChange("asset", event.target.value) } @@ -686,7 +682,7 @@ export default function PaymentHistoryPage() { handleFilterChange("dateFrom", event.target.value) } @@ -704,7 +700,7 @@ export default function PaymentHistoryPage() { handleFilterChange("dateTo", event.target.value) } @@ -717,9 +713,9 @@ export default function PaymentHistoryPage() {
Active filters: - {filters.search && ( + {activeFilters.search && ( - Search: "{filters.search}" + Search: "{activeFilters.search}" )} - {filters.status !== "all" && ( + {activeFilters.status !== "all" && ( - Status: {filters.status} + Status: {activeFilters.status} )} - {filters.asset !== "all" && ( + {activeFilters.asset !== "all" && ( - Asset: {filters.asset} + Asset: {activeFilters.asset} )} - {filters.dateFrom && ( + {activeFilters.dateFrom && ( - From: {filters.dateFrom} + From: {activeFilters.dateFrom} )} - {filters.dateTo && ( + {activeFilters.dateTo && ( - To: {filters.dateTo} + To: {activeFilters.dateTo}