Add support for service fees (#474)

* Add support for service fees

This introduces a new transaction type "service_fee"
Each outgoing payment is charged a service fee of x/1000.
The service fee entries is added with the routing fee reserve entry.
For failed payments the service fee is reversed.

* Add service_fee migration

* No service fee by default

* Fee reserve and service fee is optional

ignore NoRows errors

* Update Makefile

* Unify setting the fee on the invoice

* Proper error check

* ups

* Add service fee tests to outgoing payment tests

* Save parant id to fee tx entries

* Optionally load test DB from env variable

* Add config for free transactions

payment amounts up to NO_SERVICE_FEE_UP_TO_AMOUNT don't get a service fee charged

* Update readme

* cleanup

* fix: only charge service fees for amounts > free limit

* fix: format

* Save service fee on invoice

* naming

* Also save the payment hash where we save the preimage

we already do exactly that when in the normal PayInvoice flow.
Normally the RHash is already set here, but I guess it does not hurt to have
it consistent everywhere.

* Use the invoice routing fee field to check if a routing fee must be charged

It feels a bit indirect to check if we have set a FeeReserve.
We save the routing fee on the invoice thus we should be able to use that information

* remove nullzero from fee columns

we want those to be 0 by default

* add columns only if not existent

---------

Co-authored-by: René Aaron <rene@twentyuno.net>
This commit is contained in:
Michael Bumann
2024-01-13 16:07:11 +02:00
committed by GitHub
parent eeb46ee76a
commit 8deabc56be
13 changed files with 299 additions and 56 deletions

View File

@@ -56,6 +56,8 @@ vim .env # edit your config
+ `MAX_ACCOUNT_BALANCE`: (default: 0 = no limit) Set maximum balance (in satoshi) for each account
+ `MAX_SEND_VOLUME`: (default: 0 = no limit) Set maximum volume (in satoshi) for sending for each account
+ `MAX_RECEIVE_VOLUME`: (default: 0 = no limit) Set maximum volume (in satoshi) for receiving for each account
+ `SERVICE_FEE`: (default: 0 = no service fee) Set the service fee for each outgoing transaction in 1/1000 (e.g. 1 means a fee of 1sat for 1000sats - rounded up to the next bigger integer)
+ `NO_SERVICE_FEE_UP_TO_AMOUNT` (default: 0 = no free transactions) the amount in sats up to which no service fee should be charged
### Macaroon

View File

@@ -0,0 +1,7 @@
alter table invoices ADD COLUMN IF NOT EXISTS service_fee bigint default 0;
alter table invoices ADD COLUMN IF NOT EXISTS routing_fee bigint default 0;
-- maybe manually migrate existing data?
-- alter table invoices ALTER COLUMN fee SET DEFAULT 0;
-- update invoices set fee = 0 where fee IS NULL;
-- update invoices set routing_fee = fee where routing_fee=0;

View File

@@ -14,7 +14,9 @@ type Invoice struct {
UserID int64 `json:"user_id" validate:"required"`
User *User `json:"-" bun:"rel:belongs-to,join:user_id=id"`
Amount int64 `json:"amount" validate:"gte=0"`
Fee int64 `json:"fee" bun:",nullzero"`
Fee int64 `json:"fee"`
ServiceFee int64 `json:"service_fee"`
RoutingFee int64 `json:"routing_fee"`
Memo string `json:"memo" bun:",nullzero"`
DescriptionHash string `json:"description_hash,omitempty" bun:",nullzero"`
PaymentRequest string `json:"payment_request" bun:",nullzero"`
@@ -33,6 +35,15 @@ type Invoice struct {
SettledAt bun.NullTime `json:"settled_at"`
}
func (i *Invoice) SetFee(txEntry TransactionEntry, routingFee int64) {
i.RoutingFee = routingFee
i.Fee = routingFee
if txEntry.ServiceFee != nil {
i.ServiceFee = txEntry.ServiceFee.Amount
i.Fee += txEntry.ServiceFee.Amount
}
}
func (i *Invoice) BeforeAppendModel(ctx context.Context, query bun.Query) error {
switch query.(type) {
case *bun.UpdateQuery:

View File

@@ -9,6 +9,8 @@ const (
EntryTypeOutgoing = "outgoing"
EntryTypeFee = "fee"
EntryTypeFeeReserve = "fee_reserve"
EntryTypeServiceFee = "service_fee"
EntryTypeServiceFeeReversal = "service_fee_reversal"
EntryTypeFeeReserveReversal = "fee_reserve_reversal"
EntryTypeOutgoingReversal = "outgoing_reversal"
)
@@ -24,6 +26,7 @@ type TransactionEntry struct {
Parent *TransactionEntry `bun:"rel:belongs-to"`
CreditAccountID int64 `bun:",notnull"`
FeeReserve *TransactionEntry `bun:"rel:belongs-to"`
ServiceFee *TransactionEntry `bun:"rel:belongs-to"`
CreditAccount *Account `bun:"rel:belongs-to,join:credit_account_id=id"`
DebitAccountID int64 `bun:",notnull"`
DebitAccount *Account `bun:"rel:belongs-to,join:debit_account_id=id"`

View File

@@ -78,8 +78,10 @@ func (suite *KeySendTestSuite) TearDownSuite() {
}
func (suite *KeySendTestSuite) TestKeysendPayment() {
suite.service.Config.ServiceFee = 1
aliceFundingSats := 1000
externalSatRequested := 500
expectedServiceFee := 1
//fund alice account
invoiceResponse := suite.createAddInvoiceReq(aliceFundingSats, "integration test external payment alice", suite.aliceToken)
err := suite.mlnd.mockPaidInvoice(invoiceResponse, 0, false, nil)
@@ -95,7 +97,8 @@ func (suite *KeySendTestSuite) TestKeysendPayment() {
if err != nil {
fmt.Printf("Error when getting balance %v\n", err.Error())
}
assert.Equal(suite.T(), int64(aliceFundingSats)-int64(externalSatRequested+int(suite.mlnd.fee)), aliceBalance)
assert.Equal(suite.T(), int64(aliceFundingSats)-int64(externalSatRequested+int(suite.mlnd.fee)+expectedServiceFee), aliceBalance)
suite.service.Config.ServiceFee = 0
}
func (suite *KeySendTestSuite) TestKeysendPaymentNonExistentDestination() {

View File

@@ -14,8 +14,10 @@ import (
)
func (suite *PaymentTestSuite) TestOutGoingPayment() {
suite.service.Config.ServiceFee = 1
aliceFundingSats := 1000
externalSatRequested := 500
expectedServiceFee := 1
// 1 sat + 1 ppm
suite.mlnd.fee = 1
//fund alice account
@@ -45,10 +47,10 @@ func (suite *PaymentTestSuite) TestOutGoingPayment() {
if err != nil {
fmt.Printf("Error when getting balance %v\n", err.Error())
}
assert.Equal(suite.T(), int64(aliceFundingSats)-int64(externalSatRequested+int(suite.mlnd.fee)), aliceBalance)
assert.Equal(suite.T(), int64(aliceFundingSats)-int64(externalSatRequested+int(suite.mlnd.fee)+expectedServiceFee), aliceBalance)
// check that no additional transaction entry was created
transactonEntries, err := suite.service.TransactionEntriesFor(context.Background(), userId)
transactionEntries, err := suite.service.TransactionEntriesFor(context.Background(), userId)
if err != nil {
fmt.Printf("Error when getting transaction entries %v\n", err.Error())
}
@@ -63,29 +65,56 @@ func (suite *PaymentTestSuite) TestOutGoingPayment() {
assert.Equal(suite.T(), 1, len(outgoingInvoices))
assert.Equal(suite.T(), 1, len(incomingInvoices))
assert.Equal(suite.T(), 5, len(transactonEntries))
// check if there are 6 transaction entries:
// - [0] incoming
// - [1] outgoing
// - [2] fee_reserve
// - [3] service_fee
// - [4] fee_reserve_reversal
// - [5] fee
//
assert.Equal(suite.T(), 6, len(transactionEntries))
assert.Equal(suite.T(), int64(aliceFundingSats), transactonEntries[0].Amount)
assert.Equal(suite.T(), currentAccount.ID, transactonEntries[0].CreditAccountID)
assert.Equal(suite.T(), incomingAccount.ID, transactonEntries[0].DebitAccountID)
assert.Equal(suite.T(), int64(0), transactonEntries[0].ParentID)
assert.Equal(suite.T(), incomingInvoices[0].ID, transactonEntries[0].InvoiceID)
// the incoming funding
assert.Equal(suite.T(), int64(aliceFundingSats), transactionEntries[0].Amount)
assert.Equal(suite.T(), currentAccount.ID, transactionEntries[0].CreditAccountID)
assert.Equal(suite.T(), incomingAccount.ID, transactionEntries[0].DebitAccountID)
assert.Equal(suite.T(), int64(0), transactionEntries[0].ParentID)
assert.Equal(suite.T(), incomingInvoices[0].ID, transactionEntries[0].InvoiceID)
assert.Equal(suite.T(), int64(externalSatRequested), transactonEntries[1].Amount)
assert.Equal(suite.T(), outgoingAccount.ID, transactonEntries[1].CreditAccountID)
assert.Equal(suite.T(), currentAccount.ID, transactonEntries[1].DebitAccountID)
assert.Equal(suite.T(), int64(0), transactonEntries[1].ParentID)
assert.Equal(suite.T(), outgoingInvoices[0].ID, transactonEntries[1].InvoiceID)
// the outgoing payment
assert.Equal(suite.T(), int64(externalSatRequested), transactionEntries[1].Amount)
assert.Equal(suite.T(), outgoingAccount.ID, transactionEntries[1].CreditAccountID)
assert.Equal(suite.T(), currentAccount.ID, transactionEntries[1].DebitAccountID)
assert.Equal(suite.T(), int64(0), transactionEntries[1].ParentID)
assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[1].InvoiceID)
assert.Equal(suite.T(), int64(suite.mlnd.fee), transactonEntries[4].Amount)
assert.Equal(suite.T(), feeAccount.ID, transactonEntries[2].CreditAccountID)
assert.Equal(suite.T(), currentAccount.ID, transactonEntries[2].DebitAccountID)
assert.Equal(suite.T(), outgoingInvoices[0].ID, transactonEntries[2].InvoiceID)
// fee
assert.Equal(suite.T(), int64(suite.mlnd.fee), transactionEntries[5].Amount)
assert.Equal(suite.T(), feeAccount.ID, transactionEntries[5].CreditAccountID)
assert.Equal(suite.T(), currentAccount.ID, transactionEntries[5].DebitAccountID)
assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[5].InvoiceID)
// fee reserve + fee reserve reversal
assert.Equal(suite.T(), transactionEntries[4].Amount, transactionEntries[2].Amount) // the amount of the fee_reserve and the fee_reserve_reversal must be equal
assert.Equal(suite.T(), feeAccount.ID, transactionEntries[2].CreditAccountID)
assert.Equal(suite.T(), currentAccount.ID, transactionEntries[2].DebitAccountID)
assert.Equal(suite.T(), currentAccount.ID, transactionEntries[4].CreditAccountID)
assert.Equal(suite.T(), feeAccount.ID, transactionEntries[4].DebitAccountID)
assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[2].InvoiceID)
assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[4].InvoiceID)
// service fee
assert.Equal(suite.T(), int64(expectedServiceFee), transactionEntries[3].Amount)
assert.Equal(suite.T(), feeAccount.ID, transactionEntries[3].CreditAccountID)
assert.Equal(suite.T(), currentAccount.ID, transactionEntries[3].DebitAccountID)
assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[3].InvoiceID)
// make sure fee entry parent id is previous entry
assert.Equal(suite.T(), transactonEntries[1].ID, transactonEntries[4].ParentID)
assert.Equal(suite.T(), transactionEntries[1].ID, transactionEntries[5].ParentID)
assert.Equal(suite.T(), transactionEntries[1].ID, transactionEntries[3].ParentID)
//fetch transactions, make sure the fee is there
// fetch transactions, make sure the fee is there
// check invoices again
req := httptest.NewRequest(http.MethodGet, "/gettxs", nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", suite.aliceToken))
@@ -94,7 +123,8 @@ func (suite *PaymentTestSuite) TestOutGoingPayment() {
responseBody := &[]ExpectedOutgoingInvoice{}
assert.Equal(suite.T(), http.StatusOK, rec.Code)
assert.NoError(suite.T(), json.NewDecoder(rec.Body).Decode(&responseBody))
assert.Equal(suite.T(), int64(suite.mlnd.fee), (*responseBody)[0].Fee)
assert.Equal(suite.T(), int64(suite.mlnd.fee)+int64(expectedServiceFee), (*responseBody)[0].Fee)
suite.service.Config.ServiceFee = 0 // reset ServiceFee config (we don't expect the service fee everywhere)
}
func (suite *PaymentTestSuite) TestOutGoingPaymentWithNegativeBalance() {

View File

@@ -78,8 +78,10 @@ func (suite *PaymentTestErrorsSuite) SetupSuite() {
}
func (suite *PaymentTestErrorsSuite) TestExternalFailingInvoice() {
suite.service.Config.ServiceFee = 1
userFundingSats := 1000
externalSatRequested := 500
expectedServiceFee := 1
//fund user account
invoiceResponse := suite.createAddInvoiceReq(userFundingSats, "integration test external payment user", suite.userToken)
err := suite.mlnd.mockPaidInvoice(invoiceResponse, 0, false, nil)
@@ -130,13 +132,23 @@ func (suite *PaymentTestErrorsSuite) TestExternalFailingInvoice() {
userId := getUserIdFromToken(suite.userToken)
invoices, err := invoicesFor(suite.service, userId, common.InvoiceTypeOutgoing)
// verify transaction entries data
feeAccount, _ := suite.service.AccountFor(context.Background(), common.AccountTypeFees, userId)
incomingAccount, _ := suite.service.AccountFor(context.Background(), common.AccountTypeIncoming, userId)
outgoingAccount, _ := suite.service.AccountFor(context.Background(), common.AccountTypeOutgoing, userId)
currentAccount, _ := suite.service.AccountFor(context.Background(), common.AccountTypeCurrent, userId)
outgoingInvoices, err := invoicesFor(suite.service, userId, common.InvoiceTypeOutgoing)
if err != nil {
fmt.Printf("Error when getting invoices %v\n", err.Error())
}
assert.Equal(suite.T(), 1, len(invoices))
assert.Equal(suite.T(), common.InvoiceStateError, invoices[0].State)
assert.Equal(suite.T(), SendPaymentMockError, invoices[0].ErrorMessage)
incomingInvoices, err := invoicesFor(suite.service, userId, common.InvoiceTypeIncoming)
if err != nil {
fmt.Printf("Error when getting invoices %v\n", err.Error())
}
assert.Equal(suite.T(), 1, len(outgoingInvoices))
assert.Equal(suite.T(), common.InvoiceStateError, outgoingInvoices[0].State)
assert.Equal(suite.T(), SendPaymentMockError, outgoingInvoices[0].ErrorMessage)
transactionEntries, err := suite.service.TransactionEntriesFor(context.Background(), userId)
if err != nil {
@@ -148,21 +160,69 @@ func (suite *PaymentTestErrorsSuite) TestExternalFailingInvoice() {
fmt.Printf("Error when getting balance %v\n", err.Error())
}
// check if there are 5 transaction entries:
// - the incoming payment
// - the outgoing payment
// - the fee reserve + the fee reserve reversal
// - the outgoing payment reversal
// with reversed credit and debit account ids for payment 2/5 & payment 3/4
assert.Equal(suite.T(), 5, len(transactionEntries))
assert.Equal(suite.T(), transactionEntries[1].CreditAccountID, transactionEntries[4].DebitAccountID)
assert.Equal(suite.T(), transactionEntries[1].DebitAccountID, transactionEntries[4].CreditAccountID)
assert.Equal(suite.T(), transactionEntries[2].CreditAccountID, transactionEntries[3].DebitAccountID)
assert.Equal(suite.T(), transactionEntries[2].DebitAccountID, transactionEntries[3].CreditAccountID)
// check if there are 7 transaction entries:
// - [0] incoming
// - [1] outgoing
// - [2] fee_reserve
// - [3] service_fee
// - [4] fee_reserve_reversal
// - [5] service_fee_reversal
// - [6] outgoing_reversal
//
assert.Equal(suite.T(), 7, len(transactionEntries))
// the incoming funding
assert.Equal(suite.T(), int64(userFundingSats), transactionEntries[0].Amount)
assert.Equal(suite.T(), currentAccount.ID, transactionEntries[0].CreditAccountID)
assert.Equal(suite.T(), incomingAccount.ID, transactionEntries[0].DebitAccountID)
assert.Equal(suite.T(), int64(0), transactionEntries[0].ParentID)
assert.Equal(suite.T(), incomingInvoices[0].ID, transactionEntries[0].InvoiceID)
// the outgoing payment
assert.Equal(suite.T(), int64(externalSatRequested), transactionEntries[1].Amount)
assert.Equal(suite.T(), outgoingAccount.ID, transactionEntries[1].CreditAccountID)
assert.Equal(suite.T(), currentAccount.ID, transactionEntries[1].DebitAccountID)
assert.Equal(suite.T(), int64(0), transactionEntries[1].ParentID)
assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[1].InvoiceID)
// fee reserve + fee reserve reversal
assert.Equal(suite.T(), transactionEntries[4].Amount, transactionEntries[2].Amount) // the amount of the fee_reserve and the fee_reserve_reversal must be equal
assert.Equal(suite.T(), feeAccount.ID, transactionEntries[2].CreditAccountID)
assert.Equal(suite.T(), currentAccount.ID, transactionEntries[2].DebitAccountID)
assert.Equal(suite.T(), currentAccount.ID, transactionEntries[4].CreditAccountID)
assert.Equal(suite.T(), feeAccount.ID, transactionEntries[4].DebitAccountID)
assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[2].InvoiceID)
assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[4].InvoiceID)
// service fee
assert.Equal(suite.T(), int64(expectedServiceFee), transactionEntries[3].Amount)
assert.Equal(suite.T(), feeAccount.ID, transactionEntries[3].CreditAccountID)
assert.Equal(suite.T(), currentAccount.ID, transactionEntries[3].DebitAccountID)
assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[3].InvoiceID)
// service fee reversal
assert.Equal(suite.T(), int64(expectedServiceFee), transactionEntries[5].Amount)
assert.Equal(suite.T(), currentAccount.ID, transactionEntries[5].CreditAccountID)
assert.Equal(suite.T(), feeAccount.ID, transactionEntries[5].DebitAccountID)
assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[5].InvoiceID)
// the outgoing payment reversal
assert.Equal(suite.T(), int64(externalSatRequested), transactionEntries[6].Amount)
assert.Equal(suite.T(), currentAccount.ID, transactionEntries[6].CreditAccountID)
assert.Equal(suite.T(), outgoingAccount.ID, transactionEntries[6].DebitAccountID)
assert.Equal(suite.T(), int64(0), transactionEntries[1].ParentID)
assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[6].InvoiceID)
// outgoing debit account must be the outgoing reversal credit account
assert.Equal(suite.T(), transactionEntries[1].CreditAccountID, transactionEntries[6].DebitAccountID)
assert.Equal(suite.T(), transactionEntries[1].DebitAccountID, transactionEntries[6].CreditAccountID)
// outgoing amounts and reversal amounts
assert.Equal(suite.T(), transactionEntries[1].Amount, int64(externalSatRequested))
assert.Equal(suite.T(), transactionEntries[4].Amount, int64(externalSatRequested))
assert.Equal(suite.T(), transactionEntries[6].Amount, int64(externalSatRequested))
// assert that balance is the same
assert.Equal(suite.T(), int64(userFundingSats), userBalance)
suite.service.Config.ServiceFee = 0 // reset service fee - we don't expect this everywhere
}
func (suite *PaymentTestErrorsSuite) TearDownSuite() {

View File

@@ -54,6 +54,7 @@ func (svc *LndhubService) CheckPendingOutgoingPayments(ctx context.Context, pend
func (svc *LndhubService) GetTransactionEntryByInvoiceId(ctx context.Context, id int64) (models.TransactionEntry, error) {
entry := models.TransactionEntry{}
feeReserveEntry := models.TransactionEntry{}
serviceFeeEntry := models.TransactionEntry{}
err := svc.DB.NewSelect().Model(&entry).Where("invoice_id = ? and entry_type = ?", id, models.EntryTypeOutgoing).Limit(1).Scan(ctx)
if err != nil {
@@ -70,11 +71,24 @@ func (svc *LndhubService) GetTransactionEntryByInvoiceId(ctx context.Context, id
return entry, err
}
err = svc.DB.NewSelect().Model(&feeReserveEntry).Where("invoice_id = ? and entry_type = ?", id, models.EntryTypeFeeReserve).Limit(1).Scan(ctx)
if err != nil {
// The fee reserve transaction entry is optional thus we ignore NoRow errors.
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return entry, err
}
entry.FeeReserve = &feeReserveEntry
return entry, err
if err == nil {
entry.FeeReserve = &feeReserveEntry
}
err = svc.DB.NewSelect().Model(&serviceFeeEntry).Where("invoice_id = ? and entry_type = ?", id, models.EntryTypeServiceFee).Limit(1).Scan(ctx)
// The service fee transaction entry is optional thus we ignore NoRow errors.
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return entry, err
}
if err == nil {
entry.ServiceFee = &serviceFeeEntry
}
return entry, nil
}
// Should be called in a goroutine as the tracking can potentially take a long time
@@ -123,8 +137,9 @@ func (svc *LndhubService) TrackOutgoingPaymentstatus(ctx context.Context, invoic
return
}
if payment.Status == lnrpc.Payment_SUCCEEDED {
invoice.Fee = payment.FeeSat
invoice.SetFee(entry, payment.FeeSat)
invoice.Preimage = payment.PaymentPreimage
invoice.RHash = payment.PaymentHash
svc.Logger.Infof("Completed payment detected: hash %s", payment.PaymentHash)
err = svc.HandleSuccessfulPayment(ctx, invoice, entry)
if err != nil {

View File

@@ -31,6 +31,8 @@ type Config struct {
PrometheusPort int `envconfig:"PROMETHEUS_PORT" default:"9092"`
WebhookUrl string `envconfig:"WEBHOOK_URL"`
FeeReserve bool `envconfig:"FEE_RESERVE" default:"false"`
ServiceFee int `envconfig:"SERVICE_FEE" default:"0"`
NoServiceFeeUpToAmount int `envconfig:"NO_SERVICE_FEE_UP_TO_AMOUNT" default:"0"`
AllowAccountCreation bool `envconfig:"ALLOW_ACCOUNT_CREATION" default:"true"`
MinPasswordEntropy int `envconfig:"MIN_PASSWORD_ENTROPY" default:"0"`
MaxReceiveAmount int64 `envconfig:"MAX_RECEIVE_AMOUNT" default:"0"`

View File

@@ -235,7 +235,7 @@ func (svc *LndhubService) PayInvoice(ctx context.Context, invoice *models.Invoic
// The payment was successful.
// These changes to the invoice are persisted in the `HandleSuccessfulPayment` function
invoice.Preimage = paymentResponse.PaymentPreimageStr
invoice.Fee = paymentResponse.PaymentRoute.TotalFees
invoice.SetFee(entry, paymentResponse.PaymentRoute.TotalFees)
invoice.RHash = paymentResponse.PaymentHashStr
err = svc.HandleSuccessfulPayment(context.Background(), invoice, entry)
return &paymentResponse, err
@@ -258,6 +258,13 @@ func (svc *LndhubService) HandleFailedPayment(ctx context.Context, invoice *mode
svc.Logger.Errorf("Could not revert fee reserve entry entry user_id:%v invoice_id:%v error %s", invoice.UserID, invoice.ID, err.Error())
return err
}
//revert the service fee if necessary
err = svc.RevertServiceFee(ctx, &entryToRevert, invoice, tx)
if err != nil {
sentry.CaptureException(err)
svc.Logger.Errorf("Could not revert service fee entry entry user_id:%v invoice_id:%v error %s", invoice.UserID, invoice.ID, err.Error())
return err
}
//revert the payment if necessary
entry := models.TransactionEntry{
@@ -318,8 +325,10 @@ func (svc *LndhubService) InsertTransactionEntry(ctx context.Context, invoice *m
return entry, err
}
//if external payment: add fee reserve to entry
// add fee entries (fee reserve and service fee)
feeLimit := svc.CalcFeeLimit(invoice.DestinationPubkeyHex, invoice.Amount)
serviceFee := svc.CalcServiceFee(invoice.Amount)
if feeLimit != 0 {
feeReserveEntry := models.TransactionEntry{
UserID: invoice.UserID,
@@ -328,6 +337,7 @@ func (svc *LndhubService) InsertTransactionEntry(ctx context.Context, invoice *m
DebitAccountID: debitAccount.ID,
Amount: feeLimit,
EntryType: models.EntryTypeFeeReserve,
ParentID: entry.ID,
}
_, err = tx.NewInsert().Model(&feeReserveEntry).Exec(ctx)
if err != nil {
@@ -335,6 +345,22 @@ func (svc *LndhubService) InsertTransactionEntry(ctx context.Context, invoice *m
}
entry.FeeReserve = &feeReserveEntry
}
if serviceFee != 0 {
serviceFeeEntry := models.TransactionEntry{
UserID: invoice.UserID,
InvoiceID: invoice.ID,
CreditAccountID: feeAccount.ID,
DebitAccountID: debitAccount.ID,
Amount: serviceFee,
EntryType: models.EntryTypeServiceFee,
ParentID: entry.ID,
}
_, err = tx.NewInsert().Model(&serviceFeeEntry).Exec(ctx)
if err != nil {
return entry, err
}
entry.ServiceFee = &serviceFeeEntry
}
err = tx.Commit()
if err != nil {
return entry, err
@@ -359,22 +385,35 @@ func (svc *LndhubService) RevertFeeReserve(ctx context.Context, entry *models.Tr
return nil
}
func (svc *LndhubService) AddFeeEntry(ctx context.Context, entry *models.TransactionEntry, invoice *models.Invoice, tx bun.Tx) (err error) {
if entry.FeeReserve != nil {
// add transaction entry for fee
// if there was no fee reserve then this is an internal payment
// and no fee entry is needed
// if there is a fee reserve then we must use the same account id's
entry := models.TransactionEntry{
func (svc *LndhubService) RevertServiceFee(ctx context.Context, entry *models.TransactionEntry, invoice *models.Invoice, tx bun.Tx) (err error) {
if entry.ServiceFee != nil {
entryToRevert := entry.ServiceFee
serviceFeeRevert := models.TransactionEntry{
UserID: entryToRevert.UserID,
InvoiceID: invoice.ID,
CreditAccountID: entryToRevert.DebitAccountID,
DebitAccountID: entryToRevert.CreditAccountID,
Amount: entryToRevert.Amount,
EntryType: models.EntryTypeServiceFeeReversal,
}
_, err = tx.NewInsert().Model(&serviceFeeRevert).Exec(ctx)
return err
}
return nil
}
func (svc *LndhubService) AddRoutingFeeEntry(ctx context.Context, entry *models.TransactionEntry, invoice *models.Invoice, tx bun.Tx) (err error) {
if invoice.RoutingFee != 0 {
routingFeeEntry := models.TransactionEntry{
UserID: invoice.UserID,
InvoiceID: invoice.ID,
CreditAccountID: entry.FeeReserve.CreditAccountID,
DebitAccountID: entry.FeeReserve.DebitAccountID,
Amount: int64(invoice.Fee),
Amount: int64(invoice.RoutingFee),
ParentID: entry.ID,
EntryType: models.EntryTypeFee,
}
_, err = tx.NewInsert().Model(&entry).Exec(ctx)
_, err = tx.NewInsert().Model(&routingFeeEntry).Exec(ctx)
return err
}
return nil
@@ -408,7 +447,7 @@ func (svc *LndhubService) HandleSuccessfulPayment(ctx context.Context, invoice *
}
//add the real fee entry
err = svc.AddFeeEntry(ctx, &parentEntry, invoice, tx)
err = svc.AddRoutingFeeEntry(ctx, &parentEntry, invoice, tx)
if err != nil {
tx.Rollback()
sentry.CaptureException(err)
@@ -464,7 +503,7 @@ func (svc *LndhubService) AddOutgoingInvoice(ctx context.Context, userID int64,
}
pHash := sha256.New()
pHash.Write(preImage)
invoice.RHash = hex.EncodeToString(pHash.Sum(nil))
invoice.Preimage = hex.EncodeToString(preImage)
}

View File

@@ -56,3 +56,60 @@ func TestCalcFeeWithMaxGlobalFee(t *testing.T) {
expectedFee := svc.Config.MaxFeeAmount
assert.Equal(t, expectedFee, feeLimit)
}
func TestCalcServiceFee(t *testing.T) {
var serviceFee int64
svc.Config.ServiceFee = 0
serviceFee = svc.CalcServiceFee(10000)
assert.Equal(t, int64(0), serviceFee)
svc.Config.ServiceFee = 5
serviceFee = svc.CalcServiceFee(1000)
assert.Equal(t, int64(5), serviceFee)
serviceFee = svc.CalcServiceFee(100)
assert.Equal(t, int64(1), serviceFee)
serviceFee = svc.CalcServiceFee(212121)
assert.Equal(t, int64(1061), serviceFee)
svc.Config.ServiceFee = 1
serviceFee = svc.CalcServiceFee(1000)
assert.Equal(t, int64(1), serviceFee)
serviceFee = svc.CalcServiceFee(100)
assert.Equal(t, int64(1), serviceFee)
serviceFee = svc.CalcServiceFee(212121)
assert.Equal(t, int64(213), serviceFee)
}
func TestCalcServiceFeeWithFreeAmounts(t *testing.T) {
var serviceFee int64
svc.Config.ServiceFee = 5
svc.Config.NoServiceFeeUpToAmount = 2121
serviceFee = svc.CalcServiceFee(2100)
assert.Equal(t, int64(0), serviceFee)
serviceFee = svc.CalcServiceFee(2121)
assert.Equal(t, int64(0), serviceFee)
serviceFee = svc.CalcServiceFee(2122)
assert.Equal(t, int64(11), serviceFee)
}
func TestSetFeeOnInvoice(t *testing.T) {
invoice := &models.Invoice{
Amount: 500,
}
entry := &models.TransactionEntry{}
entry.ServiceFee = &models.TransactionEntry{
Amount: 42,
}
invoice.SetFee(*entry, 21)
assert.Equal(t, int64(21), invoice.RoutingFee)
assert.Equal(t, int64(42), invoice.ServiceFee)
assert.Equal(t, int64(63), invoice.Fee)
}

View File

@@ -183,6 +183,9 @@ func (svc *LndhubService) CheckOutgoingPaymentAllowed(c echo.Context, lnpayReq *
if svc.Config.FeeReserve {
minimumBalance += svc.CalcFeeLimit(lnpayReq.PayReq.Destination, lnpayReq.PayReq.NumSatoshis)
}
if svc.Config.ServiceFee != 0 {
minimumBalance += svc.CalcServiceFee(lnpayReq.PayReq.NumSatoshis)
}
if currentBalance < minimumBalance {
return &responses.NotEnoughBalanceError, nil
}
@@ -238,6 +241,16 @@ func (svc *LndhubService) CheckIncomingPaymentAllowed(c echo.Context, amount, us
return nil, nil
}
func (svc *LndhubService) CalcServiceFee(amount int64) int64 {
if svc.Config.ServiceFee == 0 {
return 0
}
if svc.Config.NoServiceFeeUpToAmount != 0 && amount <= int64(svc.Config.NoServiceFeeUpToAmount) {
return 0
}
serviceFee := int64(math.Ceil(float64(amount) * float64(svc.Config.ServiceFee) / 1000.0))
return serviceFee
}
func (svc *LndhubService) CalcFeeLimit(destination string, amount int64) int64 {
if svc.LndClient.IsIdentityPubkey(destination) {

View File

@@ -214,8 +214,9 @@ func (client *DefaultClient) FinalizeInitializedPayments(ctx context.Context, sv
switch payment.Status {
case lnrpc.Payment_SUCCEEDED:
invoice.Fee = payment.FeeSat
invoice.SetFee(t, payment.FeeSat)
invoice.Preimage = payment.PaymentPreimage
invoice.RHash = payment.PaymentHash
if err = svc.HandleSuccessfulPayment(ctx, &invoice, t); err != nil {
captureErr(client.logger, err, log.JSON{