From 817291c9a71f4fe08f23eceeb5dba48ff3656e3a Mon Sep 17 00:00:00 2001 From: MaysWind Date: Mon, 26 May 2025 00:47:19 +0800 Subject: [PATCH] support user custom exchange rates data --- cmd/database.go | 8 + cmd/webserver.go | 2 + conf/ezbookkeeping.ini | 1 + pkg/api/data_managements.go | 41 +++-- pkg/api/exchange_rates.go | 73 ++++++++ pkg/errs/error.go | 27 +-- pkg/errs/user_custom_exchange_rate.go | 10 ++ .../exchange_rates_datasource_container.go | 3 + pkg/exchangerates/user_custom_datasource.go | 101 +++++++++++ pkg/models/exchange_rate.go | 82 ++++++++- pkg/services/user_custom_exchange_rates.go | 153 ++++++++++++++++ pkg/settings/setting.go | 4 +- src/lib/services.ts | 9 + src/locales/de.json | 9 + src/locales/en.json | 9 + src/locales/es.json | 9 + src/locales/it.json | 9 + src/locales/ja.json | 9 + src/locales/ru.json | 9 + src/locales/uk.json | 9 + src/locales/vi.json | 9 + src/locales/zh_Hans.json | 9 + src/locales/zh_Hant.json | 9 + src/models/exchange_rate.ts | 15 +- src/router/desktop.ts | 4 +- src/router/mobile.ts | 10 +- src/stores/exchangeRates.ts | 139 ++++++++++++++- src/stores/index.ts | 6 + src/styles/mobile/font-size-default.css | 1 + src/styles/mobile/font-size-large.css | 1 + src/styles/mobile/font-size-small.css | 1 + src/styles/mobile/font-size-x-large.css | 1 + src/styles/mobile/font-size-xx-large.css | 1 + src/styles/mobile/font-size-xxx-large.css | 1 + src/styles/mobile/font-size-xxxx-large.css | 1 + src/styles/mobile/global.css | 4 + src/views/base/ExchangeRatesPageBase.ts | 4 + .../ListPage.vue} | 90 +++++++++- .../list/dialogs/UpdateDialog.vue | 141 +++++++++++++++ .../ListPage.vue} | 164 ++++++++++++++---- src/views/mobile/exchangerates/UpdatePage.vue | 142 +++++++++++++++ 41 files changed, 1257 insertions(+), 73 deletions(-) create mode 100644 pkg/errs/user_custom_exchange_rate.go create mode 100644 pkg/exchangerates/user_custom_datasource.go create mode 100644 pkg/services/user_custom_exchange_rates.go rename src/views/desktop/{ExchangeRatesPage.vue => exchangerates/ListPage.vue} (75%) create mode 100644 src/views/desktop/exchangerates/list/dialogs/UpdateDialog.vue rename src/views/mobile/{ExchangeRatesPage.vue => exchangerates/ListPage.vue} (54%) create mode 100644 src/views/mobile/exchangerates/UpdatePage.vue diff --git a/cmd/database.go b/cmd/database.go index d2ce10da..6eda1a9f 100644 --- a/cmd/database.go +++ b/cmd/database.go @@ -133,5 +133,13 @@ func updateAllDatabaseTablesStructure(c *core.CliContext) error { log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction picture table maintained successfully") + err = datastore.Container.UserDataStore.SyncStructs(new(models.UserCustomExchangeRate)) + + if err != nil { + return err + } + + log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user custom exchange rate table maintained successfully") + return nil } diff --git a/cmd/webserver.go b/cmd/webserver.go index cd12561d..b3b524d3 100644 --- a/cmd/webserver.go +++ b/cmd/webserver.go @@ -359,6 +359,8 @@ func startWebServer(c *core.CliContext) error { // Exchange Rates apiV1Route.GET("/exchange_rates/latest.json", bindApi(api.ExchangeRates.LatestExchangeRateHandler)) + apiV1Route.POST("/exchange_rates/user_custom/update.json", bindApi(api.ExchangeRates.UserCustomExchangeRateUpdateHandler)) + apiV1Route.POST("/exchange_rates/user_custom/delete.json", bindApi(api.ExchangeRates.UserCustomExchangeRateDeleteHandler)) } } diff --git a/conf/ezbookkeeping.ini b/conf/ezbookkeeping.ini index c6d6966c..bddff559 100644 --- a/conf/ezbookkeeping.ini +++ b/conf/ezbookkeeping.ini @@ -363,6 +363,7 @@ custom_map_tile_server_default_zoom_level = 14 # "national_bank_of_ukraine": https://bank.gov.ua/ua/markets/exchangerates # "central_bank_of_uzbekistan": https://cbu.uz/en/arkhiv-kursov-valyut/ # "international_monetary_fund": https://www.imf.org/external/np/fin/data/param_rms_mth.aspx +# "user_custom": users set their own exchange rates data in the UI data_source = euro_central_bank # Requesting exchange rates data timeout (0 - 4294967295 milliseconds) diff --git a/pkg/api/data_managements.go b/pkg/api/data_managements.go index 32b26e1a..0fdb0ffd 100644 --- a/pkg/api/data_managements.go +++ b/pkg/api/data_managements.go @@ -20,14 +20,15 @@ const pageCountForDataExport = 1000 // DataManagementsApi represents data management api type DataManagementsApi struct { ApiUsingConfig - tokens *services.TokenService - users *services.UserService - accounts *services.AccountService - transactions *services.TransactionService - categories *services.TransactionCategoryService - tags *services.TransactionTagService - pictures *services.TransactionPictureService - templates *services.TransactionTemplateService + tokens *services.TokenService + users *services.UserService + accounts *services.AccountService + transactions *services.TransactionService + categories *services.TransactionCategoryService + tags *services.TransactionTagService + pictures *services.TransactionPictureService + templates *services.TransactionTemplateService + userCustomExchangeRates *services.UserCustomExchangeRatesService } // Initialize a data management api singleton instance @@ -36,14 +37,15 @@ var ( ApiUsingConfig: ApiUsingConfig{ container: settings.Container, }, - tokens: services.Tokens, - users: services.Users, - accounts: services.Accounts, - transactions: services.Transactions, - categories: services.TransactionCategories, - tags: services.TransactionTags, - pictures: services.TransactionPictures, - templates: services.TransactionTemplates, + tokens: services.Tokens, + users: services.Users, + accounts: services.Accounts, + transactions: services.Transactions, + categories: services.TransactionCategories, + tags: services.TransactionTags, + pictures: services.TransactionPictures, + templates: services.TransactionTemplates, + userCustomExchangeRates: services.UserCustomExchangeRates, } ) @@ -179,6 +181,13 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Er return nil, errs.Or(err, errs.ErrOperationFailed) } + err = a.userCustomExchangeRates.DeleteAllCustomExchangeRates(c, uid) + + if err != nil { + log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all user custom exchange rates, because %s", err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + log.Infof(c, "[data_managements.ClearDataHandler] user \"uid:%d\" has cleared all data", uid) return true, nil } diff --git a/pkg/api/exchange_rates.go b/pkg/api/exchange_rates.go index db7b32bd..84663b05 100644 --- a/pkg/api/exchange_rates.go +++ b/pkg/api/exchange_rates.go @@ -4,12 +4,17 @@ import ( "github.com/mayswind/ezbookkeeping/pkg/core" "github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/exchangerates" + "github.com/mayswind/ezbookkeeping/pkg/log" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/services" "github.com/mayswind/ezbookkeeping/pkg/settings" ) // ExchangeRatesApi represents exchange rate api type ExchangeRatesApi struct { ApiUsingConfig + users *services.UserService + userCustomExchangeRates *services.UserCustomExchangeRatesService } // Initialize a exchange rate api singleton instance @@ -18,6 +23,8 @@ var ( ApiUsingConfig: ApiUsingConfig{ container: settings.Container, }, + users: services.Users, + userCustomExchangeRates: services.UserCustomExchangeRates, } ) @@ -37,3 +44,69 @@ func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, * return exchangeRateResponse, nil } + +// UserCustomExchangeRateUpdateHandler updates user custom exchange rates data by request parameters for current user +func (a *ExchangeRatesApi) UserCustomExchangeRateUpdateHandler(c *core.WebContext) (any, *errs.Error) { + var customExchangeRateUpdateReq models.UserCustomExchangeRateUpdateRequest + err := c.ShouldBindJSON(&customExchangeRateUpdateReq) + + if err != nil { + log.Warnf(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] parse request failed, because %s", err.Error()) + return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + uid := c.GetCurrentUid() + user, err := a.users.GetUserById(c, uid) + + if err != nil { + log.Errorf(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] failed to get user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + if customExchangeRateUpdateReq.Currency == user.DefaultCurrency { + return nil, errs.ErrCannotUpdateExchangeRateForDefaultCurrency + } + + newCustomExchangeRate, defaultCurrencyExchangeRate, err := a.userCustomExchangeRates.UpdateCustomExchangeRate(c, uid, customExchangeRateUpdateReq.Currency, customExchangeRateUpdateReq.Rate, user.DefaultCurrency) + + if err != nil { + log.Errorf(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] failed to update user custom exchange rate \"currency:%s\" for user \"uid:%d\", because %s", customExchangeRateUpdateReq.Currency, uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + log.Infof(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] user \"uid:%d\" has updated user custom exchange rate \"currency:%s\" successfully", uid, customExchangeRateUpdateReq.Currency) + return newCustomExchangeRate.ToUserCustomExchangeRateUpdateResponse(defaultCurrencyExchangeRate.Rate), nil +} + +// UserCustomExchangeRateDeleteHandler deletes an existed user custom exchange rates data by request parameters for current user +func (a *ExchangeRatesApi) UserCustomExchangeRateDeleteHandler(c *core.WebContext) (any, *errs.Error) { + var customExchangeRateDeleteReq models.UserCustomExchangeRateDeleteRequest + err := c.ShouldBindJSON(&customExchangeRateDeleteReq) + + if err != nil { + log.Warnf(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] parse request failed, because %s", err.Error()) + return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + uid := c.GetCurrentUid() + user, err := a.users.GetUserById(c, uid) + + if err != nil { + log.Errorf(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] failed to get user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + if customExchangeRateDeleteReq.Currency == user.DefaultCurrency { + return nil, errs.ErrCannotDeleteExchangeRateForDefaultCurrency + } + + err = a.userCustomExchangeRates.DeleteCustomExchangeRate(c, uid, customExchangeRateDeleteReq.Currency) + + if err != nil { + log.Errorf(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] failed to delete user custom exchange rate \"currency:%s\" for user \"uid:%d\", because %s", customExchangeRateDeleteReq.Currency, uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + log.Infof(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] user \"uid:%d\" has deleted user custom exchange rate \"currency:%s\"", uid, customExchangeRateDeleteReq.Currency) + return true, nil +} diff --git a/pkg/errs/error.go b/pkg/errs/error.go index c73c3194..560ef440 100644 --- a/pkg/errs/error.go +++ b/pkg/errs/error.go @@ -25,19 +25,20 @@ const ( // Sub categories of normal error const ( - NormalSubcategoryGlobal = 0 - NormalSubcategoryUser = 1 - NormalSubcategoryToken = 2 - NormalSubcategoryTwofactor = 3 - NormalSubcategoryAccount = 4 - NormalSubcategoryTransaction = 5 - NormalSubcategoryCategory = 6 - NormalSubcategoryTag = 7 - NormalSubcategoryDataManagement = 8 - NormalSubcategoryMapProxy = 9 - NormalSubcategoryTemplate = 10 - NormalSubcategoryPicture = 11 - NormalSubcategoryConverter = 12 + NormalSubcategoryGlobal = 0 + NormalSubcategoryUser = 1 + NormalSubcategoryToken = 2 + NormalSubcategoryTwofactor = 3 + NormalSubcategoryAccount = 4 + NormalSubcategoryTransaction = 5 + NormalSubcategoryCategory = 6 + NormalSubcategoryTag = 7 + NormalSubcategoryDataManagement = 8 + NormalSubcategoryMapProxy = 9 + NormalSubcategoryTemplate = 10 + NormalSubcategoryPicture = 11 + NormalSubcategoryConverter = 12 + NormalSubcategoryUserCustomExchangeRate = 13 ) // Error represents the specific error returned to user diff --git a/pkg/errs/user_custom_exchange_rate.go b/pkg/errs/user_custom_exchange_rate.go new file mode 100644 index 00000000..e5d4a0ad --- /dev/null +++ b/pkg/errs/user_custom_exchange_rate.go @@ -0,0 +1,10 @@ +package errs + +import "net/http" + +// Error codes related to user custom exchange rates +var ( + ErrUserCustomExchangeRateNotFound = NewNormalError(NormalSubcategoryUserCustomExchangeRate, 0, http.StatusBadRequest, "user custom exchange rate data not found") + ErrCannotUpdateExchangeRateForDefaultCurrency = NewNormalError(NormalSubcategoryUserCustomExchangeRate, 1, http.StatusBadRequest, "cannot update exchange rate data for base currency") + ErrCannotDeleteExchangeRateForDefaultCurrency = NewNormalError(NormalSubcategoryUserCustomExchangeRate, 2, http.StatusBadRequest, "cannot delete exchange rate data for base currency") +) diff --git a/pkg/exchangerates/exchange_rates_datasource_container.go b/pkg/exchangerates/exchange_rates_datasource_container.go index bd888cc9..a4adc03c 100644 --- a/pkg/exchangerates/exchange_rates_datasource_container.go +++ b/pkg/exchangerates/exchange_rates_datasource_container.go @@ -68,6 +68,9 @@ func InitializeExchangeRatesDataSource(config *settings.Config) error { } else if config.ExchangeRatesDataSource == settings.InternationalMonetaryFundDataSource { Container.Current = newCommonHttpExchangeRatesDataSource(&InternationalMonetaryFundDataSource{}) return nil + } else if config.ExchangeRatesDataSource == settings.UserCustomExchangeRatesDataSource { + Container.Current = newUserCustomExchangeRatesDataSource() + return nil } return errs.ErrInvalidExchangeRatesDataSource diff --git a/pkg/exchangerates/user_custom_datasource.go b/pkg/exchangerates/user_custom_datasource.go new file mode 100644 index 00000000..6dc9d234 --- /dev/null +++ b/pkg/exchangerates/user_custom_datasource.go @@ -0,0 +1,101 @@ +package exchangerates + +import ( + "sort" + "time" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/log" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/services" + "github.com/mayswind/ezbookkeeping/pkg/settings" + "github.com/mayswind/ezbookkeeping/pkg/validators" +) + +const userDataSourceType = "user_custom" + +// UserCustomExchangeRatesDataSource defines the structure of user custom exchange rates data source +type UserCustomExchangeRatesDataSource struct { + ExchangeRatesDataSource + users *services.UserService + userCustomExchangeRates *services.UserCustomExchangeRatesService +} + +func (e *UserCustomExchangeRatesDataSource) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) { + user, err := e.users.GetUserById(c, uid) + + if err != nil { + log.Errorf(c, "[user_custom_datasource.GetLatestExchangeRates] failed to get user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + customExchangeRates, err := e.userCustomExchangeRates.GetAllCustomExchangeRatesByUid(c, uid) + + if err != nil { + log.Errorf(c, "[user_custom_datasource.GetLatestExchangeRates] failed to get user custom exchange rates for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + baseCurrencyRate := int64(0) + hasDefaultCurrencyRate := false + + for i := 0; i < len(customExchangeRates); i++ { + customExchangeRate := customExchangeRates[i] + + if customExchangeRate.Currency == user.DefaultCurrency { + baseCurrencyRate = customExchangeRate.Rate + hasDefaultCurrencyRate = true + break + } + } + + allExchangeRates := make(models.LatestExchangeRateSlice, 0, len(customExchangeRates)) + latestUpdateTime := int64(0) + + for i := 0; i < len(customExchangeRates); i++ { + customExchangeRate := customExchangeRates[i] + + if _, exists := validators.AllCurrencyNames[customExchangeRate.Currency]; !exists { + continue + } + + if customExchangeRate.UpdatedUnixTime > latestUpdateTime { + latestUpdateTime = customExchangeRate.UpdatedUnixTime + } + + if hasDefaultCurrencyRate && baseCurrencyRate > 0 { + allExchangeRates = append(allExchangeRates, customExchangeRate.ToLatestExchangeRate(baseCurrencyRate)) + } + } + + sort.Sort(allExchangeRates) + + if latestUpdateTime < 1 { + latestUpdateTime = time.Now().Unix() + } + + if !hasDefaultCurrencyRate { + allExchangeRates = append(allExchangeRates, &models.LatestExchangeRate{ + Currency: user.DefaultCurrency, + Rate: "1", + }) + } + + finalExchangeRateResponse := &models.LatestExchangeRateResponse{ + DataSource: userDataSourceType, + ReferenceUrl: "", + UpdateTime: latestUpdateTime, + BaseCurrency: user.DefaultCurrency, + ExchangeRates: allExchangeRates, + } + + return finalExchangeRateResponse, nil +} + +func newUserCustomExchangeRatesDataSource() *UserCustomExchangeRatesDataSource { + return &UserCustomExchangeRatesDataSource{ + users: services.Users, + userCustomExchangeRates: services.UserCustomExchangeRates, + } +} diff --git a/pkg/models/exchange_rate.go b/pkg/models/exchange_rate.go index bd7ceb0c..58f71166 100644 --- a/pkg/models/exchange_rate.go +++ b/pkg/models/exchange_rate.go @@ -1,6 +1,39 @@ package models -import "strings" +import ( + "strings" + + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +const UserCustomExchangeRateFactorInDatabase = int64(100000000) + +// UserCustomExchangeRate represents user custom exchange rate data +type UserCustomExchangeRate struct { + Uid int64 `xorm:"PK NOT NULL"` + DeletedUnixTime int64 `xorm:"PK NOT NULL"` + Currency string `xorm:"PK VARCHAR(3) NOT NULL"` + Rate int64 `xorm:"NOT NULL"` + CreatedUnixTime int64 + UpdatedUnixTime int64 +} + +// UserCustomExchangeRateUpdateRequest represents all parameters of user custom exchange rate data updating request +type UserCustomExchangeRateUpdateRequest struct { + Currency string `json:"currency" binding:"required,len=3,validCurrency"` + Rate string `json:"rate"` +} + +// UserCustomExchangeRateDeleteRequest represents all parameters of user custom exchange rate data deleting request +type UserCustomExchangeRateDeleteRequest struct { + Currency string `json:"currency" binding:"required,len=3,validCurrency"` +} + +// UserCustomExchangeRateUpdateResponse represents a view-object of the result of updating user custom exchange rate data +type UserCustomExchangeRateUpdateResponse struct { + LatestExchangeRate + UpdateTime int64 `json:"updateTime"` +} // LatestExchangeRateResponse returns a view-object which contains latest exchange rate type LatestExchangeRateResponse struct { @@ -17,6 +50,53 @@ type LatestExchangeRate struct { Rate string `json:"rate"` } +// ToLatestExchangeRate returns a data pair of currency and exchange rate according to database model +func (r *UserCustomExchangeRate) ToLatestExchangeRate(baseCurrencyRate int64) *LatestExchangeRate { + rate := float64(0) + + if baseCurrencyRate > 0 { + rate = float64(r.Rate) / float64(baseCurrencyRate) + } + + return &LatestExchangeRate{ + Currency: r.Currency, + Rate: utils.Float64ToString(rate), + } +} + +// ToUserCustomExchangeRateUpdateResponse returns a view-object of the result of updating user custom exchange rate data according to database model +func (r *UserCustomExchangeRate) ToUserCustomExchangeRateUpdateResponse(baseCurrencyRate int64) *UserCustomExchangeRateUpdateResponse { + return &UserCustomExchangeRateUpdateResponse{ + LatestExchangeRate: *r.ToLatestExchangeRate(baseCurrencyRate), + UpdateTime: r.UpdatedUnixTime, + } +} + +// CreateUserCustomExchangeRate returns a user custom exchange rate database model according to currency and rate +func CreateUserCustomExchangeRate(uid int64, currency string, exchangeRate string, baseCurrencyRate int64) (*UserCustomExchangeRate, error) { + if baseCurrencyRate <= 0 { + return &UserCustomExchangeRate{ + Uid: uid, + Currency: currency, + Rate: UserCustomExchangeRateFactorInDatabase, + }, nil + } + + rate, err := utils.StringToFloat64(exchangeRate) + + if err != nil { + return nil, err + } + + rate = rate * float64(baseCurrencyRate) + + return &UserCustomExchangeRate{ + Uid: uid, + Currency: currency, + Rate: int64(rate), + }, nil +} + // LatestExchangeRateSlice represents the slice data structure of LatestExchangeRate type LatestExchangeRateSlice []*LatestExchangeRate diff --git a/pkg/services/user_custom_exchange_rates.go b/pkg/services/user_custom_exchange_rates.go new file mode 100644 index 00000000..5bfc8fed --- /dev/null +++ b/pkg/services/user_custom_exchange_rates.go @@ -0,0 +1,153 @@ +package services + +import ( + "time" + "xorm.io/xorm" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/datastore" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/models" +) + +// UserCustomExchangeRatesService represents user custom exchange rate data service +type UserCustomExchangeRatesService struct { + ServiceUsingDB +} + +// Initialize a user custom exchange rate data service singleton instance +var ( + UserCustomExchangeRates = &UserCustomExchangeRatesService{ + ServiceUsingDB: ServiceUsingDB{ + container: datastore.Container, + }, + } +) + +// GetAllCustomExchangeRatesByUid returns all user exchange rate data models of user +func (s *UserCustomExchangeRatesService) GetAllCustomExchangeRatesByUid(c core.Context, uid int64) ([]*models.UserCustomExchangeRate, error) { + if uid <= 0 { + return nil, errs.ErrUserIdInvalid + } + + var customExchangeRates []*models.UserCustomExchangeRate + err := s.UserDataDB(uid).NewSession(c).Where("uid=? AND deleted_unix_time=?", uid, 0).Find(&customExchangeRates) + + return customExchangeRates, err +} + +// UpdateCustomExchangeRate updates user exchange rate data model to database +func (s *UserCustomExchangeRatesService) UpdateCustomExchangeRate(c core.Context, uid int64, currency string, rate string, defaultCurrency string) (*models.UserCustomExchangeRate, *models.UserCustomExchangeRate, error) { + if uid <= 0 { + return nil, nil, errs.ErrUserIdInvalid + } + + now := time.Now().Unix() + newCustomExchangeRate := &models.UserCustomExchangeRate{} + defaultCurrencyExchangeRate := &models.UserCustomExchangeRate{} + + err := s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error { + oldCustomExchangeRate := &models.UserCustomExchangeRate{} + has, err := sess.Where("uid=? AND deleted_unix_time=? AND currency=?", uid, 0, currency).Get(oldCustomExchangeRate) + + if err != nil { + return err + } + + if has { + updateOldExchangeRateModel := &models.UserCustomExchangeRate{ + DeletedUnixTime: now, + } + + _, err = sess.Cols("deleted_unix_time").Where("uid=? AND deleted_unix_time=? AND currency=?", uid, 0, currency).Update(updateOldExchangeRateModel) + + if err != nil { + return err + } + } + + if currency != defaultCurrency { + has, err := sess.Where("uid=? AND deleted_unix_time=? AND currency=?", uid, 0, defaultCurrency).Get(defaultCurrencyExchangeRate) + + if err != nil { + return err + } + + if !has { + defaultCurrencyExchangeRate, _ = models.CreateUserCustomExchangeRate(uid, defaultCurrency, "1", 0) + defaultCurrencyExchangeRate.CreatedUnixTime = now + defaultCurrencyExchangeRate.UpdatedUnixTime = now + defaultCurrencyExchangeRate.DeletedUnixTime = 0 + _, err = sess.Insert(defaultCurrencyExchangeRate) + + if err != nil { + return err + } + } + } else { + defaultCurrencyExchangeRate = oldCustomExchangeRate + } + + newCustomExchangeRate, err = models.CreateUserCustomExchangeRate(uid, currency, rate, defaultCurrencyExchangeRate.Rate) + newCustomExchangeRate.CreatedUnixTime = now + newCustomExchangeRate.UpdatedUnixTime = now + newCustomExchangeRate.DeletedUnixTime = 0 + _, err = sess.Insert(newCustomExchangeRate) + + return err + }) + + if err != nil { + return nil, nil, err + } + + return newCustomExchangeRate, defaultCurrencyExchangeRate, err +} + +// DeleteCustomExchangeRate deletes an existed user exchange rate data from database +func (s *UserCustomExchangeRatesService) DeleteCustomExchangeRate(c core.Context, uid int64, currency string) error { + if uid <= 0 { + return errs.ErrUserIdInvalid + } + + now := time.Now().Unix() + + updateModel := &models.UserCustomExchangeRate{ + DeletedUnixTime: now, + } + + return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error { + deletedRows, err := sess.Cols("deleted_unix_time").Where("uid=? AND deleted_unix_time=? AND currency=?", uid, 0, currency).Update(updateModel) + + if err != nil { + return err + } else if deletedRows < 1 { + return errs.ErrUserCustomExchangeRateNotFound + } + + return err + }) +} + +// DeleteAllCustomExchangeRates deletes all existed user exchange rate data from database +func (s *UserCustomExchangeRatesService) DeleteAllCustomExchangeRates(c core.Context, uid int64) error { + if uid <= 0 { + return errs.ErrUserIdInvalid + } + + now := time.Now().Unix() + + updateModel := &models.UserCustomExchangeRate{ + DeletedUnixTime: now, + } + + return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error { + _, err := sess.Cols("deleted_unix_time").Where("uid=? AND deleted_unix_time=?", uid, 0).Update(updateModel) + + if err != nil { + return err + } + + return nil + }) +} diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go index 3cbcb5cf..152d3796 100644 --- a/pkg/settings/setting.go +++ b/pkg/settings/setting.go @@ -117,6 +117,7 @@ const ( NationalBankOfUkraineDataSource string = "national_bank_of_ukraine" CentralBankOfUzbekistanDataSource string = "central_bank_of_uzbekistan" InternationalMonetaryFundDataSource string = "international_monetary_fund" + UserCustomExchangeRatesDataSource string = "user_custom" ) const ( @@ -914,7 +915,8 @@ func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectio dataSource == SwissNationalBankDataSource || dataSource == NationalBankOfUkraineDataSource || dataSource == CentralBankOfUzbekistanDataSource || - dataSource == InternationalMonetaryFundDataSource { + dataSource == InternationalMonetaryFundDataSource || + dataSource == UserCustomExchangeRatesDataSource { config.ExchangeRatesDataSource = dataSource } else { return errs.ErrInvalidExchangeRatesDataSource diff --git a/src/lib/services.ts b/src/lib/services.ts index 87783d1c..9a8ed72f 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -37,6 +37,9 @@ import type { DataStatisticsResponse } from '@/models/data_management.ts'; import type { + UserCustomExchangeRateUpdateRequest, + UserCustomExchangeRateDeleteRequest, + UserCustomExchangeRateUpdateResponse, LatestExchangeRateResponse } from '@/models/exchange_rate.ts'; import type { @@ -574,6 +577,12 @@ export default { timeout: getExchangeRatesRequestTimeout() || DEFAULT_API_TIMEOUT } as ApiRequestConfig); }, + updateUserCustomExchangeRate: (req: UserCustomExchangeRateUpdateRequest): ApiResponsePromise => { + return axios.post>('v1/exchange_rates/user_custom/update.json', req); + }, + deleteUserCustomExchangeRate: (req: UserCustomExchangeRateDeleteRequest): ApiResponsePromise => { + return axios.post>('v1/exchange_rates/user_custom/delete.json', req); + }, generateQrCodeUrl: (qrCodeName: string): string => { return `${getBasePath()}${BASE_QRCODE_PATH}/${qrCodeName}.png`; }, diff --git a/src/locales/de.json b/src/locales/de.json index 7e0d446c..3b94aa09 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1171,6 +1171,9 @@ "invalid beancount file": "Invalid Beancount file", "not support include directive for beancount file": "Not support \"include\" directive for Beancount file", "invalid amount expression": "Amount expression is invalid", + "user custom exchange rate data not found": "User custom exchange rate data is not found", + "cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency", + "cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency", "query items cannot be blank": "Abfrageelemente dürfen nicht leer sein", "query items too much": "Zu viele Abfrageelemente", "query items have invalid item": "Ungültiges Element in Abfrageelementen", @@ -2039,6 +2042,7 @@ "WebAuthn is not enabled": "WebAuthn ist nicht aktiviert", "Are you sure you want to re-login?": "Sind Sie sicher, dass Sie sich erneut anmelden möchten?", "Exchange Rates Data": "Wechselkursdaten", + "User Custom": "User Custom", "Base Currency": "Basiswährung", "Base Amount": "Basisbetrag", "Set as Base": "Als Basis festlegen", @@ -2049,6 +2053,11 @@ "Exchange rates data has been updated": "Wechselkursdaten wurden aktualisiert", "Exchange rates data is up to date": "Wechselkursdaten sind aktuell", "Unable to retrieve exchange rates data": "Wechselkursdaten können nicht abgerufen werden", + "Update User Custom Exchange Rate": "Update User Custom Exchange Rate", + "You have updated exchange rate": "You have updated exchange rate", + "Unable to update user custom exchange rate": "Unable to update user custom exchange rate", + "Are you sure you want to delete this user custom exchange rate?": "Are you sure you want to delete this user custom exchange rate?", + "Unable to delete this user custom exchange rate": "Unable to delete this user custom exchange rate", "Use on Mobile Device": "Auf Mobilgerät verwenden", "You can scan the QR code below on your mobile device.": "Sie können den untenstehenden QR-Code auf Ihrem Mobilgerät scannen.", "Switch to Mobile Version": "Zur mobilen Version wechseln", diff --git a/src/locales/en.json b/src/locales/en.json index 796b96f1..ad348bf9 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1171,6 +1171,9 @@ "invalid beancount file": "Invalid Beancount file", "not support include directive for beancount file": "Not support \"include\" directive for Beancount file", "invalid amount expression": "Amount expression is invalid", + "user custom exchange rate data not found": "User custom exchange rate data is not found", + "cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency", + "cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency", "query items cannot be blank": "There are no query items", "query items too much": "There are too many query items", "query items have invalid item": "There is invalid item in query items", @@ -2039,6 +2042,7 @@ "WebAuthn is not enabled": "WebAuthn is not enabled", "Are you sure you want to re-login?": "Are you sure you want to re-login?", "Exchange Rates Data": "Exchange Rates Data", + "User Custom": "User Custom", "Base Currency": "Base Currency", "Base Amount": "Base Amount", "Set as Base": "Set as Base", @@ -2049,6 +2053,11 @@ "Exchange rates data has been updated": "Exchange rates data has been updated", "Exchange rates data is up to date": "Exchange rates data is up to date", "Unable to retrieve exchange rates data": "Unable to retrieve exchange rates data", + "Update User Custom Exchange Rate": "Update User Custom Exchange Rate", + "You have updated exchange rate": "You have updated exchange rate", + "Unable to update user custom exchange rate": "Unable to update user custom exchange rate", + "Are you sure you want to delete this user custom exchange rate?": "Are you sure you want to delete this user custom exchange rate?", + "Unable to delete this user custom exchange rate": "Unable to delete this user custom exchange rate", "Use on Mobile Device": "Use on Mobile Device", "You can scan the QR code below on your mobile device.": "You can scan the QR code below on your mobile device.", "Switch to Mobile Version": "Switch to Mobile Version", diff --git a/src/locales/es.json b/src/locales/es.json index 7c4391fc..967b0689 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1170,6 +1170,9 @@ "invalid beancount file": "Invalid Beancount file", "not support include directive for beancount file": "Not support \"include\" directive for Beancount file", "invalid amount expression": "Amount expression is invalid", + "user custom exchange rate data not found": "User custom exchange rate data is not found", + "cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency", + "cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency", "query items cannot be blank": "--", "query items too much": "--", "query items have invalid item": "Hay un elemento no válido en los elementos de consulta", @@ -2038,6 +2041,7 @@ "WebAuthn is not enabled": "WebAuthn no está habilitado", "Are you sure you want to re-login?": "¿Está seguro de que desea volver a iniciar sesión?", "Exchange Rates Data": "Datos de tipos de cambio", + "User Custom": "User Custom", "Base Currency": "Moneda base", "Base Amount": "Importe base", "Set as Base": "Establecer como base", @@ -2048,6 +2052,11 @@ "Exchange rates data has been updated": "Se han actualizado los datos de tipos de cambio.", "Exchange rates data is up to date": "Los datos de tipos de cambio están actualizados.", "Unable to retrieve exchange rates data": "No se pueden recuperar datos de tipos de cambio", + "Update User Custom Exchange Rate": "Update User Custom Exchange Rate", + "You have updated exchange rate": "You have updated exchange rate", + "Unable to update user custom exchange rate": "Unable to update user custom exchange rate", + "Are you sure you want to delete this user custom exchange rate?": "Are you sure you want to delete this user custom exchange rate?", + "Unable to delete this user custom exchange rate": "Unable to delete this user custom exchange rate", "Use on Mobile Device": "Usar en dispositivo móvil", "You can scan the QR code below on your mobile device.": "Puede escanear el código QR a continuación en su dispositivo móvil.", "Switch to Mobile Version": "Cambiar a la versión móvil", diff --git a/src/locales/it.json b/src/locales/it.json index 3fad21d9..3e001732 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -1171,6 +1171,9 @@ "invalid beancount file": "File Beancount non valido", "not support include directive for beancount file": "Direttiva \"include\" non supportata per il file Beancount", "invalid amount expression": "Espressione dell'importo non valida", + "user custom exchange rate data not found": "User custom exchange rate data is not found", + "cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency", + "cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency", "query items cannot be blank": "Non ci sono elementi di query", "query items too much": "Ci sono troppi elementi di query", "query items have invalid item": "C'è un elemento non valido negli elementi di query", @@ -2039,6 +2042,7 @@ "WebAuthn is not enabled": "WebAuthn non è abilitato", "Are you sure you want to re-login?": "Sei sicuro di voler accedere di nuovo?", "Exchange Rates Data": "Dati tassi di cambio", + "User Custom": "User Custom", "Base Currency": "Valuta di base", "Base Amount": "Importo di base", "Set as Base": "Imposta come base", @@ -2049,6 +2053,11 @@ "Exchange rates data has been updated": "I dati sui tassi di cambio sono stati aggiornati", "Exchange rates data is up to date": "I dati sui tassi di cambio sono aggiornati", "Unable to retrieve exchange rates data": "Impossibile recuperare i dati sui tassi di cambio", + "Update User Custom Exchange Rate": "Update User Custom Exchange Rate", + "You have updated exchange rate": "You have updated exchange rate", + "Unable to update user custom exchange rate": "Unable to update user custom exchange rate", + "Are you sure you want to delete this user custom exchange rate?": "Are you sure you want to delete this user custom exchange rate?", + "Unable to delete this user custom exchange rate": "Unable to delete this user custom exchange rate", "Use on Mobile Device": "Usa su dispositivo mobile", "You can scan the QR code below on your mobile device.": "Puoi scansionare il codice QR qui sotto sul tuo dispositivo mobile.", "Switch to Mobile Version": "Passa alla versione mobile", diff --git a/src/locales/ja.json b/src/locales/ja.json index 65d9418e..edbdc4d0 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -1171,6 +1171,9 @@ "invalid beancount file": "Invalid Beancount file", "not support include directive for beancount file": "Not support \"include\" directive for Beancount file", "invalid amount expression": "Amount expression is invalid", + "user custom exchange rate data not found": "User custom exchange rate data is not found", + "cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency", + "cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency", "query items cannot be blank": "クエリ項目がありません", "query items too much": "クエリ項目が多すぎます", "query items have invalid item": "クエリ項目に無効な項目があります", @@ -2039,6 +2042,7 @@ "WebAuthn is not enabled": "WebAuthnが有効になっていません", "Are you sure you want to re-login?": "再ログインしますか?", "Exchange Rates Data": "為替レートデータ", + "User Custom": "User Custom", "Base Currency": "ベース通貨", "Base Amount": "ベース金額", "Set as Base": "ベースとして設定", @@ -2049,6 +2053,11 @@ "Exchange rates data has been updated": "為替レートデータが更新されました", "Exchange rates data is up to date": "為替レートデータは最新です", "Unable to retrieve exchange rates data": "為替レートデータを取得できません", + "Update User Custom Exchange Rate": "Update User Custom Exchange Rate", + "You have updated exchange rate": "You have updated exchange rate", + "Unable to update user custom exchange rate": "Unable to update user custom exchange rate", + "Are you sure you want to delete this user custom exchange rate?": "Are you sure you want to delete this user custom exchange rate?", + "Unable to delete this user custom exchange rate": "Unable to delete this user custom exchange rate", "Use on Mobile Device": "モバイルデバイスで使う", "You can scan the QR code below on your mobile device.": "以下のQRコードをモバイルデバイスでスキャンできます。", "Switch to Mobile Version": "モバイルバージョンに切り替え", diff --git a/src/locales/ru.json b/src/locales/ru.json index 9f68483e..6839bba3 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1171,6 +1171,9 @@ "invalid beancount file": "Invalid Beancount file", "not support include directive for beancount file": "Not support \"include\" directive for Beancount file", "invalid amount expression": "Amount expression is invalid", + "user custom exchange rate data not found": "User custom exchange rate data is not found", + "cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency", + "cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency", "query items cannot be blank": "Нет элементов запроса", "query items too much": "Слишком много элементов запроса", "query items have invalid item": "В элементах запроса присутствует недопустимый элемент", @@ -2039,6 +2042,7 @@ "WebAuthn is not enabled": "WebAuthn не включен", "Are you sure you want to re-login?": "Вы уверены, что хотите войти снова?", "Exchange Rates Data": "Данные о курсах валют", + "User Custom": "User Custom", "Base Currency": "Базовая валюта", "Base Amount": "Базовая сумма", "Set as Base": "Установить как базовую", @@ -2049,6 +2053,11 @@ "Exchange rates data has been updated": "Данные о курсах валют обновлены", "Exchange rates data is up to date": "Данные о курсах валют актуальны", "Unable to retrieve exchange rates data": "Не удалось получить данные о курсах валют", + "Update User Custom Exchange Rate": "Update User Custom Exchange Rate", + "You have updated exchange rate": "You have updated exchange rate", + "Unable to update user custom exchange rate": "Unable to update user custom exchange rate", + "Are you sure you want to delete this user custom exchange rate?": "Are you sure you want to delete this user custom exchange rate?", + "Unable to delete this user custom exchange rate": "Unable to delete this user custom exchange rate", "Use on Mobile Device": "Использовать на мобильном устройстве", "You can scan the QR code below on your mobile device.": "Вы можете отсканировать QR-код ниже на своем мобильном устройстве.", "Switch to Mobile Version": "Переключиться на мобильную версию", diff --git a/src/locales/uk.json b/src/locales/uk.json index 0b772c39..98d2bbc6 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -1171,6 +1171,9 @@ "invalid beancount file": "Недійсний файл Beancount", "not support include directive for beancount file": "Не підтримується директива \"include\" у файлі Beancount", "invalid amount expression": "Недійсний вираз суми", + "user custom exchange rate data not found": "User custom exchange rate data is not found", + "cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency", + "cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency", "query items cannot be blank": "Елементи запиту не можуть бути порожніми", "query items too much": "Занадто багато елементів запиту", "query items have invalid item": "Запит містить недійсний елемент", @@ -2039,6 +2042,7 @@ "WebAuthn is not enabled": "WebAuthn не увімкнено", "Are you sure you want to re-login?": "Ви впевнені, що хочете увійти знову?", "Exchange Rates Data": "Дані про курси валют", + "User Custom": "User Custom", "Base Currency": "Базова валюта", "Base Amount": "Базова сума", "Set as Base": "Зробити базовою", @@ -2049,6 +2053,11 @@ "Exchange rates data has been updated": "Курси валют оновлено", "Exchange rates data is up to date": "Курси валют оновлено", "Unable to retrieve exchange rates data": "Не вдалося отримати курси валют", + "Update User Custom Exchange Rate": "Update User Custom Exchange Rate", + "You have updated exchange rate": "You have updated exchange rate", + "Unable to update user custom exchange rate": "Unable to update user custom exchange rate", + "Are you sure you want to delete this user custom exchange rate?": "Are you sure you want to delete this user custom exchange rate?", + "Unable to delete this user custom exchange rate": "Unable to delete this user custom exchange rate", "Use on Mobile Device": "Використовувати на мобільному пристрої", "You can scan the QR code below on your mobile device.": "Ви можете відсканувати наведений нижче QR-код на своєму мобільному пристрої.", "Switch to Mobile Version": "Переключитися на мобільну версію", diff --git a/src/locales/vi.json b/src/locales/vi.json index ddfcb023..90aa93f0 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -1171,6 +1171,9 @@ "invalid beancount file": "Invalid Beancount file", "not support include directive for beancount file": "Not support \"include\" directive for Beancount file", "invalid amount expression": "Amount expression is invalid", + "user custom exchange rate data not found": "User custom exchange rate data is not found", + "cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency", + "cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency", "query items cannot be blank": "Không có mục truy vấn", "query items too much": "Có quá nhiều mục truy vấn", "query items have invalid item": "Có mục không hợp lệ trong các mục truy vấn", @@ -2039,6 +2042,7 @@ "WebAuthn is not enabled": "WebAuthn chưa được bật", "Are you sure you want to re-login?": "Bạn có chắc chắn muốn đăng nhập lại không?", "Exchange Rates Data": "Dữ liệu tỷ giá hối đoái", + "User Custom": "User Custom", "Base Currency": "Tiền tệ cơ sở", "Base Amount": "Số tiền cơ sở", "Set as Base": "Đặt làm cơ sở", @@ -2049,6 +2053,11 @@ "Exchange rates data has been updated": "Dữ liệu tỷ giá hối đoái đã được cập nhật", "Exchange rates data is up to date": "Dữ liệu tỷ giá hối đoái đã được cập nhật", "Unable to retrieve exchange rates data": "Không thể truy xuất dữ liệu tỷ giá hối đoái", + "Update User Custom Exchange Rate": "Update User Custom Exchange Rate", + "You have updated exchange rate": "You have updated exchange rate", + "Unable to update user custom exchange rate": "Unable to update user custom exchange rate", + "Are you sure you want to delete this user custom exchange rate?": "Are you sure you want to delete this user custom exchange rate?", + "Unable to delete this user custom exchange rate": "Unable to delete this user custom exchange rate", "Use on Mobile Device": "Sử dụng trên thiết bị di động", "You can scan the QR code below on your mobile device.": "Bạn có thể quét mã QR bên dưới trên thiết bị di động của mình.", "Switch to Mobile Version": "Chuyển sang phiên bản di động", diff --git a/src/locales/zh_Hans.json b/src/locales/zh_Hans.json index 60344aa8..e529258c 100644 --- a/src/locales/zh_Hans.json +++ b/src/locales/zh_Hans.json @@ -1171,6 +1171,9 @@ "invalid beancount file": "无效的 Beancount 文件", "not support include directive for beancount file": "不支持 Beancount 文件的 \"include\" 指令", "invalid amount expression": "金额表达式无效", + "user custom exchange rate data not found": "用户自定义汇率数据不存在", + "cannot update exchange rate data for base currency": "不能更新默认货币的汇率数据", + "cannot delete exchange rate data for base currency": "不能删除默认货币的汇率数据", "query items cannot be blank": "请求项目不能为空", "query items too much": "请求项目过多", "query items have invalid item": "请求项目中有非法项目", @@ -2039,6 +2042,7 @@ "WebAuthn is not enabled": "WebAuthn 没有启用", "Are you sure you want to re-login?": "您确定要重新登录?", "Exchange Rates Data": "汇率数据", + "User Custom": "用户自定义", "Base Currency": "基准货币", "Base Amount": "基准金额", "Set as Base": "设置为基准", @@ -2049,6 +2053,11 @@ "Exchange rates data has been updated": "汇率数据已更新", "Exchange rates data is up to date": "汇率数据已是最新", "Unable to retrieve exchange rates data": "无法获取汇率数据", + "Update User Custom Exchange Rate": "更新用户自定义汇率", + "You have updated exchange rate": "您已经更新了汇率", + "Unable to update user custom exchange rate": "无法更新用户自定义汇率", + "Are you sure you want to delete this user custom exchange rate?": "您确定要删除该用户自定义汇率?", + "Unable to delete this user custom exchange rate": "无法删除该用户自定义汇率", "Use on Mobile Device": "在移动设备使用", "You can scan the QR code below on your mobile device.": "您可以在您的移动设备上扫描下方二维码。", "Switch to Mobile Version": "切换到移动版", diff --git a/src/locales/zh_Hant.json b/src/locales/zh_Hant.json index 645285aa..3dbf9417 100644 --- a/src/locales/zh_Hant.json +++ b/src/locales/zh_Hant.json @@ -1171,6 +1171,9 @@ "invalid beancount file": "無效的 Beancount 檔案", "not support include directive for beancount file": "不支援 Beancount 檔案的 \"include\" 指令", "invalid amount expression": "金額表達式無效", + "user custom exchange rate data not found": "使用者自訂匯率資料不存在", + "cannot update exchange rate data for base currency": "不能更新基準貨幣的匯率資料", + "cannot delete exchange rate data for base currency": "不能刪除基準貨幣的匯率資料", "query items cannot be blank": "查詢項目不能為空", "query items too much": "查詢項目過多", "query items have invalid item": "查詢項目中有非法項目", @@ -2039,6 +2042,7 @@ "WebAuthn is not enabled": "WebAuthn 沒有啟用", "Are you sure you want to re-login?": "您確定要重新登入?", "Exchange Rates Data": "匯率資料", + "User Custom": "使用者自訂", "Base Currency": "基準貨幣", "Base Amount": "基準金額", "Set as Base": "設定為基準", @@ -2049,6 +2053,11 @@ "Exchange rates data has been updated": "匯率資料已更新", "Exchange rates data is up to date": "匯率資料已是最新", "Unable to retrieve exchange rates data": "無法取得匯率資料", + "Update User Custom Exchange Rate": "更新使用者自訂匯率", + "You have updated exchange rate": "您已經更新了匯率", + "Unable to update user custom exchange rate": "無法更新使用者自訂匯率", + "Are you sure you want to delete this user custom exchange rate?": "您確定要刪除此使用者自訂匯率?", + "Unable to delete this user custom exchange rate": "無法刪除此使用者自訂匯率", "Use on Mobile Device": "在行動裝置使用", "You can scan the QR code below on your mobile device.": "您可以在您的行動裝置上掃描下方的 QR Code。", "Switch to Mobile Version": "切換到行動版", diff --git a/src/models/exchange_rate.ts b/src/models/exchange_rate.ts index 1a1df03d..78757158 100644 --- a/src/models/exchange_rate.ts +++ b/src/models/exchange_rate.ts @@ -1,3 +1,16 @@ +export interface UserCustomExchangeRateUpdateRequest { + readonly currency: string; + readonly rate: string; +} + +export interface UserCustomExchangeRateDeleteRequest { + readonly currency: string; +} + +export interface UserCustomExchangeRateUpdateResponse extends LatestExchangeRate { + readonly updateTime: number; +} + export interface LatestExchangeRate { readonly currency: string; readonly rate: string; @@ -6,7 +19,7 @@ export interface LatestExchangeRate { export interface LatestExchangeRateResponse { readonly dataSource: string; readonly referenceUrl: string; - readonly updateTime: number; + updateTime: number; readonly baseCurrency: string; readonly exchangeRates: LatestExchangeRate[]; } diff --git a/src/router/desktop.ts b/src/router/desktop.ts index bacd92c9..a2b65814 100644 --- a/src/router/desktop.ts +++ b/src/router/desktop.ts @@ -28,7 +28,7 @@ import TransactionTemplateListPage from '@/views/desktop/templates/ListPage.vue' import UserSettingsPage from '@/views/desktop/user/UserSettingsPage.vue'; import AppSettingsPage from '@/views/desktop/app/AppSettingsPage.vue'; -import ExchangeRatesPage from '@/views/desktop/ExchangeRatesPage.vue'; +import ExchangeRatesListPage from '@/views/desktop/exchangerates/ListPage.vue'; import AboutPage from '@/views/desktop/AboutPage.vue'; function checkLogin(): NavigationGuardReturn { @@ -168,7 +168,7 @@ const router = createRouter({ }, { path: '/exchange_rates', - component: ExchangeRatesPage, + component: ExchangeRatesListPage, beforeEnter: checkLogin }, { diff --git a/src/router/mobile.ts b/src/router/mobile.ts index d8e257c5..04b2bd42 100644 --- a/src/router/mobile.ts +++ b/src/router/mobile.ts @@ -25,7 +25,8 @@ import TransactionTagFilterSettingsPage from '@/views/mobile/settings/Transactio import SettingsPage from '@/views/mobile/SettingsPage.vue'; import ApplicationLockPage from '@/views/mobile/ApplicationLockPage.vue'; -import ExchangeRatesPage from '@/views/mobile/ExchangeRatesPage.vue'; +import ExchangeRatesListPage from '@/views/mobile/exchangerates/ListPage.vue'; +import ExchangeRatesUpdatePage from '@/views/mobile/exchangerates/UpdatePage.vue'; import AboutPage from '@/views/mobile/AboutPage.vue'; import UserProfilePage from '@/views/mobile/users/UserProfilePage.vue'; @@ -236,7 +237,12 @@ const routes: Router.RouteParameters[] = [ }, { path: '/exchange_rates', - async: asyncResolve(ExchangeRatesPage), + async: asyncResolve(ExchangeRatesListPage), + beforeEnter: [checkLogin] + }, + { + path: '/exchange_rates/update', + async: asyncResolve(ExchangeRatesUpdatePage), beforeEnter: [checkLogin] }, { diff --git a/src/stores/exchangeRates.ts b/src/stores/exchangeRates.ts index c7b7f801..e91ef88e 100644 --- a/src/stores/exchangeRates.ts +++ b/src/stores/exchangeRates.ts @@ -1,7 +1,13 @@ import { ref, computed } from 'vue'; import { defineStore } from 'pinia'; -import type { LatestExchangeRate, LatestExchangeRateResponse } from '@/models/exchange_rate.ts'; +import type { BeforeResolveFunction } from '@/core/base.ts'; + +import type { + UserCustomExchangeRateUpdateResponse, + LatestExchangeRate, + LatestExchangeRateResponse +} from '@/models/exchange_rate.ts'; import { isEquals } from '@/lib/common.ts'; import { getCurrentUnixTime, formatUnixTime } from '@/lib/datetime.ts'; @@ -11,6 +17,7 @@ import logger from '@/lib/logger.ts'; import services from '@/lib/services.ts'; const exchangeRatesLocalStorageKey = 'ebk_app_exchange_rates'; +const userDataSourceType = 'user_custom'; interface LatestExchangeRates { readonly time?: number; @@ -34,6 +41,14 @@ function clearExchangeRatesFromLocalStorage(): void { export const useExchangeRatesStore = defineStore('exchangeRates', () => { const latestExchangeRates = ref(getExchangeRatesFromLocalStorage()); + const isUserCustomExchangeRates = computed((): boolean => { + if (!latestExchangeRates.value || !latestExchangeRates.value.data) { + return false; + } + + return latestExchangeRates.value.data.dataSource === userDataSourceType; + }); + const exchangeRatesLastUpdateTime = computed(() => { const exchangeRates = latestExchangeRates.value || {}; return exchangeRates && exchangeRates.data ? exchangeRates.data.updateTime : null; @@ -54,6 +69,55 @@ export const useExchangeRatesStore = defineStore('exchangeRates', () => { return exchangeRateMap; }); + function updateExchangeRateToLatestExchangeRateList(exchangeRate: LatestExchangeRate, updateTime: number): void { + if (!latestExchangeRates.value || !latestExchangeRates.value.data || !latestExchangeRates.value.data.exchangeRates) { + return; + } + + const exchangeRates = latestExchangeRates.value.data.exchangeRates; + let changed = false; + + for (let i = 0; i < exchangeRates.length; i++) { + if (exchangeRates[i].currency === exchangeRate.currency) { + exchangeRates.splice(i, 1, exchangeRate); + changed = true; + break; + } + } + + if (!changed) { + exchangeRates.push(exchangeRate); + changed = true; + } + + latestExchangeRates.value.data.updateTime = updateTime; + + if (changed) { + setExchangeRatesToLocalStorage(latestExchangeRates.value); + } + } + + function removeExchangeRateFromLatestExchangeRateList(currency: string): void { + if (!latestExchangeRates.value || !latestExchangeRates.value.data || !latestExchangeRates.value.data.exchangeRates) { + return; + } + + const exchangeRates = latestExchangeRates.value.data.exchangeRates; + let changed = false; + + for (let i = 0; i < exchangeRates.length; i++) { + if (exchangeRates[i].currency === currency) { + exchangeRates.splice(i, 1); + changed = true; + break; + } + } + + if (changed) { + setExchangeRatesToLocalStorage(latestExchangeRates.value); + } + } + function resetLatestExchangeRates(): void { latestExchangeRates.value = {}; clearExchangeRatesFromLocalStorage(); @@ -114,6 +178,76 @@ export const useExchangeRatesStore = defineStore('exchangeRates', () => { }); } + function updateUserCustomExchangeRate({ currency, rate }: { currency: string, rate: number }): Promise { + return new Promise((resolve, reject) => { + services.updateUserCustomExchangeRate({ + currency: currency, + rate: rate.toString() + }).then(response => { + const data = response.data; + + if (!data || !data.success || !data.result) { + reject({ message: 'Unable to update user custom exchange rate' }); + return; + } + + const exchangeRate: LatestExchangeRate = { + currency: data.result.currency, + rate: data.result.rate + }; + + updateExchangeRateToLatestExchangeRateList(exchangeRate, data.result.updateTime); + + resolve(data.result); + }).catch(error => { + logger.error('failed to update user custom exchange rate', error); + + if (error.response && error.response.data && error.response.data.errorMessage) { + reject({ error: error.response.data }); + } else if (!error.processed) { + reject({ message: 'Unable to update user custom exchange rate' }); + } else { + reject(error); + } + }); + }); + } + + function deleteUserCustomExchangeRate({ currency, beforeResolve }: { currency: string, beforeResolve?: BeforeResolveFunction }): Promise { + return new Promise((resolve, reject) => { + services.deleteUserCustomExchangeRate({ + currency: currency + }).then(response => { + const data = response.data; + + if (!data || !data.success || !data.result) { + reject({ message: 'Unable to delete this user custom exchange rate' }); + return; + } + + if (beforeResolve) { + beforeResolve(() => { + removeExchangeRateFromLatestExchangeRateList(currency); + }); + } else { + removeExchangeRateFromLatestExchangeRateList(currency); + } + + resolve(data.result); + }).catch(error => { + logger.error('failed to delete user custom exchange rate', error); + + if (error.response && error.response.data && error.response.data.errorMessage) { + reject({ error: error.response.data }); + } else if (!error.processed) { + reject({ message: 'Unable to delete this user custom exchange rate' }); + } else { + reject(error); + } + }); + }); + } + function getExchangedAmount(amount: number, fromCurrency: string, toCurrency: string): number | null { if (amount === 0) { return 0; @@ -145,11 +279,14 @@ export const useExchangeRatesStore = defineStore('exchangeRates', () => { // states latestExchangeRates, // computed states + isUserCustomExchangeRates, exchangeRatesLastUpdateTime, latestExchangeRateMap, // functions resetLatestExchangeRates, getLatestExchangeRates, + updateUserCustomExchangeRate, + deleteUserCustomExchangeRate, getExchangedAmount }; }); diff --git a/src/stores/index.ts b/src/stores/index.ts index a71c5064..463718fb 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -378,6 +378,8 @@ export const useRootStore = defineStore('root', () => { } function updateUserProfile(req: UserProfileUpdateRequest): Promise { + const userDefaultCurrency = userStore.currentUserDefaultCurrency; + return new Promise((resolve, reject) => { services.updateProfile(req).then(response => { const data = response.data; @@ -407,6 +409,10 @@ export const useRootStore = defineStore('root', () => { statisticsStore.updateTransactionStatisticsInvalidState(true); } + if (data.result.user && data.result.user.defaultCurrency !== userDefaultCurrency) { + exchangeRatesStore.resetLatestExchangeRates(); + } + resolve(data.result); }).catch(error => { logger.error('failed to save user profile', error); diff --git a/src/styles/mobile/font-size-default.css b/src/styles/mobile/font-size-default.css index 2cc673ad..f6af8fd2 100644 --- a/src/styles/mobile/font-size-default.css +++ b/src/styles/mobile/font-size-default.css @@ -47,6 +47,7 @@ --ebk-icon-font-size: 28px; --ebk-icon-text-margin: 2px; --ebk-hide-icon-font-size: 18px; + --ebk-separate-icon-font-size: 24px; --ebk-big-icon-button-size: 42px; --ebk-icon-after-text-font-size: 16px; --ebk-right-bottom-icon-font-size: 13px; diff --git a/src/styles/mobile/font-size-large.css b/src/styles/mobile/font-size-large.css index 1be864fe..d351c901 100644 --- a/src/styles/mobile/font-size-large.css +++ b/src/styles/mobile/font-size-large.css @@ -47,6 +47,7 @@ --ebk-icon-font-size: 29px; --ebk-icon-text-margin: 3px; --ebk-hide-icon-font-size: 19px; + --ebk-separate-icon-font-size: 28px; --ebk-big-icon-button-size: 44px; --ebk-icon-after-text-font-size: 17px; --ebk-right-bottom-icon-font-size: 13px; diff --git a/src/styles/mobile/font-size-small.css b/src/styles/mobile/font-size-small.css index 884f3d03..c5eec05d 100644 --- a/src/styles/mobile/font-size-small.css +++ b/src/styles/mobile/font-size-small.css @@ -47,6 +47,7 @@ --ebk-icon-font-size: 28px; --ebk-icon-text-margin: 2px; --ebk-hide-icon-font-size: 18px; + --ebk-separate-icon-font-size: 20px; --ebk-big-icon-button-size: 42px; --ebk-icon-after-text-font-size: 15px; --ebk-right-bottom-icon-font-size: 13px; diff --git a/src/styles/mobile/font-size-x-large.css b/src/styles/mobile/font-size-x-large.css index 528d387e..811a23f4 100644 --- a/src/styles/mobile/font-size-x-large.css +++ b/src/styles/mobile/font-size-x-large.css @@ -47,6 +47,7 @@ --ebk-icon-font-size: 30px; --ebk-icon-text-margin: 3px; --ebk-hide-icon-font-size: 20px; + --ebk-separate-icon-font-size: 32px; --ebk-big-icon-button-size: 48px; --ebk-icon-after-text-font-size: 17px; --ebk-right-bottom-icon-font-size: 14px; diff --git a/src/styles/mobile/font-size-xx-large.css b/src/styles/mobile/font-size-xx-large.css index 75c9c4f5..6c21849d 100644 --- a/src/styles/mobile/font-size-xx-large.css +++ b/src/styles/mobile/font-size-xx-large.css @@ -47,6 +47,7 @@ --ebk-icon-font-size: 32px; --ebk-icon-text-margin: 4px; --ebk-hide-icon-font-size: 24px; + --ebk-separate-icon-font-size: 36px; --ebk-big-icon-button-size: 54px; --ebk-icon-after-text-font-size: 18px; --ebk-right-bottom-icon-font-size: 15px; diff --git a/src/styles/mobile/font-size-xxx-large.css b/src/styles/mobile/font-size-xxx-large.css index 7f354315..b39399bd 100644 --- a/src/styles/mobile/font-size-xxx-large.css +++ b/src/styles/mobile/font-size-xxx-large.css @@ -47,6 +47,7 @@ --ebk-icon-font-size: 34px; --ebk-icon-text-margin: 4px; --ebk-hide-icon-font-size: 26px; + --ebk-separate-icon-font-size: 40px; --ebk-big-icon-button-size: 58px; --ebk-icon-after-text-font-size: 20px; --ebk-right-bottom-icon-font-size: 17px; diff --git a/src/styles/mobile/font-size-xxxx-large.css b/src/styles/mobile/font-size-xxxx-large.css index 63eaf5e6..1ee8ebc3 100644 --- a/src/styles/mobile/font-size-xxxx-large.css +++ b/src/styles/mobile/font-size-xxxx-large.css @@ -47,6 +47,7 @@ --ebk-icon-font-size: 36px; --ebk-icon-text-margin: 4px; --ebk-hide-icon-font-size: 28px; + --ebk-separate-icon-font-size: 44px; --ebk-big-icon-button-size: 64px; --ebk-icon-after-text-font-size: 22px; --ebk-right-bottom-icon-font-size: 19px; diff --git a/src/styles/mobile/global.css b/src/styles/mobile/global.css index eaa4dac8..cfa7a5e0 100644 --- a/src/styles/mobile/global.css +++ b/src/styles/mobile/global.css @@ -152,6 +152,10 @@ i.icon.la, i.icon.las, i.icon.lab { font-size: var(--ebk-hide-icon-font-size); } +.separate-icon { + font-size: var(--ebk-separate-icon-font-size); +} + .transaction-tag-icon { font-size: var(--ebk-transaction-tag-icon-font-size); align-self: center; diff --git a/src/views/base/ExchangeRatesPageBase.ts b/src/views/base/ExchangeRatesPageBase.ts index 7f3e7e17..84be2b05 100644 --- a/src/views/base/ExchangeRatesPageBase.ts +++ b/src/views/base/ExchangeRatesPageBase.ts @@ -22,7 +22,9 @@ export function useExchangeRatesPageBase() { const baseCurrency = ref(userStore.currentUserDefaultCurrency); const baseAmount = ref(100); + const defaultCurrency = computed(() => userStore.currentUserDefaultCurrency); const exchangeRatesData = computed(() => exchangeRatesStore.latestExchangeRates.data); + const isUserCustomExchangeRates = computed(() => exchangeRatesStore.isUserCustomExchangeRates); const exchangeRatesDataUpdateTime = computed(() => { const exchangeRatesLastUpdateTime = exchangeRatesStore.exchangeRatesLastUpdateTime; @@ -55,7 +57,9 @@ export function useExchangeRatesPageBase() { baseCurrency, baseAmount, // computed states + defaultCurrency, exchangeRatesData, + isUserCustomExchangeRates, exchangeRatesDataUpdateTime, availableExchangeRates, // functions diff --git a/src/views/desktop/ExchangeRatesPage.vue b/src/views/desktop/exchangerates/ListPage.vue similarity index 75% rename from src/views/desktop/ExchangeRatesPage.vue rename to src/views/desktop/exchangerates/ListPage.vue index 23460ef6..61a6345a 100644 --- a/src/views/desktop/ExchangeRatesPage.vue +++ b/src/views/desktop/exchangerates/ListPage.vue @@ -7,8 +7,9 @@
{{ tt('Data source') }}

- {{ exchangeRatesData.dataSource }} - {{ exchangeRatesData.dataSource }} + {{ exchangeRatesData.dataSource }} + {{ exchangeRatesData.dataSource }} + {{ tt('User Custom') }} {{ tt('None') }} @@ -65,6 +66,9 @@ {{ tt('Exchange Rates Data') }} + {{ tt('Update') }} diff --git a/src/views/mobile/ExchangeRatesPage.vue b/src/views/mobile/exchangerates/ListPage.vue similarity index 54% rename from src/views/mobile/ExchangeRatesPage.vue rename to src/views/mobile/exchangerates/ListPage.vue index 6f0542a5..7b0ef3e1 100644 --- a/src/views/mobile/ExchangeRatesPage.vue +++ b/src/views/mobile/exchangerates/ListPage.vue @@ -1,5 +1,5 @@