alby-hub/api/transactions.go
Alchemist 35d666d469
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>
2026-08-07 10:52:14 +07:00

249 lines
7.8 KiB
Go

package api
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"
)
func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, description string, toAppId *uint) (*MakeInvoiceResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
if toAppId != nil && api.appsSvc.GetAppById(*toAppId) == nil {
return nil, errors.New("app does not exist")
}
transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, toAppId, nil, nil)
if err != nil {
return nil, err
}
return toApiTransaction(transaction), nil
}
func (api *api) LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
transaction, err := api.svc.GetTransactionsService().LookupTransaction(ctx, paymentHash, nil, lnClient, nil)
if err != nil {
return nil, err
}
return toApiTransaction(transaction), nil
}
func (api *api) SetTransactionUserLabels(ctx context.Context, id uint, labels map[string]string) error {
return api.svc.GetTransactionsService().SetTransactionUserLabels(ctx, id, labels)
}
// 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
}
forceFilterByAppId := false
if appId != nil {
forceFilterByAppId = true
}
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 dbTransactions {
apiTransactions = append(apiTransactions, *toApiTransaction(&transaction))
}
return &ListTransactionsResponse{
Transactions: apiTransactions,
TotalCount: totalCount,
}, nil
}
func (api *api) SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}, appId *uint) (*SendPaymentResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
transaction, err := api.svc.GetTransactionsService().SendPaymentSync(invoice, amountMsat, metadata, lnClient, appId, nil)
if err != nil {
return nil, err
}
return toApiTransaction(transaction), nil
}
func toApiTransaction(transaction *transactions.Transaction) *Transaction {
updatedAt := transaction.UpdatedAt.Format(time.RFC3339)
createdAt := transaction.CreatedAt.Format(time.RFC3339)
var settledAt *string
var preimage *string
if transaction.SettledAt != nil {
settledAtValue := transaction.SettledAt.Format(time.RFC3339)
settledAt = &settledAtValue
preimage = transaction.Preimage
}
var metadata Metadata
if transaction.Metadata != nil {
jsonErr := json.Unmarshal(transaction.Metadata, &metadata)
if jsonErr != nil {
logger.Logger.WithError(jsonErr).WithFields(logrus.Fields{
"payment_hash": transaction.PaymentHash,
"metadata": transaction.Metadata,
}).Error("Failed to deserialize transaction metadata")
}
}
var boostagram *Boostagram
if transaction.Boostagram != nil {
var txBoostagram transactions.Boostagram
jsonErr := json.Unmarshal(transaction.Boostagram, &txBoostagram)
if jsonErr != nil {
logger.Logger.WithError(jsonErr).WithFields(logrus.Fields{
"payment_hash": transaction.PaymentHash,
"boostagram": transaction.Boostagram,
}).Error("Failed to deserialize transaction boostagram info")
}
boostagram = toApiBoostagram(&txBoostagram)
}
return &Transaction{
ID: transaction.ID,
Type: transaction.Type,
State: strings.ToLower(transaction.State),
Invoice: transaction.PaymentRequest,
Description: transaction.Description,
DescriptionHash: transaction.DescriptionHash,
Preimage: preimage,
PaymentHash: transaction.PaymentHash,
Amount: transaction.AmountMsat,
AmountSat: transaction.AmountMsat / 1000,
AmountMsat: transaction.AmountMsat,
AppId: transaction.AppId,
FeesPaid: transaction.FeeMsat,
FeesPaidSat: transaction.FeeMsat / 1000,
FeesPaidMsat: transaction.FeeMsat,
UpdatedAt: updatedAt,
CreatedAt: createdAt,
SettledAt: settledAt,
Metadata: metadata,
Boostagram: boostagram,
FailureReason: transaction.FailureReason,
}
}
func (api *api) Transfer(ctx context.Context, fromAppId *uint, toAppId *uint, amountMsat uint64, description string) error {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return ErrLNClientNotStarted
}
for _, appId := range []*uint{fromAppId, toAppId} {
if appId != nil {
dbApp := api.appsSvc.GetAppById(*appId)
if dbApp == nil {
return errors.New("app does not exist")
}
if !dbApp.Isolated {
return errors.New("app is not isolated")
}
}
}
// default to "transfer"
if description == "" {
description = "transfer"
}
transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, toAppId, nil, nil)
if err != nil {
return err
}
_, err = api.svc.GetTransactionsService().SendPaymentSync(transaction.PaymentRequest, nil, nil, lnClient, fromAppId, nil)
return err
}
func toApiBoostagram(boostagram *transactions.Boostagram) *Boostagram {
return &Boostagram{
AppName: boostagram.AppName,
Name: boostagram.Name,
Podcast: boostagram.Podcast,
URL: boostagram.URL,
Episode: boostagram.Episode.String(),
FeedId: boostagram.FeedId.String(),
ItemId: boostagram.ItemId.String(),
Timestamp: boostagram.Timestamp,
Message: boostagram.Message,
SenderId: boostagram.SenderId.String(),
SenderName: boostagram.SenderName,
Time: boostagram.Time,
Action: boostagram.Action,
ValueSatTotal: boostagram.ValueMsatTotal / 1000,
ValueMsatTotal: boostagram.ValueMsatTotal,
}
}