transactions_service.go raw

   1  package transactions
   2  
   3  import (
   4  	"context"
   5  	"crypto/rand"
   6  	"crypto/sha256"
   7  	"encoding/hex"
   8  	"encoding/json"
   9  	"errors"
  10  	"fmt"
  11  	"math"
  12  	"regexp"
  13  	"slices"
  14  	"strconv"
  15  	"strings"
  16  	"sync"
  17  	"time"
  18  
  19  	decodepay "github.com/nbd-wtf/ln-decodepay"
  20  	"github.com/sirupsen/logrus"
  21  	"gorm.io/datatypes"
  22  	"gorm.io/gorm"
  23  	"gorm.io/gorm/clause"
  24  
  25  	"github.com/getAlby/hub/constants"
  26  	"github.com/getAlby/hub/db"
  27  	"github.com/getAlby/hub/db/queries"
  28  	"github.com/getAlby/hub/events"
  29  	"github.com/getAlby/hub/lnclient"
  30  	"github.com/getAlby/hub/logger"
  31  )
  32  
  33  type transactionsService struct {
  34  	db             *gorm.DB
  35  	eventPublisher events.EventPublisher
  36  }
  37  
  38  type TransactionsService interface {
  39  	events.EventSubscriber
  40  	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)
  41  	LookupTransaction(ctx context.Context, paymentHash string, transactionType *string, lnClient lnclient.LNClient, appId *uint) (*Transaction, error)
  42  	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)
  43  	SendPaymentSync(payReq string, amountMsat *uint64, metadata map[string]interface{}, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error)
  44  	SendKeysend(amountMsat uint64, destination string, customRecords []lnclient.TLVRecord, preimage string, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error)
  45  	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)
  46  	SettleHoldInvoice(ctx context.Context, preimage string, lnClient lnclient.LNClient) (*Transaction, error)
  47  	CancelHoldInvoice(ctx context.Context, paymentHash string, lnClient lnclient.LNClient) error
  48  	SetTransactionMetadata(ctx context.Context, id uint, metadata map[string]interface{}) error
  49  	SetTransactionUserLabels(ctx context.Context, id uint, labels map[string]string) error
  50  }
  51  
  52  const (
  53  	BoostagramTlvType = 7629169
  54  	WhatsatTlvType    = 34349334
  55  	CustomKeyTlvType  = 696969
  56  )
  57  
  58  // Prevent races when checking the current balance and creating payment
  59  // transactions from concurrent goroutines.
  60  var balanceValidationLock = &sync.Mutex{}
  61  
  62  type Transaction = db.Transaction
  63  
  64  type ListTransactionsFilters struct {
  65  	Type          *string
  66  	MinAmountMsat *uint64
  67  	HideFailed    bool
  68  	SearchTerm    string
  69  }
  70  
  71  var paymentHashRegex = regexp.MustCompile("^[0-9a-f]{64}$")
  72  
  73  // escapeLikePattern makes a string match literally in a LIKE ... ESCAPE '\'
  74  // clause by escaping the wildcard characters % and _. This is not an SQL
  75  // injection concern (search terms are always passed as bound parameters);
  76  // without it a term like "50%" would behave as a wildcard pattern.
  77  // The backslash must be escaped first.
  78  func escapeLikePattern(s string) string {
  79  	s = strings.ReplaceAll(s, `\`, `\\`)
  80  	s = strings.ReplaceAll(s, "%", `\%`)
  81  	s = strings.ReplaceAll(s, "_", `\_`)
  82  	return s
  83  }
  84  
  85  type Boostagram struct {
  86  	AppName        string         `json:"app_name"`
  87  	Name           string         `json:"name"`
  88  	Podcast        string         `json:"podcast"`
  89  	URL            string         `json:"url"`
  90  	Episode        StringOrNumber `json:"episode,omitempty"`
  91  	FeedId         StringOrNumber `json:"feedID,omitempty"`
  92  	ItemId         StringOrNumber `json:"itemID,omitempty"`
  93  	Timestamp      int64          `json:"ts,omitempty"`
  94  	Message        string         `json:"message,omitempty"`
  95  	SenderId       StringOrNumber `json:"sender_id"`
  96  	SenderName     string         `json:"sender_name"`
  97  	Time           string         `json:"time"`
  98  	Action         string         `json:"action"`
  99  	ValueMsatTotal int64          `json:"value_msat_total"`
 100  }
 101  
 102  type StringOrNumber struct {
 103  	StringData string
 104  	NumberData int64
 105  }
 106  
 107  func (sn *StringOrNumber) UnmarshalJSON(data []byte) error {
 108  	if err := json.Unmarshal(data, &sn.StringData); err == nil {
 109  		return nil
 110  	}
 111  
 112  	if err := json.Unmarshal(data, &sn.NumberData); err == nil {
 113  		return nil
 114  	}
 115  
 116  	return fmt.Errorf("cannot unmarshal %s into StringOrNumber type", data)
 117  }
 118  
 119  func (sn StringOrNumber) String() string {
 120  	if sn.StringData != "" {
 121  		return sn.StringData
 122  	}
 123  	return fmt.Sprintf("%d", sn.NumberData)
 124  }
 125  
 126  type notFoundError struct {
 127  }
 128  
 129  func NewNotFoundError() error {
 130  	return &notFoundError{}
 131  }
 132  
 133  func (err *notFoundError) Error() string {
 134  	return "The transaction requested was not found"
 135  }
 136  
 137  type insufficientBalanceError struct {
 138  }
 139  
 140  func NewInsufficientBalanceError() error {
 141  	return &insufficientBalanceError{}
 142  }
 143  
 144  func (err *insufficientBalanceError) Error() string {
 145  	return "Insufficient balance remaining to make the requested payment"
 146  }
 147  
 148  type quotaExceededError struct {
 149  }
 150  
 151  func NewQuotaExceededError() error {
 152  	return &quotaExceededError{}
 153  }
 154  
 155  func (err *quotaExceededError) Error() string {
 156  	return "Your app does not have enough budget remaining to make this payment. Please review this app in the connections page of your Alby Hub."
 157  }
 158  
 159  func NewTransactionsService(db *gorm.DB, eventPublisher events.EventPublisher) *transactionsService {
 160  	return &transactionsService{
 161  		db:             db,
 162  		eventPublisher: eventPublisher,
 163  	}
 164  }
 165  
 166  func (svc *transactionsService) 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) {
 167  	logger.Logger.WithFields(logrus.Fields{
 168  		"app_id":           appId,
 169  		"request_event_id": requestEventId,
 170  		"amount_msat":      amountMsat,
 171  		"description":      description,
 172  		"description_hash": descriptionHash,
 173  		"expiry":           expiry,
 174  		"metadata":         metadata,
 175  	}).Debug("Making invoice")
 176  
 177  	if amountMsat%1000 != 0 {
 178  		return nil, errors.New("the amount must be a whole number of satoshis")
 179  	}
 180  	if amountMsat < 1000 {
 181  		return nil, errors.New("the amount must be at least 1 satoshi")
 182  	}
 183  
 184  	var metadataBytes []byte
 185  	if metadata != nil {
 186  		var err error
 187  		metadataBytes, err = json.Marshal(metadata)
 188  		if err != nil {
 189  			logger.Logger.WithError(err).Error("Failed to serialize metadata")
 190  			return nil, err
 191  		}
 192  		if len(metadataBytes) > constants.INVOICE_METADATA_MAX_LENGTH {
 193  			return nil, fmt.Errorf("encoded invoice metadata provided is too large. Limit: %d Received: %d", constants.INVOICE_METADATA_MAX_LENGTH, len(metadataBytes))
 194  		}
 195  	}
 196  
 197  	if metadata["app_id"] != nil {
 198  		overwriteAppIdType, ok := metadata["app_id"].(float64)
 199  		if !ok {
 200  			return nil, errors.New("failed to overwrite app ID")
 201  		}
 202  		overwriteAppId := uint(overwriteAppIdType)
 203  		logger.Logger.WithField("app_id", overwriteAppId).Info("Making invoice with overwritten app ID")
 204  		appId = &overwriteAppId
 205  	}
 206  
 207  	lnClientTransaction, err := lnClient.MakeInvoice(ctx, int64(amountMsat), description, descriptionHash, int64(expiry), throughNodePubkey)
 208  	if err != nil {
 209  		logger.Logger.WithError(err).Error("Failed to create transaction")
 210  		return nil, err
 211  	}
 212  
 213  	var preimage *string
 214  	if lnClientTransaction.Preimage != "" {
 215  		preimage = &lnClientTransaction.Preimage
 216  	}
 217  
 218  	var expiresAt *time.Time
 219  	if lnClientTransaction.ExpiresAt != nil {
 220  		expiresAtValue := time.Unix(*lnClientTransaction.ExpiresAt, 0)
 221  		expiresAt = &expiresAtValue
 222  	}
 223  
 224  	dbTransaction := db.Transaction{
 225  		AppId:           appId,
 226  		RequestEventId:  requestEventId,
 227  		Type:            lnClientTransaction.Type,
 228  		State:           constants.TRANSACTION_STATE_PENDING,
 229  		AmountMsat:      uint64(lnClientTransaction.AmountMsat),
 230  		FeeMsat:         uint64(max(lnClientTransaction.FeesPaidMsat, 0)),
 231  		Description:     description,
 232  		DescriptionHash: descriptionHash,
 233  		PaymentRequest:  lnClientTransaction.Invoice,
 234  		PaymentHash:     lnClientTransaction.PaymentHash,
 235  		ExpiresAt:       expiresAt,
 236  		Preimage:        preimage,
 237  		Metadata:        datatypes.JSON(metadataBytes),
 238  	}
 239  	err = svc.db.Create(&dbTransaction).Error
 240  	if err != nil {
 241  		logger.Logger.WithError(err).Error("Failed to create DB transaction")
 242  		return nil, err
 243  	}
 244  	return &dbTransaction, nil
 245  }
 246  
 247  func (svc *transactionsService) 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) {
 248  	var err error
 249  	var metadataBytes []byte
 250  	if metadata != nil {
 251  		metadataBytes, err = json.Marshal(metadata)
 252  		if err != nil {
 253  			logger.Logger.WithError(err).Error("Failed to serialize metadata")
 254  			return nil, err
 255  		}
 256  		if len(metadataBytes) > constants.INVOICE_METADATA_MAX_LENGTH {
 257  			return nil, fmt.Errorf("encoded invoice metadata provided is too large. Limit: %d Received: %d", constants.INVOICE_METADATA_MAX_LENGTH, len(metadataBytes))
 258  		}
 259  	}
 260  
 261  	lnClientTransaction, err := lnClient.MakeHoldInvoice(ctx, int64(amountMsat), description, descriptionHash, int64(expiry), paymentHash, minCltvExpiryDelta)
 262  	if err != nil {
 263  		logger.Logger.WithError(err).Error("Failed to create hold invoice via LN client")
 264  		return nil, err
 265  	}
 266  
 267  	var preimage *string
 268  	if lnClientTransaction.Preimage != "" {
 269  		preimage = &lnClientTransaction.Preimage
 270  	}
 271  
 272  	var expiresAt *time.Time
 273  	if lnClientTransaction.ExpiresAt != nil {
 274  		expiresAtValue := time.Unix(*lnClientTransaction.ExpiresAt, 0)
 275  		expiresAt = &expiresAtValue
 276  	}
 277  
 278  	dbTransaction := db.Transaction{
 279  		AppId:           appId,
 280  		RequestEventId:  requestEventId,
 281  		Type:            constants.TRANSACTION_TYPE_INCOMING,
 282  		State:           constants.TRANSACTION_STATE_PENDING,
 283  		AmountMsat:      uint64(lnClientTransaction.AmountMsat),
 284  		Description:     description,
 285  		DescriptionHash: descriptionHash,
 286  		PaymentRequest:  lnClientTransaction.Invoice,
 287  		PaymentHash:     lnClientTransaction.PaymentHash,
 288  		ExpiresAt:       expiresAt,
 289  		Preimage:        preimage,
 290  		Metadata:        datatypes.JSON(metadataBytes),
 291  		Hold:            true,
 292  	}
 293  	err = svc.db.Create(&dbTransaction).Error
 294  	if err != nil {
 295  		logger.Logger.WithError(err).Error("Failed to create hold invoice DB transaction")
 296  		return nil, err
 297  	}
 298  	return &dbTransaction, nil
 299  }
 300  
 301  func (svc *transactionsService) SendPaymentSync(payReq string, amountMsat *uint64, metadata map[string]interface{}, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error) {
 302  	var metadataBytes []byte
 303  	if metadata != nil {
 304  		var err error
 305  		metadataBytes, err = json.Marshal(metadata)
 306  		if err != nil {
 307  			logger.Logger.WithError(err).Error("Failed to serialize metadata")
 308  			return nil, err
 309  		}
 310  		if len(metadataBytes) > constants.INVOICE_METADATA_MAX_LENGTH {
 311  			return nil, fmt.Errorf("encoded payment metadata provided is too large. Limit: %d Received: %d", constants.INVOICE_METADATA_MAX_LENGTH, len(metadataBytes))
 312  		}
 313  	}
 314  
 315  	payReq = strings.ToLower(payReq)
 316  	paymentRequest, err := decodepay.Decodepay(payReq)
 317  	if err != nil {
 318  		logger.Logger.WithFields(logrus.Fields{
 319  			"bolt11": payReq,
 320  		}).Errorf("Failed to decode bolt11 invoice: %v", err)
 321  
 322  		return nil, err
 323  	}
 324  
 325  	if time.Now().After(time.Unix(int64(paymentRequest.CreatedAt+paymentRequest.Expiry), 0)) {
 326  		logger.Logger.WithFields(logrus.Fields{
 327  			"bolt11": payReq,
 328  			"expiry": time.Unix(int64(paymentRequest.CreatedAt+paymentRequest.Expiry), 0),
 329  		}).Errorf("this invoice has expired")
 330  
 331  		return nil, errors.New("this invoice has expired")
 332  	}
 333  
 334  	// A payment is a self-payment if we have an incoming transaction for the
 335  	// exact same invoice (i.e. it was generated by this hub).
 336  	selfPayment := false
 337  	var incomingTransaction db.Transaction
 338  	result := svc.db.Limit(1).Find(&incomingTransaction, &db.Transaction{
 339  		Type: constants.TRANSACTION_TYPE_INCOMING,
 340  		// NOTE: filter by payment hash so the payment hash index is used,
 341  		// but also match the payment request as wrapped invoices share
 342  		// the same hash but have different payment requests
 343  		PaymentHash:    paymentRequest.PaymentHash,
 344  		PaymentRequest: payReq,
 345  	})
 346  	if result.Error == nil && result.RowsAffected > 0 {
 347  		selfPayment = true
 348  	}
 349  
 350  	var dbTransaction db.Transaction
 351  
 352  	paymentAmountMsat := uint64(paymentRequest.MSatoshi)
 353  	if amountMsat != nil && paymentRequest.MSatoshi == 0 {
 354  		paymentAmountMsat = *amountMsat
 355  	}
 356  
 357  	err = func() error {
 358  		balanceValidationLock.Lock()
 359  		defer balanceValidationLock.Unlock()
 360  		return svc.db.Transaction(func(tx *gorm.DB) error {
 361  			var existingSettledTransaction db.Transaction
 362  			if tx.Limit(1).Find(&existingSettledTransaction, &db.Transaction{
 363  				Type:           constants.TRANSACTION_TYPE_OUTGOING,
 364  				PaymentHash:    paymentRequest.PaymentHash,
 365  				PaymentRequest: payReq,
 366  				State:          constants.TRANSACTION_STATE_SETTLED,
 367  			}).RowsAffected > 0 {
 368  				logger.Logger.WithFields(logrus.Fields{
 369  					"payment_request": payReq,
 370  					"payment_hash":    paymentRequest.PaymentHash,
 371  				}).Debug("this invoice has already been paid")
 372  				return errors.New("this invoice has already been paid")
 373  			}
 374  			if tx.Limit(1).Find(&existingSettledTransaction, &db.Transaction{
 375  				Type:           constants.TRANSACTION_TYPE_OUTGOING,
 376  				PaymentHash:    paymentRequest.PaymentHash,
 377  				PaymentRequest: payReq,
 378  				State:          constants.TRANSACTION_STATE_PENDING,
 379  			}).RowsAffected > 0 {
 380  				logger.Logger.WithFields(logrus.Fields{
 381  					"payment_request": payReq,
 382  					"payment_hash":    paymentRequest.PaymentHash,
 383  				}).Debug("this invoice is already being paid")
 384  				return errors.New("there is already a payment pending for this invoice")
 385  			}
 386  
 387  			err := svc.validateCanPay(tx, appId, paymentAmountMsat, paymentRequest.Description, selfPayment)
 388  			if err != nil {
 389  				return err
 390  			}
 391  
 392  			var expiresAt *time.Time
 393  			if paymentRequest.Expiry > 0 {
 394  				expiresAtValue := time.Now().Add(time.Duration(paymentRequest.Expiry) * time.Second)
 395  				expiresAt = &expiresAtValue
 396  			}
 397  			dbTransaction = db.Transaction{
 398  				AppId:           appId,
 399  				RequestEventId:  requestEventId,
 400  				Type:            constants.TRANSACTION_TYPE_OUTGOING,
 401  				State:           constants.TRANSACTION_STATE_PENDING,
 402  				FeeReserveMsat:  CalculateFeeReserveMsat(paymentAmountMsat),
 403  				AmountMsat:      paymentAmountMsat,
 404  				PaymentRequest:  payReq,
 405  				PaymentHash:     paymentRequest.PaymentHash,
 406  				Description:     paymentRequest.Description,
 407  				DescriptionHash: paymentRequest.DescriptionHash,
 408  				ExpiresAt:       expiresAt,
 409  				SelfPayment:     selfPayment,
 410  				Metadata:        datatypes.JSON(metadataBytes),
 411  			}
 412  			err = tx.Create(&dbTransaction).Error
 413  			return err
 414  		})
 415  	}()
 416  
 417  	if err != nil {
 418  		logger.Logger.WithFields(logrus.Fields{
 419  			"bolt11": payReq,
 420  		}).WithError(err).Error("Failed to create DB transaction")
 421  		return nil, err
 422  	}
 423  
 424  	logger.Logger.WithFields(logrus.Fields{
 425  		"app_id":           appId,
 426  		"request_event_id": requestEventId,
 427  		"amount_msat":      paymentAmountMsat,
 428  		"description":      paymentRequest.Description,
 429  		"description_hash": paymentRequest.DescriptionHash,
 430  		"expiry":           paymentRequest.Expiry,
 431  		"self_payment":     selfPayment,
 432  		"metadata":         metadata,
 433  	}).Debug("Initiating payment")
 434  
 435  	var response *lnclient.PayInvoiceResponse
 436  	if selfPayment {
 437  		response, err = svc.interceptSelfPayment(payReq, paymentRequest.PaymentHash, lnClient)
 438  	} else {
 439  		response, err = lnClient.SendPaymentSync(payReq, amountMsat)
 440  	}
 441  
 442  	if err != nil {
 443  		logger.Logger.WithFields(logrus.Fields{
 444  			"bolt11": payReq,
 445  		}).WithError(err).Error("Failed to send payment")
 446  
 447  		if _, markFailedErr := svc.markPaymentFailed(&dbTransaction, err.Error()); markFailedErr != nil {
 448  			logger.Logger.WithFields(logrus.Fields{
 449  				"bolt11": payReq,
 450  			}).WithError(markFailedErr).Error("Failed to mark payment as failed")
 451  		}
 452  
 453  		return nil, err
 454  	}
 455  
 456  	// the payment definitely succeeded
 457  	settledTransaction, err := svc.markTransactionSettled(&dbTransaction, response.Preimage, response.FeeMsat, selfPayment)
 458  	if err != nil {
 459  		return nil, err
 460  	}
 461  
 462  	return settledTransaction, nil
 463  }
 464  
 465  func (svc *transactionsService) SendKeysend(amountMsat uint64, destination string, customRecords []lnclient.TLVRecord, preimage string, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error) {
 466  	if preimage == "" {
 467  		preImageBytes, err := makePreimageHex()
 468  		if err != nil {
 469  			return nil, err
 470  		}
 471  		preimage = hex.EncodeToString(preImageBytes)
 472  	}
 473  
 474  	preImageBytes, err := hex.DecodeString(preimage)
 475  	if err != nil || len(preImageBytes) != 32 {
 476  		logger.Logger.WithFields(logrus.Fields{
 477  			"preimage": preimage,
 478  		}).WithError(err).Error("Invalid preimage")
 479  		return nil, err
 480  	}
 481  
 482  	paymentHash256 := sha256.New()
 483  	paymentHash256.Write(preImageBytes)
 484  	paymentHashBytes := paymentHash256.Sum(nil)
 485  	paymentHash := hex.EncodeToString(paymentHashBytes)
 486  
 487  	metadata := map[string]interface{}{}
 488  
 489  	metadata["destination"] = destination
 490  
 491  	metadata["tlv_records"] = customRecords
 492  	metadataBytes, err := json.Marshal(metadata)
 493  	if err != nil {
 494  		logger.Logger.WithError(err).Error("Failed to serialize transaction metadata")
 495  		return nil, err
 496  	}
 497  	boostagramBytes := svc.getBoostagramBytesFromCustomRecords(customRecords)
 498  
 499  	var dbTransaction db.Transaction
 500  
 501  	selfPayment := destination == lnClient.GetPubkey()
 502  
 503  	err = func() error {
 504  		balanceValidationLock.Lock()
 505  		defer balanceValidationLock.Unlock()
 506  		return svc.db.Transaction(func(tx *gorm.DB) error {
 507  			err := svc.validateCanPay(tx, appId, amountMsat, "", selfPayment)
 508  			if err != nil {
 509  				return err
 510  			}
 511  
 512  			dbTransaction = db.Transaction{
 513  				AppId:          appId,
 514  				Description:    svc.getDescriptionFromCustomRecords(customRecords),
 515  				RequestEventId: requestEventId,
 516  				Type:           constants.TRANSACTION_TYPE_OUTGOING,
 517  				State:          constants.TRANSACTION_STATE_PENDING,
 518  				FeeReserveMsat: CalculateFeeReserveMsat(uint64(amountMsat)),
 519  				AmountMsat:     amountMsat,
 520  				Metadata:       datatypes.JSON(metadataBytes),
 521  				Boostagram:     datatypes.JSON(boostagramBytes),
 522  				PaymentHash:    paymentHash,
 523  				Preimage:       &preimage,
 524  				SelfPayment:    selfPayment,
 525  			}
 526  			err = tx.Create(&dbTransaction).Error
 527  
 528  			return err
 529  		})
 530  	}()
 531  
 532  	if err != nil {
 533  		logger.Logger.WithFields(logrus.Fields{
 534  			"destination": destination,
 535  			"amount_msat": amountMsat,
 536  		}).WithError(err).Error("Failed to create DB transaction")
 537  		return nil, err
 538  	}
 539  
 540  	var payKeysendResponse *lnclient.PayKeysendResponse
 541  
 542  	if selfPayment {
 543  		// for keysend self-payments we need to create an incoming payment at the time of the payment
 544  		recipientAppId := svc.getAppIdFromCustomRecords(customRecords, svc.db)
 545  		dbTransaction := db.Transaction{
 546  			AppId:          recipientAppId,
 547  			RequestEventId: nil, // it is related to this request but for a different app
 548  			Type:           constants.TRANSACTION_TYPE_INCOMING,
 549  			State:          constants.TRANSACTION_STATE_PENDING,
 550  			AmountMsat:     amountMsat,
 551  			PaymentHash:    paymentHash,
 552  			Preimage:       &preimage,
 553  			Description:    svc.getDescriptionFromCustomRecords(customRecords),
 554  			Metadata:       datatypes.JSON(metadataBytes),
 555  			Boostagram:     datatypes.JSON(boostagramBytes),
 556  			SelfPayment:    true,
 557  		}
 558  		err = svc.db.Create(&dbTransaction).Error
 559  		if err != nil {
 560  			logger.Logger.WithError(err).Error("Failed to create DB transaction")
 561  			return nil, err
 562  		}
 563  
 564  		_, err = svc.interceptSelfPayment("", paymentHash, lnClient)
 565  		if err == nil {
 566  			payKeysendResponse = &lnclient.PayKeysendResponse{
 567  				FeeMsat: 0,
 568  			}
 569  		}
 570  	} else {
 571  		payKeysendResponse, err = lnClient.SendKeysend(amountMsat, destination, customRecords, preimage)
 572  	}
 573  
 574  	if err != nil {
 575  		logger.Logger.WithFields(logrus.Fields{
 576  			"destination": destination,
 577  			"amount_msat": amountMsat,
 578  		}).WithError(err).Error("Failed to send payment")
 579  
 580  		if _, markFailedErr := svc.markPaymentFailed(&dbTransaction, err.Error()); markFailedErr != nil {
 581  			logger.Logger.WithFields(logrus.Fields{
 582  				"destination": destination,
 583  				"amount_msat": amountMsat,
 584  			}).WithError(markFailedErr).Error("Failed to mark payment as failed")
 585  		}
 586  
 587  		return nil, err
 588  	}
 589  
 590  	// the payment definitely succeeded
 591  	settledTransaction, err := svc.markTransactionSettled(&dbTransaction, preimage, payKeysendResponse.FeeMsat, selfPayment)
 592  	if err != nil {
 593  		return nil, err
 594  	}
 595  
 596  	return settledTransaction, nil
 597  }
 598  
 599  func (svc *transactionsService) LookupTransaction(ctx context.Context, paymentHash string, transactionType *string, lnClient lnclient.LNClient, appId *uint) (*Transaction, error) {
 600  	transaction := db.Transaction{}
 601  
 602  	tx := svc.db
 603  
 604  	var isIsolatedApp bool
 605  	if appId != nil {
 606  		err := svc.db.
 607  			Model(&db.App{}).
 608  			Where("id", *appId).
 609  			Pluck("isolated", &isIsolatedApp).
 610  			Error
 611  		if err != nil {
 612  			if errors.Is(err, gorm.ErrRecordNotFound) {
 613  				return nil, NewNotFoundError()
 614  			}
 615  			return nil, err
 616  		}
 617  	}
 618  
 619  	if isIsolatedApp {
 620  		tx = tx.Where("app_id = ?", *appId)
 621  	}
 622  
 623  	if transactionType != nil {
 624  		tx = tx.Where("type = ?", *transactionType)
 625  	}
 626  
 627  	// order settled first, otherwise by created date, as there can be multiple outgoing payments
 628  	// for the same payment hash (if you tried to pay an invoice multiple times - e.g. the first time failed)
 629  	result := tx.Order("settled_at desc, created_at desc").Limit(1).Find(&transaction, &db.Transaction{
 630  		// Type:        transactionType,
 631  		PaymentHash: paymentHash,
 632  	})
 633  
 634  	if result.Error != nil {
 635  		logger.Logger.WithError(result.Error).Error("Failed to lookup transaction")
 636  		return nil, result.Error
 637  	}
 638  
 639  	if result.RowsAffected == 0 {
 640  		logger.Logger.WithFields(logrus.Fields{
 641  			"payment_hash": paymentHash,
 642  			"app_id":       appId,
 643  		}).WithError(result.Error).Error("transaction not found")
 644  		return nil, NewNotFoundError()
 645  	}
 646  
 647  	if transaction.State == constants.TRANSACTION_STATE_PENDING {
 648  		svc.checkUnsettledTransaction(ctx, &transaction, lnClient)
 649  	}
 650  
 651  	return &transaction, nil
 652  }
 653  
 654  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) {
 655  	svc.checkUnsettledTransactions(ctx, lnClient)
 656  
 657  	var isIsolatedApp bool
 658  	if appId != nil {
 659  		err := svc.db.
 660  			Model(&db.App{}).
 661  			Where("id", *appId).
 662  			Pluck("isolated", &isIsolatedApp).
 663  			Error
 664  		if err != nil {
 665  			if errors.Is(err, gorm.ErrRecordNotFound) {
 666  				return nil, 0, NewNotFoundError()
 667  			}
 668  			return nil, 0, err
 669  		}
 670  	}
 671  
 672  	tx := svc.db
 673  
 674  	if isIsolatedApp || forceFilterByAppId {
 675  		tx = tx.Where("app_id = ?", *appId)
 676  	}
 677  
 678  	if !unpaidOutgoing && !unpaidIncoming {
 679  		tx = tx.Where("state = ?", constants.TRANSACTION_STATE_SETTLED)
 680  	} else if unpaidOutgoing && !unpaidIncoming {
 681  		tx = tx.Where("state = ? OR type = ?", constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_TYPE_OUTGOING)
 682  	} else if unpaidIncoming && !unpaidOutgoing {
 683  		tx = tx.Where("state = ? OR type = ?", constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_TYPE_INCOMING)
 684  	}
 685  
 686  	if filters != nil {
 687  		if filters.Type != nil {
 688  			tx = tx.Where("type = ?", *filters.Type)
 689  		}
 690  		if filters.MinAmountMsat != nil {
 691  			tx = tx.Where("amount_msat >= ?", *filters.MinAmountMsat)
 692  		}
 693  		if filters.HideFailed {
 694  			tx = tx.Where("state != ?", constants.TRANSACTION_STATE_FAILED)
 695  		}
 696  		if searchTerm := strings.ToLower(strings.TrimSpace(filters.SearchTerm)); searchTerm != "" {
 697  			likePattern := "%" + escapeLikePattern(searchTerm) + "%"
 698  			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 '\')`
 699  			if svc.db.Dialector.Name() == "postgres" {
 700  				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 '\')`
 701  			}
 702  			conditions := `LOWER(description) LIKE ? ESCAPE '\' OR ` + labelsCondition
 703  			args := []interface{}{likePattern, likePattern, likePattern}
 704  
 705  			paymentHash := ""
 706  			if paymentHashRegex.MatchString(searchTerm) {
 707  				paymentHash = searchTerm
 708  			} else if strings.HasPrefix(searchTerm, "ln") {
 709  				if paymentRequest, err := decodepay.Decodepay(searchTerm); err == nil {
 710  					paymentHash = strings.ToLower(paymentRequest.PaymentHash)
 711  				}
 712  			}
 713  			if paymentHash != "" {
 714  				conditions += " OR payment_hash = ?"
 715  				args = append(args, paymentHash)
 716  			}
 717  
 718  			tx = tx.Where(conditions, args...)
 719  		}
 720  	}
 721  
 722  	if from > 0 {
 723  		tx = tx.Where("updated_at >= ?", time.Unix(int64(from), 0))
 724  	}
 725  	if until > 0 {
 726  		tx = tx.Where("updated_at <= ?", time.Unix(int64(until), 0))
 727  	}
 728  
 729  	var totalCount64 int64
 730  	result := tx.Model(&db.Transaction{}).Count(&totalCount64)
 731  	if result.Error != nil {
 732  		logger.Logger.WithError(result.Error).Error("Failed to count DB transactions")
 733  		return nil, 0, result.Error
 734  	}
 735  	totalCount = uint64(totalCount64)
 736  
 737  	tx = tx.Order("updated_at desc")
 738  
 739  	if limit > 0 {
 740  		tx = tx.Limit(int(limit))
 741  	}
 742  	if offset > 0 {
 743  		tx = tx.Offset(int(offset))
 744  	}
 745  
 746  	result = tx.Find(&transactions)
 747  	if result.Error != nil {
 748  		logger.Logger.WithError(result.Error).Error("Failed to list DB transactions")
 749  		return nil, 0, result.Error
 750  	}
 751  
 752  	return transactions, totalCount, nil
 753  }
 754  
 755  func (svc *transactionsService) checkUnsettledTransactions(ctx context.Context, lnClient lnclient.LNClient) {
 756  	// Only check unsettled transactions for clients that don't support async events
 757  	// checkUnsettledTransactions does not work for keysend payments!
 758  	if slices.Contains(lnClient.GetSupportedNIP47NotificationTypes(), "payment_received") {
 759  		return
 760  	}
 761  
 762  	// check pending payments less than a day old
 763  	transactions := []Transaction{}
 764  	result := svc.db.Where("state = ? AND created_at > ?", constants.TRANSACTION_STATE_PENDING, time.Now().Add(-24*time.Hour)).Find(&transactions)
 765  	if result.Error != nil {
 766  		logger.Logger.WithError(result.Error).Error("Failed to list DB transactions")
 767  		return
 768  	}
 769  	for _, transaction := range transactions {
 770  		svc.checkUnsettledTransaction(ctx, &transaction, lnClient)
 771  	}
 772  }
 773  func (svc *transactionsService) checkUnsettledTransaction(ctx context.Context, transaction *db.Transaction, lnClient lnclient.LNClient) {
 774  	if slices.Contains(lnClient.GetSupportedNIP47NotificationTypes(), "payment_received") {
 775  		return
 776  	}
 777  
 778  	lnClientTransaction, err := lnClient.LookupInvoice(ctx, transaction.PaymentHash)
 779  	if err != nil {
 780  		logger.Logger.WithFields(logrus.Fields{
 781  			"bolt11": transaction.PaymentRequest,
 782  		}).WithError(err).Error("Failed to check transaction")
 783  		return
 784  	}
 785  	// update transaction state
 786  	if lnClientTransaction.SettledAt != nil {
 787  		_, err = svc.markTransactionSettled(transaction, lnClientTransaction.Preimage, uint64(lnClientTransaction.FeesPaidMsat), false)
 788  		if err != nil {
 789  			logger.Logger.WithError(err).Error("Failed to mark payment sent when checking unsettled transaction")
 790  		}
 791  	}
 792  }
 793  
 794  func (svc *transactionsService) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) {
 795  	switch event.Event {
 796  	case "nwc_lnclient_payment_received":
 797  		lnClientTransaction, ok := event.Properties.(*lnclient.Transaction)
 798  		if !ok {
 799  			logger.Logger.WithField("event", event).Error("Failed to cast event")
 800  			return
 801  		}
 802  
 803  		var dbTransaction db.Transaction
 804  		result := svc.db.Limit(1).Find(&dbTransaction, &db.Transaction{
 805  			Type:        constants.TRANSACTION_TYPE_INCOMING,
 806  			PaymentHash: lnClientTransaction.PaymentHash,
 807  		})
 808  
 809  		if result.Error != nil {
 810  			logger.Logger.WithFields(logrus.Fields{
 811  				"payment_hash": lnClientTransaction.PaymentHash,
 812  			}).WithError(result.Error).Error("Failed to find transaction")
 813  			return
 814  		}
 815  
 816  		if result.RowsAffected == 0 {
 817  			var appId *uint
 818  			description := lnClientTransaction.Description
 819  			var metadataBytes []byte
 820  			var boostagramBytes []byte
 821  			if lnClientTransaction.Metadata != nil {
 822  				var err error
 823  				metadataBytes, err = json.Marshal(lnClientTransaction.Metadata)
 824  				if err != nil {
 825  					logger.Logger.WithError(err).Error("Failed to serialize transaction metadata")
 826  					return
 827  				}
 828  
 829  				var customRecords []lnclient.TLVRecord
 830  				customRecords, _ = lnClientTransaction.Metadata["tlv_records"].([]lnclient.TLVRecord)
 831  				boostagramBytes = svc.getBoostagramBytesFromCustomRecords(customRecords)
 832  				extractedDescription := svc.getDescriptionFromCustomRecords(customRecords)
 833  				if extractedDescription != "" {
 834  					description = extractedDescription
 835  				}
 836  				// find app by custom key/value records
 837  				appId = svc.getAppIdFromCustomRecords(customRecords, svc.db)
 838  			}
 839  			var expiresAt *time.Time
 840  			if lnClientTransaction.ExpiresAt != nil {
 841  				expiresAtValue := time.Unix(*lnClientTransaction.ExpiresAt, 0)
 842  				expiresAt = &expiresAtValue
 843  			}
 844  			dbTransaction = db.Transaction{
 845  				Type:            constants.TRANSACTION_TYPE_INCOMING,
 846  				AmountMsat:      uint64(lnClientTransaction.AmountMsat),
 847  				PaymentRequest:  lnClientTransaction.Invoice,
 848  				PaymentHash:     lnClientTransaction.PaymentHash,
 849  				Description:     description,
 850  				DescriptionHash: lnClientTransaction.DescriptionHash,
 851  				ExpiresAt:       expiresAt,
 852  				Metadata:        datatypes.JSON(metadataBytes),
 853  				Boostagram:      datatypes.JSON(boostagramBytes),
 854  				AppId:           appId,
 855  			}
 856  			if _, err := svc.createSettledTransactionFromNotification(&dbTransaction, lnClientTransaction.Preimage, uint64(lnClientTransaction.FeesPaidMsat), false); err != nil {
 857  				logger.Logger.WithFields(logrus.Fields{
 858  					"payment_hash": lnClientTransaction.PaymentHash,
 859  				}).WithError(err).Error("Failed to create settled transaction")
 860  			}
 861  			return
 862  		}
 863  
 864  		if _, err := svc.markTransactionSettled(&dbTransaction, lnClientTransaction.Preimage, uint64(lnClientTransaction.FeesPaidMsat), false); err != nil {
 865  			logger.Logger.WithFields(logrus.Fields{
 866  				"payment_hash": lnClientTransaction.PaymentHash,
 867  			}).WithError(err).Error("Failed to mark transaction as settled")
 868  		}
 869  
 870  	case "nwc_lnclient_hold_invoice_accepted":
 871  		lnClientTransaction, ok := event.Properties.(*lnclient.Transaction)
 872  		if !ok {
 873  			logger.Logger.WithField("event", event).Error("Failed to cast event properties for hold invoice accepted")
 874  			return
 875  		}
 876  		if lnClientTransaction.SettleDeadline == nil {
 877  			logger.Logger.WithField("event", event).Error("Transaction has no settle deadline")
 878  			return
 879  		}
 880  		svc.markHoldInvoiceAccepted(lnClientTransaction.Invoice, lnClientTransaction.PaymentHash, *lnClientTransaction.SettleDeadline, false)
 881  
 882  	case "nwc_lnclient_payment_sent":
 883  		lnClientTransaction, ok := event.Properties.(*lnclient.Transaction)
 884  		if !ok {
 885  			logger.Logger.WithField("event", event).Error("Failed to cast event")
 886  			return
 887  		}
 888  
 889  		var dbTransaction db.Transaction
 890  
 891  		// first lookup by pending
 892  		result := svc.db.Limit(1).Find(&dbTransaction, &db.Transaction{
 893  			Type:        constants.TRANSACTION_TYPE_OUTGOING,
 894  			State:       constants.TRANSACTION_STATE_PENDING,
 895  			PaymentHash: lnClientTransaction.PaymentHash,
 896  		})
 897  
 898  		if result.Error != nil {
 899  			logger.Logger.WithFields(logrus.Fields{
 900  				"payment_hash": lnClientTransaction.PaymentHash,
 901  			}).WithError(result.Error).Error("Failed to find transaction")
 902  			return
 903  		}
 904  
 905  		if result.RowsAffected == 0 {
 906  			// if no pending payment was found, lookup by failed, latest updated first
 907  			result := svc.db.Limit(1).Order("updated_at DESC").Find(&dbTransaction, &db.Transaction{
 908  				Type:        constants.TRANSACTION_TYPE_OUTGOING,
 909  				State:       constants.TRANSACTION_STATE_FAILED,
 910  				PaymentHash: lnClientTransaction.PaymentHash,
 911  			})
 912  
 913  			if result.Error != nil {
 914  				logger.Logger.WithFields(logrus.Fields{
 915  					"payment_hash": lnClientTransaction.PaymentHash,
 916  				}).WithError(result.Error).Error("Failed to find transaction")
 917  				return
 918  			}
 919  
 920  			if result.RowsAffected == 0 {
 921  				result := svc.db.Limit(1).Find(&dbTransaction, &db.Transaction{
 922  					Type:        constants.TRANSACTION_TYPE_OUTGOING,
 923  					PaymentHash: lnClientTransaction.PaymentHash,
 924  				})
 925  
 926  				if result.Error != nil {
 927  					logger.Logger.WithFields(logrus.Fields{
 928  						"payment_hash": lnClientTransaction.PaymentHash,
 929  					}).WithError(result.Error).Error("Failed to find transaction")
 930  					return
 931  				}
 932  
 933  				if result.RowsAffected == 0 {
 934  					dbTransaction = db.Transaction{
 935  						Type:            constants.TRANSACTION_TYPE_OUTGOING,
 936  						AmountMsat:      uint64(lnClientTransaction.AmountMsat),
 937  						FeeReserveMsat:  0,
 938  						PaymentRequest:  lnClientTransaction.Invoice,
 939  						PaymentHash:     lnClientTransaction.PaymentHash,
 940  						Description:     lnClientTransaction.Description,
 941  						DescriptionHash: lnClientTransaction.DescriptionHash,
 942  					}
 943  
 944  					if lnClientTransaction.ExpiresAt != nil {
 945  						expiresAtValue := time.Unix(*lnClientTransaction.ExpiresAt, 0)
 946  						dbTransaction.ExpiresAt = &expiresAtValue
 947  					}
 948  
 949  					if _, err := svc.createSettledTransactionFromNotification(&dbTransaction, lnClientTransaction.Preimage, uint64(lnClientTransaction.FeesPaidMsat), false); err != nil {
 950  						logger.Logger.WithFields(logrus.Fields{
 951  							"payment_hash": lnClientTransaction.PaymentHash,
 952  						}).WithError(err).Error("Failed to create settled transaction")
 953  					}
 954  					return
 955  				}
 956  			}
 957  		}
 958  
 959  		if _, err := svc.markTransactionSettled(&dbTransaction, lnClientTransaction.Preimage, uint64(lnClientTransaction.FeesPaidMsat), false); err != nil {
 960  			logger.Logger.WithFields(logrus.Fields{
 961  				"payment_hash": lnClientTransaction.PaymentHash,
 962  			}).WithError(err).Error("Failed to update transaction")
 963  		}
 964  	case "nwc_lnclient_payment_failed":
 965  		paymentFailedAsyncProperties, ok := event.Properties.(*lnclient.PaymentFailedEventProperties)
 966  		if !ok {
 967  			logger.Logger.WithField("event", event).Error("Failed to cast event")
 968  			return
 969  		}
 970  
 971  		lnClientTransaction := paymentFailedAsyncProperties.Transaction
 972  
 973  		var dbTransaction db.Transaction
 974  		result := svc.db.Limit(1).Find(&dbTransaction, &db.Transaction{
 975  			Type:        constants.TRANSACTION_TYPE_OUTGOING,
 976  			State:       constants.TRANSACTION_STATE_PENDING,
 977  			PaymentHash: lnClientTransaction.PaymentHash,
 978  		})
 979  
 980  		if result.RowsAffected == 0 {
 981  			logger.Logger.WithField("event", event).Error("Failed to find pending outgoing transaction by payment hash")
 982  			return
 983  		}
 984  
 985  		if _, err := svc.markPaymentFailed(&dbTransaction, paymentFailedAsyncProperties.Reason); err != nil {
 986  			logger.Logger.WithFields(logrus.Fields{
 987  				"payment_hash": lnClientTransaction.PaymentHash,
 988  			}).WithError(err).Error("Failed to mark payment as failed")
 989  		}
 990  	}
 991  }
 992  
 993  func (svc *transactionsService) markHoldInvoiceAccepted(paymentRequest string, paymentHash string, settleDeadline uint32, selfPayment bool) {
 994  	logger.Logger.WithFields(logrus.Fields{
 995  		"payment_request": paymentRequest,
 996  		"payment_hash":    paymentHash,
 997  		"self_payment":    selfPayment,
 998  	}).Info("Processing hold invoice accepted event")
 999  
1000  	var dbTransaction db.Transaction
1001  	err := svc.db.Transaction(func(tx *gorm.DB) error {
1002  		// NOTE: filter by payment hash so the payment hash index is used,
1003  		// but also match the payment request as wrapped invoices share the same hash
1004  		result := tx.Where("payment_hash = ? AND payment_request = ? AND type = ? AND state = ?", paymentHash, paymentRequest, constants.TRANSACTION_TYPE_INCOMING, constants.TRANSACTION_STATE_PENDING).First(&dbTransaction)
1005  		if result.Error != nil {
1006  			if errors.Is(result.Error, gorm.ErrRecordNotFound) {
1007  				logger.Logger.WithFields(logrus.Fields{
1008  					"payment_request": paymentRequest,
1009  					"payment_hash":    paymentHash,
1010  				}).Warn("No corresponding pending incoming transaction found in DB for accepted hold invoice")
1011  			}
1012  			logger.Logger.WithFields(logrus.Fields{
1013  				"payment_request": paymentRequest,
1014  				"payment_hash":    paymentHash,
1015  			}).WithError(result.Error).Error("Failed to query DB for accepted hold invoice")
1016  			return result.Error
1017  		}
1018  
1019  		err := tx.Model(&dbTransaction).UpdateColumns(map[string]interface{}{
1020  			"state":           constants.TRANSACTION_STATE_ACCEPTED,
1021  			"self_payment":    selfPayment,
1022  			"settle_deadline": settleDeadline,
1023  		}).Error
1024  		if err != nil {
1025  			logger.Logger.WithFields(logrus.Fields{
1026  				"payment_request": paymentRequest,
1027  				"payment_hash":    paymentHash,
1028  				"id":              dbTransaction.ID,
1029  			}).WithError(err).Error("Failed to update hold invoice state to accepted in DB")
1030  			return err
1031  		}
1032  
1033  		logger.Logger.WithFields(logrus.Fields{
1034  			"payment_request": paymentRequest,
1035  			"payment_hash":    paymentHash,
1036  			"id":              dbTransaction.ID,
1037  		}).Info("Updated hold invoice state to accepted in DB")
1038  
1039  		return nil
1040  	})
1041  	if err != nil {
1042  		logger.Logger.WithFields(logrus.Fields{
1043  			"payment_request": paymentRequest,
1044  			"payment_hash":    paymentHash,
1045  			"id":              dbTransaction.ID,
1046  		}).WithError(err).Error("Failed DB transaction for hold invoice accepted event")
1047  	} else {
1048  		svc.eventPublisher.Publish(&events.Event{
1049  			Event:      "nwc_hold_invoice_accepted",
1050  			Properties: &dbTransaction,
1051  		})
1052  	}
1053  }
1054  
1055  func (svc *transactionsService) interceptSelfPayment(paymentRequest string, paymentHash string, lnClient lnclient.LNClient) (*lnclient.PayInvoiceResponse, error) {
1056  	logger.Logger.WithFields(logrus.Fields{
1057  		"payment_request": paymentRequest,
1058  		"payment_hash":    paymentHash,
1059  	}).Debug("Intercepting self payment")
1060  	incomingTransaction := db.Transaction{}
1061  	result := svc.db.Limit(1).Find(&incomingTransaction, &db.Transaction{
1062  		Type:  constants.TRANSACTION_TYPE_INCOMING,
1063  		State: constants.TRANSACTION_STATE_PENDING,
1064  		// NOTE: for keysend, payment request will be ""
1065  		PaymentRequest: paymentRequest,
1066  		PaymentHash:    paymentHash,
1067  	})
1068  	if result.Error != nil {
1069  		return nil, result.Error
1070  	}
1071  
1072  	if result.RowsAffected == 0 {
1073  		return nil, NewNotFoundError()
1074  	}
1075  
1076  	if incomingTransaction.Hold {
1077  		return svc.interceptSelfHoldPayment(paymentRequest, paymentHash, lnClient)
1078  	}
1079  
1080  	if incomingTransaction.Preimage == nil {
1081  		return nil, errors.New("preimage is not set on transaction. Self payments not supported")
1082  	}
1083  
1084  	_, err := svc.markTransactionSettled(&incomingTransaction, *incomingTransaction.Preimage, uint64(0), true)
1085  	if err != nil {
1086  		return nil, err
1087  	}
1088  
1089  	return &lnclient.PayInvoiceResponse{
1090  		Preimage: *incomingTransaction.Preimage,
1091  		FeeMsat:  0,
1092  	}, nil
1093  }
1094  
1095  func (svc *transactionsService) interceptSelfHoldPayment(paymentRequest string, paymentHash string, lnClient lnclient.LNClient) (*lnclient.PayInvoiceResponse, error) {
1096  	settledChannel := make(chan *db.Transaction)
1097  	canceledChannel := make(chan *db.Transaction)
1098  
1099  	holdInvoiceUpdatedConsumer := newHoldInvoiceUpdatedConsumer(paymentRequest, settledChannel, canceledChannel)
1100  
1101  	svc.eventPublisher.RegisterSubscriber(holdInvoiceUpdatedConsumer)
1102  	defer svc.eventPublisher.RemoveSubscriber(holdInvoiceUpdatedConsumer)
1103  
1104  	clientInfo, err := lnClient.GetInfo(context.Background())
1105  	if err != nil {
1106  		return nil, errors.New("failed to get client info")
1107  	}
1108  	if clientInfo.BlockHeight == 0 {
1109  		return nil, errors.New("invalid client block height")
1110  	}
1111  
1112  	fakeSettleDeadline := clientInfo.BlockHeight + 24
1113  
1114  	svc.markHoldInvoiceAccepted(paymentRequest, paymentHash, fakeSettleDeadline, true)
1115  
1116  	select {
1117  	case settledTransaction := <-settledChannel:
1118  		logger.Logger.WithField("settled_transaction", settledTransaction).Info("self hold payment was settled")
1119  		if settledTransaction.Preimage == nil {
1120  			return nil, errors.New("preimage is not set on self hold payment")
1121  		}
1122  
1123  		return &lnclient.PayInvoiceResponse{
1124  			Preimage: *settledTransaction.Preimage,
1125  			FeeMsat:  0,
1126  		}, nil
1127  	case canceledTransaction := <-canceledChannel:
1128  		logger.Logger.WithField("canceled_transaction", canceledTransaction).Info("self hold payment was canceled")
1129  		return nil, lnclient.NewHoldInvoiceCanceledError()
1130  	}
1131  }
1132  
1133  func (svc *transactionsService) validateCanPay(tx *gorm.DB, appId *uint, amountMsat uint64, description string, selfPayment bool) error {
1134  	amountWithFeeReserveMsat := amountMsat
1135  	if !selfPayment {
1136  		amountWithFeeReserveMsat += CalculateFeeReserveMsat(amountMsat)
1137  	}
1138  
1139  	// ensure balance for isolated apps
1140  	if appId != nil {
1141  		var app db.App
1142  		result := tx.Limit(1).Find(&app, &db.App{
1143  			ID: *appId,
1144  		})
1145  		if result.RowsAffected == 0 {
1146  			return NewNotFoundError()
1147  		}
1148  
1149  		var appPermission db.AppPermission
1150  		result = tx.Limit(1).Find(&appPermission, &db.AppPermission{
1151  			AppId: *appId,
1152  			Scope: constants.PAY_INVOICE_SCOPE,
1153  		})
1154  		if result.RowsAffected == 0 {
1155  			return errors.New("app does not have pay_invoice scope")
1156  		}
1157  
1158  		if app.Isolated {
1159  			balanceMsat, err := queries.GetIsolatedBalanceMsat(tx, appPermission.AppId)
1160  			if err != nil {
1161  				return fmt.Errorf("failed to calculate isolated balance for app: %w", err)
1162  			}
1163  
1164  			if int64(amountWithFeeReserveMsat) > balanceMsat {
1165  				logger.Logger.WithFields(logrus.Fields{
1166  					"balance_msat":                 balanceMsat,
1167  					"self_payment":                 selfPayment,
1168  					"amount_msat":                  amountMsat,
1169  					"amount_with_fee_reserve_msat": amountWithFeeReserveMsat,
1170  				}).Debug("Insufficient budget to make payment from isolated app")
1171  				message := NewInsufficientBalanceError().Error()
1172  				if description != "" {
1173  					message += " " + description
1174  				}
1175  
1176  				svc.eventPublisher.Publish(&events.Event{
1177  					Event: "nwc_permission_denied",
1178  					Properties: map[string]interface{}{
1179  						"app_name": app.Name,
1180  						"code":     constants.ERROR_INSUFFICIENT_BALANCE,
1181  						"message":  message,
1182  					},
1183  				})
1184  				return NewInsufficientBalanceError()
1185  			}
1186  		}
1187  
1188  		if appPermission.MaxAmountSat > 0 {
1189  			budgetUsageMsat, err := queries.GetBudgetUsageMsat(tx, &appPermission)
1190  			if err != nil {
1191  				return fmt.Errorf("failed to calculate budget usage for app: %w", err)
1192  			}
1193  			if int(amountWithFeeReserveMsat/1000) > appPermission.MaxAmountSat-int(budgetUsageMsat/1000) {
1194  				message := NewQuotaExceededError().Error()
1195  				if description != "" {
1196  					message += " " + description
1197  				}
1198  				svc.eventPublisher.Publish(&events.Event{
1199  					Event: "nwc_permission_denied",
1200  					Properties: map[string]interface{}{
1201  						"app_name": app.Name,
1202  						"code":     constants.ERROR_QUOTA_EXCEEDED,
1203  						"message":  message,
1204  					},
1205  				})
1206  				return NewQuotaExceededError()
1207  			}
1208  		}
1209  	}
1210  
1211  	return nil
1212  }
1213  
1214  // max of 1% or 10000 millisats (10 sats)
1215  func CalculateFeeReserveMsat(amountMsat uint64) uint64 {
1216  	return uint64(math.Max(math.Ceil(float64(amountMsat)*0.01), 10000))
1217  }
1218  
1219  func makePreimageHex() ([]byte, error) {
1220  	bytes := make([]byte, 32) // 32 bytes * 8 bits/byte = 256 bits
1221  	_, err := rand.Read(bytes)
1222  	if err != nil {
1223  		return nil, err
1224  	}
1225  	return bytes, nil
1226  }
1227  
1228  func (svc *transactionsService) getBoostagramBytesFromCustomRecords(customRecords []lnclient.TLVRecord) []byte {
1229  	for _, record := range customRecords {
1230  		if record.Type == BoostagramTlvType {
1231  			bytes, err := hex.DecodeString(record.Value)
1232  			if err != nil {
1233  				logger.Logger.WithField("value", record.Value).WithError(err).Error("failed to decode boostagram tlv hex value")
1234  				return nil
1235  			}
1236  
1237  			// ensure the boostagram is valid json
1238  			var boostagram Boostagram
1239  			if err := json.Unmarshal(bytes, &boostagram); err != nil {
1240  				logger.Logger.WithField("value", string(bytes)).WithError(err).Error("failed to unmarshal boostagram to json")
1241  				return nil
1242  			}
1243  
1244  			return bytes
1245  		}
1246  	}
1247  
1248  	return nil
1249  }
1250  
1251  func (svc *transactionsService) getDescriptionFromCustomRecords(customRecords []lnclient.TLVRecord) string {
1252  	var description string
1253  
1254  	for _, record := range customRecords {
1255  		switch record.Type {
1256  		case BoostagramTlvType:
1257  			bytes, err := hex.DecodeString(record.Value)
1258  			if err != nil {
1259  				continue
1260  			}
1261  			var boostagram Boostagram
1262  			if err := json.Unmarshal(bytes, &boostagram); err != nil {
1263  				continue
1264  			}
1265  			return boostagram.Message
1266  
1267  		// TODO: consider adding support for this in LDK
1268  		case WhatsatTlvType:
1269  			bytes, err := hex.DecodeString(record.Value)
1270  			if err == nil {
1271  				description = string(bytes)
1272  			}
1273  		}
1274  	}
1275  
1276  	return description
1277  }
1278  
1279  func (svc *transactionsService) getAppIdFromCustomRecords(customRecords []lnclient.TLVRecord, tx *gorm.DB) *uint {
1280  	app := db.App{}
1281  	for _, record := range customRecords {
1282  		if record.Type == CustomKeyTlvType {
1283  			decodedString, err := hex.DecodeString(record.Value)
1284  			if err != nil {
1285  				logger.Logger.WithError(err).Error("Failed to parse custom key TLV record as hex")
1286  				continue
1287  			}
1288  			customValue, err := strconv.ParseUint(string(decodedString), 10, 64)
1289  			if err != nil {
1290  				logger.Logger.WithError(err).Error("Failed to parse custom key TLV record as number")
1291  				continue
1292  			}
1293  			err = tx.Take(&app, &db.App{
1294  				ID: uint(customValue),
1295  			}).Error
1296  			if err != nil {
1297  				logger.Logger.WithError(err).Error("Failed to find app by id from custom key TLV record")
1298  				continue
1299  			}
1300  			return &app.ID
1301  		}
1302  	}
1303  	return nil
1304  }
1305  
1306  func (svc *transactionsService) SettleHoldInvoice(ctx context.Context, preimage string, lnClient lnclient.LNClient) (*Transaction, error) {
1307  	if len(preimage) != 64 {
1308  		return nil, errors.New("invalid preimage format")
1309  	}
1310  	preimageBytes, err := hex.DecodeString(preimage)
1311  	if err != nil {
1312  		return nil, fmt.Errorf("invalid preimage hex: %w", err)
1313  	}
1314  
1315  	paymentHashBytes := sha256.Sum256(preimageBytes)
1316  	paymentHash := hex.EncodeToString(paymentHashBytes[:])
1317  
1318  	var dbTransaction db.Transaction
1319  	result := svc.db.Limit(1).Find(&dbTransaction, &db.Transaction{
1320  		Type:        constants.TRANSACTION_TYPE_INCOMING,
1321  		State:       constants.TRANSACTION_STATE_ACCEPTED,
1322  		PaymentHash: paymentHash,
1323  	})
1324  
1325  	if result.RowsAffected == 0 {
1326  		logger.Logger.WithField("payment_hash", paymentHash).Error("Failed to find accepted hold invoice")
1327  		return nil, errors.New("failed to find accepted hold invoice")
1328  	}
1329  
1330  	if !dbTransaction.SelfPayment {
1331  		err = lnClient.SettleHoldInvoice(ctx, preimage)
1332  	}
1333  
1334  	if err != nil {
1335  		logger.Logger.WithFields(logrus.Fields{
1336  			"preimage": preimage,
1337  		}).WithError(err).Error("Failed to settle hold invoice via LN client")
1338  		// Don't mark DB as failed here, as the settle might succeed later or might have already succeeded.
1339  		return nil, err
1340  	}
1341  
1342  	settledTransaction, err := svc.markTransactionSettled(&dbTransaction, preimage, 0, dbTransaction.SelfPayment)
1343  	if err != nil {
1344  		logger.Logger.WithFields(logrus.Fields{
1345  			"payment_hash": paymentHash,
1346  			"preimage":     preimage,
1347  		}).WithError(err).Error("Failed to mark hold invoice as settled")
1348  		return nil, err
1349  	}
1350  
1351  	return settledTransaction, nil
1352  }
1353  
1354  func (svc *transactionsService) CancelHoldInvoice(ctx context.Context, paymentHash string, lnClient lnclient.LNClient) error {
1355  
1356  	var dbTransaction db.Transaction
1357  	result := svc.db.Limit(1).Find(&dbTransaction, &db.Transaction{
1358  		Type:        constants.TRANSACTION_TYPE_INCOMING,
1359  		State:       constants.TRANSACTION_STATE_ACCEPTED,
1360  		PaymentHash: paymentHash,
1361  	})
1362  
1363  	if result.RowsAffected == 0 {
1364  		logger.Logger.WithField("payment_hash", paymentHash).Error("Failed to find accepted hold invoice")
1365  		return NewNotFoundError()
1366  	}
1367  
1368  	if !dbTransaction.SelfPayment {
1369  		err := lnClient.CancelHoldInvoice(ctx, paymentHash)
1370  		if err != nil {
1371  			logger.Logger.WithFields(logrus.Fields{
1372  				"payment_hash": paymentHash,
1373  			}).WithError(err).Error("Failed to cancel hold invoice via LN client")
1374  			// Don't mark DB as failed here, cancellation might have already happened or might succeed later.
1375  			return err
1376  		}
1377  	}
1378  
1379  	markedFailed, err := svc.markPaymentFailed(&dbTransaction, "Hold invoice was cancelled")
1380  	if err != nil {
1381  		logger.Logger.WithFields(logrus.Fields{
1382  			"payment_hash": paymentHash,
1383  		}).WithError(err).Error("Failed to mark hold invoice as failed due to cancellation")
1384  		return err
1385  	}
1386  
1387  	if !markedFailed {
1388  		// a concurrent cancellation already marked the invoice as failed and
1389  		// published the canceled event
1390  		logger.Logger.WithFields(logrus.Fields{
1391  			"payment_hash": paymentHash,
1392  		}).Info("Hold invoice was already marked as failed")
1393  		return nil
1394  	}
1395  
1396  	logger.Logger.WithFields(logrus.Fields{
1397  		"payment_hash": paymentHash,
1398  	}).Info("Marked hold invoice as failed in DB due to cancellation")
1399  
1400  	svc.eventPublisher.Publish(&events.Event{
1401  		Event:      "nwc_hold_invoice_canceled",
1402  		Properties: &dbTransaction,
1403  	})
1404  
1405  	return nil
1406  }
1407  
1408  func (svc *transactionsService) SetTransactionMetadata(ctx context.Context, id uint, metadata map[string]interface{}) error {
1409  	var metadataBytes []byte
1410  	metadataBytes, err := json.Marshal(metadata)
1411  	if err != nil {
1412  		logger.Logger.WithError(err).Error("Failed to serialize metadata")
1413  		return err
1414  	}
1415  	if len(metadataBytes) > constants.INVOICE_METADATA_MAX_LENGTH {
1416  		return fmt.Errorf("encoded invoice metadata provided is too large. Limit: %d Received: %d", constants.INVOICE_METADATA_MAX_LENGTH, len(metadataBytes))
1417  	}
1418  
1419  	// UpdateColumn so we don't bump updated_at — metadata edits (e.g. user
1420  	// labels) shouldn't reorder the transaction in the list.
1421  	err = svc.db.Model(&db.Transaction{}).Where("id", id).UpdateColumn("metadata", datatypes.JSON(metadataBytes)).Error
1422  	if err != nil {
1423  		logger.Logger.WithError(err).WithField("metadata", metadata).Error("Failed to update transaction metadata")
1424  		return err
1425  	}
1426  
1427  	return nil
1428  }
1429  
1430  func (svc *transactionsService) SetTransactionUserLabels(ctx context.Context, id uint, labels map[string]string) error {
1431  	transaction := db.Transaction{}
1432  	err := svc.db.WithContext(ctx).First(&transaction, id).Error
1433  
1434  	if err != nil {
1435  		if errors.Is(err, gorm.ErrRecordNotFound) {
1436  			return NewNotFoundError()
1437  		}
1438  		return err
1439  	}
1440  
1441  	metadata := map[string]interface{}{}
1442  	if transaction.Metadata != nil {
1443  		if err := json.Unmarshal(transaction.Metadata, &metadata); err != nil {
1444  			return fmt.Errorf("failed to decode existing metadata: %w", err)
1445  		}
1446  	}
1447  
1448  	sanitizedLabels := map[string]string{}
1449  	for key, value := range labels {
1450  		normalizedKey := strings.TrimSpace(key)
1451  		normalizedValue := strings.TrimSpace(value)
1452  		if normalizedKey == "" || normalizedValue == "" {
1453  			continue
1454  		}
1455  		sanitizedLabels[normalizedKey] = normalizedValue
1456  	}
1457  
1458  	if len(sanitizedLabels) == 0 {
1459  		delete(metadata, "user_labels")
1460  	} else {
1461  		metadata["user_labels"] = sanitizedLabels
1462  	}
1463  
1464  	return svc.SetTransactionMetadata(ctx, id, metadata)
1465  }
1466  
1467  // markTransactionSettled marks an existing transaction as settled in its own
1468  // database transaction and publishes the corresponding events after it
1469  // commits, so subscribers never observe uncommitted state.
1470  func (svc *transactionsService) markTransactionSettled(dbTransaction *db.Transaction, preimage string, feeMsat uint64, selfPayment bool) (*db.Transaction, error) {
1471  	if preimage == "" {
1472  		return nil, errors.New("no preimage in payment")
1473  	}
1474  
1475  	var settledTransaction *db.Transaction
1476  	var eventsToPublish []*events.Event
1477  	err := svc.db.Transaction(func(tx *gorm.DB) error {
1478  		existingSettledTransaction, err := svc.findSettledTransaction(tx, dbTransaction)
1479  		if err != nil {
1480  			return err
1481  		}
1482  		if existingSettledTransaction != nil {
1483  			logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Debug("payment already marked as sent")
1484  			settledTransaction = existingSettledTransaction
1485  			return nil
1486  		}
1487  
1488  		settledAt := time.Now()
1489  		err = tx.Model(dbTransaction).Updates(map[string]interface{}{
1490  			"State":          constants.TRANSACTION_STATE_SETTLED,
1491  			"Preimage":       &preimage,
1492  			"FeeMsat":        feeMsat,
1493  			"FeeReserveMsat": 0,
1494  			"SettledAt":      &settledAt,
1495  			"SelfPayment":    selfPayment,
1496  		}).Error
1497  		if err != nil {
1498  			logger.Logger.WithFields(logrus.Fields{
1499  				"payment_hash": dbTransaction.PaymentHash,
1500  			}).WithError(err).Error("Failed to update DB transaction")
1501  			return err
1502  		}
1503  
1504  		logger.Logger.WithFields(logrus.Fields{
1505  			"payment_hash": dbTransaction.PaymentHash,
1506  			"type":         dbTransaction.Type,
1507  		}).Info("Marked transaction as settled")
1508  
1509  		settledTransaction = dbTransaction
1510  		eventsToPublish = svc.afterTransactionSettled(tx, dbTransaction, &settledAt)
1511  		return nil
1512  	})
1513  	if err != nil {
1514  		return nil, err
1515  	}
1516  	svc.publishEvents(eventsToPublish)
1517  
1518  	return settledTransaction, nil
1519  }
1520  
1521  // createSettledTransactionFromNotification inserts a transaction directly in
1522  // its settled state, in its own database transaction, and publishes the
1523  // corresponding events after it commits. It is for the case where the
1524  // LNClient notifies us of a sent or received payment we didn't already know
1525  // about (e.g. if the LNClient is an external node, and the payment was made
1526  // or received outside of Alby Hub, or a received keysend, which has no
1527  // invoice created upfront).
1528  func (svc *transactionsService) createSettledTransactionFromNotification(dbTransaction *db.Transaction, preimage string, feeMsat uint64, selfPayment bool) (*db.Transaction, error) {
1529  	if preimage == "" {
1530  		return nil, errors.New("no preimage in payment")
1531  	}
1532  
1533  	var settledTransaction *db.Transaction
1534  	var eventsToPublish []*events.Event
1535  	err := svc.db.Transaction(func(tx *gorm.DB) error {
1536  		existingSettledTransaction, err := svc.findSettledTransaction(tx, dbTransaction)
1537  		if err != nil {
1538  			return err
1539  		}
1540  		if existingSettledTransaction != nil {
1541  			logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Debug("payment already marked as settled")
1542  			settledTransaction = existingSettledTransaction
1543  			return nil
1544  		}
1545  
1546  		settledAt := time.Now()
1547  		dbTransaction.State = constants.TRANSACTION_STATE_SETTLED
1548  		dbTransaction.Preimage = &preimage
1549  		dbTransaction.FeeMsat = feeMsat
1550  		dbTransaction.FeeReserveMsat = 0
1551  		dbTransaction.SettledAt = &settledAt
1552  		dbTransaction.SelfPayment = selfPayment
1553  		if err := tx.Create(dbTransaction).Error; err != nil {
1554  			logger.Logger.WithFields(logrus.Fields{
1555  				"payment_hash": dbTransaction.PaymentHash,
1556  			}).WithError(err).Error("Failed to create settled DB transaction")
1557  			return err
1558  		}
1559  
1560  		logger.Logger.WithFields(logrus.Fields{
1561  			"payment_hash": dbTransaction.PaymentHash,
1562  			"type":         dbTransaction.Type,
1563  		}).Info("Created settled transaction")
1564  
1565  		settledTransaction = dbTransaction
1566  		eventsToPublish = svc.afterTransactionSettled(tx, dbTransaction, &settledAt)
1567  		return nil
1568  	})
1569  	if err != nil {
1570  		return nil, err
1571  	}
1572  	svc.publishEvents(eventsToPublish)
1573  
1574  	return settledTransaction, nil
1575  }
1576  
1577  // lockTransactionsByPaymentHash takes a row lock on all transactions with the
1578  // given payment hash on postgres, so that concurrent state changes for the
1579  // same payment serialize (in sqlite transactions are serializable by default).
1580  func (svc *transactionsService) lockTransactionsByPaymentHash(tx *gorm.DB, paymentHash string) error {
1581  	if tx.Dialector.Name() != "postgres" {
1582  		return nil
1583  	}
1584  	transactionsWithPaymentHash := []db.Transaction{}
1585  	err := tx.Where(&db.Transaction{
1586  		PaymentHash: paymentHash,
1587  	}).Clauses(clause.Locking{Strength: "UPDATE"}).Find(&transactionsWithPaymentHash).Error
1588  	if err != nil {
1589  		logger.Logger.WithField("payment_hash", paymentHash).WithError(err).Error("Failed to lock transactions by payment hash")
1590  	}
1591  	return err
1592  }
1593  
1594  // findSettledTransaction returns the already-settled transaction matching
1595  // dbTransaction if one exists, locking all transactions with the same payment
1596  // hash to ensure only one transaction is settled per payment.
1597  func (svc *transactionsService) findSettledTransaction(tx *gorm.DB, dbTransaction *db.Transaction) (*db.Transaction, error) {
1598  	if err := svc.lockTransactionsByPaymentHash(tx, dbTransaction.PaymentHash); err != nil {
1599  		return nil, err
1600  	}
1601  
1602  	var existingSettledTransaction db.Transaction
1603  	result := tx.Limit(1).Find(&existingSettledTransaction, &db.Transaction{
1604  		Type:           dbTransaction.Type,
1605  		PaymentRequest: dbTransaction.PaymentRequest,
1606  		PaymentHash:    dbTransaction.PaymentHash,
1607  		State:          constants.TRANSACTION_STATE_SETTLED,
1608  	})
1609  	if result.Error != nil {
1610  		logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).WithError(result.Error).Error("Failed to check for existing settled transaction")
1611  		return nil, result.Error
1612  	}
1613  	if result.RowsAffected > 0 {
1614  		return &existingSettledTransaction, nil
1615  	}
1616  	return nil, nil
1617  }
1618  
1619  // afterTransactionSettled runs the post-settlement side effects within the
1620  // caller's database transaction and returns the events to publish after it
1621  // commits.
1622  func (svc *transactionsService) afterTransactionSettled(tx *gorm.DB, dbTransaction *db.Transaction, settledAt *time.Time) []*events.Event {
1623  	event := "nwc_payment_sent"
1624  	if dbTransaction.Type == constants.TRANSACTION_TYPE_INCOMING {
1625  		event = "nwc_payment_received"
1626  	}
1627  
1628  	eventsToPublish := []*events.Event{{
1629  		Event:      event,
1630  		Properties: dbTransaction,
1631  	}}
1632  
1633  	if dbTransaction.AppId != nil {
1634  		var app db.App
1635  		result := tx.Limit(1).Find(&app, &db.App{
1636  			ID: *dbTransaction.AppId,
1637  		})
1638  		if result.RowsAffected == 0 {
1639  			logger.Logger.WithField("app_id", dbTransaction.AppId).Error("failed to find app by id")
1640  			return eventsToPublish
1641  		}
1642  
1643  		svc.updateAppLastSettledTransactionAt(&app, tx, settledAt)
1644  
1645  		if dbTransaction.Type == constants.TRANSACTION_TYPE_OUTGOING {
1646  			if budgetWarningEvent := svc.checkBudgetUsage(&app, dbTransaction, tx); budgetWarningEvent != nil {
1647  				eventsToPublish = append(eventsToPublish, budgetWarningEvent)
1648  			}
1649  		}
1650  	}
1651  
1652  	return eventsToPublish
1653  }
1654  
1655  func (svc *transactionsService) publishEvents(eventsToPublish []*events.Event) {
1656  	for _, event := range eventsToPublish {
1657  		svc.eventPublisher.Publish(event)
1658  	}
1659  }
1660  
1661  func (svc *transactionsService) updateAppLastSettledTransactionAt(app *db.App, gormTransaction *gorm.DB, settledAt *time.Time) {
1662  	if err := gormTransaction.Model(app).Update("last_settled_transaction_at", settledAt).Error; err != nil {
1663  		logger.Logger.WithField("app_id", app.ID).WithError(err).Error("failed to update app last settled transaction time")
1664  		return
1665  	}
1666  }
1667  
1668  // checkBudgetUsage returns a budget warning event to publish after the
1669  // caller's database transaction commits, or nil if no warning is due.
1670  func (svc *transactionsService) checkBudgetUsage(app *db.App, dbTransaction *db.Transaction, gormTransaction *gorm.DB) *events.Event {
1671  	if app.Isolated {
1672  		return nil
1673  	}
1674  
1675  	var appPermission db.AppPermission
1676  	result := gormTransaction.Limit(1).Find(&appPermission, &db.AppPermission{
1677  		AppId: app.ID,
1678  		Scope: constants.PAY_INVOICE_SCOPE,
1679  	})
1680  	if result.RowsAffected == 0 {
1681  		logger.Logger.WithField("app_id", dbTransaction.AppId).Error("failed to find pay_invoice scope")
1682  		return nil
1683  	}
1684  
1685  	budgetUsageMsat, err := queries.GetBudgetUsageMsat(gormTransaction, &appPermission)
1686  	if err != nil {
1687  		logger.Logger.WithField("app_id", dbTransaction.AppId).WithError(err).Error("failed to get budget usage")
1688  		return nil
1689  	}
1690  	budgetUsageSat := budgetUsageMsat / 1000
1691  	warningUsage := uint64(math.Floor(float64(appPermission.MaxAmountSat) * 0.8))
1692  	if budgetUsageSat >= warningUsage && budgetUsageSat-dbTransaction.AmountMsat/1000 < warningUsage {
1693  		return &events.Event{
1694  			Event: "nwc_budget_warning",
1695  			Properties: map[string]interface{}{
1696  				"name": app.Name,
1697  				"id":   app.ID,
1698  			},
1699  		}
1700  	}
1701  	return nil
1702  }
1703  
1704  // markPaymentFailed marks the transaction as failed in its own database
1705  // transaction and publishes the failed event after it commits, so subscribers
1706  // never observe uncommitted state. It returns whether this call transitioned
1707  // the transaction to failed (false if it was already failed), and refuses to
1708  // mark a settled transaction as failed.
1709  func (svc *transactionsService) markPaymentFailed(dbTransaction *db.Transaction, reason string) (bool, error) {
1710  	markedFailed := false
1711  	var eventsToPublish []*events.Event
1712  	err := svc.db.Transaction(func(tx *gorm.DB) error {
1713  		// lock all transactions with the same payment hash so a concurrent
1714  		// settlement cannot slip in between the state check and the update
1715  		if err := svc.lockTransactionsByPaymentHash(tx, dbTransaction.PaymentHash); err != nil {
1716  			return err
1717  		}
1718  
1719  		var existingTransaction db.Transaction
1720  		result := tx.Limit(1).Find(&existingTransaction, &db.Transaction{
1721  			ID: dbTransaction.ID,
1722  		})
1723  
1724  		if result.Error != nil {
1725  			logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).WithError(result.Error).Error("could not find transaction to mark as failed")
1726  			return result.Error
1727  		}
1728  
1729  		if result.RowsAffected == 0 {
1730  			logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Error("could not find transaction to mark as failed")
1731  			return NewNotFoundError()
1732  		}
1733  
1734  		if existingTransaction.State == constants.TRANSACTION_STATE_FAILED {
1735  			logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Info("payment already marked as failed")
1736  			return nil
1737  		}
1738  
1739  		if existingTransaction.State == constants.TRANSACTION_STATE_SETTLED {
1740  			logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Error("cannot mark settled payment as failed")
1741  			return errors.New("cannot mark settled payment as failed")
1742  		}
1743  
1744  		err := tx.Model(dbTransaction).Updates(map[string]interface{}{
1745  			"State":          constants.TRANSACTION_STATE_FAILED,
1746  			"FeeReserveMsat": 0,
1747  			"FailureReason":  reason,
1748  		}).Error
1749  		if err != nil {
1750  			logger.Logger.WithFields(logrus.Fields{
1751  				"payment_hash": dbTransaction.PaymentHash,
1752  			}).WithError(err).Error("Failed to mark transaction as failed")
1753  			return err
1754  		}
1755  		logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Info("Marked transaction as failed")
1756  
1757  		markedFailed = true
1758  		eventsToPublish = append(eventsToPublish, &events.Event{
1759  			Event:      "nwc_payment_failed",
1760  			Properties: dbTransaction,
1761  		})
1762  		return nil
1763  	})
1764  	if err != nil {
1765  		return false, err
1766  	}
1767  	svc.publishEvents(eventsToPublish)
1768  	return markedFailed, nil
1769  }
1770