From 3d61490e89fe4e8d30b3091ff81d1a5f03588703 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Wed, 24 Dec 2025 13:55:11 -0700 Subject: [PATCH] Add currency support for billing and stats display Introduces a new 'currency' field in project configuration, allowing users to specify the ISO 4217 currency code for billing rates and earnings display. Updates CLI commands, configuration handling, and documentation to support currency selection (defaulting to USD), and adds a new internal/currency package for formatting and validating currency codes. Includes comprehensive tests for currency formatting and config integration. --- cmd/entries/manual.go | 11 +- cmd/history/stats.go | 24 ++- cmd/setup/init.go | 49 ++++- docs/configuration.md | 58 +++++- internal/config/config.go | 20 +- internal/config/config_test.go | 8 +- internal/currency/currency.go | 114 +++++++++++ internal/currency/currency_test.go | 309 +++++++++++++++++++++++++++++ 8 files changed, 576 insertions(+), 17 deletions(-) create mode 100644 internal/currency/currency.go create mode 100644 internal/currency/currency_test.go 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") + } +}