mirror of
https://github.com/getAlby/lndhub.go.git
synced 2026-02-19 20:04:29 +01:00
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:
@@ -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
|
||||
|
||||
|
||||
7
db/migrations/20231229091800_add_service_fee.up.sql
Normal file
7
db/migrations/20231229091800_add_service_fee.up.sql
Normal 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;
|
||||
@@ -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:
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user