diff --git a/db/interface.go b/db/interface.go index 630ee9a91..d3473f6bd 100644 --- a/db/interface.go +++ b/db/interface.go @@ -126,6 +126,9 @@ type Database interface { ChangeWorkspaceDeleteStatus(workspace_uuid string, status bool) Workspace UpdateWorkspaceForDeletion(uuid string) error ProcessDeleteWorkspace(workspace_uuid string) error + GetLastWithdrawal(workspace_uuid string) NewPaymentHistory + GetSumOfDeposits(workspace_uuid string) uint + GetSumOfWithdrawal(workspace_uuid string) uint DeleteAllUsersFromWorkspace(uuid string) error GetFilterStatusCount() FilterStattuCount UserHasManageBountyRoles(pubKeyFromAuth string, uuid string) bool diff --git a/db/workspaces.go b/db/workspaces.go index e7329458c..4b352a1a7 100644 --- a/db/workspaces.go +++ b/db/workspaces.go @@ -264,7 +264,7 @@ func (db database) GetWorkspaceBudgetHistory(workspace_uuid string) []BudgetHist return budgetHistory } -func (db database) ProcessUpdateBudget(invoice NewInvoiceList) error { +func (db database) ProcessUpdateBudget(non_tx_invoice NewInvoiceList) error { // Start db transaction tx := db.db.Begin() @@ -280,15 +280,25 @@ func (db database) ProcessUpdateBudget(invoice NewInvoiceList) error { return err } - created := invoice.Created - workspace_uuid := invoice.WorkspaceUuid + created := non_tx_invoice.Created + workspace_uuid := non_tx_invoice.WorkspaceUuid + + invoice := NewInvoiceList{} + tx.Where("payment_request = ?", non_tx_invoice.PaymentRequest).Find(&invoice) + + if invoice.Status { + tx.Rollback() + return errors.New("cannot process already paid invoice") + } if workspace_uuid == "" { return errors.New("cannot Create a Workspace Without a Workspace uuid") } // Get payment history and update budget - paymentHistory := db.GetPaymentHistoryByCreated(created, workspace_uuid) + paymentHistory := NewPaymentHistory{} + tx.Model(&NewPaymentHistory{}).Where("created = ?", created).Where("workspace_uuid = ? ", workspace_uuid).Find(&paymentHistory) + if paymentHistory.WorkspaceUuid != "" && paymentHistory.Amount != 0 { paymentHistory.Status = true @@ -298,9 +308,10 @@ func (db database) ProcessUpdateBudget(invoice NewInvoiceList) error { } // get Workspace budget and add payment to total budget - WorkspaceBudget := db.GetWorkspaceBudget(workspace_uuid) + workspaceBudget := NewBountyBudget{} + tx.Model(&NewBountyBudget{}).Where("workspace_uuid = ?", workspace_uuid).Find(&workspaceBudget) - if WorkspaceBudget.WorkspaceUuid == "" { + if workspaceBudget.WorkspaceUuid == "" { now := time.Now() workBudget := NewBountyBudget{ WorkspaceUuid: workspace_uuid, @@ -313,11 +324,11 @@ func (db database) ProcessUpdateBudget(invoice NewInvoiceList) error { tx.Rollback() } } else { - totalBudget := WorkspaceBudget.TotalBudget - WorkspaceBudget.TotalBudget = totalBudget + paymentHistory.Amount + totalBudget := workspaceBudget.TotalBudget + workspaceBudget.TotalBudget = totalBudget + paymentHistory.Amount - if err = tx.Model(&NewBountyBudget{}).Where("workspace_uuid = ?", WorkspaceBudget.WorkspaceUuid).Updates(map[string]interface{}{ - "total_budget": WorkspaceBudget.TotalBudget, + if err = tx.Model(&NewBountyBudget{}).Where("workspace_uuid = ?", workspaceBudget.WorkspaceUuid).Updates(map[string]interface{}{ + "total_budget": workspaceBudget.TotalBudget, }).Error; err != nil { tx.Rollback() } @@ -333,19 +344,24 @@ func (db database) ProcessUpdateBudget(invoice NewInvoiceList) error { } func (db database) AddAndUpdateBudget(invoice NewInvoiceList) NewPaymentHistory { + // Start db transaction + tx := db.db.Begin() + created := invoice.Created workspace_uuid := invoice.WorkspaceUuid - paymentHistory := db.GetPaymentHistoryByCreated(created, workspace_uuid) + paymentHistory := NewPaymentHistory{} + tx.Model(&NewPaymentHistory{}).Where("created = ?", created).Where("workspace_uuid = ? ", workspace_uuid).Find(&paymentHistory) if paymentHistory.WorkspaceUuid != "" && paymentHistory.Amount != 0 { paymentHistory.Status = true db.db.Where("created = ?", created).Where("workspace_uuid = ? ", workspace_uuid).Updates(paymentHistory) // get Workspace budget and add payment to total budget - WorkspaceBudget := db.GetWorkspaceBudget(workspace_uuid) + workspaceBudget := NewBountyBudget{} + tx.Model(&NewBountyBudget{}).Where("workspace_uuid = ?", workspace_uuid).Find(&workspaceBudget) - if WorkspaceBudget.WorkspaceUuid == "" { + if workspaceBudget.WorkspaceUuid == "" { now := time.Now() workBudget := NewBountyBudget{ WorkspaceUuid: workspace_uuid, @@ -353,14 +369,26 @@ func (db database) AddAndUpdateBudget(invoice NewInvoiceList) NewPaymentHistory Created: &now, Updated: &now, } - db.CreateWorkspaceBudget(workBudget) + + if err := tx.Create(&workBudget).Error; err != nil { + tx.Rollback() + } } else { - totalBudget := WorkspaceBudget.TotalBudget - WorkspaceBudget.TotalBudget = totalBudget + paymentHistory.Amount - db.UpdateWorkspaceBudget(WorkspaceBudget) + totalBudget := workspaceBudget.TotalBudget + workspaceBudget.TotalBudget = totalBudget + paymentHistory.Amount + + if err := tx.Model(&NewBountyBudget{}).Where("workspace_uuid = ?", workspaceBudget.WorkspaceUuid).Updates(map[string]interface{}{ + "total_budget": workspaceBudget.TotalBudget, + }).Error; err != nil { + tx.Rollback() + } } + } else { + tx.Rollback() } + tx.Commit() + return paymentHistory } @@ -487,7 +515,7 @@ func (db database) GetPaymentHistory(workspace_uuid string, r *http.Request) []N func (db database) GetPendingPaymentHistory() []NewPaymentHistory { paymentHistories := []NewPaymentHistory{} - query := `SELECT * FROM payment_histories WHERE payment_status = '` + PaymentPending + `' AND status = true ORDER BY created DESC` + query := `SELECT * FROM payment_histories WHERE payment_status = '` + PaymentPending + `' AND status = true AND payment_type = 'payment' ORDER BY created DESC` db.db.Raw(query).Find(&paymentHistories) return paymentHistories @@ -612,6 +640,26 @@ func (db database) DeleteAllUsersFromWorkspace(workspace_uuid string) error { return nil } +func (db database) GetLastWithdrawal(workspace_uuid string) NewPaymentHistory { + p := NewPaymentHistory{} + db.db.Model(&NewPaymentHistory{}).Where("workspace_uuid", workspace_uuid).Where("payment_type", "withdraw").Order("created DESC").Limit(1).Find(&p) + return p +} + +func (db database) GetSumOfDeposits(workspace_uuid string) uint { + var depositAmount uint + db.db.Model(&NewPaymentHistory{}).Where("workspace_uuid = ?", workspace_uuid).Where("status = ?", true).Where("payment_type = ?", "deposit").Select("SUM(amount)").Row().Scan(&depositAmount) + + return depositAmount +} + +func (db database) GetSumOfWithdrawal(workspace_uuid string) uint { + var depositAmount uint + db.db.Model(&NewPaymentHistory{}).Where("workspace_uuid = ?", workspace_uuid).Where("status = ?", true).Where("payment_type = ?", "withdraw").Select("SUM(amount)").Row().Scan(&depositAmount) + + return depositAmount +} + func (db database) GetFeaturePhasesBountiesCount(bountyType string, phaseUuid string) int64 { var count int64 diff --git a/handlers/bounty.go b/handlers/bounty.go index 037d10607..4216a61d5 100644 --- a/handlers/bounty.go +++ b/handlers/bounty.go @@ -26,6 +26,7 @@ type bountyHandler struct { generateBountyResponse func(bounties []db.NewBounty) []db.BountyResponse userHasAccess func(pubKeyFromAuth string, uuid string, role string) bool getInvoiceStatusByTag func(tag string) db.V2TagRes + getHoursDifference func(createdDate int64, endDate *time.Time) int64 userHasManageBountyRoles func(pubKeyFromAuth string, uuid string) bool m sync.Mutex } @@ -33,12 +34,12 @@ type bountyHandler struct { func NewBountyHandler(httpClient HttpClient, database db.Database) *bountyHandler { dbConf := db.NewDatabaseConfig(&gorm.DB{}) return &bountyHandler{ - httpClient: httpClient, db: database, getSocketConnections: db.Store.GetSocketConnections, userHasAccess: dbConf.UserHasAccess, getInvoiceStatusByTag: GetInvoiceStatusByTag, + getHoursDifference: utils.GetHoursDifference, userHasManageBountyRoles: dbConf.UserHasManageBountyRoles, } } @@ -897,6 +898,7 @@ func (h *bountyHandler) UpdateBountyPaymentStatus(w http.ResponseWriter, r *http json.NewEncoder(w).Encode(msg) } +// Todo: change back to BountyBudgetWithdraw func (h *bountyHandler) BountyBudgetWithdraw(w http.ResponseWriter, r *http.Request) { h.m.Lock() @@ -905,112 +907,63 @@ func (h *bountyHandler) BountyBudgetWithdraw(w http.ResponseWriter, r *http.Requ if pubKeyFromAuth == "" { fmt.Println("[bounty] no pubkey from auth") - w.WriteHeader(http.StatusUnauthorized) h.m.Unlock() + + w.WriteHeader(http.StatusUnauthorized) return } - request := db.WithdrawBudgetRequest{} + request := db.NewWithdrawBudgetRequest{} body, err := io.ReadAll(r.Body) r.Body.Close() if err != nil { - w.WriteHeader(http.StatusNotAcceptable) h.m.Unlock() + + w.WriteHeader(http.StatusNotAcceptable) return } err = json.Unmarshal(body, &request) if err != nil { - w.WriteHeader(http.StatusNotAcceptable) h.m.Unlock() + + w.WriteHeader(http.StatusNotAcceptable) return } - log.Printf("[bounty] [BountyBudgetWithdraw] Logging body: workspace_uuid: %s, pubkey: %s, invoice: %s", request.OrgUuid, pubKeyFromAuth, request.PaymentRequest) + lastWithdrawal := h.db.GetLastWithdrawal(request.WorkspaceUuid) - // check if user is the admin of the workspace - // or has a withdraw bounty budget role - hasRole := h.userHasAccess(pubKeyFromAuth, request.OrgUuid, db.WithdrawBudget) - if !hasRole { - w.WriteHeader(http.StatusUnauthorized) - errMsg := formatPayError("You don't have appropriate permissions to withdraw bounty budget") - json.NewEncoder(w).Encode(errMsg) - h.m.Unlock() - return - } + if lastWithdrawal.ID > 0 { + now := time.Now() + withdrawCreated := lastWithdrawal.Created + withdrawTime := utils.ConvertTimeToTimestamp(withdrawCreated.String()) - amount := utils.GetInvoiceAmount(request.PaymentRequest) + hoursDiff := h.getHoursDifference(int64(withdrawTime), &now) - if amount > 0 { - // check if the workspace bounty balance - // is greater than the amount - orgBudget := h.db.GetWorkspaceBudget(request.OrgUuid) - if amount > orgBudget.TotalBudget { - w.WriteHeader(http.StatusForbidden) - errMsg := formatPayError("Workspace budget is not enough to withdraw the amount") - json.NewEncoder(w).Encode(errMsg) + // Check that last withdraw time is greater than 1 + if hoursDiff < 1 { h.m.Unlock() + + w.WriteHeader(http.StatusUnauthorized) + errMsg := formatPayError("Your last withdrawal is not more than an hour ago") + log.Println("Your last withdrawal is not more than an hour ago", hoursDiff, lastWithdrawal.Created, request.WorkspaceUuid) + json.NewEncoder(w).Encode(errMsg) return } - paymentSuccess, paymentError := h.PayLightningInvoice(request.PaymentRequest) - if paymentSuccess.Success { - // withdraw amount from workspace budget - h.db.WithdrawBudget(pubKeyFromAuth, request.OrgUuid, amount) - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(paymentSuccess) - } else { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(paymentError) - } - } else { - w.WriteHeader(http.StatusForbidden) - errMsg := formatPayError("Could not pay lightning invoice") - json.NewEncoder(w).Encode(errMsg) } - h.m.Unlock() -} - -// Todo: change back to NewBountyBudgetWithdraw -func (h *bountyHandler) NewBountyBudgetWithdraw(w http.ResponseWriter, r *http.Request) { - h.m.Lock() - - ctx := r.Context() - pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) - - if pubKeyFromAuth == "" { - fmt.Println("[bounty] no pubkey from auth") - w.WriteHeader(http.StatusUnauthorized) - h.m.Unlock() - return - } - - request := db.NewWithdrawBudgetRequest{} - body, err := io.ReadAll(r.Body) - r.Body.Close() - - if err != nil { - w.WriteHeader(http.StatusNotAcceptable) - h.m.Unlock() - return - } - - err = json.Unmarshal(body, &request) - if err != nil { - w.WriteHeader(http.StatusNotAcceptable) - h.m.Unlock() - return - } + log.Printf("[bounty] [BountyBudgetWithdraw] Logging body: workspace_uuid: %s, pubkey: %s, invoice: %s", request.WorkspaceUuid, pubKeyFromAuth, request.PaymentRequest) // check if user is the admin of the workspace // or has a withdraw bounty budget role hasRole := h.userHasAccess(pubKeyFromAuth, request.WorkspaceUuid, db.WithdrawBudget) if !hasRole { + h.m.Unlock() + w.WriteHeader(http.StatusUnauthorized) errMsg := formatPayError("You don't have appropriate permissions to withdraw bounty budget") json.NewEncoder(w).Encode(errMsg) - h.m.Unlock() return } @@ -1021,29 +974,49 @@ func (h *bountyHandler) NewBountyBudgetWithdraw(w http.ResponseWriter, r *http.R // is greater than the amount orgBudget := h.db.GetWorkspaceBudget(request.WorkspaceUuid) if amount > orgBudget.TotalBudget { + h.m.Unlock() + w.WriteHeader(http.StatusForbidden) errMsg := formatPayError("Workspace budget is not enough to withdraw the amount") json.NewEncoder(w).Encode(errMsg) + return + } + + // Check that the deposit is more than the withdrawal plus amount to withdraw + sumOfWithdrawals := h.db.GetSumOfWithdrawal(request.WorkspaceUuid) + sumOfDeposits := h.db.GetSumOfDeposits(request.WorkspaceUuid) + + if sumOfDeposits < sumOfWithdrawals+amount { h.m.Unlock() + + w.WriteHeader(http.StatusUnauthorized) + errMsg := formatPayError("Your deposits is lesser than your withdral") + json.NewEncoder(w).Encode(errMsg) return } + paymentSuccess, paymentError := h.PayLightningInvoice(request.PaymentRequest) if paymentSuccess.Success { // withdraw amount from workspace budget h.db.WithdrawBudget(pubKeyFromAuth, request.WorkspaceUuid, amount) + + h.m.Unlock() + w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(paymentSuccess) } else { + h.m.Unlock() + w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(paymentError) } } else { + h.m.Unlock() + w.WriteHeader(http.StatusForbidden) errMsg := formatPayError("Could not pay lightning invoice") json.NewEncoder(w).Encode(errMsg) } - - h.m.Unlock() } func formatPayError(errorMsg string) db.InvoicePayError { @@ -1323,7 +1296,6 @@ func (h *bountyHandler) PollInvoice(w http.ResponseWriter, r *http.Request) { ctx := r.Context() pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) paymentRequest := chi.URLParam(r, "paymentRequest") - var err error if pubKeyFromAuth == "" { fmt.Println("[bounty] no pubkey from auth") @@ -1342,119 +1314,12 @@ func (h *bountyHandler) PollInvoice(w http.ResponseWriter, r *http.Request) { if invoiceRes.Response.Settled { // Todo if an invoice is settled invoice := h.db.GetInvoice(paymentRequest) - invData := h.db.GetUserInvoiceData(paymentRequest) dbInvoice := h.db.GetInvoice(paymentRequest) // Make any change only if the invoice has not been settled if !dbInvoice.Status { - amount := invData.Amount if invoice.Type == "BUDGET" { h.db.AddAndUpdateBudget(invoice) - } else if invoice.Type == "KEYSEND" { - if config.IsV2Payment { - url := fmt.Sprintf("%s/pay", config.V2BotUrl) - - // Build v2 keysend payment data - bodyData := utils.BuildV2KeysendBodyData(amount, invData.UserPubkey, invData.RouteHint, "") - jsonBody := []byte(bodyData) - - req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonBody)) - req.Header.Set("x-admin-token", config.V2BotToken) - req.Header.Set("Content-Type", "application/json") - log.Printf("[bounty] Making Bounty V2 Payment PollInvoice: amount: %d, pubkey: %s, route_hint: %s", amount, invData.UserPubkey, invData.RouteHint) - - res, err := h.httpClient.Do(req) - - if err != nil { - log.Printf("[bounty] Request Failed: %s", err) - h.m.Unlock() - return - } - - defer res.Body.Close() - body, err := io.ReadAll(res.Body) - if err != nil { - fmt.Println("[read body]", err) - w.WriteHeader(http.StatusNotAcceptable) - h.m.Unlock() - return - } - - // Unmarshal result - v2KeysendRes := db.V2SendOnionRes{} - err = json.Unmarshal(body, &v2KeysendRes) - - if err != nil { - fmt.Println("[Unmarshal]", err) - w.WriteHeader(http.StatusNotAcceptable) - h.m.Unlock() - return - } - - if res.StatusCode == 200 { - fmt.Println("V2 Status Code Is 200") - // if the payment has a completed status - if v2KeysendRes.Status == db.PaymentComplete { - fmt.Println("V2 Payment Is Completed") - bounty, err := h.db.GetBountyByCreated(uint(invData.Created)) - if err == nil { - now := time.Now() - bounty.Paid = true - bounty.PaidDate = &now - bounty.Completed = true - bounty.CompletionDate = &now - } - - h.db.UpdateBounty(bounty) - } - } else { - log.Printf("[bounty] V2 Keysend Payment to %s Failed, with Error: %s", invData.UserPubkey, err) - } - } else { - url := fmt.Sprintf("%s/payment", config.RelayUrl) - - bodyData := utils.BuildKeysendBodyData(amount, invData.UserPubkey, invData.RouteHint, "") - - jsonBody := []byte(bodyData) - - req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonBody)) - - req.Header.Set("x-user-token", config.RelayAuthKey) - req.Header.Set("Content-Type", "application/json") - res, _ := h.httpClient.Do(req) - - defer res.Body.Close() - - body, _ := io.ReadAll(res.Body) - - if res.StatusCode == 200 { - // Unmarshal result - keysendRes := db.KeysendSuccess{} - err = json.Unmarshal(body, &keysendRes) - - if err != nil { - w.WriteHeader(http.StatusForbidden) - json.NewEncoder(w).Encode("Could not decode keysend response") - return - } - - bounty, err := h.db.GetBountyByCreated(uint(invData.Created)) - if err == nil { - now := time.Now() - bounty.Paid = true - bounty.PaidDate = &now - bounty.Completed = true - bounty.CompletionDate = &now - } - - h.db.UpdateBounty(bounty) - } else { - // Unmarshal result - keysendError := db.KeysendError{} - err = json.Unmarshal(body, &keysendError) - log.Printf("[bounty] Keysend Payment to %s Failed, with Error: %s", invData.UserPubkey, err) - } - } } // Update the invoice status h.db.UpdateInvoice(paymentRequest) diff --git a/handlers/bounty_test.go b/handlers/bounty_test.go index 4cece13dc..59f58802b 100644 --- a/handlers/bounty_test.go +++ b/handlers/bounty_test.go @@ -1757,6 +1757,7 @@ func TestBountyBudgetWithdraw(t *testing.T) { ctx := context.Background() mockHttpClient := mocks.NewHttpClient(t) bHandler := NewBountyHandler(mockHttpClient, db.TestDB) + handlerUserHasAccess := func(pubKeyFromAuth string, uuid string, role string) bool { return true } @@ -1765,6 +1766,10 @@ func TestBountyBudgetWithdraw(t *testing.T) { return false } + getHoursDifference := func(createdDate int64, endDate *time.Time) int64 { + return 2 + } + person := db.Person{ Uuid: uuid.New().String(), OwnerAlias: "test-alias", @@ -1786,6 +1791,23 @@ func TestBountyBudgetWithdraw(t *testing.T) { db.TestDB.CreateOrEditWorkspace(workspace) budgetAmount := uint(5000) + + paymentTime := time.Now() + + payment := db.NewPaymentHistory{ + Amount: budgetAmount, + WorkspaceUuid: workspace.Uuid, + PaymentType: db.Deposit, + SenderPubKey: person.OwnerPubKey, + ReceiverPubKey: person.OwnerPubKey, + Tag: "test_deposit", + Status: true, + Created: &paymentTime, + Updated: &paymentTime, + } + + db.TestDB.AddPaymentHistory(payment) + budget := db.NewBountyBudget{ WorkspaceUuid: workspace.Uuid, TotalBudget: budgetAmount, @@ -1829,7 +1851,7 @@ func TestBountyBudgetWithdraw(t *testing.T) { rr := httptest.NewRecorder() handler := http.HandlerFunc(bHandler.BountyBudgetWithdraw) - validData := []byte(`{"orgUuid": "workspace-uuid", "paymentRequest": "invoice"}`) + validData := []byte(`{"workspace_uuid": "workspace-uuid", "paymentRequest": "invoice"}`) req, err := http.NewRequestWithContext(authorizedCtx, http.MethodPost, "/budget/withdraw", bytes.NewReader(validData)) if err != nil { t.Fatal(err) @@ -1850,9 +1872,9 @@ func TestBountyBudgetWithdraw(t *testing.T) { amount := utils.GetInvoiceAmount(invoice) assert.Equal(t, uint(10000), amount) - withdrawRequest := db.WithdrawBudgetRequest{ + withdrawRequest := db.NewWithdrawBudgetRequest{ PaymentRequest: invoice, - OrgUuid: workspace.Uuid, + WorkspaceUuid: workspace.Uuid, } requestBody, _ := json.Marshal(withdrawRequest) req, _ := http.NewRequestWithContext(authorizedCtx, http.MethodPost, "/budget/withdraw", bytes.NewReader(requestBody)) @@ -1884,9 +1906,9 @@ func TestBountyBudgetWithdraw(t *testing.T) { invoice := "lnbc3u1pngsqv8pp5vl6ep8llmg3f9sfu8j7ctcnphylpnjduuyljqf3sc30z6ejmrunqdqzvscqzpgxqyz5vqrzjqwnw5tv745sjpvft6e3f9w62xqk826vrm3zaev4nvj6xr3n065aukqqqqyqqz9gqqyqqqqqqqqqqqqqqqqsp5n9hrrw6pr89qn3c82vvhy697wp45zdsyhm7tnu536ga77ytvxxaq9qrssqqqhenjtquz8wz5tym8v830h9gjezynjsazystzj6muhw4rd9ccc40p8sazjuk77hhcj0xn72lfyee3tsfl7lucxkx5xgtfaqya9qldcqr3072z" - withdrawRequest := db.WithdrawBudgetRequest{ + withdrawRequest := db.NewWithdrawBudgetRequest{ PaymentRequest: invoice, - OrgUuid: workspace.Uuid, + WorkspaceUuid: workspace.Uuid, } requestBody, _ := json.Marshal(withdrawRequest) @@ -1905,6 +1927,7 @@ func TestBountyBudgetWithdraw(t *testing.T) { }) t.Run("400 BadRequest error if there is an error with invoice payment", func(t *testing.T) { + bHandler.getHoursDifference = getHoursDifference mockHttpClient.On("Do", mock.AnythingOfType("*http.Request")).Return(&http.Response{ StatusCode: 400, @@ -1913,9 +1936,9 @@ func TestBountyBudgetWithdraw(t *testing.T) { invoice := "lnbcrt1u1pnv5ejzdqad9h8vmmfvdjjqen0wgsrzvpsxqcrqpp58xyhvymlhc8q05z930fknk2vdl8wnpm5zlx5lgp4ev9u8h7yd4kssp5nu652c5y0epuxeawn8szcgdrjxwk7pfkdh9tsu44r7hacg52nfgq9qrsgqcqpjxqrrssrzjqgtzc5n3vcmlhqfq4vpxreqskxzay6xhdrxx7c38ckqs95v5459uyqqqqyqq9ggqqsqqqqqqqqqqqqqq9gwyffzjpnrwt6yswwd4znt2xqnwjwxgq63qxudru95a8pqeer2r7sduurtstz5x60y4e7m4y9nx6rqy5sr9k08vtwv6s37xh0z5pdwpgqxeqdtv" - withdrawRequest := db.WithdrawBudgetRequest{ + withdrawRequest := db.NewWithdrawBudgetRequest{ PaymentRequest: invoice, - OrgUuid: workspace.Uuid, + WorkspaceUuid: workspace.Uuid, } requestBody, _ := json.Marshal(withdrawRequest) req, _ := http.NewRequestWithContext(authorizedCtx, http.MethodPost, "/budget/withdraw", bytes.NewReader(requestBody)) @@ -1934,25 +1957,30 @@ func TestBountyBudgetWithdraw(t *testing.T) { }) t.Run("Should test that an Workspace's Budget Total Amount is accurate after three (3) successful 'Budget Withdrawal Requests'", func(t *testing.T) { - paymentAmount := uint(1000) initialBudget := budget.TotalBudget invoice := "lnbcrt10u1pnv7nz6dqld9h8vmmfvdjjqen0wgsrzvpsxqcrqvqpp54v0synj4q3j2usthzt8g5umteky6d2apvgtaxd7wkepkygxgqdyssp5lhv2878qjas3azv3nnu8r6g3tlgejl7mu7cjzc9q5haygrpapd4s9qrsgqcqpjxqrrssrzjqgtzc5n3vcmlhqfq4vpxreqskxzay6xhdrxx7c38ckqs95v5459uyqqqqyqqtwsqqgqqqqqqqqqqqqqq9gea2fjj7q302ncprk2pawk4zdtayycvm0wtjpprml96h9vujvmqdp0n5z8v7lqk44mq9620jszwaevj0mws7rwd2cegxvlmfszwgpgfqp2xafjf" + bHandler.userHasAccess = handlerUserHasAccess + bHandler.getHoursDifference = getHoursDifference for i := 0; i < 3; i++ { expectedFinalBudget := initialBudget - (paymentAmount * uint(i+1)) mockHttpClient.ExpectedCalls = nil mockHttpClient.Calls = nil + // add a zero amount withdrawal with a time lesser than 2 + loop index hours to beat the 1 hour withdrawal timer + dur := int(time.Hour.Hours())*2 + i + 1 + paymentTime = time.Now().Add(-time.Hour * time.Duration(dur)) + mockHttpClient.On("Do", mock.AnythingOfType("*http.Request")).Return(&http.Response{ StatusCode: 200, Body: io.NopCloser(bytes.NewBufferString(`{"status": "COMPLETE", "amt_msat": "1000", "timestamp": "" }`)), }, nil) - withdrawRequest := db.WithdrawBudgetRequest{ + withdrawRequest := db.NewWithdrawBudgetRequest{ PaymentRequest: invoice, - OrgUuid: workspace.Uuid, + WorkspaceUuid: workspace.Uuid, } requestBody, _ := json.Marshal(withdrawRequest) req, _ := http.NewRequestWithContext(authorizedCtx, http.MethodPost, "/budget/withdraw", bytes.NewReader(requestBody)) @@ -1968,6 +1996,7 @@ func TestBountyBudgetWithdraw(t *testing.T) { finalBudget := db.TestDB.GetWorkspaceBudget(workspace.Uuid) assert.Equal(t, expectedFinalBudget, finalBudget.TotalBudget, "The workspace's final budget should reflect the deductions from the successful withdrawals") + } }) @@ -2111,85 +2140,6 @@ func TestPollInvoice(t *testing.T) { mockHttpClient.AssertExpectations(t) }) - t.Run("Should mock relay payment is successful update the bounty associated with the invoice and set the paid as true", func(t *testing.T) { - expectedUrl := fmt.Sprintf("%s/invoice?payment_request=%s", config.RelayUrl, invoice.PaymentRequest) - expectedBody := fmt.Sprintf(`{"success": true, "response": { "settled": true, "payment_request": "%s", "payment_hash": "payment_hash", "preimage": "preimage", "Amount": %d}}`, invoice.OwnerPubkey, bountyAmount) - - expectedV2Url := fmt.Sprintf("%s/check_invoice", botURL) - expectedV2InvoiceBody := `{"status": "paid", "amt_msat": "", "timestamp": ""}` - - r := io.NopCloser(bytes.NewReader([]byte(expectedBody))) - rv2 := io.NopCloser(bytes.NewReader([]byte(expectedV2InvoiceBody))) - - if botURL != "" && botToken != "" { - mockHttpClient.On("Do", mock.MatchedBy(func(req *http.Request) bool { - return req.Method == http.MethodPost && expectedV2Url == req.URL.String() && req.Header.Get("x-admin-token") == botToken - })).Return(&http.Response{ - StatusCode: 200, - Body: rv2, - }, nil).Once() - } else { - mockHttpClient.On("Do", mock.MatchedBy(func(req *http.Request) bool { - return req.Method == http.MethodGet && expectedUrl == req.URL.String() && req.Header.Get("x-user-token") == config.RelayAuthKey - })).Return(&http.Response{ - StatusCode: 200, - Body: r, - }, nil).Once() - } - - expectedPaymentUrl := fmt.Sprintf("%s/payment", config.RelayUrl) - expectedV2PaymentUrl := fmt.Sprintf("%s/pay", botURL) - - expectedPaymentBody := fmt.Sprintf(`{"amount": %d, "destination_key": "%s", "text": "memotext added for notification", "data": ""}`, bountyAmount, invoice.OwnerPubkey) - - expectedV2PaymentBody := - fmt.Sprintf(`{"amt_msat": %d, "dest": "%s", "route_hint": "%s", "data": "", "wait": true}`, bountyAmount*1000, invoice.OwnerPubkey, invoiceData.RouteHint) - - r2 := io.NopCloser(bytes.NewReader([]byte(`{"success": true, "response": { "sumAmount": "1"}}`))) - r3 := io.NopCloser(bytes.NewReader([]byte(`{"status": "COMPLETE", "amt_msat": "", "timestamp": "" }`))) - - if botURL != "" && botToken != "" { - mockHttpClient.On("Do", mock.MatchedBy(func(req *http.Request) bool { - bodyByt, _ := io.ReadAll(req.Body) - return req.Method == http.MethodPost && expectedV2PaymentUrl == req.URL.String() && req.Header.Get("x-admin-token") == botToken && expectedV2PaymentBody == string(bodyByt) - })).Return(&http.Response{ - StatusCode: 200, - Body: r3, - }, nil).Once() - } else { - mockHttpClient.On("Do", mock.MatchedBy(func(req *http.Request) bool { - bodyByt, _ := io.ReadAll(req.Body) - return req.Method == http.MethodPost && expectedPaymentUrl == req.URL.String() && req.Header.Get("x-user-token") == config.RelayAuthKey && expectedPaymentBody == string(bodyByt) - })).Return(&http.Response{ - StatusCode: 200, - Body: r2, - }, nil).Once() - } - - ro := chi.NewRouter() - ro.Post("/poll/invoice/{paymentRequest}", bHandler.PollInvoice) - - rr := httptest.NewRecorder() - req, err := http.NewRequestWithContext(authorizedCtx, http.MethodPost, "/poll/invoice/"+invoice.PaymentRequest, bytes.NewBufferString(`{}`)) - if err != nil { - t.Fatal(err) - } - - ro.ServeHTTP(rr, req) - - invData := db.TestDB.GetUserInvoiceData(invoice.PaymentRequest) - updatedBounty, err := db.TestDB.GetBountyByCreated(uint(invData.Created)) - if err != nil { - t.Fatal(err) - } - updatedInvoice := db.TestDB.GetInvoice(invoice.PaymentRequest) - - assert.True(t, updatedBounty.Paid, "Expected bounty to be marked as paid") - assert.True(t, updatedInvoice.Status, "Expected invoice status to be true") - assert.Equal(t, http.StatusOK, rr.Code) - mockHttpClient.AssertExpectations(t) - }) - t.Run("If the invoice is settled and the invoice.Type is equal to BUDGET the invoice amount should be added to the workspace budget and the payment status of the related invoice should be sent to true on the payment history table", func(t *testing.T) { db.TestDB.DeleteInvoice(paymentRequest) diff --git a/handlers/workspaces.go b/handlers/workspaces.go index 1f16e04ed..d1f487f0c 100644 --- a/handlers/workspaces.go +++ b/handlers/workspaces.go @@ -1020,6 +1020,28 @@ func (oh *workspaceHandler) GetFeaturesByWorkspaceUuid(w http.ResponseWriter, r json.NewEncoder(w).Encode(workspaceFeatures) } +func (oh *workspaceHandler) GetLastWithdrawal(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) + if pubKeyFromAuth == "" { + fmt.Println("[workspaces] no pubkey from auth") + w.WriteHeader(http.StatusUnauthorized) + return + } + + workspace_uuid := chi.URLParam(r, "workspace_uuid") + lastWithdrawal := oh.db.GetLastWithdrawal(workspace_uuid) + + now := time.Now() + withdrawCreated := lastWithdrawal.Created + withdrawTime := utils.ConvertTimeToTimestamp(withdrawCreated.String()) + + hoursDiff := utils.GetHoursDifference(int64(withdrawTime), &now) + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(hoursDiff) +} + func GetAllUserWorkspaces(pubkey string) []db.Workspace { // get the workspaces created by the user, then get all the workspaces // the user has been added to, loop through to get the workspace diff --git a/mocks/Database.go b/mocks/Database.go index 8cd6daf78..4c40aeb6b 100644 --- a/mocks/Database.go +++ b/mocks/Database.go @@ -3587,6 +3587,52 @@ func (_c *Database_GetInvoice_Call) RunAndReturn(run func(string) db.NewInvoiceL return _c } +// GetLastWithdrawal provides a mock function with given fields: workspace_uuid +func (_m *Database) GetLastWithdrawal(workspace_uuid string) db.NewPaymentHistory { + ret := _m.Called(workspace_uuid) + + if len(ret) == 0 { + panic("no return value specified for GetLastWithdrawal") + } + + var r0 db.NewPaymentHistory + if rf, ok := ret.Get(0).(func(string) db.NewPaymentHistory); ok { + r0 = rf(workspace_uuid) + } else { + r0 = ret.Get(0).(db.NewPaymentHistory) + } + + return r0 +} + +// Database_GetLastWithdrawal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLastWithdrawal' +type Database_GetLastWithdrawal_Call struct { + *mock.Call +} + +// GetLastWithdrawal is a helper method to define mock.On call +// - workspace_uuid string +func (_e *Database_Expecter) GetLastWithdrawal(workspace_uuid interface{}) *Database_GetLastWithdrawal_Call { + return &Database_GetLastWithdrawal_Call{Call: _e.mock.On("GetLastWithdrawal", workspace_uuid)} +} + +func (_c *Database_GetLastWithdrawal_Call) Run(run func(workspace_uuid string)) *Database_GetLastWithdrawal_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Database_GetLastWithdrawal_Call) Return(_a0 db.NewPaymentHistory) *Database_GetLastWithdrawal_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Database_GetLastWithdrawal_Call) RunAndReturn(run func(string) db.NewPaymentHistory) *Database_GetLastWithdrawal_Call { + _c.Call.Return(run) + return _c +} + // GetLeaderBoard provides a mock function with given fields: uuid func (_m *Database) GetLeaderBoard(uuid string) []db.LeaderBoard { ret := _m.Called(uuid) @@ -4887,6 +4933,98 @@ func (_c *Database_GetPreviousWorkspaceBountyByCreated_Call) RunAndReturn(run fu return _c } +// GetSumOfDeposits provides a mock function with given fields: workspace_uuid +func (_m *Database) GetSumOfDeposits(workspace_uuid string) uint { + ret := _m.Called(workspace_uuid) + + if len(ret) == 0 { + panic("no return value specified for GetSumOfDeposits") + } + + var r0 uint + if rf, ok := ret.Get(0).(func(string) uint); ok { + r0 = rf(workspace_uuid) + } else { + r0 = ret.Get(0).(uint) + } + + return r0 +} + +// Database_GetSumOfDeposits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSumOfDeposits' +type Database_GetSumOfDeposits_Call struct { + *mock.Call +} + +// GetSumOfDeposits is a helper method to define mock.On call +// - workspace_uuid string +func (_e *Database_Expecter) GetSumOfDeposits(workspace_uuid interface{}) *Database_GetSumOfDeposits_Call { + return &Database_GetSumOfDeposits_Call{Call: _e.mock.On("GetSumOfDeposits", workspace_uuid)} +} + +func (_c *Database_GetSumOfDeposits_Call) Run(run func(workspace_uuid string)) *Database_GetSumOfDeposits_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Database_GetSumOfDeposits_Call) Return(_a0 uint) *Database_GetSumOfDeposits_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Database_GetSumOfDeposits_Call) RunAndReturn(run func(string) uint) *Database_GetSumOfDeposits_Call { + _c.Call.Return(run) + return _c +} + +// GetSumOfWithdrawal provides a mock function with given fields: workspace_uuid +func (_m *Database) GetSumOfWithdrawal(workspace_uuid string) uint { + ret := _m.Called(workspace_uuid) + + if len(ret) == 0 { + panic("no return value specified for GetSumOfWithdrawal") + } + + var r0 uint + if rf, ok := ret.Get(0).(func(string) uint); ok { + r0 = rf(workspace_uuid) + } else { + r0 = ret.Get(0).(uint) + } + + return r0 +} + +// Database_GetSumOfWithdrawal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSumOfWithdrawal' +type Database_GetSumOfWithdrawal_Call struct { + *mock.Call +} + +// GetSumOfWithdrawal is a helper method to define mock.On call +// - workspace_uuid string +func (_e *Database_Expecter) GetSumOfWithdrawal(workspace_uuid interface{}) *Database_GetSumOfWithdrawal_Call { + return &Database_GetSumOfWithdrawal_Call{Call: _e.mock.On("GetSumOfWithdrawal", workspace_uuid)} +} + +func (_c *Database_GetSumOfWithdrawal_Call) Run(run func(workspace_uuid string)) *Database_GetSumOfWithdrawal_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Database_GetSumOfWithdrawal_Call) Return(_a0 uint) *Database_GetSumOfWithdrawal_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Database_GetSumOfWithdrawal_Call) RunAndReturn(run func(string) uint) *Database_GetSumOfWithdrawal_Call { + _c.Call.Return(run) + return _c +} + // GetTribe provides a mock function with given fields: uuid func (_m *Database) GetTribe(uuid string) db.Tribe { ret := _m.Called(uuid) diff --git a/routes/bounty.go b/routes/bounty.go index 639a22c44..d6d83d2e0 100644 --- a/routes/bounty.go +++ b/routes/bounty.go @@ -38,7 +38,6 @@ func BountyRoutes() chi.Router { r.Post("/pay/{id}", bountyHandler.MakeBountyPayment) r.Get("/payment/status/{id}", bountyHandler.GetBountyPaymentStatus) r.Put("/payment/status/{id}", bountyHandler.UpdateBountyPaymentStatus) - r.Post("/budget_workspace/withdraw", bountyHandler.NewBountyBudgetWithdraw) r.Get("/payment/status/{id}", bountyHandler.GetBountyPaymentStatus) r.Post("/", bountyHandler.CreateOrEditBounty) diff --git a/routes/workspaces.go b/routes/workspaces.go index e5bbd9d10..c829bbc4c 100644 --- a/routes/workspaces.go +++ b/routes/workspaces.go @@ -51,6 +51,8 @@ func WorkspaceRoutes() chi.Router { r.Get("/{workspace_uuid}/features", workspaceHandlers.GetFeaturesByWorkspaceUuid) r.Get("/{workspace_uuid}/repository/{uuid}", workspaceHandlers.GetWorkspaceRepoByWorkspaceUuidAndRepoUuid) r.Delete("/{workspace_uuid}/repository/{uuid}", workspaceHandlers.DeleteWorkspaceRepository) + + r.Get("/{workspace_uuid}/lastwithdrawal", workspaceHandlers.GetLastWithdrawal) }) return r } diff --git a/utils/helpers.go b/utils/helpers.go index e6a826ae2..2b51ef58c 100644 --- a/utils/helpers.go +++ b/utils/helpers.go @@ -5,6 +5,7 @@ import ( "encoding/base32" "fmt" "strconv" + "strings" "time" decodepay "github.com/nbd-wtf/ln-decodepay" @@ -71,9 +72,44 @@ func GetInvoiceExpired(paymentRequest string) bool { } } +func ConvertTimeToTimestamp(date string) int { + format := "2006-01-02 15:04:05" + dateTouse := date + + if strings.Contains(date, "+") { + dateSplit := strings.Split(date, "+") + dateTouse = strings.Trim(dateSplit[0], " ") + } + + t, err := time.Parse(format, dateTouse) + if err != nil { + fmt.Println("Parse string to timestamp", err) + } else { + return int(t.Unix()) + } + return 0 +} + +func AddHoursToTimestamp(timestamp int, hours int) int { + tm := time.Unix(int64(timestamp), 0) + + dur := int(time.Hour.Hours()) * hours + tm = tm.Add(time.Hour * time.Duration(dur)) + + return int(tm.Unix()) +} + func GetDateDaysDifference(createdDate int64, paidDate *time.Time) int64 { firstDate := time.Unix(createdDate, 0) - difference := paidDate.Sub(*&firstDate) + difference := paidDate.Sub(firstDate) days := int64(difference.Hours() / 24) return days } + +func GetHoursDifference(createdDate int64, endDate *time.Time) int64 { + firstDate := time.Unix(createdDate, 0) + difference := endDate.Sub(firstDate) + + hours := int64(difference.Hours()) + return hours +} diff --git a/utils/helpers_test.go b/utils/helpers_test.go index 6fdaa9c31..f8e82f0a4 100644 --- a/utils/helpers_test.go +++ b/utils/helpers_test.go @@ -78,3 +78,30 @@ func TestGetInvoiceExpired(t *testing.T) { isInvoiceExpired := GetInvoiceExpired(expiredInvoice) assert.Equal(t, true, isInvoiceExpired) } + +func TestConvertTimeToTimestamp(t *testing.T) { + dateWithPlus := "2024-10-16 09:21:21.743327+00" + dateWithoutPlus := "2024-10-16 09:21:21.743327" + + dateTimestamp1 := ConvertTimeToTimestamp(dateWithPlus) + dateTimestamp2 := ConvertTimeToTimestamp(dateWithoutPlus) + + assert.Greater(t, dateTimestamp1, 7000000) + assert.Greater(t, dateTimestamp2, 7000000) +} + +func TestGetHoursDifference(t *testing.T) { + time1 := time.Now().Unix() + time2 := time.Now().Add(time.Hour * 1) + + hourDiff := GetHoursDifference(time1, &time2) + assert.Equal(t, hourDiff, int64(1)) +} + +func TestAddHoursToTimestamp(t *testing.T) { + time1 := time.Now().Unix() + time2 := time.Now().Unix() + + hoursAdd := AddHoursToTimestamp(int(time2), 2) + assert.Greater(t, hoursAdd, int(time1)) +}