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)
}