From 8deabc56bee7d5e4bf002e9dc89571e5d9dbe712 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Sat, 13 Jan 2024 16:07:11 +0200 Subject: [PATCH] Add support for service fees (#474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- README.md | 2 + .../20231229091800_add_service_fee.up.sql | 7 ++ db/models/invoice.go | 13 ++- db/models/transactionentry.go | 3 + integration_tests/keysend_test.go | 5 +- integration_tests/outgoing_payment_test.go | 70 ++++++++++---- integration_tests/payment_failure_test.go | 92 +++++++++++++++---- lib/service/checkpayments.go | 23 ++++- lib/service/config.go | 2 + lib/service/invoices.go | 65 ++++++++++--- lib/service/invoices_test.go | 57 ++++++++++++ lib/service/user.go | 13 +++ rabbitmq/rabbitmq.go | 3 +- 13 files changed, 299 insertions(+), 56 deletions(-) create mode 100644 db/migrations/20231229091800_add_service_fee.up.sql diff --git a/README.md b/README.md index 664c46a..a1da72c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/db/migrations/20231229091800_add_service_fee.up.sql b/db/migrations/20231229091800_add_service_fee.up.sql new file mode 100644 index 0000000..6bd7cb6 --- /dev/null +++ b/db/migrations/20231229091800_add_service_fee.up.sql @@ -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; diff --git a/db/models/invoice.go b/db/models/invoice.go index ba03e5f..0430480 100644 --- a/db/models/invoice.go +++ b/db/models/invoice.go @@ -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: diff --git a/db/models/transactionentry.go b/db/models/transactionentry.go index 2abbe52..fae2056 100644 --- a/db/models/transactionentry.go +++ b/db/models/transactionentry.go @@ -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"` diff --git a/integration_tests/keysend_test.go b/integration_tests/keysend_test.go index 7ce7955..58da0f8 100644 --- a/integration_tests/keysend_test.go +++ b/integration_tests/keysend_test.go @@ -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() { diff --git a/integration_tests/outgoing_payment_test.go b/integration_tests/outgoing_payment_test.go index f1fd8a6..efa7d8b 100644 --- a/integration_tests/outgoing_payment_test.go +++ b/integration_tests/outgoing_payment_test.go @@ -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() { diff --git a/integration_tests/payment_failure_test.go b/integration_tests/payment_failure_test.go index b73ec94..5d8e984 100644 --- a/integration_tests/payment_failure_test.go +++ b/integration_tests/payment_failure_test.go @@ -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() { diff --git a/lib/service/checkpayments.go b/lib/service/checkpayments.go index c7cb264..b665362 100644 --- a/lib/service/checkpayments.go +++ b/lib/service/checkpayments.go @@ -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 { diff --git a/lib/service/config.go b/lib/service/config.go index 3f8f271..de83de4 100644 --- a/lib/service/config.go +++ b/lib/service/config.go @@ -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"` diff --git a/lib/service/invoices.go b/lib/service/invoices.go index 0b2ba4b..172e6e7 100644 --- a/lib/service/invoices.go +++ b/lib/service/invoices.go @@ -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) } diff --git a/lib/service/invoices_test.go b/lib/service/invoices_test.go index 5ee1536..d18866e 100644 --- a/lib/service/invoices_test.go +++ b/lib/service/invoices_test.go @@ -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) +} diff --git a/lib/service/user.go b/lib/service/user.go index a41cdee..c20af31 100644 --- a/lib/service/user.go +++ b/lib/service/user.go @@ -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) { diff --git a/rabbitmq/rabbitmq.go b/rabbitmq/rabbitmq.go index 9308f41..677f9d4 100644 --- a/rabbitmq/rabbitmq.go +++ b/rabbitmq/rabbitmq.go @@ -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{