diff --git a/cmd/entries/manual.go b/cmd/entries/manual.go index a56ecd3..0f3de97 100644 --- a/cmd/entries/manual.go +++ b/cmd/entries/manual.go @@ -7,6 +7,7 @@ import ( "time" "github.com/DylanDevelops/tmpo/internal/config" + "github.com/DylanDevelops/tmpo/internal/currency" "github.com/DylanDevelops/tmpo/internal/project" "github.com/DylanDevelops/tmpo/internal/storage" "github.com/DylanDevelops/tmpo/internal/ui" @@ -167,9 +168,15 @@ func ManualCmd() *cobra.Command { } if entry.HourlyRate != nil { + // Get currency from config, fallback to USD + currencyCode := "USD" + if cfg, _, err := config.FindAndLoad(); err == nil { + currencyCode = cfg.GetCurrencyOrDefault() + } + earnings := entry.RoundedHours() * *entry.HourlyRate - fmt.Printf(" %s %s\n", ui.BoldInfo("Hourly Rate:"), fmt.Sprintf("$%.2f", *entry.HourlyRate)) - fmt.Printf(" %s %s\n", ui.BoldInfo("Earnings:"), fmt.Sprintf("$%.2f", earnings)) + fmt.Printf(" %s %s\n", ui.BoldInfo("Hourly Rate:"), currency.FormatCurrency(*entry.HourlyRate, currencyCode)) + fmt.Printf(" %s %s\n", ui.BoldInfo("Earnings:"), currency.FormatCurrency(earnings, currencyCode)) } ui.NewlineBelow() diff --git a/cmd/history/stats.go b/cmd/history/stats.go index 3a58f51..2df6b7d 100644 --- a/cmd/history/stats.go +++ b/cmd/history/stats.go @@ -6,6 +6,8 @@ import ( "sort" "time" + "github.com/DylanDevelops/tmpo/internal/config" + "github.com/DylanDevelops/tmpo/internal/currency" "github.com/DylanDevelops/tmpo/internal/storage" "github.com/DylanDevelops/tmpo/internal/ui" "github.com/spf13/cobra" @@ -119,13 +121,15 @@ func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) { } } + currencyCode := getCurrencyCode() + ui.PrintSuccess(ui.EmojiStats, fmt.Sprintf("Stats for %s", ui.Bold(periodName))) fmt.Println() ui.PrintInfo(4, ui.Bold("Total Time"), fmt.Sprintf("%s (%.2f hours)", ui.FormatDuration(totalDuration), totalDuration.Hours())) ui.PrintInfo(4, ui.Bold("Total Entries"), fmt.Sprintf("%d", len(entries))) if hasAnyEarnings { - ui.PrintInfo(4, ui.Bold("Earnings"), fmt.Sprintf("$%.2f", totalEarnings)) + ui.PrintInfo(4, ui.Bold("Earnings"), currency.FormatCurrency(totalEarnings, currencyCode)) } fmt.Println() @@ -144,7 +148,7 @@ func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) { fmt.Printf(" %s %s (%.1f%%)\n", ui.Bold(fmt.Sprintf("%-20s", project)), ui.FormatDuration(duration), percentage) if earnings, ok := projectEarnings[project]; ok && earnings > 0 { - fmt.Printf(" %s %s\n", ui.Muted("└─ Earnings:"), fmt.Sprintf("$%.2f", earnings)) + fmt.Printf(" %s %s\n", ui.Muted("└─ Earnings:"), currency.FormatCurrency(earnings, currencyCode)) } } @@ -193,6 +197,7 @@ func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) { } allProjects, _ := db.GetAllProjects() + currencyCode := getCurrencyCode() ui.PrintSuccess(ui.EmojiStats, ui.Bold("All-Time Statistics")) ui.PrintInfo(4, ui.Bold("Total Time"), fmt.Sprintf("%s (%.2f hours)", ui.FormatDuration(totalDuration), totalDuration.Hours())) @@ -200,7 +205,7 @@ func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) { ui.PrintInfo(4, ui.Bold("Projects Tracked"), fmt.Sprintf("%d", len(allProjects))) if hasAnyEarnings { - ui.PrintInfo(4, ui.Bold("Earnings"), fmt.Sprintf("$%.2f", totalEarnings)) + ui.PrintInfo(4, ui.Bold("Earnings"), currency.FormatCurrency(totalEarnings, currencyCode)) } fmt.Println() @@ -219,9 +224,20 @@ func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) { fmt.Printf(" %s %s (%.1f%%)\n", ui.Bold(fmt.Sprintf("%-20s", project)), ui.FormatDuration(duration), percentage) if earnings, ok := projectEarnings[project]; ok && earnings > 0 { - fmt.Printf(" %s %s\n", ui.Muted("└─ Earnings:"), fmt.Sprintf("$%.2f", earnings)) + fmt.Printf(" %s %s\n", ui.Muted("└─ Earnings:"), currency.FormatCurrency(earnings, currencyCode)) } } ui.NewlineBelow() } + +// getCurrencyCode attempts to load the currency code from the current project's +// .tmporc configuration file. If no config is found or currency is not set, +// it returns "USD" as the default. +func getCurrencyCode() string { + cfg, _, err := config.FindAndLoad() + if err != nil { + return "USD" + } + return cfg.GetCurrencyOrDefault() +} diff --git a/cmd/setup/init.go b/cmd/setup/init.go index 048a7a3..d7a1677 100644 --- a/cmd/setup/init.go +++ b/cmd/setup/init.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/DylanDevelops/tmpo/internal/config" + "github.com/DylanDevelops/tmpo/internal/currency" "github.com/DylanDevelops/tmpo/internal/project" "github.com/DylanDevelops/tmpo/internal/ui" "github.com/manifoldco/promptui" @@ -37,12 +38,14 @@ func InitCmd() *cobra.Command { var name string var hourlyRate float64 var description string + var currencyCode string if acceptDefaults { // Use all defaults without prompting name = defaultName hourlyRate = 0 description = "" + currencyCode = "USD" } else { // Interactive form ui.PrintSuccess(ui.EmojiInit, "Initialize Project Configuration") @@ -98,10 +101,27 @@ func InitCmd() *cobra.Command { } description = strings.TrimSpace(descInput) + + // Currency prompt + currencyPrompt := promptui.Prompt{ + Label: "Currency code (press Enter for USD)", + Validate: validateCurrency, + } + + currencyInput, err := currencyPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + currencyCode = strings.ToUpper(strings.TrimSpace(currencyInput)) + if currencyCode == "" { + currencyCode = "USD" + } } // Create the .tmporc file - err := config.CreateWithTemplate(name, hourlyRate, description) + err := config.CreateWithTemplate(name, hourlyRate, description, currencyCode) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) @@ -110,11 +130,14 @@ func InitCmd() *cobra.Command { fmt.Println() ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created .tmporc for project %s", ui.Bold(name))) if hourlyRate > 0 { - ui.PrintInfo(4, ui.Bold("Hourly Rate"), fmt.Sprintf("$%.2f", hourlyRate)) + ui.PrintInfo(4, ui.Bold("Hourly Rate"), currency.FormatCurrency(hourlyRate, currencyCode)) } if description != "" { ui.PrintInfo(4, ui.Bold("Description"), description) } + if currencyCode != "" && currencyCode != "USD" { + ui.PrintInfo(4, ui.Bold("Currency"), currencyCode) + } fmt.Println() ui.PrintMuted(0, "You can edit .tmporc to customize your project settings.") @@ -168,3 +191,25 @@ func validateHourlyRate(input string) error { return nil } + +// validateCurrency validates that the input is empty or a valid currency code format +func validateCurrency(input string) error { + input = strings.TrimSpace(input) + if input == "" { + return nil // Allow empty for default + } + + // Currency codes should be 3 letters + if len(input) != 3 { + return fmt.Errorf("currency code must be 3 letters (e.g., USD, EUR, GBP)") + } + + // Check that it's all letters + for _, char := range input { + if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') { + return fmt.Errorf("currency code must contain only letters") + } + } + + return nil +} diff --git a/docs/configuration.md b/docs/configuration.md index c41f9cb..b5b5a2d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,6 +30,7 @@ tmpo init # - Project name (defaults to auto-detected name) # - Hourly rate (optional, press Enter to skip) # - Description (optional, press Enter to skip) +# - Currency code (optional, defaults to USD) ``` For quick setup without prompts, use the `--accept-defaults` flag: @@ -57,6 +58,9 @@ hourly_rate: 125.50 # [OPTIONAL] Description for this project description: Client project for Acme Corp + +# [OPTIONAL] Currency code for billing display (USD, EUR, GBP, JPY, etc.) +currency: USD ``` ### Configuration Fields @@ -73,12 +77,13 @@ project_name: Client Website Redesign #### `hourly_rate` (optional) -Your billing rate in dollars per hour. When set, tmpo will calculate estimated earnings based on tracked time. +Your billing rate per hour. When set, tmpo will calculate estimated earnings based on tracked time. The currency symbol displayed is determined by the `currency` field (defaults to USD if not specified). **Example:** ```yaml hourly_rate: 150.00 +currency: USD # Will display as $150.00 ``` Set to `0` or omit to disable rate tracking: @@ -97,6 +102,47 @@ A longer description or notes about the project. This is for your reference and description: Q1 2024 website redesign for Acme Corp. Main contact: john@acme.com ``` +#### `currency` (optional) + +The ISO 4217 currency code for displaying billing rates and earnings. This determines the currency symbol shown in stats, reports, and summaries. + +**Supported Currencies:** + +tmpo supports 30+ currencies including: + +- **Americas:** USD ($), CAD (CA$), BRL (R$), MXN (MX$) +- **Europe:** EUR (€), GBP (£), CHF (Fr), SEK (kr), NOK (kr) +- **Asia:** JPY (¥), CNY (¥), INR (₹), KRW (₩), SGD (S$) +- **Oceania:** AUD (A$), NZD (NZ$) + +And many more. See the [full currency code list](https://en.wikipedia.org/wiki/ISO_4217#Active_codes). + +**Examples:** + +```yaml +# US-based project +hourly_rate: 150 +currency: USD +# Displays as: $150.00 + +# European project +hourly_rate: 120 +currency: EUR +# Displays as: €120.00 + +# UK project +hourly_rate: 100 +currency: GBP +# Displays as: £100.00 + +# Japanese project +hourly_rate: 15000 +currency: JPY +# Displays as: ¥15000.00 +``` + +If not specified or if an unknown currency code is provided, `currency` defaults to USD. Currency codes are case-insensitive (USD, usd, or Usd all work). + ## Project Detection Priority When you run `tmpo start`, the project name is determined in this order: @@ -150,13 +196,15 @@ tmpo init # Project name: Client A - Web Development # Hourly rate: 150 # Description: [press Enter to skip] +# Currency code: USD -# Client B - $175/hour +# Client B - £175/hour cd ~/projects/client-b tmpo init # Project name: Client B - Game Development # Hourly rate: 175 # Description: [press Enter to skip] +# Currency code: GBP # Personal project - no billing cd ~/projects/my-app @@ -166,9 +214,11 @@ tmpo init --accept-defaults # Quick setup with defaults Alternatively, you can manually create `.tmporc` files: ```bash -cat > ~/projects/client-a/.tmporc << EOF -project_name: Client A - Web Development +# Client configuration +cat > ~/projects/client-project/.tmporc << EOF +project_name: Client Project - Web Development hourly_rate: 150.00 +currency: USD EOF ``` diff --git a/internal/config/config.go b/internal/config/config.go index 7601fa0..c4fe3ef 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" + "github.com/DylanDevelops/tmpo/internal/currency" "go.yaml.in/yaml/v3" ) @@ -14,12 +15,14 @@ import ( // ProjectName is the human-readable name of the project. // HourlyRate is the billable hourly rate for the project; when zero it will be omitted from YAML. // Description is an optional free-form description of the project; when empty it will be omitted from YAML. +// Currency is the ISO 4217 currency code (e.g., USD, EUR, GBP) for billing display; when empty it will be omitted from YAML. // // ! IMPORTANT When adding new fields to this struct, also update configTemplate below. ! type Config struct { ProjectName string `yaml:"project_name"` HourlyRate float64 `yaml:"hourly_rate,omitempty"` Description string `yaml:"description,omitempty"` + Currency string `yaml:"currency,omitempty"` } // configTemplate is the template used when creating new .tmporc files via CreateWithTemplate. @@ -29,6 +32,7 @@ type Config struct { // %s - project name (string) // %.2f - hourly rate (float64, 2 decimal places) // %s - description (string) +// %s - currency code (string) // // ! IMPORTANT: When adding new fields to the Config struct above, update this template. ! const configTemplate = `# tmpo project configuration @@ -42,6 +46,9 @@ hourly_rate: %.2f # [OPTIONAL] Description for this project description: "%s" + +# [OPTIONAL] Currency code for billing display (USD, EUR, GBP, JPY, etc.) +currency: %s ` // Load reads a YAML configuration file from the provided path and unmarshals it into a Config. @@ -99,13 +106,13 @@ func Create(projectName string, hourlyRate float64) error { // CreateWithTemplate creates a new .tmporc file with a user-friendly format that includes // all fields (even if empty) and helpful comments. This provides a better user experience // by showing all available configuration options. -func CreateWithTemplate(projectName string, hourlyRate float64, description string) error { +func CreateWithTemplate(projectName string, hourlyRate float64, description string, currency string) error { tmporc := filepath.Join(".", ".tmporc") if _, err := os.Stat(tmporc); err == nil { return fmt.Errorf(".tmporc already exists") } - content := fmt.Sprintf(configTemplate, projectName, hourlyRate, description) + content := fmt.Sprintf(configTemplate, projectName, hourlyRate, description, currency) if err := os.WriteFile(tmporc, []byte(content), 0644); err != nil { return fmt.Errorf("failed to write config: %w", err) @@ -148,3 +155,12 @@ func FindAndLoad() (*Config, string, error) { return nil, "", fmt.Errorf(".tmporc not found") } + +// GetCurrencyOrDefault returns the configured currency code, or "USD" if not set. +// This provides a convenient way to get the currency with a sensible default. +func (c *Config) GetCurrencyOrDefault() string { + if c.Currency == "" { + return currency.DefaultCurrency + } + return c.Currency +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 84f273c..353c712 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -179,7 +179,7 @@ func TestCreateWithTemplate(t *testing.T) { err = os.Chdir(tmpDir) assert.NoError(t, err) - err = CreateWithTemplate("templated-project", 99.99, "Test description") + err = CreateWithTemplate("templated-project", 99.99, "Test description", "EUR") assert.NoError(t, err) // Verify file was created @@ -192,6 +192,7 @@ func TestCreateWithTemplate(t *testing.T) { assert.Contains(t, string(content), "project_name: templated-project") assert.Contains(t, string(content), "hourly_rate: 99.99") assert.Contains(t, string(content), "description: \"Test description\"") + assert.Contains(t, string(content), "currency: EUR") assert.Contains(t, string(content), "# [OPTIONAL]") // Verify it can be loaded @@ -200,6 +201,7 @@ func TestCreateWithTemplate(t *testing.T) { assert.Equal(t, "templated-project", cfg.ProjectName) assert.Equal(t, 99.99, cfg.HourlyRate) assert.Equal(t, "Test description", cfg.Description) + assert.Equal(t, "EUR", cfg.Currency) }) t.Run("returns error if file exists", func(t *testing.T) { @@ -212,11 +214,11 @@ func TestCreateWithTemplate(t *testing.T) { assert.NoError(t, err) // Create initial file - err = CreateWithTemplate("first", 100.0, "desc") + err = CreateWithTemplate("first", 100.0, "desc", "USD") assert.NoError(t, err) // Try to create again - err = CreateWithTemplate("second", 200.0, "desc2") + err = CreateWithTemplate("second", 200.0, "desc2", "EUR") assert.Error(t, err) assert.Contains(t, err.Error(), "already exists") }) diff --git a/internal/currency/currency.go b/internal/currency/currency.go new file mode 100644 index 0000000..f7ef3e7 --- /dev/null +++ b/internal/currency/currency.go @@ -0,0 +1,114 @@ +package currency + +import ( + "fmt" + "strings" +) + +// DefaultCurrency is the currency code used when none is specified in configuration. +const DefaultCurrency = "USD" + +// currencySymbols maps ISO 4217 currency codes to their display symbols. +// This map includes the most commonly used global currencies. +var currencySymbols = map[string]string{ + // Americas + "USD": "$", // United States Dollar + "CAD": "CA$", // Canadian Dollar + "BRL": "R$", // Brazilian Real + "MXN": "MX$", // Mexican Peso + "ARS": "AR$", // Argentine Peso + + // Europe + "EUR": "€", // Euro + "GBP": "£", // British Pound Sterling + "CHF": "Fr", // Swiss Franc + "SEK": "kr", // Swedish Krona + "NOK": "kr", // Norwegian Krone + "DKK": "kr", // Danish Krone + "PLN": "zł", // Polish Zloty + "CZK": "Kč", // Czech Koruna + + // Asia + "JPY": "¥", // Japanese Yen + "CNY": "¥", // Chinese Yuan + "INR": "₹", // Indian Rupee + "KRW": "₩", // South Korean Won + "SGD": "S$", // Singapore Dollar + "HKD": "HK$", // Hong Kong Dollar + "THB": "฿", // Thai Baht + "IDR": "Rp", // Indonesian Rupiah + "MYR": "RM", // Malaysian Ringgit + "PHP": "₱", // Philippine Peso + "VND": "₫", // Vietnamese Dong + + // Oceania + "AUD": "A$", // Australian Dollar + "NZD": "NZ$", // New Zealand Dollar + + // Middle East & Africa + "AED": "د.إ", // UAE Dirham + "SAR": "﷼", // Saudi Riyal + "ILS": "₪", // Israeli Shekel + "ZAR": "R", // South African Rand + "EGP": "E£", // Egyptian Pound + "TRY": "₺", // Turkish Lira +} + +// FormatCurrency formats an amount with the appropriate currency symbol. +// The currencyCode is normalized to uppercase and looked up in the symbol map. +// If the currency code is empty or unknown, it defaults to USD ($). +// +// Examples: +// FormatCurrency(150.00, "USD") returns "$150.00" +// FormatCurrency(99.99, "EUR") returns "€99.99" +// FormatCurrency(1234.56, "GBP") returns "£1234.56" +// FormatCurrency(100.00, "") returns "$100.00" +// FormatCurrency(100.00, "UNKNOWN") returns "$100.00" +func FormatCurrency(amount float64, currencyCode string) string { + // Normalize currency code to uppercase + currencyCode = strings.ToUpper(strings.TrimSpace(currencyCode)) + + // Default to USD if empty or unknown + if currencyCode == "" || !IsSupported(currencyCode) { + currencyCode = DefaultCurrency + } + + symbol := GetSymbol(currencyCode) + return fmt.Sprintf("%s%.2f", symbol, amount) +} + +// GetSymbol returns the display symbol for the given currency code. +// The currency code is normalized to uppercase before lookup. +// If the currency code is not found, it returns the code itself. +// +// Examples: +// GetSymbol("USD") returns "$" +// GetSymbol("eur") returns "€" +// GetSymbol("UNKNOWN") returns "UNKNOWN" +func GetSymbol(currencyCode string) string { + currencyCode = strings.ToUpper(strings.TrimSpace(currencyCode)) + + if symbol, exists := currencySymbols[currencyCode]; exists { + return symbol + } + + return currencyCode +} + +// IsSupported returns true if the given currency code is in the supported currencies map. +// The currency code is normalized to uppercase before checking. +func IsSupported(currencyCode string) bool { + currencyCode = strings.ToUpper(strings.TrimSpace(currencyCode)) + _, exists := currencySymbols[currencyCode] + return exists +} + +// GetSupportedCurrencies returns a sorted list of all supported currency codes. +// This is useful for documentation or validation purposes. +func GetSupportedCurrencies() []string { + currencies := make([]string, 0, len(currencySymbols)) + for code := range currencySymbols { + currencies = append(currencies, code) + } + return currencies +} diff --git a/internal/currency/currency_test.go b/internal/currency/currency_test.go new file mode 100644 index 0000000..42c0085 --- /dev/null +++ b/internal/currency/currency_test.go @@ -0,0 +1,309 @@ +package currency + +import ( + "testing" +) + +func TestFormatCurrency(t *testing.T) { + tests := []struct { + name string + amount float64 + currencyCode string + expected string + }{ + // USD tests + { + name: "USD with standard amount", + amount: 150.00, + currencyCode: "USD", + expected: "$150.00", + }, + { + name: "USD with decimal places", + amount: 99.99, + currencyCode: "USD", + expected: "$99.99", + }, + { + name: "USD with zero", + amount: 0.00, + currencyCode: "USD", + expected: "$0.00", + }, + { + name: "USD with large amount", + amount: 123456.78, + currencyCode: "USD", + expected: "$123456.78", + }, + + // Euro tests + { + name: "EUR with standard amount", + amount: 100.00, + currencyCode: "EUR", + expected: "€100.00", + }, + { + name: "EUR lowercase", + amount: 50.50, + currencyCode: "eur", + expected: "€50.50", + }, + + // GBP tests + { + name: "GBP with standard amount", + amount: 200.00, + currencyCode: "GBP", + expected: "£200.00", + }, + + // Asian currencies + { + name: "JPY with standard amount", + amount: 10000.00, + currencyCode: "JPY", + expected: "¥10000.00", + }, + { + name: "INR with standard amount", + amount: 5000.00, + currencyCode: "INR", + expected: "₹5000.00", + }, + { + name: "KRW with standard amount", + amount: 100000.00, + currencyCode: "KRW", + expected: "₩100000.00", + }, + + // Other currencies + { + name: "CAD with standard amount", + amount: 75.00, + currencyCode: "CAD", + expected: "CA$75.00", + }, + { + name: "AUD with standard amount", + amount: 150.00, + currencyCode: "AUD", + expected: "A$150.00", + }, + { + name: "CHF with standard amount", + amount: 100.00, + currencyCode: "CHF", + expected: "Fr100.00", + }, + + // Edge cases + { + name: "Empty currency code defaults to USD", + amount: 100.00, + currencyCode: "", + expected: "$100.00", + }, + { + name: "Unknown currency code defaults to USD", + amount: 100.00, + currencyCode: "XYZ", + expected: "$100.00", + }, + { + name: "Whitespace in currency code", + amount: 50.00, + currencyCode: " USD ", + expected: "$50.00", + }, + { + name: "Mixed case currency code", + amount: 75.25, + currencyCode: "GbP", + expected: "£75.25", + }, + { + name: "Very small amount", + amount: 0.01, + currencyCode: "USD", + expected: "$0.01", + }, + { + name: "Amount with many decimal places (should round to 2)", + amount: 99.999, + currencyCode: "USD", + expected: "$100.00", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatCurrency(tt.amount, tt.currencyCode) + if result != tt.expected { + t.Errorf("FormatCurrency(%f, %q) = %q, expected %q", + tt.amount, tt.currencyCode, result, tt.expected) + } + }) + } +} + +func TestGetSymbol(t *testing.T) { + tests := []struct { + name string + currencyCode string + expected string + }{ + { + name: "USD returns dollar sign", + currencyCode: "USD", + expected: "$", + }, + { + name: "EUR returns euro sign", + currencyCode: "EUR", + expected: "€", + }, + { + name: "GBP returns pound sign", + currencyCode: "GBP", + expected: "£", + }, + { + name: "JPY returns yen sign", + currencyCode: "JPY", + expected: "¥", + }, + { + name: "Lowercase currency code", + currencyCode: "usd", + expected: "$", + }, + { + name: "Mixed case currency code", + currencyCode: "Eur", + expected: "€", + }, + { + name: "Unknown currency returns code itself", + currencyCode: "XYZ", + expected: "XYZ", + }, + { + name: "Whitespace is trimmed", + currencyCode: " GBP ", + expected: "£", + }, + { + name: "Empty string returns empty", + currencyCode: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetSymbol(tt.currencyCode) + if result != tt.expected { + t.Errorf("GetSymbol(%q) = %q, expected %q", + tt.currencyCode, result, tt.expected) + } + }) + } +} + +func TestIsSupported(t *testing.T) { + tests := []struct { + name string + currencyCode string + expected bool + }{ + { + name: "USD is supported", + currencyCode: "USD", + expected: true, + }, + { + name: "EUR is supported", + currencyCode: "EUR", + expected: true, + }, + { + name: "GBP is supported", + currencyCode: "GBP", + expected: true, + }, + { + name: "JPY is supported", + currencyCode: "JPY", + expected: true, + }, + { + name: "INR is supported", + currencyCode: "INR", + expected: true, + }, + { + name: "Lowercase USD is supported", + currencyCode: "usd", + expected: true, + }, + { + name: "Unknown currency is not supported", + currencyCode: "XYZ", + expected: false, + }, + { + name: "Empty string is not supported", + currencyCode: "", + expected: false, + }, + { + name: "Whitespace around supported code", + currencyCode: " EUR ", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsSupported(tt.currencyCode) + if result != tt.expected { + t.Errorf("IsSupported(%q) = %v, expected %v", + tt.currencyCode, result, tt.expected) + } + }) + } +} + +func TestGetSupportedCurrencies(t *testing.T) { + currencies := GetSupportedCurrencies() + + // Check that we have a reasonable number of currencies + if len(currencies) < 20 { + t.Errorf("GetSupportedCurrencies() returned %d currencies, expected at least 20", + len(currencies)) + } + + // Check that common currencies are included + commonCurrencies := []string{"USD", "EUR", "GBP", "JPY", "CNY", "INR", "CAD", "AUD"} + for _, code := range commonCurrencies { + found := false + for _, c := range currencies { + if c == code { + found = true + break + } + } + if !found { + t.Errorf("GetSupportedCurrencies() missing expected currency: %s", code) + } + } +} + +func TestDefaultCurrency(t *testing.T) { + if DefaultCurrency != "USD" { + t.Errorf("DefaultCurrency = %q, expected %q", DefaultCurrency, "USD") + } +}