feat: filter transactions (#2464)

* feat: filter transactions

* fix: harden transaction filters

* refactor: use explicit nullable transaction filters with HideFailed polarity

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: set transaction filters in a dialog from wallet actions menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: filter transactions by search term and type

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: reject invalid transaction filters and reset page synchronously

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: parse complete minimum amount value in transactions filter dialog

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Roland Bewick <roland.bewick@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alchemist 2026-08-07 04:52:14 +01:00 committed by GitHub
parent 3b3e784fa6
commit 35d666d469
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 780 additions and 138 deletions

View file

@ -43,7 +43,7 @@ type API interface {
SignMessage(ctx context.Context, message string) (*SignMessageResponse, error)
RedeemOnchainFunds(ctx context.Context, toAddress string, amountSat uint64, feeRate *uint64, sendAll bool) (*RedeemOnchainFundsResponse, error)
GetBalances(ctx context.Context) (*BalancesResponse, error)
ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error)
ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64, filters ListTransactionsFilters) (*ListTransactionsResponse, error)
ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error)
SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}, fromAppId *uint) (*SendPaymentResponse, error)
CreateInvoice(ctx context.Context, amountMsat uint64, description string, toAppId *uint) (*MakeInvoiceResponse, error)
@ -304,38 +304,38 @@ type InfoResponseRelay struct {
}
type InfoResponse struct {
BackendType string `json:"backendType"`
SetupCompleted bool `json:"setupCompleted"`
OAuthRedirect bool `json:"oauthRedirect"`
Running bool `json:"running"`
Unlocked bool `json:"unlocked"`
AlbyAuthUrl string `json:"albyAuthUrl"`
NextBackupReminder string `json:"nextBackupReminder"`
AlbyUserIdentifier string `json:"albyUserIdentifier"`
AlbyAccountConnected bool `json:"albyAccountConnected"`
Version string `json:"version"`
Network string `json:"network"`
EnableAdvancedSetup bool `json:"enableAdvancedSetup"`
LdkVssEnabled bool `json:"ldkVssEnabled"`
VssSupported bool `json:"vssSupported"`
StartupState string `json:"startupState"`
StartupError string `json:"startupError"`
StartupErrorTime time.Time `json:"startupErrorTime"`
AutoUnlockPasswordSupported bool `json:"autoUnlockPasswordSupported"`
AutoUnlockPasswordEnabled bool `json:"autoUnlockPasswordEnabled"`
Currency string `json:"currency"`
BitcoinDisplayFormat string `json:"bitcoinDisplayFormat"`
Relays []InfoResponseRelay `json:"relays"`
NodeAlias string `json:"nodeAlias"`
MempoolUrl string `json:"mempoolUrl"`
ChainDataSourceType string `json:"chainDataSourceType,omitempty"`
ChainDataSourceAddress string `json:"chainDataSourceAddress,omitempty"`
JitChannelsLiquiditySource string `json:"jitChannelsLiquiditySource,omitempty"`
JitChannelsMinPaymentSizeMsat *uint64 `json:"jitChannelsMinPaymentSizeMsat,omitempty"`
JitChannelsMaxPaymentSizeMsat *uint64 `json:"jitChannelsMaxPaymentSizeMsat,omitempty"`
JitChannelsEnabled bool `json:"jitChannelsEnabled"`
HideUpdateBanner bool `json:"hideUpdateBanner"`
SupportsBolt12 bool `json:"supportsBolt12"`
BackendType string `json:"backendType"`
SetupCompleted bool `json:"setupCompleted"`
OAuthRedirect bool `json:"oauthRedirect"`
Running bool `json:"running"`
Unlocked bool `json:"unlocked"`
AlbyAuthUrl string `json:"albyAuthUrl"`
NextBackupReminder string `json:"nextBackupReminder"`
AlbyUserIdentifier string `json:"albyUserIdentifier"`
AlbyAccountConnected bool `json:"albyAccountConnected"`
Version string `json:"version"`
Network string `json:"network"`
EnableAdvancedSetup bool `json:"enableAdvancedSetup"`
LdkVssEnabled bool `json:"ldkVssEnabled"`
VssSupported bool `json:"vssSupported"`
StartupState string `json:"startupState"`
StartupError string `json:"startupError"`
StartupErrorTime time.Time `json:"startupErrorTime"`
AutoUnlockPasswordSupported bool `json:"autoUnlockPasswordSupported"`
AutoUnlockPasswordEnabled bool `json:"autoUnlockPasswordEnabled"`
Currency string `json:"currency"`
BitcoinDisplayFormat string `json:"bitcoinDisplayFormat"`
Relays []InfoResponseRelay `json:"relays"`
NodeAlias string `json:"nodeAlias"`
MempoolUrl string `json:"mempoolUrl"`
ChainDataSourceType string `json:"chainDataSourceType,omitempty"`
ChainDataSourceAddress string `json:"chainDataSourceAddress,omitempty"`
JitChannelsLiquiditySource string `json:"jitChannelsLiquiditySource,omitempty"`
JitChannelsMinPaymentSizeMsat *uint64 `json:"jitChannelsMinPaymentSizeMsat,omitempty"`
JitChannelsMaxPaymentSizeMsat *uint64 `json:"jitChannelsMaxPaymentSizeMsat,omitempty"`
JitChannelsEnabled bool `json:"jitChannelsEnabled"`
HideUpdateBanner bool `json:"hideUpdateBanner"`
SupportsBolt12 bool `json:"supportsBolt12"`
}
type UpdateSettingsRequest struct {
@ -497,6 +497,13 @@ type SetTransactionUserLabelsRequest struct {
Labels map[string]string `json:"labels"`
}
type ListTransactionsFilters struct {
Type *string
MinAmountMsat *uint64
HideFailed bool
SearchTerm string
}
type ListTransactionsResponse struct {
TotalCount uint64 `json:"totalCount"`
Transactions []Transaction `json:"transactions"`

View file

@ -4,9 +4,13 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/transactions"
"github.com/sirupsen/logrus"
@ -45,7 +49,47 @@ func (api *api) SetTransactionUserLabels(ctx context.Context, id uint, labels ma
return api.svc.GetTransactionsService().SetTransactionUserLabels(ctx, id, labels)
}
func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error) {
// ParseListTransactionsFilters parses transaction filter query parameters
// shared by the HTTP and Wails transports. Invalid values return an error.
func ParseListTransactionsFilters(query url.Values) (ListTransactionsFilters, error) {
filters := ListTransactionsFilters{}
if transactionType := query.Get("type"); transactionType != "" {
if transactionType != constants.TRANSACTION_TYPE_INCOMING && transactionType != constants.TRANSACTION_TYPE_OUTGOING {
return filters, fmt.Errorf("invalid type: %s", transactionType)
}
filters.Type = &transactionType
}
if minAmountSatParam := query.Get("minAmountSat"); minAmountSatParam != "" {
minAmountSat, err := strconv.ParseUint(minAmountSatParam, 10, 64)
if err != nil || minAmountSat == 0 {
return filters, fmt.Errorf("invalid minAmountSat: %s", minAmountSatParam)
}
const msatPerSat = uint64(1000)
if minAmountSat > ^uint64(0)/msatPerSat {
return filters, fmt.Errorf("minAmountSat is too large")
}
minAmountMsat := minAmountSat * msatPerSat
filters.MinAmountMsat = &minAmountMsat
}
if hideFailedParam := query.Get("hideFailed"); hideFailedParam != "" {
hideFailed, err := strconv.ParseBool(hideFailedParam)
if err != nil {
return filters, fmt.Errorf("invalid hideFailed: %s", hideFailedParam)
}
filters.HideFailed = hideFailed
}
filters.SearchTerm = strings.TrimSpace(query.Get("search"))
return filters, nil
}
func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64, filters ListTransactionsFilters) (*ListTransactionsResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
@ -56,13 +100,18 @@ func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64,
forceFilterByAppId = true
}
transactions, totalCount, err := api.svc.GetTransactionsService().ListTransactions(ctx, 0, 0, limit, offset, true, false, nil, lnClient, appId, forceFilterByAppId)
dbTransactions, totalCount, err := api.svc.GetTransactionsService().ListTransactions(ctx, 0, 0, limit, offset, true, false, lnClient, appId, forceFilterByAppId, &transactions.ListTransactionsFilters{
Type: filters.Type,
MinAmountMsat: filters.MinAmountMsat,
HideFailed: filters.HideFailed,
SearchTerm: filters.SearchTerm,
})
if err != nil {
return nil, err
}
apiTransactions := []Transaction{}
for _, transaction := range transactions {
for _, transaction := range dbTransactions {
apiTransactions = append(apiTransactions, *toApiTransaction(&transaction))
}

View file

@ -2,6 +2,7 @@ package api
import (
"context"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
@ -60,3 +61,38 @@ func TestCreateInvoice_ToAppNotFound(t *testing.T) {
require.Error(t, err)
assert.Equal(t, "app does not exist", err.Error())
}
func TestParseListTransactionsFilters(t *testing.T) {
minAmountMsat := uint64(1000_000)
outgoing := "outgoing"
filters, err := ParseListTransactionsFilters(url.Values{
"type": {"outgoing"},
"minAmountSat": {"1000"},
"hideFailed": {"true"},
"search": {" coffee "},
})
require.NoError(t, err)
assert.Equal(t, ListTransactionsFilters{
Type: &outgoing,
MinAmountMsat: &minAmountMsat,
HideFailed: true,
SearchTerm: "coffee",
}, filters)
filters, err = ParseListTransactionsFilters(url.Values{})
require.NoError(t, err)
assert.Equal(t, ListTransactionsFilters{}, filters)
for _, invalidQuery := range []url.Values{
{"type": {"sideways"}},
{"minAmountSat": {"abc"}},
{"minAmountSat": {"-1"}},
{"minAmountSat": {"0"}},
{"minAmountSat": {"18446744073709551615"}},
{"hideFailed": {"maybe"}},
} {
_, err = ParseListTransactionsFilters(invalidQuery)
assert.Error(t, err, "query: %v", invalidQuery)
}
}

View file

@ -0,0 +1,140 @@
import React from "react";
import { Button } from "src/components/ui/button";
import { Checkbox } from "src/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "src/components/ui/dialog";
import { Input } from "src/components/ui/input";
import { Label } from "src/components/ui/label";
import { ToggleGroup, ToggleGroupItem } from "src/components/ui/toggle-group";
import {
defaultTransactionFilters,
type TransactionFilters,
} from "src/hooks/useTransactions";
const TYPE_OPTIONS: { label: string; value: string }[] = [
{ label: "All", value: "all" },
{ label: "Sent", value: "outgoing" },
{ label: "Received", value: "incoming" },
];
type TransactionsFilterDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
filters: TransactionFilters;
onFiltersChange: (filters: TransactionFilters) => void;
};
export function TransactionsFilterDialog({
open,
onOpenChange,
filters,
onFiltersChange,
}: TransactionsFilterDialogProps) {
const [searchTerm, setSearchTerm] = React.useState("");
const [type, setType] = React.useState("all");
const [minAmountSat, setMinAmountSat] = React.useState("");
const [hideFailed, setHideFailed] = React.useState(false);
React.useEffect(() => {
if (open) {
setSearchTerm(filters.searchTerm ?? "");
setType(filters.type ?? "all");
setMinAmountSat(filters.minAmountSat ? String(filters.minAmountSat) : "");
setHideFailed(!!filters.hideFailed);
}
}, [open, filters]);
function onSubmit(e: React.FormEvent) {
e.preventDefault();
const parsedMinAmountSat = Number(minAmountSat);
onFiltersChange({
searchTerm: searchTerm.trim() || undefined,
type: type === "incoming" || type === "outgoing" ? type : undefined,
minAmountSat:
Number.isSafeInteger(parsedMinAmountSat) && parsedMinAmountSat > 0
? parsedMinAmountSat
: undefined,
hideFailed,
});
onOpenChange(false);
}
function onReset() {
onFiltersChange({ ...defaultTransactionFilters });
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form onSubmit={onSubmit}>
<DialogHeader>
<DialogTitle>Filter Transactions</DialogTitle>
<DialogDescription>
Choose which payments appear in your transaction list.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 mt-5">
<Label htmlFor="searchTerm">Search</Label>
<Input
autoFocus
id="searchTerm"
type="text"
placeholder="Description, payment hash, invoice or label"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="grid gap-2 mt-4">
<Label>Direction</Label>
<ToggleGroup
type="single"
variant="outline"
value={type}
onValueChange={(value) => value && setType(value)}
>
{TYPE_OPTIONS.map((option) => (
<ToggleGroupItem key={option.value} value={option.value}>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<div className="grid gap-2 mt-4">
<Label htmlFor="minAmountSat">Minimum amount (sats)</Label>
<Input
id="minAmountSat"
type="number"
min="1"
placeholder="Show all amounts"
value={minAmountSat}
onChange={(e) => setMinAmountSat(e.target.value.trim())}
/>
</div>
<div className="flex items-center mt-4">
<Checkbox
id="hideFailed"
checked={hideFailed}
onCheckedChange={(checked) => setHideFailed(checked === true)}
/>
<Label htmlFor="hideFailed" className="ml-2 cursor-pointer">
Hide failed payments
</Label>
</div>
<DialogFooter className="mt-5">
<Button type="button" variant="secondary" onClick={onReset}>
Reset
</Button>
<Button type="submit">Apply Filters</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -5,7 +5,12 @@ import EmptyState from "src/components/EmptyState";
import Loading from "src/components/Loading";
import TransactionItem from "src/components/TransactionItem";
import { LIST_TRANSACTIONS_LIMIT } from "src/constants";
import { getTransactionsUrl, useTransactions } from "src/hooks/useTransactions";
import {
getTransactionsUrl,
hasActiveTransactionFilters,
useTransactions,
} from "src/hooks/useTransactions";
import useTransactionFiltersStore from "src/state/TransactionFiltersStore";
type TransactionsListProps = {
appId?: number;
@ -23,20 +28,36 @@ function TransactionsList({
emptyVariant,
}: TransactionsListProps) {
const [page, setPage] = useState(1);
const { filters } = useTransactionFiltersStore();
// Reset pagination during render when the filters or app change, so no
// request is made for a page that may not exist under the new list.
const [prevListIdentity, setPrevListIdentity] = useState({ appId, filters });
if (
prevListIdentity.appId !== appId ||
prevListIdentity.filters !== filters
) {
setPrevListIdentity({ appId, filters });
setPage(1);
}
const transactionListRef = useRef<HTMLDivElement>(null);
const transactionListKey = getTransactionsUrl(
appId,
LIST_TRANSACTIONS_LIMIT,
page
page,
filters
);
const { data: transactionData, isLoading } = useTransactions(
appId,
false,
LIST_TRANSACTIONS_LIMIT,
page
page,
filters
);
const transactions = transactionData?.transactions || [];
const totalCount = transactionData?.totalCount || 0;
const hasActiveFilters = hasActiveTransactionFilters(filters);
const handlePageChange = (page: number) => {
setPage(page);
@ -55,8 +76,12 @@ function TransactionsList({
{!transactions.length ? (
<EmptyState
icon={emptyIcon}
title={emptyTitle}
description={emptyDescription}
title={hasActiveFilters ? "No matching payments" : emptyTitle}
description={
hasActiveFilters
? "Try changing your filters to see more payments."
: emptyDescription
}
variant={emptyVariant}
/>
) : (

View file

@ -1,27 +1,46 @@
import { DownloadIcon, EllipsisVerticalIcon } from "lucide-react";
import { DownloadIcon, EllipsisVerticalIcon, FunnelIcon } from "lucide-react";
import { useState } from "react";
import { TransactionsFilterDialog } from "src/components/TransactionsFilterDialog";
import { Button } from "src/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "src/components/ui/dropdown-menu";
import { ProDropdownMenuItem } from "src/components/UpgradeDialog";
import useTransactionFiltersStore from "src/state/TransactionFiltersStore";
import { handleExportTransactions } from "./transactions-utils";
export const TransactionsListMenu = ({ appId }: { appId?: number }) => {
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
const { filters, setFilters } = useTransactionFiltersStore();
return (
<DropdownMenu>
<Button asChild size="icon" variant="ghost">
<DropdownMenuTrigger>
<EllipsisVerticalIcon className="h-4 w-4" />
</DropdownMenuTrigger>
</Button>
<DropdownMenuContent align="end">
<ProDropdownMenuItem onClick={() => handleExportTransactions(appId)}>
<DownloadIcon className="h-4 w-4" />
Export Transactions
</ProDropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<>
<DropdownMenu>
<Button asChild size="icon" variant="ghost">
<DropdownMenuTrigger>
<EllipsisVerticalIcon className="h-4 w-4" />
</DropdownMenuTrigger>
</Button>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setFilterDialogOpen(true)}>
<FunnelIcon className="h-4 w-4" />
Filter Transactions
</DropdownMenuItem>
<ProDropdownMenuItem onClick={() => handleExportTransactions(appId)}>
<DownloadIcon className="h-4 w-4" />
Export Transactions
</ProDropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TransactionsFilterDialog
open={filterDialogOpen}
onOpenChange={setFilterDialogOpen}
filters={filters}
onFiltersChange={setFilters}
/>
</>
);
};

View file

@ -4,9 +4,12 @@ import {
CreditCardIcon,
DownloadIcon,
EllipsisVerticalIcon,
FunnelIcon,
} from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import ExternalLink from "src/components/ExternalLink";
import { TransactionsFilterDialog } from "src/components/TransactionsFilterDialog";
import { Button } from "src/components/ui/button";
import {
DropdownMenu,
@ -16,55 +19,84 @@ import {
DropdownMenuTrigger,
} from "src/components/ui/dropdown-menu";
import { ProDropdownMenuItem } from "src/components/UpgradeDialog";
import useTransactionFiltersStore from "src/state/TransactionFiltersStore";
import { handleExportTransactions } from "./transactions-utils";
export function WalletActionsMenu({
hasChannelManagement,
isOnchain,
}: {
hasChannelManagement: boolean;
isOnchain: boolean;
}) {
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
const { filters, setFilters } = useTransactionFiltersStore();
return (
<DropdownMenu>
<Button asChild size="icon" variant="ghost">
<DropdownMenuTrigger>
<EllipsisVerticalIcon className="h-4 w-4" />
</DropdownMenuTrigger>
</Button>
<DropdownMenuContent align="end">
<div className="sm:hidden">
{hasChannelManagement && (
<>
<DropdownMenu>
<Button
asChild
size="icon"
variant="ghost"
className={isOnchain ? "sm:hidden" : undefined}
>
<DropdownMenuTrigger>
<EllipsisVerticalIcon className="h-4 w-4" />
</DropdownMenuTrigger>
</Button>
<DropdownMenuContent align="end">
<div className="sm:hidden">
{hasChannelManagement && (
<DropdownMenuItem asChild>
<Link to="/wallet/swap" className="w-full cursor-pointer">
<ArrowDownUpIcon className="h-4 w-4" />
Swap
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link to="/wallet/swap" className="w-full cursor-pointer">
<ArrowDownUpIcon className="h-4 w-4" />
Swap
<Link
to="/internal-apps/zapplanner"
className="w-full cursor-pointer"
>
<CalendarSyncIcon className="h-4 w-4" />
Recurring
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ExternalLink
to="https://www.getalby.com/topup"
className="w-full cursor-pointer"
>
<CreditCardIcon className="h-4 w-4" />
Buy
</ExternalLink>
</DropdownMenuItem>
{!isOnchain && <DropdownMenuSeparator />}
</div>
{!isOnchain && (
<>
<DropdownMenuItem onClick={() => setFilterDialogOpen(true)}>
<FunnelIcon className="h-4 w-4" />
Filter Transactions
</DropdownMenuItem>
<ProDropdownMenuItem onClick={() => handleExportTransactions()}>
<DownloadIcon className="h-4 w-4" />
Export Transactions
</ProDropdownMenuItem>
</>
)}
<DropdownMenuItem asChild>
<Link
to="/internal-apps/zapplanner"
className="w-full cursor-pointer"
>
<CalendarSyncIcon className="h-4 w-4" />
Recurring
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ExternalLink
to="https://www.getalby.com/topup"
className="w-full cursor-pointer"
>
<CreditCardIcon className="h-4 w-4" />
Buy
</ExternalLink>
</DropdownMenuItem>
<DropdownMenuSeparator />
</div>
<ProDropdownMenuItem onClick={() => handleExportTransactions()}>
<DownloadIcon className="h-4 w-4" />
Export Transactions
</ProDropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuContent>
</DropdownMenu>
{!isOnchain && (
<TransactionsFilterDialog
open={filterDialogOpen}
onOpenChange={setFilterDialogOpen}
filters={filters}
onFiltersChange={setFilters}
/>
)}
</>
);
}

View file

@ -3,7 +3,7 @@ import {
CalendarSyncIcon,
CreditCardIcon,
} from "lucide-react";
import { Outlet } from "react-router";
import { Outlet, useMatch } from "react-router";
import AppHeader from "src/components/AppHeader";
import Loading from "src/components/Loading";
import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
@ -17,6 +17,7 @@ export default function WalletLayout() {
useSyncWallet();
const { data: info, hasChannelManagement } = useInfo();
const { data: balances } = useBalances(true);
const isOnchain = !!useMatch("/wallet/onchain");
if (!info || !balances) {
return <Loading />;
@ -59,7 +60,10 @@ export default function WalletLayout() {
<CreditCardIcon />
Buy
</ExternalLinkButton>
<WalletActionsMenu hasChannelManagement={!!hasChannelManagement} />
<WalletActionsMenu
hasChannelManagement={!!hasChannelManagement}
isOnchain={isOnchain}
/>
</div>
}
/>

View file

@ -7,12 +7,53 @@ const pollConfiguration: SWRConfiguration = {
refreshInterval: 10000,
};
export function getTransactionsUrl(appId?: number, limit = 100, page = 1) {
export type TransactionFilters = {
searchTerm?: string;
type?: "incoming" | "outgoing";
minAmountSat?: number;
hideFailed?: boolean;
};
export const defaultTransactionFilters: TransactionFilters = {};
export function hasActiveTransactionFilters(filters: TransactionFilters) {
return (
!!filters.searchTerm ||
!!filters.type ||
(filters.minAmountSat ?? 0) > 0 ||
!!filters.hideFailed
);
}
export function getTransactionsUrl(
appId?: number,
limit = 100,
page = 1,
filters?: TransactionFilters
) {
const offset = (page - 1) * limit;
let url = `/api/transactions?limit=${limit}&offset=${offset}`;
const searchParams = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
if (appId) {
url += `&appId=${appId}`;
searchParams.set("appId", String(appId));
}
if (filters?.searchTerm) {
searchParams.set("search", filters.searchTerm);
}
if (filters?.type) {
searchParams.set("type", filters.type);
}
if (filters?.minAmountSat && filters.minAmountSat > 0) {
searchParams.set("minAmountSat", String(filters.minAmountSat));
}
if (filters?.hideFailed) {
searchParams.set("hideFailed", "true");
}
const url = `/api/transactions?${searchParams.toString()}`;
return url;
}
@ -21,9 +62,10 @@ export function useTransactions(
appId?: number,
poll = false,
limit = 100,
page = 1
page = 1,
filters?: TransactionFilters
) {
const url = getTransactionsUrl(appId, limit, page);
const url = getTransactionsUrl(appId, limit, page, filters);
return useSWR<ListTransactionsResponse>(
url,

View file

@ -0,0 +1,17 @@
import {
defaultTransactionFilters,
type TransactionFilters,
} from "src/hooks/useTransactions";
import { create } from "zustand";
interface TransactionFiltersStore {
readonly filters: TransactionFilters;
setFilters(filters: TransactionFilters): void;
}
const useTransactionFiltersStore = create<TransactionFiltersStore>((set) => ({
filters: defaultTransactionFilters,
setFilters: (filters) => set({ filters }),
}));
export default useTransactionFiltersStore;

View file

@ -765,7 +765,14 @@ func (httpSvc *HttpService) listTransactionsHandler(c echo.Context) error {
}
}
transactions, err := httpSvc.api.ListTransactions(ctx, appId, limit, offset)
filters, err := api.ParseListTransactionsFilters(c.QueryParams())
if err != nil {
return c.JSON(http.StatusBadRequest, ErrorResponse{
Message: err.Error(),
})
}
transactions, err := httpSvc.api.ListTransactions(ctx, appId, limit, offset, filters)
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{

View file

@ -6,6 +6,7 @@ import (
"github.com/getAlby/go-nostr"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/nip47/models"
"github.com/getAlby/hub/transactions"
"github.com/sirupsen/logrus"
)
@ -50,7 +51,9 @@ func (controller *nip47Controller) HandleListTransactionsEvent(ctx context.Conte
transactionType = &listParams.Type
}
dbTransactions, totalCount, err := controller.transactionsService.ListTransactions(ctx, listParams.From, listParams.Until, limit, listParams.Offset, listParams.Unpaid || listParams.UnpaidOutgoing, listParams.Unpaid || listParams.UnpaidIncoming, transactionType, controller.lnClient, &appId, false)
dbTransactions, totalCount, err := controller.transactionsService.ListTransactions(ctx, listParams.From, listParams.Until, limit, listParams.Offset, listParams.Unpaid || listParams.UnpaidOutgoing, listParams.Unpaid || listParams.UnpaidIncoming, controller.lnClient, &appId, false, &transactions.ListTransactionsFilters{
Type: transactionType,
})
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"params": listParams,

View file

@ -11,6 +11,7 @@ import (
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/tests"
"gorm.io/datatypes"
)
func TestListTransactions_Paid(t *testing.T) {
@ -48,7 +49,7 @@ func TestListTransactions_Paid(t *testing.T) {
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
incomingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, false, false, nil, svc.LNClient, nil, false)
incomingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, false, false, svc.LNClient, nil, false, nil)
assert.NoError(t, err)
assert.Equal(t, uint64(1), totalCount)
assert.Equal(t, 1, len(incomingTransactions))
@ -114,7 +115,7 @@ func TestListTransactions_UnpaidIncoming(t *testing.T) {
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
incomingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, false, true, nil, svc.LNClient, nil, false)
incomingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, false, true, svc.LNClient, nil, false, nil)
assert.NoError(t, err)
assert.Equal(t, uint64(3), totalCount)
assert.Equal(t, 3, len(incomingTransactions))
@ -182,7 +183,7 @@ func TestListTransactions_UnpaidOutgoing(t *testing.T) {
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
outgoingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, true, false, nil, svc.LNClient, nil, false)
outgoingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, true, false, svc.LNClient, nil, false, nil)
assert.NoError(t, err)
assert.Equal(t, uint64(3), totalCount)
assert.Equal(t, 3, len(outgoingTransactions))
@ -250,7 +251,7 @@ func TestListTransactions_Unpaid(t *testing.T) {
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
outgoingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, true, true, nil, svc.LNClient, nil, false)
outgoingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, true, true, svc.LNClient, nil, false, nil)
assert.NoError(t, err)
assert.Equal(t, uint64(5), totalCount)
assert.Equal(t, 5, len(outgoingTransactions))
@ -286,7 +287,7 @@ func TestListTransactions_Limit(t *testing.T) {
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
incomingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, 0, 0, 1, 0, false, false, nil, svc.LNClient, nil, false)
incomingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, 0, 0, 1, 0, false, false, svc.LNClient, nil, false, nil)
assert.NoError(t, err)
assert.Equal(t, uint64(2), totalCount)
assert.Equal(t, 1, len(incomingTransactions))
@ -343,13 +344,210 @@ func TestListTransactions_Offset(t *testing.T) {
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
incomingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, 0, 0, 1, 2, false, false, nil, svc.LNClient, nil, false)
incomingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, 0, 0, 1, 2, false, false, svc.LNClient, nil, false, nil)
assert.NoError(t, err)
assert.Equal(t, uint64(4), totalCount)
assert.Equal(t, 1, len(incomingTransactions))
assert.Equal(t, "third", incomingTransactions[0].Description)
}
func TestListTransactions_MinAmount(t *testing.T) {
ctx := context.TODO()
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
mockPreimage := tests.MockLNClientTransaction.Preimage
svc.DB.Create(&db.Transaction{
State: constants.TRANSACTION_STATE_SETTLED,
Type: constants.TRANSACTION_TYPE_INCOMING,
PaymentRequest: tests.MockLNClientTransaction.Invoice,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
Preimage: &mockPreimage,
AmountMsat: 1000,
Description: "small",
})
svc.DB.Create(&db.Transaction{
State: constants.TRANSACTION_STATE_SETTLED,
Type: constants.TRANSACTION_TYPE_INCOMING,
PaymentRequest: tests.MockLNClientTransaction.Invoice,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
Preimage: &mockPreimage,
AmountMsat: 10000,
Description: "large",
})
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
minAmountMsat := uint64(5000)
filteredTransactions, totalCount, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, false, false, svc.LNClient, nil, false, &ListTransactionsFilters{
MinAmountMsat: &minAmountMsat,
})
assert.NoError(t, err)
assert.Equal(t, uint64(1), totalCount)
require.Len(t, filteredTransactions, 1)
assert.Equal(t, "large", filteredTransactions[0].Description)
}
func TestListTransactions_HideFailed(t *testing.T) {
ctx := context.TODO()
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
mockPreimage := tests.MockLNClientTransaction.Preimage
svc.DB.Create(&db.Transaction{
State: constants.TRANSACTION_STATE_SETTLED,
Type: constants.TRANSACTION_TYPE_OUTGOING,
PaymentRequest: tests.MockLNClientTransaction.Invoice,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
Preimage: &mockPreimage,
AmountMsat: 123000,
Description: "settled",
UpdatedAt: time.Now().Add(2 * time.Minute),
})
svc.DB.Create(&db.Transaction{
State: constants.TRANSACTION_STATE_FAILED,
Type: constants.TRANSACTION_TYPE_OUTGOING,
PaymentRequest: tests.MockLNClientTransaction.Invoice,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
Preimage: &mockPreimage,
AmountMsat: 123000,
Description: "failed",
UpdatedAt: time.Now().Add(1 * time.Minute),
})
svc.DB.Create(&db.Transaction{
State: constants.TRANSACTION_STATE_PENDING,
Type: constants.TRANSACTION_TYPE_OUTGOING,
PaymentRequest: tests.MockLNClientTransaction.Invoice,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
Preimage: &mockPreimage,
AmountMsat: 123000,
Description: "pending",
})
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
filteredTransactions, totalCount, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, true, false, svc.LNClient, nil, false, &ListTransactionsFilters{
HideFailed: true,
})
assert.NoError(t, err)
assert.Equal(t, uint64(2), totalCount)
require.Len(t, filteredTransactions, 2)
assert.Equal(t, "settled", filteredTransactions[0].Description)
assert.Equal(t, "pending", filteredTransactions[1].Description)
}
func TestListTransactions_Type(t *testing.T) {
ctx := context.TODO()
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
mockPreimage := tests.MockLNClientTransaction.Preimage
svc.DB.Create(&db.Transaction{
State: constants.TRANSACTION_STATE_SETTLED,
Type: constants.TRANSACTION_TYPE_INCOMING,
PaymentRequest: tests.MockLNClientTransaction.Invoice,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
Preimage: &mockPreimage,
AmountMsat: 123000,
Description: "received",
})
svc.DB.Create(&db.Transaction{
State: constants.TRANSACTION_STATE_SETTLED,
Type: constants.TRANSACTION_TYPE_OUTGOING,
PaymentRequest: tests.MockLNClientTransaction.Invoice,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
Preimage: &mockPreimage,
AmountMsat: 123000,
Description: "sent",
})
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transactionType := constants.TRANSACTION_TYPE_OUTGOING
filteredTransactions, totalCount, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, false, false, svc.LNClient, nil, false, &ListTransactionsFilters{
Type: &transactionType,
})
assert.NoError(t, err)
assert.Equal(t, uint64(1), totalCount)
require.Len(t, filteredTransactions, 1)
assert.Equal(t, "sent", filteredTransactions[0].Description)
}
func TestListTransactions_Search(t *testing.T) {
ctx := context.TODO()
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
mockPreimage := tests.MockLNClientTransaction.Preimage
svc.DB.Create(&db.Transaction{
State: constants.TRANSACTION_STATE_SETTLED,
Type: constants.TRANSACTION_TYPE_OUTGOING,
PaymentRequest: "lnbc1coffee",
PaymentHash: "3086c621ecbef1ba99446fca8f484e2dbef77b28ee76a94ab8bb8b0e7f60a0f1",
Preimage: &mockPreimage,
AmountMsat: 123000,
Description: "Coffee shop",
})
svc.DB.Create(&db.Transaction{
State: constants.TRANSACTION_STATE_SETTLED,
Type: constants.TRANSACTION_TYPE_INCOMING,
PaymentRequest: tests.MockLNClientTransaction.Invoice,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
Preimage: &mockPreimage,
AmountMsat: 123000,
Description: "Zap",
Metadata: datatypes.JSON(`{"user_labels":{"category":"Drinks"}}`),
})
svc.DB.Create(&db.Transaction{
State: constants.TRANSACTION_STATE_SETTLED,
Type: constants.TRANSACTION_TYPE_OUTGOING,
PaymentRequest: "lnbc1discount",
PaymentHash: "af88b1571c1a0b2b1e8c05bf74e6a2f6b3a4f4a2be27077b1c5f5e2e4f6a8b9c",
Preimage: &mockPreimage,
AmountMsat: 123000,
Description: "50% discount",
})
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
for _, testCase := range []struct {
searchTerm string
expectedDescriptions []string
}{
{"COFFEE", []string{"Coffee shop"}},
// exact payment hash
{tests.MockLNClientTransaction.PaymentHash, []string{"Zap"}},
// full invoice is decoded and matched by its payment hash
{tests.MockLNClientTransaction.Invoice, []string{"Zap"}},
// invoices are not matched by substring
{"lnbc1disc", []string{}},
// partial payment hashes are not matched
{tests.MockLNClientTransaction.PaymentHash[:32], []string{}},
{"drinks", []string{"Zap"}},
{"category", []string{"Zap"}},
{"50%", []string{"50% discount"}},
{"nonexistent", []string{}},
} {
filteredTransactions, totalCount, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, false, false, svc.LNClient, nil, false, &ListTransactionsFilters{
SearchTerm: testCase.searchTerm,
})
assert.NoError(t, err, "search: %s", testCase.searchTerm)
assert.Equal(t, uint64(len(testCase.expectedDescriptions)), totalCount, "search: %s", testCase.searchTerm)
require.Len(t, filteredTransactions, len(testCase.expectedDescriptions), "search: %s", testCase.searchTerm)
for i, description := range testCase.expectedDescriptions {
assert.Equal(t, description, filteredTransactions[i].Description, "search: %s", testCase.searchTerm)
}
}
}
func TestListTransactions_FromUntil(t *testing.T) {
ctx := context.TODO()
@ -394,7 +592,7 @@ func TestListTransactions_FromUntil(t *testing.T) {
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
incomingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, uint64(time.Now().Add(4*time.Minute).Unix()), uint64(time.Now().Add(6*time.Minute).Unix()), 0, 0, false, false, nil, svc.LNClient, nil, false)
incomingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, uint64(time.Now().Add(4*time.Minute).Unix()), uint64(time.Now().Add(6*time.Minute).Unix()), 0, 0, false, false, svc.LNClient, nil, false, nil)
assert.NoError(t, err)
assert.Equal(t, uint64(1), totalCount)
assert.Equal(t, 1, len(incomingTransactions))
@ -456,7 +654,7 @@ func TestListTransactions_FromUntilUnpaidOutgoing(t *testing.T) {
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
incomingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, uint64(time.Now().Add(4*time.Minute).Unix()), uint64(time.Now().Add(6*time.Minute).Unix()), 0, 0, true, false, nil, svc.LNClient, nil, false)
incomingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, uint64(time.Now().Add(4*time.Minute).Unix()), uint64(time.Now().Add(6*time.Minute).Unix()), 0, 0, true, false, svc.LNClient, nil, false, nil)
assert.NoError(t, err)
assert.Equal(t, uint64(1), totalCount)
assert.Equal(t, "second", incomingTransactions[0].Description)
@ -518,7 +716,7 @@ func TestListTransactions_FromUntilUnpaidIncoming(t *testing.T) {
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
incomingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, uint64(time.Now().Add(4*time.Minute).Unix()), uint64(time.Now().Add(6*time.Minute).Unix()), 0, 0, false, true, nil, svc.LNClient, nil, false)
incomingTransactions, totalCount, err := transactionsService.ListTransactions(ctx, uint64(time.Now().Add(4*time.Minute).Unix()), uint64(time.Now().Add(6*time.Minute).Unix()), 0, 0, false, true, svc.LNClient, nil, false, nil)
assert.NoError(t, err)
assert.Equal(t, uint64(1), totalCount)
assert.Equal(t, "second", incomingTransactions[0].Description)

View file

@ -314,7 +314,7 @@ func TestNotifications_FailedKnownPendingAndExistingFailedPayment(t *testing.T)
},
}, map[string]interface{}{})
transactions, totalCount, err := transactionsService.ListTransactions(ctx, uint64(0), uint64(0), uint64(0), uint64(0), true, false, nil, svc.LNClient, nil, false)
transactions, totalCount, err := transactionsService.ListTransactions(ctx, uint64(0), uint64(0), uint64(0), uint64(0), true, false, svc.LNClient, nil, false, nil)
assert.NoError(t, err)
assert.Equal(t, uint64(2), totalCount)
for _, transaction := range transactions {
@ -348,7 +348,7 @@ func TestNotifications_SentAfterMarkedPaymentFailed(t *testing.T) {
Properties: tests.MockLNClientTransaction,
}, map[string]interface{}{})
transactions, totalCount, err := transactionsService.ListTransactions(ctx, uint64(0), uint64(0), uint64(0), uint64(0), true, false, nil, svc.LNClient, nil, false)
transactions, totalCount, err := transactionsService.ListTransactions(ctx, uint64(0), uint64(0), uint64(0), uint64(0), true, false, svc.LNClient, nil, false, nil)
assert.NoError(t, err)
assert.Equal(t, uint64(1), totalCount)
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, transactions[0].State)
@ -392,7 +392,7 @@ func TestNotifications_SentAfterMarkedTwoPaymentsFailed(t *testing.T) {
Properties: tests.MockLNClientTransaction,
}, map[string]interface{}{})
transactions, totalCount, err := transactionsService.ListTransactions(ctx, uint64(0), uint64(0), uint64(0), uint64(0), true, false, nil, svc.LNClient, nil, false)
transactions, totalCount, err := transactionsService.ListTransactions(ctx, uint64(0), uint64(0), uint64(0), uint64(0), true, false, svc.LNClient, nil, false, nil)
assert.NoError(t, err)
assert.Equal(t, uint64(2), totalCount)
assert.Equal(t, latestFailedTransaction.ID, transactions[0].ID)
@ -436,7 +436,7 @@ func TestNotifications_SentWithFailedAndPendingPayment(t *testing.T) {
Properties: tests.MockLNClientTransaction,
}, map[string]interface{}{})
transactions, totalCount, err := transactionsService.ListTransactions(ctx, uint64(0), uint64(0), uint64(0), uint64(0), true, false, nil, svc.LNClient, nil, false)
transactions, totalCount, err := transactionsService.ListTransactions(ctx, uint64(0), uint64(0), uint64(0), uint64(0), true, false, svc.LNClient, nil, false, nil)
assert.NoError(t, err)
assert.Equal(t, uint64(2), totalCount)
assert.Equal(t, pendingTransaction.ID, transactions[0].ID)

View file

@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"math"
"regexp"
"slices"
"strconv"
"strings"
@ -38,7 +39,7 @@ type TransactionsService interface {
events.EventSubscriber
MakeInvoice(ctx context.Context, amountMsat uint64, description string, descriptionHash string, expiry uint64, metadata map[string]interface{}, lnClient lnclient.LNClient, appId *uint, requestEventId *uint, throughNodePubkey *string) (*Transaction, error)
LookupTransaction(ctx context.Context, paymentHash string, transactionType *string, lnClient lnclient.LNClient, appId *uint) (*Transaction, error)
ListTransactions(ctx context.Context, from, until, limit, offset uint64, unpaidOutgoing bool, unpaidIncoming bool, transactionType *string, lnClient lnclient.LNClient, appId *uint, forceFilterByAppId bool) (transactions []Transaction, totalCount uint64, err error)
ListTransactions(ctx context.Context, from, until, limit, offset uint64, unpaidOutgoing bool, unpaidIncoming bool, lnClient lnclient.LNClient, appId *uint, forceFilterByAppId bool, filters *ListTransactionsFilters) (transactions []Transaction, totalCount uint64, err error)
SendPaymentSync(payReq string, amountMsat *uint64, metadata map[string]interface{}, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error)
SendKeysend(amountMsat uint64, destination string, customRecords []lnclient.TLVRecord, preimage string, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error)
MakeHoldInvoice(ctx context.Context, amountMsat uint64, description string, descriptionHash string, expiry uint64, paymentHash string, minCltvExpiryDelta *uint64, metadata map[string]interface{}, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error)
@ -60,6 +61,27 @@ var balanceValidationLock = &sync.Mutex{}
type Transaction = db.Transaction
type ListTransactionsFilters struct {
Type *string
MinAmountMsat *uint64
HideFailed bool
SearchTerm string
}
var paymentHashRegex = regexp.MustCompile("^[0-9a-f]{64}$")
// escapeLikePattern makes a string match literally in a LIKE ... ESCAPE '\'
// clause by escaping the wildcard characters % and _. This is not an SQL
// injection concern (search terms are always passed as bound parameters);
// without it a term like "50%" would behave as a wildcard pattern.
// The backslash must be escaped first.
func escapeLikePattern(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, "%", `\%`)
s = strings.ReplaceAll(s, "_", `\_`)
return s
}
type Boostagram struct {
AppName string `json:"app_name"`
Name string `json:"name"`
@ -640,7 +662,7 @@ func (svc *transactionsService) LookupTransaction(ctx context.Context, paymentHa
return &transaction, nil
}
func (svc *transactionsService) ListTransactions(ctx context.Context, from, until, limit, offset uint64, unpaidOutgoing bool, unpaidIncoming bool, transactionType *string, lnClient lnclient.LNClient, appId *uint, forceFilterByAppId bool) (transactions []Transaction, totalCount uint64, err error) {
func (svc *transactionsService) ListTransactions(ctx context.Context, from, until, limit, offset uint64, unpaidOutgoing bool, unpaidIncoming bool, lnClient lnclient.LNClient, appId *uint, forceFilterByAppId bool, filters *ListTransactionsFilters) (transactions []Transaction, totalCount uint64, err error) {
svc.checkUnsettledTransactions(ctx, lnClient)
var isIsolatedApp bool
@ -672,8 +694,40 @@ func (svc *transactionsService) ListTransactions(ctx context.Context, from, unti
tx = tx.Where("state = ? OR type = ?", constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_TYPE_INCOMING)
}
if transactionType != nil {
tx = tx.Where("type = ?", *transactionType)
if filters != nil {
if filters.Type != nil {
tx = tx.Where("type = ?", *filters.Type)
}
if filters.MinAmountMsat != nil {
tx = tx.Where("amount_msat >= ?", *filters.MinAmountMsat)
}
if filters.HideFailed {
tx = tx.Where("state != ?", constants.TRANSACTION_STATE_FAILED)
}
if searchTerm := strings.ToLower(strings.TrimSpace(filters.SearchTerm)); searchTerm != "" {
likePattern := "%" + escapeLikePattern(searchTerm) + "%"
labelsCondition := `EXISTS (SELECT 1 FROM json_each(transactions.metadata, '$.user_labels') AS user_labels WHERE LOWER(user_labels.key) LIKE ? ESCAPE '\' OR LOWER(user_labels.value) LIKE ? ESCAPE '\')`
if svc.db.Dialector.Name() == "postgres" {
labelsCondition = `EXISTS (SELECT 1 FROM jsonb_each_text((transactions.metadata->'user_labels')::jsonb) AS user_labels WHERE LOWER(user_labels.key) LIKE ? ESCAPE '\' OR LOWER(user_labels.value) LIKE ? ESCAPE '\')`
}
conditions := `LOWER(description) LIKE ? ESCAPE '\' OR ` + labelsCondition
args := []interface{}{likePattern, likePattern, likePattern}
paymentHash := ""
if paymentHashRegex.MatchString(searchTerm) {
paymentHash = searchTerm
} else if strings.HasPrefix(searchTerm, "ln") {
if paymentRequest, err := decodepay.Decodepay(searchTerm); err == nil {
paymentHash = strings.ToLower(paymentRequest.PaymentHash)
}
}
if paymentHash != "" {
conditions += " OR payment_hash = ?"
args = append(args, paymentHash)
}
tx = tx.Where(conditions, args...)
}
}
if from > 0 {

View file

@ -327,28 +327,37 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string
offset := uint64(0)
var appId *uint
// Extract limit and offset parameters
paramRegex := regexp.MustCompile(`[?&](limit|offset|appId)=([^&]+)`)
paramMatches := paramRegex.FindAllStringSubmatch(route, -1)
for _, match := range paramMatches {
switch match[1] {
case "limit":
if parsedLimit, err := strconv.ParseUint(match[2], 10, 64); err == nil {
limit = parsedLimit
}
case "offset":
if parsedOffset, err := strconv.ParseUint(match[2], 10, 64); err == nil {
offset = parsedOffset
}
case "appId":
if parsedAppId, err := strconv.ParseUint(match[2], 10, 64); err == nil {
var unsignedAppId = uint(parsedAppId)
appId = &unsignedAppId
}
parsedUrl, err := url.Parse(route)
if err != nil {
return WailsRequestRouterResponse{Body: nil, Error: "invalid route"}
}
query := parsedUrl.Query()
if limitParam := query.Get("limit"); limitParam != "" {
if parsedLimit, err := strconv.ParseUint(limitParam, 10, 64); err == nil {
limit = parsedLimit
}
}
transactions, err := app.api.ListTransactions(ctx, appId, limit, offset)
if offsetParam := query.Get("offset"); offsetParam != "" {
if parsedOffset, err := strconv.ParseUint(offsetParam, 10, 64); err == nil {
offset = parsedOffset
}
}
if appIdParam := query.Get("appId"); appIdParam != "" {
if parsedAppId, err := strconv.ParseUint(appIdParam, 10, 64); err == nil {
var unsignedAppId = uint(parsedAppId)
appId = &unsignedAppId
}
}
filters, err := api.ParseListTransactionsFilters(query)
if err != nil {
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
}
transactions, err := app.api.ListTransactions(ctx, appId, limit, offset, filters)
if err != nil {
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
}