mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
feat: show pending and failed outgoing transactions (#682)
* feat: show pending and failed outgoing transactions * fix: use gray color for pending outgoing transactions * fix: list transactions query and date display of unsettled transactions * feat: add env variable to log db queries * chore: add extra list transactions filter tests
This commit is contained in:
parent
bfb79a3493
commit
e6f1d5b0e9
15 changed files with 455 additions and 103 deletions
|
|
@ -23,6 +23,7 @@ FRONTEND_URL=http://localhost:5173
|
|||
#RELAY=ws://localhost:7447/v1
|
||||
#PORT=8080
|
||||
|
||||
#LOG_DB_QUERIES=true
|
||||
|
||||
# Alby OAuth configuration
|
||||
#ALBY_OAUTH_CLIENT_SECRET=
|
||||
|
|
|
|||
|
|
@ -208,6 +208,7 @@ type ListTransactionsResponse = []Transaction
|
|||
// TODO: camelCase
|
||||
type Transaction struct {
|
||||
Type string `json:"type"`
|
||||
State string `json:"state"`
|
||||
Invoice string `json:"invoice"`
|
||||
Description string `json:"description"`
|
||||
DescriptionHash string `json:"descriptionHash"`
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/getAlby/hub/logger"
|
||||
|
|
@ -38,7 +39,7 @@ func (api *api) ListTransactions(ctx context.Context, limit uint64, offset uint6
|
|||
if api.svc.GetLNClient() == nil {
|
||||
return nil, errors.New("LNClient not started")
|
||||
}
|
||||
transactions, err := api.svc.GetTransactionsService().ListTransactions(ctx, 0, 0, limit, offset, false, nil, api.svc.GetLNClient(), nil)
|
||||
transactions, err := api.svc.GetTransactionsService().ListTransactions(ctx, 0, 0, limit, offset, true, false, nil, api.svc.GetLNClient(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -99,6 +100,7 @@ func toApiTransaction(transaction *transactions.Transaction) *Transaction {
|
|||
|
||||
return &Transaction{
|
||||
Type: transaction.Type,
|
||||
State: strings.ToLower(transaction.State),
|
||||
Invoice: transaction.PaymentRequest,
|
||||
Description: transaction.Description,
|
||||
DescriptionHash: transaction.DescriptionHash,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ type AppConfig struct {
|
|||
DdProfilerEnabled bool `envconfig:"DD_PROFILER_ENABLED" default:"false"`
|
||||
EnableAdvancedSetup bool `envconfig:"ENABLE_ADVANCED_SETUP" default:"true"`
|
||||
AutoUnlockPassword string `envconfig:"AUTO_UNLOCK_PASSWORD"`
|
||||
LogDBQueries bool `envconfig:"LOG_DB_QUERIES" default:"false"`
|
||||
}
|
||||
|
||||
func (c *AppConfig) IsDefaultClientId() bool {
|
||||
|
|
|
|||
11
db/db.go
11
db/db.go
|
|
@ -7,11 +7,18 @@ import (
|
|||
"github.com/getAlby/hub/logger"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
gorm_logger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func NewDB(uri string) (*gorm.DB, error) {
|
||||
func NewDB(uri string, logDBQueries bool) (*gorm.DB, error) {
|
||||
|
||||
config := &gorm.Config{}
|
||||
if logDBQueries {
|
||||
config.Logger = gorm_logger.Default.LogMode(gorm_logger.Info)
|
||||
}
|
||||
|
||||
// avoid SQLITE_BUSY errors with _txlock=IMMEDIATE
|
||||
gormDB, err := gorm.Open(sqlite.Open(uri+"?_txlock=IMMEDIATE"), &gorm.Config{})
|
||||
gormDB, err := gorm.Open(sqlite.Open(uri+"?_txlock=IMMEDIATE"), config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export default function AppAvatar({ app, className }: Props) {
|
|||
/>
|
||||
)}
|
||||
{!image && (
|
||||
<span className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-white text-sm font-medium capitalize">
|
||||
<span className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-white text-sm font-medium capitalize pointer-events-none">
|
||||
{app.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
ChevronDown,
|
||||
ChevronUp,
|
||||
CopyIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
|
@ -38,7 +39,20 @@ function TransactionItem({ tx }: Props) {
|
|||
const { toast } = useToast();
|
||||
const [showDetails, setShowDetails] = React.useState(false);
|
||||
const type = tx.type;
|
||||
const Icon = tx.type == "outgoing" ? ArrowUpIcon : ArrowDownIcon;
|
||||
const typeStateText =
|
||||
type == "incoming"
|
||||
? "Received"
|
||||
: tx.state === "settled" // we only fetch settled incoming payments
|
||||
? "Sent"
|
||||
: tx.state === "pending"
|
||||
? "Sending"
|
||||
: "Failed";
|
||||
const Icon =
|
||||
tx.state === "failed"
|
||||
? XIcon
|
||||
: tx.type == "outgoing"
|
||||
? ArrowUpIcon
|
||||
: ArrowDownIcon;
|
||||
const app =
|
||||
tx.appId !== undefined
|
||||
? apps?.find((app) => app.id === tx.appId)
|
||||
|
|
@ -48,6 +62,48 @@ function TransactionItem({ tx }: Props) {
|
|||
copyToClipboard(text, toast);
|
||||
};
|
||||
|
||||
const typeStateIcon = (
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-center items-center rounded-full w-10 h-10 md:w-14 md:h-14 relative",
|
||||
tx.state === "failed"
|
||||
? "bg-red-100 dark:bg-red-950"
|
||||
: tx.state === "pending"
|
||||
? "bg-gray-100 dark:bg-gray-950"
|
||||
: type === "outgoing"
|
||||
? "bg-orange-100 dark:bg-orange-950"
|
||||
: "bg-green-100 dark:bg-emerald-950"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
strokeWidth={3}
|
||||
className={cn(
|
||||
"w-6 h-6 md:w-8 md:h-8",
|
||||
tx.state === "failed"
|
||||
? "stroke-rose-400 dark:stroke-red-600"
|
||||
: tx.state === "pending"
|
||||
? "stroke-gray-400 dark:stroke-gray-600"
|
||||
: type === "outgoing"
|
||||
? "stroke-orange-400 dark:stroke-amber-600"
|
||||
: "stroke-green-400 dark:stroke-emerald-500"
|
||||
)}
|
||||
/>
|
||||
{app && (
|
||||
<div
|
||||
className="absolute -bottom-1 -right-1"
|
||||
title={`${typeStateText} via ${app.name === "getalby.com" ? "Alby Account" : app.name}`}
|
||||
>
|
||||
<AppAvatar
|
||||
app={app}
|
||||
className="border-none p-0 rounded-full w-[18px] h-[18px] md:w-6 md:h-6 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
|
|
@ -57,46 +113,21 @@ function TransactionItem({ tx }: Props) {
|
|||
}}
|
||||
>
|
||||
<DialogTrigger className="p-3 mb-4 hover:bg-muted/50 data-[state=selected]:bg-muted cursor-pointer rounded-md slashed-zero transaction sensitive">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-center items-center rounded-full w-10 h-10 md:w-14 md:h-14 relative",
|
||||
type === "outgoing"
|
||||
? "bg-orange-100 dark:bg-orange-950"
|
||||
: "bg-green-100 dark:bg-emerald-950"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
strokeWidth={3}
|
||||
className={cn(
|
||||
"w-6 h-6 md:w-8 md:h-8",
|
||||
type === "outgoing"
|
||||
? "stroke-orange-400 dark:stroke-amber-600"
|
||||
: "stroke-green-400 dark:stroke-emerald-500"
|
||||
)}
|
||||
/>
|
||||
{app && (
|
||||
<div
|
||||
className="absolute -bottom-1 -right-1"
|
||||
title={`${type == "incoming" ? "Received" : "Sent"} via ${app.name === "getalby.com" ? "Alby Account" : app.name}`}
|
||||
>
|
||||
<AppAvatar
|
||||
app={app}
|
||||
className="border-none p-0 rounded-full w-[18px] h-[18px] md:w-6 md:h-6 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-3",
|
||||
tx.state === "pending" && "animate-pulse"
|
||||
)}
|
||||
>
|
||||
{typeStateIcon}
|
||||
<div className="overflow-hidden mr-3 max-w-full text-left flex flex-col items-start justify-center">
|
||||
<div>
|
||||
<p className="flex items-center gap-2 truncate">
|
||||
<span className="md:text-xl font-semibold">
|
||||
{type == "incoming" ? "Received" : "Sent"}
|
||||
{typeStateText}
|
||||
</span>
|
||||
<span className="text-xs md:text-base truncate text-muted-foreground">
|
||||
{dayjs(tx.settledAt).fromNow()}
|
||||
{dayjs(tx.settledAt || tx.createdAt).fromNow()}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -132,57 +163,38 @@ function TransactionItem({ tx }: Props) {
|
|||
</DialogTrigger>
|
||||
<DialogContent className="slashed-zero">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`${type == "outgoing" ? "Sent" : "Received"} Bitcoin`}</DialogTitle>
|
||||
<DialogTitle
|
||||
className={cn(tx.state === "pending" && "animate-pulse")}
|
||||
>{`${typeStateText} Bitcoin Payment`}</DialogTitle>
|
||||
<DialogDescription className="text-start text-foreground">
|
||||
<div className="flex items-center mt-6">
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-center items-center rounded-full w-10 h-10 md:w-14 md:h-14",
|
||||
type === "outgoing"
|
||||
? "bg-orange-100 dark:bg-orange-950"
|
||||
: "bg-green-100 dark:bg-emerald-950"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
strokeWidth={3}
|
||||
className={cn(
|
||||
"w-6 h-6 md:w-8 md:h-8",
|
||||
type === "outgoing"
|
||||
? "stroke-orange-400 dark:stroke-amber-600"
|
||||
: "stroke-green-400 dark:stroke-emerald-500"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center mt-6",
|
||||
tx.state === "pending" && "animate-pulse"
|
||||
)}
|
||||
>
|
||||
{typeStateIcon}
|
||||
<div className="ml-4">
|
||||
<p className="text-xl md:text-2xl font-semibold">
|
||||
{new Intl.NumberFormat().format(Math.floor(tx.amount / 1000))}{" "}
|
||||
{Math.floor(tx.amount / 1000) == 1 ? "sat" : "sats"}
|
||||
</p>
|
||||
{/* <p className="text-sm md:text-base text-muted-foreground">
|
||||
Fiat Amount
|
||||
</p> */}
|
||||
</div>
|
||||
</div>
|
||||
{app && (
|
||||
<div className="mt-8">
|
||||
<p>App</p>
|
||||
<Link to={`/apps/${app.nostrPubkey}`}>
|
||||
<div className="flex items-center justify-start gap-1 mt-1">
|
||||
<AppAvatar
|
||||
app={app}
|
||||
className="border-none p-0 rounded-full w-6 h-6"
|
||||
/>
|
||||
<p className="text-muted-foreground">
|
||||
{app.name === "getalby.com" ? "Alby Account" : app.name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-semibold">
|
||||
{app.name === "getalby.com" ? "Alby Account" : app.name}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<p>Date & Time</p>
|
||||
<p className="text-muted-foreground">
|
||||
{dayjs(tx.settledAt)
|
||||
{dayjs(tx.settledAt || tx.createdAt)
|
||||
.tz(dayjs.tz.guess())
|
||||
.format("D MMMM YYYY, HH:mm")}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,22 @@
|
|||
import { ArrowUp } from "lucide-react";
|
||||
import { ArrowUp, InfoIcon } from "lucide-react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import AppHeader from "src/components/AppHeader";
|
||||
import BalanceCard from "src/components/BalanceCard";
|
||||
import Loading from "src/components/Loading";
|
||||
import { useBalances } from "src/hooks/useBalances";
|
||||
import { useChannels } from "src/hooks/useChannels";
|
||||
import { useTransactions } from "src/hooks/useTransactions";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
|
||||
import { LinkButton } from "src/components/ui/button";
|
||||
import { useInfo } from "src/hooks/useInfo";
|
||||
|
||||
export default function Send() {
|
||||
const { hasChannelManagement } = useInfo();
|
||||
const { data: balances } = useBalances();
|
||||
const { data: channels } = useChannels();
|
||||
const { data: transactions } = useTransactions();
|
||||
|
||||
if (!balances || !channels) {
|
||||
return <Loading />;
|
||||
|
|
@ -23,6 +28,26 @@ export default function Send() {
|
|||
title="Send"
|
||||
description="Pay a lightning invoice created by any bitcoin lightning wallet"
|
||||
/>
|
||||
{transactions?.some(
|
||||
(tx) =>
|
||||
tx.state === "pending" &&
|
||||
dayjs().diff(dayjs(tx.createdAt)) <
|
||||
1000 * 60 * 60 * 24 /* payment pending in last 24h */
|
||||
/* TODO: remove diff check when expired transactions are marked as failed */
|
||||
) && (
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle>Pending Payment</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className="mb-2">
|
||||
You have one or more payments that have not settled.
|
||||
</div>
|
||||
<LinkButton to={"/wallet"} size={"sm"}>
|
||||
View Payments
|
||||
</LinkButton>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex gap-12 w-full">
|
||||
<div className="w-full max-w-lg">
|
||||
<Outlet />
|
||||
|
|
|
|||
|
|
@ -390,6 +390,7 @@ export type BalancesResponse = {
|
|||
|
||||
export type Transaction = {
|
||||
type: "incoming" | "outgoing";
|
||||
state: "settled" | "pending" | "failed";
|
||||
appId: number | undefined;
|
||||
invoice: string;
|
||||
description: string;
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ func (controller *nip47Controller) HandleListTransactionsEvent(ctx context.Conte
|
|||
transactionType = &listParams.Type
|
||||
}
|
||||
|
||||
dbTransactions, err := controller.transactionsService.ListTransactions(ctx, listParams.From, listParams.Until, limit, listParams.Offset, listParams.Unpaid, transactionType, controller.lnClient, &appId)
|
||||
// TODO: listParams.Unpaid needs to be updated to support ability to fetch only unpaid outgoing transactions
|
||||
dbTransactions, err := controller.transactionsService.ListTransactions(ctx, listParams.From, listParams.Until, limit, listParams.Offset, listParams.Unpaid, listParams.Unpaid, transactionType, controller.lnClient, &appId)
|
||||
if err != nil {
|
||||
logger.Logger.WithFields(logrus.Fields{
|
||||
"params": listParams,
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ func TestHandleListTransactionsEvent(t *testing.T) {
|
|||
SettledAt: &settledAt,
|
||||
State: constants.TRANSACTION_STATE_SETTLED,
|
||||
AppId: &app.ID,
|
||||
CreatedAt: time.Now().Add(time.Duration(-i) * time.Hour),
|
||||
UpdatedAt: time.Now().Add(time.Duration(-i) * time.Hour),
|
||||
}).Error
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ func NewService(ctx context.Context) (*service, error) {
|
|||
}
|
||||
}
|
||||
|
||||
gormDB, err := db.NewDB(appConfig.DatabaseUri)
|
||||
gormDB, err := db.NewDB(appConfig.DatabaseUri, appConfig.LogDBQueries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import (
|
|||
const testDB = "test.db"
|
||||
|
||||
func CreateTestService() (svc *TestService, err error) {
|
||||
gormDb, err := db.NewDB(testDB)
|
||||
gormDb, err := db.NewDB(testDB, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestListTransactions(t *testing.T) {
|
||||
func TestListTransactions_Paid(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
defer tests.RemoveTestService()
|
||||
|
|
@ -35,10 +35,18 @@ func TestListTransactions(t *testing.T) {
|
|||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
})
|
||||
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,
|
||||
})
|
||||
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
|
||||
incomingTransactions, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, false, nil, svc.LNClient, nil)
|
||||
incomingTransactions, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, false, false, nil, svc.LNClient, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(incomingTransactions))
|
||||
assert.Equal(t, uint64(123000), incomingTransactions[0].AmountMsat)
|
||||
|
|
@ -47,7 +55,7 @@ func TestListTransactions(t *testing.T) {
|
|||
assert.Zero(t, incomingTransactions[0].FeeReserveMsat)
|
||||
}
|
||||
|
||||
func TestListTransactions_Unsettled(t *testing.T) {
|
||||
func TestListTransactions_UnpaidIncoming(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
defer tests.RemoveTestService()
|
||||
|
|
@ -62,6 +70,16 @@ func TestListTransactions_Unsettled(t *testing.T) {
|
|||
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
|
||||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
svc.DB.Create(&db.Transaction{
|
||||
State: constants.TRANSACTION_STATE_FAILED,
|
||||
Type: constants.TRANSACTION_TYPE_INCOMING,
|
||||
PaymentRequest: tests.MockLNClientTransaction.Invoice,
|
||||
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
|
||||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
UpdatedAt: time.Now().Add(-1 * time.Second),
|
||||
})
|
||||
svc.DB.Create(&db.Transaction{
|
||||
State: constants.TRANSACTION_STATE_PENDING,
|
||||
|
|
@ -70,13 +88,166 @@ func TestListTransactions_Unsettled(t *testing.T) {
|
|||
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
|
||||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
UpdatedAt: time.Now().Add(-2 * time.Second),
|
||||
})
|
||||
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,
|
||||
UpdatedAt: time.Now().Add(-2 * time.Second),
|
||||
})
|
||||
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,
|
||||
UpdatedAt: time.Now().Add(-2 * time.Second),
|
||||
})
|
||||
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
|
||||
incomingTransactions, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, true, nil, svc.LNClient, nil)
|
||||
incomingTransactions, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, false, true, nil, svc.LNClient, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, len(incomingTransactions))
|
||||
assert.Equal(t, 3, len(incomingTransactions))
|
||||
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, incomingTransactions[0].State)
|
||||
assert.Equal(t, constants.TRANSACTION_STATE_FAILED, incomingTransactions[1].State)
|
||||
assert.Equal(t, constants.TRANSACTION_STATE_PENDING, incomingTransactions[2].State)
|
||||
for _, transaction := range incomingTransactions {
|
||||
assert.Equal(t, constants.TRANSACTION_TYPE_INCOMING, transaction.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTransactions_UnpaidOutgoing(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
defer tests.RemoveTestService()
|
||||
svc, err := tests.CreateTestService()
|
||||
assert.NoError(t, err)
|
||||
|
||||
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,
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
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,
|
||||
UpdatedAt: time.Now().Add(-1 * time.Second),
|
||||
})
|
||||
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,
|
||||
UpdatedAt: time.Now().Add(-2 * time.Second),
|
||||
})
|
||||
svc.DB.Create(&db.Transaction{
|
||||
State: constants.TRANSACTION_STATE_FAILED,
|
||||
Type: constants.TRANSACTION_TYPE_INCOMING,
|
||||
PaymentRequest: tests.MockLNClientTransaction.Invoice,
|
||||
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
|
||||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
UpdatedAt: time.Now().Add(-2 * time.Second),
|
||||
})
|
||||
svc.DB.Create(&db.Transaction{
|
||||
State: constants.TRANSACTION_STATE_PENDING,
|
||||
Type: constants.TRANSACTION_TYPE_INCOMING,
|
||||
PaymentRequest: tests.MockLNClientTransaction.Invoice,
|
||||
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
|
||||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
UpdatedAt: time.Now().Add(-2 * time.Second),
|
||||
})
|
||||
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
|
||||
outgoingTransactions, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, true, false, nil, svc.LNClient, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, len(outgoingTransactions))
|
||||
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, outgoingTransactions[0].State)
|
||||
assert.Equal(t, constants.TRANSACTION_STATE_FAILED, outgoingTransactions[1].State)
|
||||
assert.Equal(t, constants.TRANSACTION_STATE_PENDING, outgoingTransactions[2].State)
|
||||
for _, transaction := range outgoingTransactions {
|
||||
assert.Equal(t, constants.TRANSACTION_TYPE_OUTGOING, transaction.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTransactions_Unpaid(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
defer tests.RemoveTestService()
|
||||
svc, err := tests.CreateTestService()
|
||||
assert.NoError(t, err)
|
||||
|
||||
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,
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
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,
|
||||
UpdatedAt: time.Now().Add(-1 * time.Second),
|
||||
})
|
||||
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,
|
||||
UpdatedAt: time.Now().Add(-2 * time.Second),
|
||||
})
|
||||
svc.DB.Create(&db.Transaction{
|
||||
State: constants.TRANSACTION_STATE_FAILED,
|
||||
Type: constants.TRANSACTION_TYPE_INCOMING,
|
||||
PaymentRequest: tests.MockLNClientTransaction.Invoice,
|
||||
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
|
||||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
UpdatedAt: time.Now().Add(-2 * time.Second),
|
||||
})
|
||||
svc.DB.Create(&db.Transaction{
|
||||
State: constants.TRANSACTION_STATE_PENDING,
|
||||
Type: constants.TRANSACTION_TYPE_INCOMING,
|
||||
PaymentRequest: tests.MockLNClientTransaction.Invoice,
|
||||
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
|
||||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
UpdatedAt: time.Now().Add(-2 * time.Second),
|
||||
})
|
||||
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
|
||||
outgoingTransactions, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, true, true, nil, svc.LNClient, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 5, len(outgoingTransactions))
|
||||
}
|
||||
|
||||
func TestListTransactions_Limit(t *testing.T) {
|
||||
|
|
@ -95,7 +266,7 @@ func TestListTransactions_Limit(t *testing.T) {
|
|||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
Description: "first",
|
||||
CreatedAt: time.Now().Add(1 * time.Minute),
|
||||
UpdatedAt: time.Now().Add(1 * time.Minute),
|
||||
})
|
||||
svc.DB.Create(&db.Transaction{
|
||||
State: constants.TRANSACTION_STATE_SETTLED,
|
||||
|
|
@ -109,7 +280,7 @@ func TestListTransactions_Limit(t *testing.T) {
|
|||
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
|
||||
incomingTransactions, err := transactionsService.ListTransactions(ctx, 0, 0, 1, 0, false, nil, svc.LNClient, nil)
|
||||
incomingTransactions, err := transactionsService.ListTransactions(ctx, 0, 0, 1, 0, false, false, nil, svc.LNClient, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(incomingTransactions))
|
||||
assert.Equal(t, "first", incomingTransactions[0].Description)
|
||||
|
|
@ -131,7 +302,7 @@ func TestListTransactions_Offset(t *testing.T) {
|
|||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
Description: "first",
|
||||
CreatedAt: time.Now().Add(3 * time.Minute),
|
||||
UpdatedAt: time.Now().Add(3 * time.Minute),
|
||||
})
|
||||
svc.DB.Create(&db.Transaction{
|
||||
State: constants.TRANSACTION_STATE_SETTLED,
|
||||
|
|
@ -141,7 +312,7 @@ func TestListTransactions_Offset(t *testing.T) {
|
|||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
Description: "second",
|
||||
CreatedAt: time.Now().Add(2 * time.Minute),
|
||||
UpdatedAt: time.Now().Add(2 * time.Minute),
|
||||
})
|
||||
svc.DB.Create(&db.Transaction{
|
||||
State: constants.TRANSACTION_STATE_SETTLED,
|
||||
|
|
@ -151,7 +322,7 @@ func TestListTransactions_Offset(t *testing.T) {
|
|||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
Description: "third",
|
||||
CreatedAt: time.Now().Add(1 * time.Minute),
|
||||
UpdatedAt: time.Now().Add(1 * time.Minute),
|
||||
})
|
||||
svc.DB.Create(&db.Transaction{
|
||||
State: constants.TRANSACTION_STATE_SETTLED,
|
||||
|
|
@ -165,7 +336,7 @@ func TestListTransactions_Offset(t *testing.T) {
|
|||
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
|
||||
incomingTransactions, err := transactionsService.ListTransactions(ctx, 0, 0, 1, 2, false, nil, svc.LNClient, nil)
|
||||
incomingTransactions, err := transactionsService.ListTransactions(ctx, 0, 0, 1, 2, false, false, nil, svc.LNClient, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(incomingTransactions))
|
||||
assert.Equal(t, "third", incomingTransactions[0].Description)
|
||||
|
|
@ -187,6 +358,7 @@ func TestListTransactions_FromUntil(t *testing.T) {
|
|||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
Description: "first",
|
||||
UpdatedAt: time.Now().Add(10 * time.Minute),
|
||||
CreatedAt: time.Now().Add(10 * time.Minute),
|
||||
})
|
||||
svc.DB.Create(&db.Transaction{
|
||||
|
|
@ -197,6 +369,7 @@ func TestListTransactions_FromUntil(t *testing.T) {
|
|||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
Description: "second",
|
||||
UpdatedAt: time.Now().Add(5 * time.Minute),
|
||||
CreatedAt: time.Now().Add(5 * time.Minute),
|
||||
})
|
||||
svc.DB.Create(&db.Transaction{
|
||||
|
|
@ -207,12 +380,138 @@ func TestListTransactions_FromUntil(t *testing.T) {
|
|||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
Description: "third",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
|
||||
incomingTransactions, err := transactionsService.ListTransactions(ctx, uint64(time.Now().Add(4*time.Minute).Unix()), uint64(time.Now().Add(6*time.Minute).Unix()), 0, 0, false, nil, svc.LNClient, nil)
|
||||
incomingTransactions, 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)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(incomingTransactions))
|
||||
assert.Equal(t, "second", incomingTransactions[0].Description)
|
||||
}
|
||||
|
||||
func TestListTransactions_FromUntilUnpaidOutgoing(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
defer tests.RemoveTestService()
|
||||
svc, err := tests.CreateTestService()
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockPreimage := tests.MockLNClientTransaction.Preimage
|
||||
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: "first",
|
||||
UpdatedAt: time.Now().Add(10 * time.Minute),
|
||||
CreatedAt: time.Now().Add(10 * 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: "second",
|
||||
UpdatedAt: time.Now().Add(5 * time.Minute),
|
||||
CreatedAt: time.Now().Add(5 * time.Minute),
|
||||
})
|
||||
svc.DB.Create(&db.Transaction{
|
||||
State: constants.TRANSACTION_STATE_PENDING,
|
||||
Type: constants.TRANSACTION_TYPE_INCOMING,
|
||||
PaymentRequest: tests.MockLNClientTransaction.Invoice,
|
||||
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
|
||||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
Description: "second",
|
||||
UpdatedAt: time.Now().Add(5 * time.Minute),
|
||||
CreatedAt: time.Now().Add(5 * 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: "third",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
|
||||
incomingTransactions, 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)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(incomingTransactions))
|
||||
assert.Equal(t, "second", incomingTransactions[0].Description)
|
||||
assert.Equal(t, constants.TRANSACTION_TYPE_OUTGOING, incomingTransactions[0].Type)
|
||||
}
|
||||
|
||||
func TestListTransactions_FromUntilUnpaidIncoming(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
defer tests.RemoveTestService()
|
||||
svc, err := tests.CreateTestService()
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockPreimage := tests.MockLNClientTransaction.Preimage
|
||||
svc.DB.Create(&db.Transaction{
|
||||
State: constants.TRANSACTION_STATE_PENDING,
|
||||
Type: constants.TRANSACTION_TYPE_INCOMING,
|
||||
PaymentRequest: tests.MockLNClientTransaction.Invoice,
|
||||
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
|
||||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
Description: "first",
|
||||
UpdatedAt: time.Now().Add(10 * time.Minute),
|
||||
CreatedAt: time.Now().Add(10 * time.Minute),
|
||||
})
|
||||
svc.DB.Create(&db.Transaction{
|
||||
State: constants.TRANSACTION_STATE_PENDING,
|
||||
Type: constants.TRANSACTION_TYPE_INCOMING,
|
||||
PaymentRequest: tests.MockLNClientTransaction.Invoice,
|
||||
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
|
||||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
Description: "second",
|
||||
UpdatedAt: time.Now().Add(5 * time.Minute),
|
||||
CreatedAt: time.Now().Add(5 * 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: "second",
|
||||
UpdatedAt: time.Now().Add(5 * time.Minute),
|
||||
CreatedAt: time.Now().Add(5 * time.Minute),
|
||||
})
|
||||
svc.DB.Create(&db.Transaction{
|
||||
State: constants.TRANSACTION_STATE_PENDING,
|
||||
Type: constants.TRANSACTION_TYPE_INCOMING,
|
||||
PaymentRequest: tests.MockLNClientTransaction.Invoice,
|
||||
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
|
||||
Preimage: &mockPreimage,
|
||||
AmountMsat: 123000,
|
||||
Description: "third",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
|
||||
incomingTransactions, 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)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(incomingTransactions))
|
||||
assert.Equal(t, "second", incomingTransactions[0].Description)
|
||||
assert.Equal(t, constants.TRANSACTION_TYPE_INCOMING, incomingTransactions[0].Type)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ type TransactionsService interface {
|
|||
events.EventSubscriber
|
||||
MakeInvoice(ctx context.Context, amount int64, description string, descriptionHash string, expiry int64, metadata map[string]interface{}, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*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, unpaid bool, transactionType *string, lnClient lnclient.LNClient, appId *uint) (transactions []Transaction, err error)
|
||||
ListTransactions(ctx context.Context, from, until, limit, offset uint64, unpaidOutgoing bool, unpaidIncoming bool, transactionType *string, lnClient lnclient.LNClient, appId *uint) (transactions []Transaction, err error)
|
||||
SendPaymentSync(ctx context.Context, payReq string, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error)
|
||||
SendKeysend(ctx context.Context, amount uint64, destination string, customRecords []lnclient.TLVRecord, preimage string, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error)
|
||||
}
|
||||
|
|
@ -474,16 +474,19 @@ func (svc *transactionsService) LookupTransaction(ctx context.Context, paymentHa
|
|||
return &transaction, nil
|
||||
}
|
||||
|
||||
func (svc *transactionsService) ListTransactions(ctx context.Context, from, until, limit, offset uint64, unpaid bool, transactionType *string, lnClient lnclient.LNClient, appId *uint) (transactions []Transaction, err error) {
|
||||
func (svc *transactionsService) ListTransactions(ctx context.Context, from, until, limit, offset uint64, unpaidOutgoing bool, unpaidIncoming bool, transactionType *string, lnClient lnclient.LNClient, appId *uint) (transactions []Transaction, err error) {
|
||||
svc.checkUnsettledTransactions(ctx, lnClient)
|
||||
|
||||
// TODO: add other filtering and pagination
|
||||
tx := svc.db
|
||||
|
||||
tx = tx.Order("settled_at desc, created_at desc")
|
||||
|
||||
if !unpaid {
|
||||
if !unpaidOutgoing && !unpaidIncoming {
|
||||
tx = tx.Where("state == ?", constants.TRANSACTION_STATE_SETTLED)
|
||||
} else if unpaidOutgoing && !unpaidIncoming {
|
||||
tx = tx.Where(tx.Where("state == ?", constants.TRANSACTION_STATE_SETTLED).
|
||||
Or("type == ?", constants.TRANSACTION_TYPE_OUTGOING))
|
||||
} else if unpaidIncoming && !unpaidOutgoing {
|
||||
tx = tx.Where(tx.Where("state == ?", constants.TRANSACTION_STATE_SETTLED).
|
||||
Or("type == ?", constants.TRANSACTION_TYPE_INCOMING))
|
||||
}
|
||||
|
||||
if transactionType != nil {
|
||||
|
|
@ -497,13 +500,6 @@ func (svc *transactionsService) ListTransactions(ctx context.Context, from, unti
|
|||
tx = tx.Where("created_at <= ?", time.Unix(int64(until), 0))
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
tx = tx.Limit(int(limit))
|
||||
}
|
||||
if offset > 0 {
|
||||
tx = tx.Offset(int(offset))
|
||||
}
|
||||
|
||||
if appId != nil {
|
||||
var app db.App
|
||||
result := svc.db.Limit(1).Find(&app, &db.App{
|
||||
|
|
@ -517,9 +513,15 @@ func (svc *transactionsService) ListTransactions(ctx context.Context, from, unti
|
|||
}
|
||||
}
|
||||
|
||||
if limit != 0 {
|
||||
tx = tx.Order("updated_at desc")
|
||||
|
||||
if limit > 0 {
|
||||
tx = tx.Limit(int(limit))
|
||||
}
|
||||
if offset > 0 {
|
||||
tx = tx.Offset(int(offset))
|
||||
}
|
||||
|
||||
result := tx.Find(&transactions)
|
||||
if result.Error != nil {
|
||||
logger.Logger.WithError(result.Error).Error("Failed to list DB transactions")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue