diff --git a/Makefile b/Makefile index 1d00c3f..d3f80ac 100644 --- a/Makefile +++ b/Makefile @@ -66,3 +66,9 @@ build: run-services: @mkdir -p "out/logs" @docker compose up -d --build + +dev-rerun: + @make env-cleanup + @make env-up + sleep 2 + @make migrate-up \ No newline at end of file diff --git a/README.md b/README.md index 1e418b4..abeae8d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ The project is built with a strict separation of concerns, ensuring high testabi - `internal/pkg/` — Internal utilities (e.g., deep-link generators). ## 📍 Roadmap -**Phase 1: MVP (Completed) ✅** +**MVP (Completed) ✅** - [x] Clean Architecture setup and Dependency Injection. - [x] PostgreSQL integration with migrations. - [x] FSM for user input handling and seamless UX. @@ -43,9 +43,11 @@ The project is built with a strict separation of concerns, ensuring high testabi - [x] Table-Driven Unit Tests for the settlement math module. - [x] CI/CD Pipeline (GitHub Actions + golangci-lint). -**Phase 2: Enhancements (Future) 🚀** +**V1.1.0 +- [x] Virtual Users (Add members without Telegram accounts). + +**Enhancements (Future) 🚀** - [ ] Deploy to VPS (DigitalOcean). -- [ ] Virtual Users (Add members without Telegram accounts). - [ ] Multi-currency support. ## 🚀 Getting Started (Dev) diff --git a/internal/delivery/telegram/bot.go b/internal/delivery/telegram/bot.go index 99e4c36..d325a1f 100644 --- a/internal/delivery/telegram/bot.go +++ b/internal/delivery/telegram/bot.go @@ -30,17 +30,22 @@ const ( ) const ( - CommandCreateFund = "create_fund" - CommandMyFund = "my_fund" - CommandJoinFund = "join_fund" - CommandBack = "back" - CommandNextMF = "next_mf" - CommandPreviousMF = "previous_mf" - CommandFund = "view_fund" - CommandLogExpense = "log_expense" - CommandLogs = "logs" - CommandSettleUp = "settle_up" - CommandMembers = "members" - CommandPreviousVFL = "previous_vfl" - CommandNextVFL = "settle_up_vfl" + CommandCreateFund = "create_fund" + CommandMyFund = "my_fund" + CommandJoinFund = "join_fund" + CommandBack = "back" + CommandNextMF = "next_mf" + CommandPreviousMF = "previous_mf" + CommandFund = "view_fund" + CommandLogExpense = "log_expense" + CommandLogs = "logs" + CommandSettleUp = "settle_up" + CommandMembers = "members" + CommandPreviousVFL = "previous_vfl" + CommandNextVFL = "settle_up_vfl" + CommandAddUser = "add_user" + CommandSelectToRemoveUser = "select_to_remove_user" + CommandPrevRVU = "prev_rvu" + CommandNextRVU = "next_rvu" + CommandRemoveUser = "remove_user" ) diff --git a/internal/delivery/telegram/handlers_base.go b/internal/delivery/telegram/handlers_base.go index ca013c5..24416f1 100644 --- a/internal/delivery/telegram/handlers_base.go +++ b/internal/delivery/telegram/handlers_base.go @@ -2,10 +2,13 @@ package telegram import ( "context" + "errors" "fmt" + "html" "log/slog" "os" "strings" + "unicode/utf8" "github.com/ganfay/split-core/internal/domain" "github.com/ganfay/split-core/internal/pkg/utils" @@ -16,23 +19,24 @@ import ( func (h *BotHandler) HandleStart(c tele.Context) error { ctx := context.Background() var user domain.User - user.TgID = c.Sender().ID user.Username = c.Sender().Username user.FirstName = c.Sender().FirstName - userCtx := domain.UserContext{ + userStates := domain.UserContext{ State: domain.StateNone, LastMsgID: c.Message().ID, ActiveFundID: -1, } - err := h.statesUC.SaveUserCtx(ctx, user.TgID, &userCtx) + err := h.statesUC.SaveUserCtx(ctx, &c.Sender().ID, &userStates) if err != nil { return err } - _, err = h.userUC.CreateUser(ctx, &user) + userCtx, save, err := h.getUserCtxH(c, ctx) if err != nil { - slog.Warn("could not register user", "err", err, "id", user.TgID) + return err } + defer save() + args := c.Args() // if url invite code @@ -49,7 +53,7 @@ func (h *BotHandler) HandleStart(c tele.Context) error { return h.error(c, "Invite code not found", err.Error(), Reply) } - err = h.fundUC.AddMember(ctx, fund, user.TgID) + err = h.fundUC.AddMember(ctx, fund.ID, userCtx.InternalID) if err != nil { return h.error(c, "Failed to join the fund", err.Error(), Reply) } @@ -72,20 +76,12 @@ func (h *BotHandler) HandleBack(c tele.Context) error { slog.Error("Error while handling back", "err", err.Error()) } }(c) - id := c.Sender().ID ctx := context.Background() - userCtx, err := h.statesUC.GetUserCtx(ctx, id) + userCtx, save, err := h.getUserCtxH(c, ctx) if err != nil { return h.error(c, "Failed to get user context", err.Error(), Edit) } - - defer func() { - err := h.statesUC.SaveUserCtx(ctx, id, userCtx) - if err != nil { - slog.Error("Failed to save user context", "err", err.Error()) - return - } - }() + defer save() slog.Debug("Handling back", "state", userCtx.State) @@ -105,42 +101,56 @@ func (h *BotHandler) HandleBack(c tele.Context) error { "👇 Choose an action below to get started:" return c.Edit(msg, h.MainMenu(), tele.ModeHTML) + case domain.StateWaitUsername, domain.StateSuccessAVU, domain.StateWaitToRemove: + userCtx.State = domain.StateViewMembers + return h.HandleMembers(c) + case domain.StateRemovedSuccess: + userCtx.State = domain.StateWaitToRemove + return h.HandleWaitRemoveUser(c) default: panic("unhandled default case") } } func (h *BotHandler) OnText(c tele.Context) error { - id := c.Sender().ID if err := c.Delete(); err != nil { - slog.Error("error delete message", "id", id, "err", err.Error()) + slog.Error("error delete message", "tg_id", c.Sender().ID, "err", err.Error()) return err } ctx := context.Background() - userCtx, err := h.statesUC.GetUserCtx(ctx, c.Sender().ID) + userCtx, save, err := h.getUserCtxH(c, ctx) if err != nil { return h.error(c, "Failed to get user context", err.Error(), Edit) } - - defer func() { - err = h.statesUC.SaveUserCtx(ctx, c.Sender().ID, userCtx) - if err != nil { - slog.Error("Failed to save user context", "err", err.Error()) - return - } - }() + defer save() text := c.Text() + fullText := strings.TrimSpace(c.Text()) + + if fullText == "" { + err = errors.New("the name cannot be empty or consist solely of spaces. Please try again") + return h.error(c, err.Error(), err.Error(), Edit) + } + + if utf8.RuneCountInString(fullText) > 30 { + err = errors.New("that name is too long! Let's go with something shorter (up to 30 characters) 😅") + return h.error(c, err.Error(), err.Error(), Edit) + } + fullText = html.EscapeString(fullText) switch userCtx.State { case domain.StateWaitExpense: storedMsg := &tele.Message{ID: userCtx.LastMsgID, Chat: c.Chat()} - purchase, err := h.fundUC.AddExpense(ctx, c, userCtx.ActiveFundID) + cost, desc, err := utils.ParsePurchase(c.Text()) + if err != nil { + return err + } + err = h.fundUC.AddExpense(ctx, userCtx.ActiveFundID, userCtx.InternalID, desc, cost) if err != nil { return h.error(c, err.Error(), err.Error(), Edit) } userCtx.State = domain.StateViewSuccessExp - msg := fmt.Sprintf("✅You successfully added a purchase at your fund\n\nAmount💲: %.2f\nDescription📝: %s", purchase.Amount, purchase.Description) + msg := fmt.Sprintf("✅You successfully added a purchase at your fund\n\nAmount💲: %.2f\nDescription📝: %s", cost, desc) _, err = c.Bot().Edit(storedMsg, msg, h.BackMenu(), tele.ModeHTML) return err case domain.StateWaitFundName: @@ -148,7 +158,7 @@ func (h *BotHandler) OnText(c tele.Context) error { botName := os.Getenv("BOT_NAME") InviteCodeInviteURL := utils.GenerateInviteCodeURL(InviteCode, botName) fund := domain.Fund{ - AuthorID: id, + AuthorID: userCtx.InternalID, Name: text, InviteCode: InviteCode, } @@ -158,7 +168,7 @@ func (h *BotHandler) OnText(c tele.Context) error { } slog.Info("Setting up fund", slog.Int("FundID", fund.ID), - slog.Int64("AuthorID", id), + slog.Int64("AuthorID", userCtx.InternalID), slog.String("Name", fund.Name), slog.String("ICode", fund.InviteCode), ) @@ -179,12 +189,12 @@ func (h *BotHandler) OnText(c tele.Context) error { return h.error(c, "Failed to get fund", err.Error(), Edit) } - err = h.fundUC.AddMember(ctx, fund, id) + err = h.fundUC.AddMember(ctx, fund.ID, userCtx.InternalID) if err != nil { if strings.Contains(err.Error(), "SQLSTATE 23505") { storedMsg := &tele.Message{ID: userCtx.LastMsgID, Chat: c.Chat()} msg := "You already exist in this fund✅" - slog.Info("User already exist in fund", "user_id", id, "fund_id", fund.ID) + slog.Info("User already exist in fund", "user_id", userCtx.InternalID, "fund_id", fund.ID) _, err = c.Bot().Edit(storedMsg, msg, h.BackMenu(), tele.ModeHTML) return err } @@ -199,13 +209,38 @@ func (h *BotHandler) OnText(c tele.Context) error { return h.error(c, "Failed to edit fund", err.Error(), Edit) } userCtx.LastMsgID = ctxMsg.ID - slog.Info("Setting up fund join code", "id", id) - case domain.StateNone, domain.StateViewHistory, domain.StateFundMenu, domain.StateViewFund, domain.StateViewSettleUp, domain.StateViewMembers, domain.StateViewSuccessExp: + slog.Info("Setting up fund join code", "id", userCtx.InternalID) + case domain.StateWaitUsername: + IID, err := h.userUC.CreateVirtualUser(ctx, fullText) + if err != nil { + return h.error(c, "Failed to create user", err.Error(), Edit) + } + err = h.fundUC.AddMember(ctx, userCtx.ActiveFundID, IID) + if err != nil { + slog.Debug("ADD MEMBER METHOD ERR", "err", err, "user_id", userCtx.ActiveFundID, "fund_id", userCtx.ActiveFundID) + return h.error(c, "Failed to add fund", err.Error(), Edit) + } + userCtx.State = domain.StateSuccessAVU + storedMsg := &tele.Message{ID: userCtx.LastMsgID, Chat: c.Chat()} + msg := fmt.Sprintf( + "✅ Member %s(IID: %d) successfully added to the fund!\n\n"+ + "You can now select them when logging new expenses. 🧾", + text, IID, + ) + _, err = c.Bot().Edit(storedMsg, msg, h.BackMenu(), tele.ModeHTML) + if err != nil { + return h.error(c, "Failed to edit fund", err.Error(), Edit) + } + return err + case domain.StateNone, domain.StateViewHistory, domain.StateFundMenu, + domain.StateViewFund, domain.StateViewSettleUp, domain.StateViewMembers, + domain.StateViewSuccessExp, domain.StateWaitToRemove, domain.StateRemovedSuccess, + domain.StateSuccessAVU: storedMsg := &tele.Message{ID: userCtx.LastMsgID, Chat: c.Chat()} msg := "No answer" _, err := c.Bot().Edit(storedMsg, msg, h.BackMenu(), tele.ModeHTML) if err != nil { - slog.Error("error to edit message", "id", id, "err", err.Error()) + slog.Error("error to edit message", "id", userCtx.InternalID, "err", err.Error()) return err } @@ -217,7 +252,7 @@ func (h *BotHandler) OnText(c tele.Context) error { } func (h *BotHandler) error(c tele.Context, userMsg string, techMsg string, mode SendMode) error { - slog.Error("Technical error", "msg", techMsg, "user_id", c.Sender().ID) + slog.Error("Technical error", "msg", techMsg, "tg_id", c.Sender().ID) displayMsg := "⚠️ Oops! Something went wrong\n\n" + userMsg @@ -225,12 +260,12 @@ func (h *BotHandler) error(c tele.Context, userMsg string, techMsg string, mode _ = c.Respond() } ctx := context.Background() - userCtx, err := h.statesUC.GetUserCtx(ctx, c.Sender().ID) + userCtx, err := h.statesUC.GetUserCtx(ctx, &c.Sender().ID) if err != nil { return fmt.Errorf("error getting user context: %s", err.Error()) } defer func() { - err := h.statesUC.SaveUserCtx(ctx, c.Sender().ID, userCtx) + err := h.statesUC.SaveUserCtx(ctx, &c.Sender().ID, userCtx) if err != nil { slog.Error("error saving user context", "user_id", c.Sender().ID, "err", err.Error()) return diff --git a/internal/delivery/telegram/handlers_funds.go b/internal/delivery/telegram/handlers_funds.go index cd67ea3..f36b1ad 100644 --- a/internal/delivery/telegram/handlers_funds.go +++ b/internal/delivery/telegram/handlers_funds.go @@ -156,7 +156,7 @@ func (h *BotHandler) HandleFund(c tele.Context) error { if err != nil { return h.error(c, "Internal Error, failed to get info about this fund, try again later", err.Error(), Edit) } - author, err := h.userUC.GetUser(ctx, fund.AuthorID) + author, err := h.userUC.GetUserByIID(ctx, userCtx.InternalID) if err != nil { return h.error(c, "Internal Error, try again later", err.Error(), Edit) } @@ -280,7 +280,7 @@ func (h *BotHandler) HandleSettleUp(c tele.Context) error { } msg := fmt.Sprintf("⚖️ Settlement for «%s»\n\n", fund.Name) msg += fmt.Sprintf("💵 Total Spent: %.2f\n", balance.TotalAmount) - msg += fmt.Sprintf("🎯 Average per person: %.2f\n\n", balance.Average) + msg += fmt.Sprintf("🎯 Average per person: %.2f\n\n\n", balance.Average) members, err := h.fundUC.GetMembers(ctx, userCtx.ActiveFundID) if err != nil { return err @@ -291,10 +291,10 @@ func (h *BotHandler) HandleSettleUp(c tele.Context) error { } else { usernames := make(map[int64]string) for _, m := range members { - usernames[m.TgID] = m.GetDisplayName() + usernames[m.ID] = m.GetDisplayName() } for _, debt := range balance.Debts { - msg += fmt.Sprintf("🔴%s ➡️➡️ %.2f ➡️➡️ %s", usernames[debt.FromID], debt.Amount, usernames[debt.ToID]) + msg += fmt.Sprintf("🔴%s ➡️➡️ %.2f ➡️➡️ %s\n\n", usernames[debt.FromID], debt.Amount, usernames[debt.ToID]) } } @@ -308,7 +308,6 @@ func (h *BotHandler) HandleMembers(c tele.Context) error { return h.error(c, "Internal error try again later", err.Error(), Edit) } defer saveCtx() - userCtx.LastMsgID = c.Message().ID userCtx.State = domain.StateViewMembers members, err := h.fundUC.GetMembers(context.Background(), userCtx.ActiveFundID) if err != nil { @@ -321,12 +320,13 @@ func (h *BotHandler) HandleMembers(c tele.Context) error { name += " (" + m.GetDisplayName() + ")" msg += fmt.Sprintf("%d. %s\n", i+1, name) } - - return c.Edit(msg, h.BackMenu(), tele.ModeHTML) + storedMsg := &tele.Message{ID: userCtx.LastMsgID, Chat: c.Chat()} + _, err = c.Bot().Edit(storedMsg, msg, h.MenuMembersView(), tele.ModeHTML) + return err } func (h *BotHandler) getUserCtxH(c tele.Context, ctx context.Context) (*domain.UserContext, func(), error) { - id := c.Sender().ID + id := &c.Sender().ID userCtx, err := h.statesUC.GetUserCtx(ctx, id) if err != nil { @@ -334,6 +334,14 @@ func (h *BotHandler) getUserCtxH(c tele.Context, ctx context.Context) (*domain.U return nil, nil, err } + if userCtx.InternalID == 0 { + internalID, err := h.userUC.GetOrCreateRealUser(ctx, id, c.Sender().Username, c.Sender().FirstName) + if err != nil { + _ = h.error(c, "Internal error try again later", err.Error(), Edit) + } + userCtx.InternalID = internalID + } + saveFunc := func() { if saveErr := h.statesUC.SaveUserCtx(ctx, id, userCtx); saveErr != nil { slog.Error("Failed to save user context", "err", saveErr) @@ -342,3 +350,82 @@ func (h *BotHandler) getUserCtxH(c tele.Context, ctx context.Context) (*domain.U return userCtx, saveFunc, nil } + +func (h *BotHandler) HandleWaitAddUser(c tele.Context) error { + ctx := context.Background() + userCtx, saveFunc, err := h.getUserCtxH(c, ctx) + if err != nil { + return h.error(c, "Internal error try again later", err.Error(), Edit) + } + defer saveFunc() + userCtx.LastMsgID = c.Message().ID + userCtx.State = domain.StateWaitUsername + msg := "Enter the name of the new fund participant ✍️\n\nIt will be added as a virtual user (not linked to Telegram)." + storedMsg := &tele.Message{ID: userCtx.LastMsgID, Chat: c.Chat()} + _, err = c.Bot().Edit(storedMsg, msg, h.BackMenu(), tele.ModeHTML) + return err +} + +func (h *BotHandler) HandleWaitRemoveUser(c tele.Context) error { + ctx := context.Background() + offset := c.Data() + intOffset, err := strconv.Atoi(offset) + if err != nil { + intOffset = 0 + slog.Warn("Failed to parse offset", "offset", offset) + } + userCtx, saveFunc, err := h.getUserCtxH(c, ctx) + if err != nil { + return h.error(c, "Internal error try again later", err.Error(), Edit) + } + defer saveFunc() + userCtx.LastMsgID = c.Message().ID + userCtx.State = domain.StateWaitToRemove + limit := 7 + users, err := h.fundUC.GetVirtualUsers(ctx, userCtx.ActiveFundID, intOffset, limit) + if err != nil { + return h.error(c, "Internal error try again later", err.Error(), Edit) + } + msg := "Select the virtual user you want to delete" + if len(users) == 0 { + msg += "\n\nNo virtual users found🫢" + } + storedMsg := &tele.Message{ID: userCtx.LastMsgID, Chat: c.Chat()} + _, err = c.Bot().Edit(storedMsg, msg, h.MenuRemoveVUsers(intOffset, users), tele.ModeHTML) + return err +} + +func (h *BotHandler) HandleRemoveVUser(c tele.Context) error { + ctx := context.Background() + IIDtoDelete, err := strconv.ParseInt(c.Data(), 10, 64) + if err != nil { + return c.Respond(&tele.CallbackResponse{ + Text: "❌ Error: invalid IID user", + ShowAlert: true, + }) + } + userCtx, saveFunc, err := h.getUserCtxH(c, ctx) + if err != nil { + return h.error(c, "Internal error try again later", err.Error(), Edit) + } + defer saveFunc() + + err = h.fundUC.RemoveUser(ctx, userCtx.ActiveFundID, IIDtoDelete) + if err != nil { + return h.error(c, "Internal error try again later", err.Error(), Edit) + } + + err = h.userUC.DeleteUser(ctx, IIDtoDelete) + if err != nil { + return h.error(c, "Internal error try again later", err.Error(), Edit) + } + + userCtx.State = domain.StateRemovedSuccess + + msg := "🗑 Member successfully removed!\n\n" + + "They are no longer part of the fund, and their expense history has been cleared." + + storedMsg := &tele.Message{ID: userCtx.LastMsgID, Chat: c.Chat()} + _, err = c.Bot().Edit(storedMsg, msg, h.BackMenu(), tele.ModeHTML) + return err +} diff --git a/internal/delivery/telegram/menus.go b/internal/delivery/telegram/menus.go index 43eb00f..7b51537 100644 --- a/internal/delivery/telegram/menus.go +++ b/internal/delivery/telegram/menus.go @@ -33,6 +33,46 @@ func (h *BotHandler) BackMenu() *tele.ReplyMarkup { return &menu } +func (h *BotHandler) MenuRemoveVUsers(offset int, u []domain.User) *tele.ReplyMarkup { + menu := tele.ReplyMarkup{ResizeKeyboard: true} + limit := 7 + btnPrev := menu.Data("⬅️ Prev", CommandPrevRVU, strconv.Itoa(offset-limit)) + btnNext := menu.Data("Next ➡️", CommandNextRVU, strconv.Itoa(offset+limit)) + btnBack := menu.Data("⬅️⬅️Back", CommandBack) + var rows []tele.Row + for _, vUser := range u { + btn := menu.Data(vUser.FirstName, CommandRemoveUser, strconv.FormatInt(vUser.ID, 10)) + rows = append(rows, menu.Row(btn)) + } + var row []tele.Btn + + if offset > 0 { + row = append(row, btnPrev) + } + if len(u) == limit { + row = append(row, btnNext) + } + if len(row) > 0 { + rows = append(rows, row) + } + rows = append(rows, menu.Row(btnBack)) + menu.Inline(rows...) + return &menu +} + +func (h *BotHandler) MenuMembersView() *tele.ReplyMarkup { + menu := tele.ReplyMarkup{ResizeKeyboard: true} + btnBack := menu.Data("🔙🔙Back", CommandBack) + btnAddVirtualUser := menu.Data("➕Add Virtual User", CommandAddUser) + btnDeleteVirtualUser := menu.Data("➖Remove Virtual User", CommandSelectToRemoveUser) + menu.Inline( + menu.Row(btnAddVirtualUser), + menu.Row(btnDeleteVirtualUser), + menu.Row(btnBack), + ) + return &menu +} + func (h *BotHandler) MenuViewFundLogs(offset int, p []domain.Purchase) *tele.ReplyMarkup { menu := tele.ReplyMarkup{ResizeKeyboard: true} limit := 7 @@ -77,7 +117,12 @@ func (h *BotHandler) MyFundMenu(c tele.Context, offset int) *tele.ReplyMarkup { ctx := context.Background() menu := tele.ReplyMarkup{ResizeKeyboard: true} limit := 5 - fundsMembers, err := h.fundUC.GetByUserID(ctx, c.Sender().ID, limit, offset) + userCtx, save, err := h.getUserCtxH(c, ctx) + if err != nil { + return &menu + } + defer save() + fundsMembers, err := h.fundUC.GetByUserID(ctx, userCtx.InternalID, limit, offset) if err != nil { err := h.error(c, "Failed to get your funds", err.Error(), Edit) if err != nil { diff --git a/internal/delivery/telegram/router.go b/internal/delivery/telegram/router.go index ea9d146..bbeaa06 100644 --- a/internal/delivery/telegram/router.go +++ b/internal/delivery/telegram/router.go @@ -20,8 +20,13 @@ func (h *BotHandler) SetupRegister(b *tele.Bot) { b.Handle("\f"+CommandLogs, h.HandleHistory) b.Handle("\f"+CommandSettleUp, h.HandleSettleUp) b.Handle("\f"+CommandMembers, h.HandleMembers) + b.Handle("\f"+CommandAddUser, h.HandleWaitAddUser) + b.Handle("\f"+CommandSelectToRemoveUser, h.HandleWaitRemoveUser) b.Handle("\f"+CommandNextVFL, h.HandleHistory) b.Handle("\f"+CommandPreviousVFL, h.HandleHistory) + b.Handle("\f"+CommandNextRVU, h.HandleWaitRemoveUser) + b.Handle("\f"+CommandPrevRVU, h.HandleWaitRemoveUser) + b.Handle("\f"+CommandRemoveUser, h.HandleRemoveVUser) b.Handle(tele.OnText, h.OnText) slog.Info("Setting up handlers") } diff --git a/internal/domain/interfaces.go b/internal/domain/interfaces.go index 255a6b2..d0b4d26 100644 --- a/internal/domain/interfaces.go +++ b/internal/domain/interfaces.go @@ -2,31 +2,32 @@ package domain import ( "context" - - tele "gopkg.in/telebot.v4" ) type FundUsecase interface { GetBalance(ctx context.Context, fundID int) (*Settlement, error) - AddExpense(ctx context.Context, c tele.Context, fundID int) (*Purchase, error) + AddExpense(ctx context.Context, fundID int, id int64, desc string, cost float64) error CreateFund(ctx context.Context, fund *Fund) (*Fund, error) GetInfo(ctx context.Context, reqFund *Fund) (*Fund, error) - GetByUserID(ctx context.Context, userID int64, limit int, offset int) ([]Fund, error) - AddMember(ctx context.Context, fund *Fund, userID int64) error - IsMember(ctx context.Context, fundID int, userID int64) (bool, error) + GetByUserID(ctx context.Context, IID int64, limit int, offset int) ([]Fund, error) + AddMember(ctx context.Context, fundID int, IID int64) error + IsMember(ctx context.Context, fundID int, IID int64) (bool, error) GetMembers(ctx context.Context, fundID int) ([]User, error) - + GetVirtualUsers(ctx context.Context, fundID int, offset, limit int) ([]User, error) + RemoveUser(ctx context.Context, fundID int, userID int64) error GetPurchasesByFundPagination(ctx context.Context, fundID int, limit int, offset int) ([]Purchase, error) - CreatePurchase(ctx context.Context, purchase *Purchase) error + CreatePurchase(ctx context.Context, fundID int, amount float64, IID int64, desc string) error } type UserUsecase interface { - CreateUser(ctx context.Context, u *User) (*User, error) - GetUser(ctx context.Context, tgID int64) (*User, error) + GetOrCreateRealUser(ctx context.Context, tgID *int64, username string, firstName string) (int64, error) + CreateVirtualUser(ctx context.Context, firstName string) (int64, error) + GetUserByIID(ctx context.Context, iID int64) (*User, error) + DeleteUser(ctx context.Context, iID int64) error } type StatesUsecase interface { - GetUserCtx(ctx context.Context, userID int64) (*UserContext, error) - SaveUserCtx(ctx context.Context, userID int64, value *UserContext) error + GetUserCtx(ctx context.Context, tgID *int64) (*UserContext, error) + SaveUserCtx(ctx context.Context, tgID *int64, value *UserContext) error } diff --git a/internal/domain/states.go b/internal/domain/states.go index 0d3494d..b2496c9 100644 --- a/internal/domain/states.go +++ b/internal/domain/states.go @@ -6,6 +6,7 @@ type UserContext struct { State State `json:"state"` LastMsgID int `json:"last_msg_id"` ActiveFundID int `json:"active_fund_id"` + InternalID int64 `json:"internal_id"` } const ( @@ -19,4 +20,8 @@ const ( StateViewSuccessExp StateViewSettleUp StateViewMembers + StateWaitUsername + StateSuccessAVU + StateWaitToRemove + StateRemovedSuccess ) diff --git a/internal/domain/user.go b/internal/domain/user.go index 7963736..1d44253 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -6,9 +6,11 @@ import ( ) type User struct { - TgID int64 + ID int64 + TgID *int64 Username string FirstName string + IsVirtual bool CreatedAt time.Time } @@ -19,5 +21,5 @@ func (u User) GetDisplayName() string { if u.FirstName != "" && u.FirstName != "." { return u.FirstName } - return fmt.Sprintf("User_%d", u.TgID%10000) + return fmt.Sprintf("User_%d", *u.TgID%10000) } diff --git a/internal/repository/postgres/FundRepository.go b/internal/repository/postgres/FundRepository.go index 92a142a..e9b928d 100644 --- a/internal/repository/postgres/FundRepository.go +++ b/internal/repository/postgres/FundRepository.go @@ -32,7 +32,7 @@ func (r *FundRepository) CreateFund(ctx context.Context, fund *domain.Fund) (*do slog.Error("Failed to rollback transaction", slog.Any("err", err)) } }() - + err = r.DB.QueryRow(ctx, `INSERT INTO app.funds (name, author_id, invite_code) VALUES ($1, $2, $3) @@ -96,9 +96,9 @@ func (r *FundRepository) GetByUserID(ctx context.Context, userID int64, limit in return funds, nil } -func (r *FundRepository) AddMember(ctx context.Context, fund *domain.Fund, userID int64) error { +func (r *FundRepository) AddMember(ctx context.Context, fundID int, userID int64) error { queryMember := `INSERT INTO app.fund_members (fund_id, user_id) VALUES ($1, $2)` - _, err := r.DB.Exec(ctx, queryMember, fund.ID, userID) + _, err := r.DB.Exec(ctx, queryMember, fundID, userID) return err } @@ -116,9 +116,9 @@ func (r *FundRepository) IsMember(ctx context.Context, fundID int, userID int64) func (r *FundRepository) GetMembers(ctx context.Context, fundID int) ([]domain.User, error) { var users []domain.User - query := `SELECT f.user_id, u.username, first_name + query := `SELECT f.user_id, COALESCE(u.tg_id, -1), COALESCE(u.username, ''), first_name FROM app.fund_members f - JOIN app.users u ON f.user_id = u.tg_id + JOIN app.users u ON f.user_id = u.id WHERE fund_id = $1` rows, err := r.DB.Query(ctx, query, fundID) @@ -128,7 +128,7 @@ func (r *FundRepository) GetMembers(ctx context.Context, fundID int) ([]domain.U defer rows.Close() for rows.Next() { var user domain.User - err = rows.Scan(&user.TgID, &user.Username, &user.FirstName) + err = rows.Scan(&user.ID, &user.TgID, &user.Username, &user.FirstName) if err != nil { return nil, err } @@ -136,3 +136,33 @@ func (r *FundRepository) GetMembers(ctx context.Context, fundID int) ([]domain.U } return users, nil } + +func (r *FundRepository) GetVirtualUsers(ctx context.Context, fundID int, offset, limit int) ([]domain.User, error) { + var users []domain.User + query := `SELECT fm.user_id, u.first_name +FROM app.fund_members fm +JOIN app.users u ON fm.user_id = u.id +WHERE fm.fund_id = $1 AND u.is_virtual = true +LIMIT $2 OFFSET $3` + rows, err := r.DB.Query(ctx, query, fundID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var user domain.User + err = rows.Scan(&user.ID, &user.FirstName) + if err != nil { + return nil, err + } + users = append(users, user) + } + return users, nil +} + +func (r *FundRepository) RemoveUser(ctx context.Context, fundID int, userID int64) error { + query := `DELETE FROM app.fund_members +WHERE fund_id = $1 AND user_id = $2` + _, err := r.DB.Exec(ctx, query, fundID, userID) + return err +} diff --git a/internal/repository/postgres/PurchaseRepository.go b/internal/repository/postgres/PurchaseRepository.go index 148ffa5..06e4fc7 100644 --- a/internal/repository/postgres/PurchaseRepository.go +++ b/internal/repository/postgres/PurchaseRepository.go @@ -20,9 +20,9 @@ func NewPurchaseRepository(pool *pgxpool.Pool) *PurchaseRepository { func (r *PurchaseRepository) GetPurchasesByFundPagination(ctx context.Context, fundID int, limit int, offset int) ([]domain.Purchase, error) { query := ` -SELECT p.id, p.fund_id, p.payer_id, u.username, u.first_name, p.amount, p.description, p.created_at +SELECT p.id, p.fund_id, p.payer_id, u.tg_id, u.username, u.first_name, p.amount, p.description, p.created_at FROM app.purchases p -JOIN app.users u ON p.payer_id = u.tg_id +JOIN app.users u ON p.payer_id = u.id WHERE p.fund_id = $1 ORDER BY created_at DESC OFFSET $2 LIMIT $3 @@ -34,7 +34,7 @@ OFFSET $2 LIMIT $3 var funds []domain.Purchase for rows.Next() { var tempPurchase domain.Purchase - err = rows.Scan(&tempPurchase.ID, &tempPurchase.FundID, &tempPurchase.Payer.TgID, &tempPurchase.Payer.Username, &tempPurchase.Payer.FirstName, &tempPurchase.Amount, &tempPurchase.Description, &tempPurchase.CreatedAt) + err = rows.Scan(&tempPurchase.ID, &tempPurchase.FundID, &tempPurchase.Payer.ID, &tempPurchase.Payer.TgID, &tempPurchase.Payer.Username, &tempPurchase.Payer.FirstName, &tempPurchase.Amount, &tempPurchase.Description, &tempPurchase.CreatedAt) if err != nil { return nil, err } @@ -45,9 +45,9 @@ OFFSET $2 LIMIT $3 func (r *PurchaseRepository) GetPurchasesByFundAll(ctx context.Context, fundID int) ([]domain.Purchase, error) { query := ` -SELECT p.id, p.fund_id, p.payer_id, u.username, u.first_name, p.amount, p.description, p.created_at +SELECT p.id, p.fund_id, p.payer_id, u.tg_id, u.username, u.first_name, p.amount, p.description, p.created_at FROM app.purchases p -JOIN app.users u ON p.payer_id = u.tg_id +JOIN app.users u ON p.payer_id = u.id WHERE p.fund_id = $1 ORDER BY created_at DESC ` @@ -58,7 +58,7 @@ ORDER BY created_at DESC var funds []domain.Purchase for rows.Next() { var tempPurchase domain.Purchase - err = rows.Scan(&tempPurchase.ID, &tempPurchase.FundID, &tempPurchase.Payer.TgID, &tempPurchase.Payer.Username, &tempPurchase.Payer.FirstName, &tempPurchase.Amount, &tempPurchase.Description, &tempPurchase.CreatedAt) + err = rows.Scan(&tempPurchase.ID, &tempPurchase.FundID, &tempPurchase.Payer.ID, &tempPurchase.Payer.TgID, &tempPurchase.Payer.Username, &tempPurchase.Payer.FirstName, &tempPurchase.Amount, &tempPurchase.Description, &tempPurchase.CreatedAt) if err != nil { return nil, err } @@ -67,11 +67,11 @@ ORDER BY created_at DESC return funds, nil } -func (r *PurchaseRepository) CreatePurchase(ctx context.Context, purchase *domain.Purchase) error { +func (r *PurchaseRepository) CreatePurchase(ctx context.Context, fundID int, amount float64, IID int64, desc string) error { query := `INSERT INTO app.purchases (fund_id, payer_id, amount, description) VALUES ($1, $2, $3, $4) ` - _, err := r.DB.Exec(ctx, query, purchase.FundID, purchase.Payer.TgID, purchase.Amount, purchase.Description) + _, err := r.DB.Exec(ctx, query, fundID, IID, amount, desc) return err } diff --git a/internal/repository/postgres/UserRepository.go b/internal/repository/postgres/UserRepository.go index a2d077e..d7f76d0 100644 --- a/internal/repository/postgres/UserRepository.go +++ b/internal/repository/postgres/UserRepository.go @@ -20,25 +20,51 @@ func NewUserRepository(db *pgxpool.Pool) *UserRepository { return &UserRepository{DB: db} } -func (r *UserRepository) CreateUser(ctx context.Context, u *domain.User) (*domain.User, error) { - err := r.DB.QueryRow(ctx, `INSERT INTO app.users (tg_id, username, first_name) - VALUES ($1, $2, $3) - ON CONFLICT (tg_id) DO NOTHING - RETURNING created_at`, u.TgID, u.Username, u.FirstName).Scan(&u.CreatedAt) +func (r *UserRepository) GetOrCreateRealUser(ctx context.Context, tgID *int64, username, firstName string) (int64, error) { + var id int64 + query := ` + INSERT INTO app.users (tg_id, username, first_name, is_virtual) + VALUES ($1, $2, $3, false) + ON CONFLICT (tg_id) DO UPDATE + SET username = EXCLUDED.username, first_name = EXCLUDED.first_name + RETURNING id` + err := r.DB.QueryRow(ctx, query, *tgID, username, firstName).Scan(&id) if errors.Is(err, pgx.ErrNoRows) { - return u, nil + return id, nil } else if err != nil { - return nil, err + return id, err + } + return id, nil +} + +func (r *UserRepository) CreateVirtualUser(ctx context.Context, firstName string) (int64, error) { + var id int64 + query := ` + INSERT INTO app.users (first_name, is_virtual) + VALUES ($1, true) + RETURNING id` + err := r.DB.QueryRow(ctx, query, firstName).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return id, nil + } else if err != nil { + return id, err } - return u, nil + return id, nil } -func (r *UserRepository) GetUser(ctx context.Context, tgID int64) (*domain.User, error) { +func (r *UserRepository) GetUserByIID(ctx context.Context, iID int64) (*domain.User, error) { var u domain.User - u.TgID = tgID - err := r.DB.QueryRow(ctx, `SELECT username, first_name, created_at FROM app.users WHERE tg_id = $1`, tgID).Scan(&u.Username, &u.FirstName, &u.CreatedAt) + query := ` +SELECT tg_id, username, first_name, is_virtual, created_at FROM app.users WHERE id = $1` + err := r.DB.QueryRow(ctx, query, iID).Scan(&u.TgID, &u.Username, &u.FirstName, &u.IsVirtual, &u.CreatedAt) if err != nil { return nil, err } return &u, nil } + +func (r *UserRepository) DeleteUser(ctx context.Context, iID int64) error { + query := `DELETE FROM app.users WHERE id = $1` + _, err := r.DB.Exec(ctx, query, iID) + return err +} diff --git a/internal/repository/postgres_migrations/000001_init.up.sql b/internal/repository/postgres_migrations/000001_init.up.sql index 187b958..cd94f75 100644 --- a/internal/repository/postgres_migrations/000001_init.up.sql +++ b/internal/repository/postgres_migrations/000001_init.up.sql @@ -2,30 +2,32 @@ CREATE SCHEMA app; CREATE TABLE IF NOT EXISTS app.users ( - tg_id BIGINT PRIMARY KEY, + id SERIAL PRIMARY KEY, + tg_id BIGINT UNIQUE, username TEXT, first_name TEXT NOT NULL, + is_virtual BOOLEAN DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS app.funds ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, - author_id BIGINT REFERENCES app.users(tg_id), + author_id BIGINT REFERENCES app.users(id), invite_code TEXT UNIQUE NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS app.fund_members ( fund_id BIGINT REFERENCES app.funds(id) ON DELETE CASCADE, - user_id BIGINT REFERENCES app.users(tg_id) ON DELETE CASCADE, + user_id BIGINT REFERENCES app.users(id) ON DELETE CASCADE, PRIMARY KEY (fund_id, user_id) ); CREATE TABLE IF NOT EXISTS app.purchases ( id SERIAL PRIMARY KEY, fund_id INTEGER REFERENCES app.funds(id) ON DELETE CASCADE, - payer_id BIGINT REFERENCES app.users(tg_id), + payer_id BIGINT REFERENCES app.users(id), amount NUMERIC(10, 2) NOT NULL, description TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() diff --git a/internal/repository/redisRepository/redis_repository.go b/internal/repository/redisRepository/redis_repository.go index 3695fe5..2c8c901 100644 --- a/internal/repository/redisRepository/redis_repository.go +++ b/internal/repository/redisRepository/redis_repository.go @@ -19,8 +19,8 @@ func NewRepository(rdb *redis.Client) *Repository { return &Repository{rdb: rdb} } -func (r *Repository) SaveUserCtx(ctx context.Context, userID int64, value *domain.UserContext) error { - key := fmt.Sprintf("user:%d", userID) +func (r *Repository) SaveUserCtx(ctx context.Context, tgID *int64, value *domain.UserContext) error { + key := fmt.Sprintf("user:%d", *tgID) data, err := utils.EncodeJSON[domain.UserContext](*value) if err != nil { return err @@ -28,8 +28,8 @@ func (r *Repository) SaveUserCtx(ctx context.Context, userID int64, value *domai return r.rdb.Set(ctx, key, data, 24*time.Hour).Err() } -func (r *Repository) GetUserCtx(ctx context.Context, userID int64) (*domain.UserContext, error) { - key := fmt.Sprintf("user:%d", userID) +func (r *Repository) GetUserCtx(ctx context.Context, tgID *int64) (*domain.UserContext, error) { + key := fmt.Sprintf("user:%d", *tgID) data, err := r.rdb.Get(ctx, key).Bytes() if err != nil { return &domain.UserContext{}, err diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 0a24ff9..e6cf614 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -7,8 +7,10 @@ import ( ) type UserRepository interface { - CreateUser(ctx context.Context, u *domain.User) (*domain.User, error) - GetUser(ctx context.Context, tgID int64) (*domain.User, error) + GetOrCreateRealUser(ctx context.Context, tgID *int64, username, firstName string) (int64, error) + CreateVirtualUser(ctx context.Context, firstName string) (int64, error) + GetUserByIID(ctx context.Context, iID int64) (*domain.User, error) + DeleteUser(ctx context.Context, iID int64) error } type FundRepository interface { @@ -16,18 +18,19 @@ type FundRepository interface { GetInfo(ctx context.Context, reqFund *domain.Fund) (*domain.Fund, error) GetByUserID(ctx context.Context, userID int64, limit int, offset int) ([]domain.Fund, error) GetMembers(ctx context.Context, fundID int) ([]domain.User, error) - - AddMember(ctx context.Context, fund *domain.Fund, userID int64) error - IsMember(ctx context.Context, fundID int, userID int64) (bool, error) + GetVirtualUsers(ctx context.Context, fundID int, offset, limit int) ([]domain.User, error) + RemoveUser(ctx context.Context, fundID int, userID int64) error + AddMember(ctx context.Context, fundID int, userID int64) error + IsMember(ctx context.Context, fundID int, IID int64) (bool, error) } type PurchaseRepository interface { GetPurchasesByFundPagination(ctx context.Context, fundID int, limit int, offset int) ([]domain.Purchase, error) GetPurchasesByFundAll(ctx context.Context, fundID int) ([]domain.Purchase, error) - CreatePurchase(ctx context.Context, purchase *domain.Purchase) error + CreatePurchase(ctx context.Context, fundID int, amount float64, IID int64, desc string) error } type RedisRepository interface { - GetUserCtx(ctx context.Context, userID int64) (*domain.UserContext, error) - SaveUserCtx(ctx context.Context, userID int64, value *domain.UserContext) error + GetUserCtx(ctx context.Context, userID *int64) (*domain.UserContext, error) + SaveUserCtx(ctx context.Context, userID *int64, value *domain.UserContext) error } diff --git a/internal/usecase/fund_test.go b/internal/usecase/fund_test.go index c561ec8..b638b50 100644 --- a/internal/usecase/fund_test.go +++ b/internal/usecase/fund_test.go @@ -20,13 +20,13 @@ func TestSettleUp(t *testing.T) { name: "One paid for three", purchases: []domain.Purchase{ { - ID: 1, FundID: 1, Payer: domain.User{TgID: 1, Username: "FirstPayer"}, Amount: 300, Description: "First payment description", + ID: 1, FundID: 1, Payer: domain.User{ID: 1, Username: "FirstPayer"}, Amount: 300, Description: "First payment description", }, }, members: []domain.User{ - {TgID: 1, Username: "FirstUser"}, - {TgID: 2, Username: "SecondUser"}, - {TgID: 3, Username: "ThirdUser"}, + {ID: 1, Username: "FirstUser"}, + {ID: 2, Username: "SecondUser"}, + {ID: 3, Username: "ThirdUser"}, }, expected: &domain.Settlement{ TotalAmount: 300, @@ -45,19 +45,19 @@ func TestSettleUp(t *testing.T) { name: "Equally", purchases: []domain.Purchase{ { - ID: 1, FundID: 1, Payer: domain.User{TgID: 1, Username: "FirstPayer"}, Amount: 300, Description: "First payment description", + ID: 1, FundID: 1, Payer: domain.User{ID: 1, Username: "FirstPayer"}, Amount: 300, Description: "First payment description", }, { - ID: 2, FundID: 1, Payer: domain.User{TgID: 2, Username: "SecondUser"}, Amount: 300, Description: "Second payment description", + ID: 2, FundID: 1, Payer: domain.User{ID: 2, Username: "SecondUser"}, Amount: 300, Description: "Second payment description", }, { - ID: 3, FundID: 1, Payer: domain.User{TgID: 3, Username: "ThirdUser"}, Amount: 300, Description: "Third payment description", + ID: 3, FundID: 1, Payer: domain.User{ID: 3, Username: "ThirdUser"}, Amount: 300, Description: "Third payment description", }, }, members: []domain.User{ - {TgID: 1, Username: "FirstUser"}, - {TgID: 2, Username: "SecondUser"}, - {TgID: 3, Username: "ThirdUser"}, + {ID: 1, Username: "FirstUser"}, + {ID: 2, Username: "SecondUser"}, + {ID: 3, Username: "ThirdUser"}, }, expected: &domain.Settlement{ TotalAmount: 900, @@ -69,19 +69,19 @@ func TestSettleUp(t *testing.T) { name: "Complex float precision", purchases: []domain.Purchase{ { - ID: 1, FundID: 1, Payer: domain.User{TgID: 1, Username: "FirstPayer"}, Amount: 123.52, Description: "First payment description", + ID: 1, FundID: 1, Payer: domain.User{ID: 1, Username: "FirstPayer"}, Amount: 123.52, Description: "First payment description", }, { - ID: 2, FundID: 1, Payer: domain.User{TgID: 2, Username: "SecondUser"}, Amount: 2000, Description: "Second payment description", + ID: 2, FundID: 1, Payer: domain.User{ID: 2, Username: "SecondUser"}, Amount: 2000, Description: "Second payment description", }, { - ID: 3, FundID: 1, Payer: domain.User{TgID: 3, Username: "ThirdUser"}, Amount: 30, Description: "Third payment description", + ID: 3, FundID: 1, Payer: domain.User{ID: 3, Username: "ThirdUser"}, Amount: 30, Description: "Third payment description", }, }, members: []domain.User{ - {TgID: 1, Username: "FirstUser"}, - {TgID: 2, Username: "SecondUser"}, - {TgID: 3, Username: "ThirdUser"}, + {ID: 1, Username: "FirstUser"}, + {ID: 2, Username: "SecondUser"}, + {ID: 3, Username: "ThirdUser"}, }, expected: &domain.Settlement{ TotalAmount: 2153.52, @@ -100,23 +100,23 @@ func TestSettleUp(t *testing.T) { name: "SomeTest", purchases: []domain.Purchase{ { - ID: 1, FundID: 1, Payer: domain.User{TgID: 1, Username: "FirstPayer"}, Amount: 123.52, Description: "First payment description", + ID: 1, FundID: 1, Payer: domain.User{ID: 1, Username: "FirstPayer"}, Amount: 123.52, Description: "First payment description", }, { - ID: 2, FundID: 1, Payer: domain.User{TgID: 2, Username: "SecondUser"}, Amount: 2000, Description: "Second payment description", + ID: 2, FundID: 1, Payer: domain.User{ID: 2, Username: "SecondUser"}, Amount: 2000, Description: "Second payment description", }, { - ID: 3, FundID: 1, Payer: domain.User{TgID: 3, Username: "ThirdUser"}, Amount: 30, Description: "Third payment description", + ID: 3, FundID: 1, Payer: domain.User{ID: 3, Username: "ThirdUser"}, Amount: 30, Description: "Third payment description", }, { - ID: 4, FundID: 1, Payer: domain.User{TgID: 4, Username: "ThirdUser"}, Amount: 3000, Description: "Third payment description", + ID: 4, FundID: 1, Payer: domain.User{ID: 4, Username: "ThirdUser"}, Amount: 3000, Description: "Third payment description", }, }, members: []domain.User{ - {TgID: 1, Username: "FirstUser"}, - {TgID: 2, Username: "SecondUser"}, - {TgID: 3, Username: "ThirdUser"}, - {TgID: 4, Username: "4th user"}, + {ID: 1, Username: "FirstUser"}, + {ID: 2, Username: "SecondUser"}, + {ID: 3, Username: "ThirdUser"}, + {ID: 4, Username: "4th user"}, }, expected: &domain.Settlement{TotalAmount: 5153.52, Average: 1288.38, Debts: []domain.Debt{ {FromID: 1, ToID: 2, Amount: 711.62}, diff --git a/internal/usecase/fund_usecase.go b/internal/usecase/fund_usecase.go index 51a474d..5478acd 100644 --- a/internal/usecase/fund_usecase.go +++ b/internal/usecase/fund_usecase.go @@ -7,10 +7,7 @@ import ( "math" "github.com/ganfay/split-core/internal/domain" - "github.com/ganfay/split-core/internal/pkg/utils" "github.com/ganfay/split-core/internal/repository" - - tele "gopkg.in/telebot.v4" ) type FundUsecase struct { @@ -43,20 +40,20 @@ func calculateSettlements(purchases []domain.Purchase, members []domain.User) *d m := make(map[int64]float64) for _, purchase := range purchases { totalAmount += purchase.Amount - m[purchase.Payer.TgID] += purchase.Amount + m[purchase.Payer.ID] += purchase.Amount } averageAmount := totalAmount / float64(len(members)) var creditors []int64 var debtors []int64 for _, member := range members { - m[member.TgID] = m[member.TgID] - averageAmount + m[member.ID] = m[member.ID] - averageAmount - bal := m[member.TgID] + bal := m[member.ID] if bal > 0.01 { - creditors = append(creditors, member.TgID) + creditors = append(creditors, member.ID) } else if bal < -0.01 { - debtors = append(debtors, member.TgID) + debtors = append(debtors, member.ID) } } var debts []domain.Debt @@ -92,35 +89,23 @@ func calculateSettlements(purchases []domain.Purchase, members []domain.User) *d return settlement } -func (u *FundUsecase) AddExpense(ctx context.Context, ctxInfoAboutPurchase tele.Context, fundID int) (*domain.Purchase, error) { - isMember, err := u.fundRepository.IsMember(ctx, fundID, ctxInfoAboutPurchase.Sender().ID) +func (u *FundUsecase) AddExpense(ctx context.Context, fundID int, id int64, desc string, cost float64) error { + isMember, err := u.fundRepository.IsMember(ctx, fundID, id) if err != nil || !isMember { - return nil, err - } - cost, desc, err := utils.ParsePurchase(ctxInfoAboutPurchase.Text()) - if err != nil { - return nil, err + return err } if cost <= 0 { - return nil, errors.New("invalid amount") + return errors.New("invalid amount") } if len(desc) > 200 { desc = desc[:197] + "..." } - user := domain.User{ - TgID: ctxInfoAboutPurchase.Sender().ID, - } - purchase := &domain.Purchase{ - FundID: fundID, - Payer: user, - Amount: cost, - Description: desc, - } - err = u.purchaseRepository.CreatePurchase(ctx, purchase) + + err = u.purchaseRepository.CreatePurchase(ctx, fundID, cost, id, desc) if err != nil { - return nil, err + return err } - return purchase, nil + return nil } func (u *FundUsecase) CreateFund(ctx context.Context, fund *domain.Fund) (*domain.Fund, error) { @@ -135,12 +120,8 @@ func (u *FundUsecase) GetByUserID(ctx context.Context, userID int64, limit int, return u.fundRepository.GetByUserID(ctx, userID, limit, offset) } -func (u *FundUsecase) AddMember(ctx context.Context, fund *domain.Fund, userID int64) error { - return u.fundRepository.AddMember(ctx, fund, userID) -} - -func (u *FundUsecase) CreateMember(ctx context.Context, fund *domain.Fund, userID int64) error { - return u.fundRepository.AddMember(ctx, fund, userID) +func (u *FundUsecase) AddMember(ctx context.Context, fundID int, userID int64) error { + return u.fundRepository.AddMember(ctx, fundID, userID) } func (u *FundUsecase) IsMember(ctx context.Context, fundID int, userID int64) (bool, error) { @@ -151,8 +132,8 @@ func (u *FundUsecase) GetPurchasesByFundPagination(ctx context.Context, fundID i return u.purchaseRepository.GetPurchasesByFundPagination(ctx, fundID, limit, offset) } -func (u *FundUsecase) CreatePurchase(ctx context.Context, purchase *domain.Purchase) error { - return u.purchaseRepository.CreatePurchase(ctx, purchase) +func (u *FundUsecase) CreatePurchase(ctx context.Context, fundID int, amount float64, IID int64, desc string) error { + return u.purchaseRepository.CreatePurchase(ctx, fundID, amount, IID, desc) } func (u *FundUsecase) GetMembers(ctx context.Context, fundID int) ([]domain.User, error) { @@ -162,3 +143,10 @@ func (u *FundUsecase) GetMembers(ctx context.Context, fundID int) ([]domain.User func (u *FundUsecase) GetPurchasesByFundAll(ctx context.Context, fundID int) ([]domain.Purchase, error) { return u.purchaseRepository.GetPurchasesByFundAll(ctx, fundID) } + +func (u *FundUsecase) GetVirtualUsers(ctx context.Context, fundID int, offset, limit int) ([]domain.User, error) { + return u.fundRepository.GetVirtualUsers(ctx, fundID, offset, limit) +} +func (u *FundUsecase) RemoveUser(ctx context.Context, fundID int, userID int64) error { + return u.fundRepository.RemoveUser(ctx, fundID, userID) +} diff --git a/internal/usecase/states_usecase.go b/internal/usecase/states_usecase.go index 6e1f03a..652588c 100644 --- a/internal/usecase/states_usecase.go +++ b/internal/usecase/states_usecase.go @@ -15,10 +15,10 @@ func NewStateUsecase(redisRep repository.RedisRepository) *StatesUsecase { return &StatesUsecase{redisRep: redisRep} } -func (r *StatesUsecase) GetUserCtx(ctx context.Context, userID int64) (*domain.UserContext, error) { - return r.redisRep.GetUserCtx(ctx, userID) +func (r *StatesUsecase) GetUserCtx(ctx context.Context, tgID *int64) (*domain.UserContext, error) { + return r.redisRep.GetUserCtx(ctx, tgID) } -func (r *StatesUsecase) SaveUserCtx(ctx context.Context, userID int64, value *domain.UserContext) error { - return r.redisRep.SaveUserCtx(ctx, userID, value) +func (r *StatesUsecase) SaveUserCtx(ctx context.Context, tgID *int64, value *domain.UserContext) error { + return r.redisRep.SaveUserCtx(ctx, tgID, value) } diff --git a/internal/usecase/user_usecase.go b/internal/usecase/user_usecase.go index 9a0cce4..8bc21d4 100644 --- a/internal/usecase/user_usecase.go +++ b/internal/usecase/user_usecase.go @@ -15,10 +15,18 @@ func NewUserUsecase(repo repository.UserRepository) domain.UserUsecase { return &userUsecase{repo: repo} } -func (u *userUsecase) CreateUser(ctx context.Context, user *domain.User) (*domain.User, error) { - return u.repo.CreateUser(ctx, user) +func (u *userUsecase) GetOrCreateRealUser(ctx context.Context, tgID *int64, username string, firstName string) (int64, error) { + return u.repo.GetOrCreateRealUser(ctx, tgID, username, firstName) } -func (u *userUsecase) GetUser(ctx context.Context, tgID int64) (*domain.User, error) { - return u.repo.GetUser(ctx, tgID) +func (u *userUsecase) CreateVirtualUser(ctx context.Context, firstName string) (int64, error) { + return u.repo.CreateVirtualUser(ctx, firstName) +} + +func (u *userUsecase) GetUserByIID(ctx context.Context, iID int64) (*domain.User, error) { + return u.repo.GetUserByIID(ctx, iID) +} + +func (u *userUsecase) DeleteUser(ctx context.Context, iID int64) error { + return u.repo.DeleteUser(ctx, iID) }