mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
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:
parent
3b3e784fa6
commit
35d666d469
16 changed files with 780 additions and 138 deletions
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
140
frontend/src/components/TransactionsFilterDialog.tsx
Normal file
140
frontend/src/components/TransactionsFilterDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
17
frontend/src/state/TransactionFiltersStore.ts
Normal file
17
frontend/src/state/TransactionFiltersStore.ts
Normal 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;
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue