257 lines
7.0 KiB
Go
257 lines
7.0 KiB
Go
// Price formatting and "pretty commercial price" conversion
|
|
// Design principle: Never show exact converted prices (e.g., $29.99 → €27.42)
|
|
// Instead: Round UP to psychologically pleasing local prices
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
)
|
|
|
|
// CurrencyInfo holds currency metadata for formatting
|
|
type CurrencyInfo struct {
|
|
Code string
|
|
Name string
|
|
Decimals int
|
|
Symbol string
|
|
SymbolPosition string
|
|
PrettyPattern string
|
|
ExchangeRate float64
|
|
}
|
|
|
|
// FormatPrice displays an amount in minor units as human-readable string
|
|
// e.g., 149900 cents USD → "USD 1,499.00" or "$1,499.00" depending on useSymbol
|
|
func FormatPrice(amountMinor int64, currencyCode string, useSymbol bool) string {
|
|
// This is a stub - in production, fetch from DB
|
|
info := getCurrencyInfo(currencyCode)
|
|
|
|
// Convert to major units (e.g., dollars from cents)
|
|
divisor := math.Pow(10, float64(info.Decimals))
|
|
major := float64(amountMinor) / divisor
|
|
|
|
// Format with thousands separator and correct decimals
|
|
formatted := formatNumber(major, info.Decimals)
|
|
|
|
if useSymbol && info.Symbol != "" {
|
|
if info.SymbolPosition == "suffix" {
|
|
return formatted + " " + info.Symbol
|
|
}
|
|
return info.Symbol + formatted
|
|
}
|
|
|
|
return currencyCode + " " + formatted
|
|
}
|
|
|
|
// PrettyPrice converts USD amount to local currency and rounds to "pretty" commercial price
|
|
// Uses roundToNine algorithm (ends in 9, step-based rounding)
|
|
// Returns amount in MINOR units (e.g., 11900 for $119.00)
|
|
func PrettyPrice(usdAmount float64, targetCurrency string, dbRate float64) int64 {
|
|
info := getCurrencyInfo(targetCurrency)
|
|
|
|
var majorAmount float64
|
|
if targetCurrency == "USD" {
|
|
majorAmount = usdAmount
|
|
} else {
|
|
// Convert to target currency major units
|
|
majorAmount = usdAmount * dbRate
|
|
}
|
|
|
|
// Round to nice "ends in 9" price
|
|
roundedMajor := roundToNine(majorAmount)
|
|
|
|
// Convert to minor units
|
|
multiplier := math.Pow(10, float64(info.Decimals))
|
|
return int64(roundedMajor * multiplier)
|
|
}
|
|
|
|
// roundToTen rounds UP to the next clean step boundary
|
|
|
|
// roundToTen rounds UP to the next clean step boundary
|
|
// Step sizes: 10 (for 100-200), 25 (for 200-250), 50 (for 250-500), 100 (for 500-1000), etc.
|
|
func roundToTen(price float64) float64 {
|
|
if price <= 0 {
|
|
return 0
|
|
}
|
|
|
|
// Find magnitude M where 10^M <= price < 10^(M+1)
|
|
M := math.Floor(math.Log10(price))
|
|
magnitude := math.Pow(10, M)
|
|
|
|
// Normalize to 1-10 range
|
|
N := price / magnitude
|
|
|
|
// Lookup step ratio based on coefficient
|
|
var stepRatio float64
|
|
switch {
|
|
case N < 2.0:
|
|
stepRatio = 0.1 // step = 10, 100, 1000...
|
|
case N < 2.5:
|
|
stepRatio = 0.25 // step = 25, 250...
|
|
case N < 5.0:
|
|
stepRatio = 0.5 // step = 50, 500...
|
|
default:
|
|
stepRatio = 1.0 // step = 100, 1000...
|
|
}
|
|
|
|
step := stepRatio * magnitude
|
|
return math.Ceil(price/step) * step
|
|
}
|
|
|
|
// roundToNine: Commercial pricing (always ends just below the step)
|
|
func roundToNine(price float64) float64 {
|
|
return roundToTen(price) - 1
|
|
}
|
|
|
|
// formatNumber formats with thousands separators and correct decimals
|
|
func formatNumber(num float64, decimals int) string {
|
|
// Round to specified decimals first
|
|
multiplier := math.Pow(10, float64(decimals))
|
|
rounded := math.Round(num*multiplier) / multiplier
|
|
|
|
// Format with decimals
|
|
format := fmt.Sprintf("%%.%df", decimals)
|
|
withDecimals := fmt.Sprintf(format, math.Abs(rounded))
|
|
|
|
// Split integer and decimal parts
|
|
parts := strings.Split(withDecimals, ".")
|
|
intPart := parts[0]
|
|
decPart := ""
|
|
if len(parts) > 1 {
|
|
decPart = parts[1]
|
|
}
|
|
|
|
// Add thousands separators
|
|
var result strings.Builder
|
|
for i, c := range intPart {
|
|
if i > 0 && (len(intPart)-i)%3 == 0 {
|
|
result.WriteRune(',')
|
|
}
|
|
result.WriteRune(c)
|
|
}
|
|
|
|
if decimals > 0 && decPart != "" {
|
|
result.WriteString(".")
|
|
result.WriteString(decPart)
|
|
}
|
|
|
|
if rounded < 0 {
|
|
return "-" + result.String()
|
|
}
|
|
return result.String()
|
|
}
|
|
|
|
// getCurrencyInfo returns currency metadata
|
|
// In production, this queries the database
|
|
func getCurrencyInfo(code string) CurrencyInfo {
|
|
// Default fallback
|
|
info := CurrencyInfo{
|
|
Code: code,
|
|
Name: code,
|
|
Decimals: 2,
|
|
Symbol: code,
|
|
SymbolPosition: "prefix",
|
|
PrettyPattern: "x9",
|
|
ExchangeRate: 1.0,
|
|
}
|
|
|
|
// Common currencies
|
|
switch code {
|
|
case "USD":
|
|
info = CurrencyInfo{
|
|
Code: "USD", Name: "US Dollar", Decimals: 2,
|
|
Symbol: "$", SymbolPosition: "prefix",
|
|
PrettyPattern: "x9", ExchangeRate: 1.0,
|
|
}
|
|
case "EUR":
|
|
info = CurrencyInfo{
|
|
Code: "EUR", Name: "Euro", Decimals: 2,
|
|
Symbol: "€", SymbolPosition: "prefix",
|
|
PrettyPattern: "x9", ExchangeRate: 0.92,
|
|
}
|
|
case "GBP":
|
|
info = CurrencyInfo{
|
|
Code: "GBP", Name: "British Pound", Decimals: 2,
|
|
Symbol: "£", SymbolPosition: "prefix",
|
|
PrettyPattern: "x9", ExchangeRate: 0.79,
|
|
}
|
|
case "JPY":
|
|
info = CurrencyInfo{
|
|
Code: "JPY", Name: "Japanese Yen", Decimals: 0,
|
|
Symbol: "¥", SymbolPosition: "prefix",
|
|
PrettyPattern: "x0", ExchangeRate: 151.5,
|
|
}
|
|
case "CAD":
|
|
info = CurrencyInfo{
|
|
Code: "CAD", Name: "Canadian Dollar", Decimals: 2,
|
|
Symbol: "C$", SymbolPosition: "prefix",
|
|
PrettyPattern: "x9", ExchangeRate: 1.36,
|
|
}
|
|
case "AUD":
|
|
info = CurrencyInfo{
|
|
Code: "AUD", Name: "Australian Dollar", Decimals: 2,
|
|
Symbol: "A$", SymbolPosition: "prefix",
|
|
PrettyPattern: "x9", ExchangeRate: 1.52,
|
|
}
|
|
case "CHF":
|
|
info = CurrencyInfo{
|
|
Code: "CHF", Name: "Swiss Franc", Decimals: 2,
|
|
Symbol: "Fr", SymbolPosition: "prefix",
|
|
PrettyPattern: "x0", ExchangeRate: 0.90,
|
|
}
|
|
case "SEK", "NOK", "DKK":
|
|
symbol := "kr"
|
|
info = CurrencyInfo{
|
|
Code: code, Name: "Krona", Decimals: 2,
|
|
Symbol: symbol, SymbolPosition: "suffix",
|
|
PrettyPattern: "x0", ExchangeRate: 10.5,
|
|
}
|
|
case "BHD":
|
|
info = CurrencyInfo{
|
|
Code: "BHD", Name: "Bahraini Dinar", Decimals: 3,
|
|
Symbol: "BD", SymbolPosition: "prefix",
|
|
PrettyPattern: "x000", ExchangeRate: 0.376,
|
|
}
|
|
case "KWD":
|
|
info = CurrencyInfo{
|
|
Code: "KWD", Name: "Kuwaiti Dinar", Decimals: 3,
|
|
Symbol: "KD", SymbolPosition: "prefix",
|
|
PrettyPattern: "x000", ExchangeRate: 0.307,
|
|
}
|
|
}
|
|
|
|
return info
|
|
}
|
|
|
|
// ExamplePrettyPrices shows examples of pretty pricing conversion
|
|
func ExamplePrettyPrices() {
|
|
fmt.Println("=== Pretty Commercial Prices ===")
|
|
fmt.Println("(Never show exact converted prices - always round UP)")
|
|
fmt.Println()
|
|
|
|
examples := []struct {
|
|
USD float64
|
|
Rate float64
|
|
Target string
|
|
}{
|
|
{23.21, 0.92, "EUR"}, // Should be €24.00, not €21.35
|
|
{47.32, 0.92, "EUR"}, // Should be €49.99, not €43.53
|
|
{123.45, 0.92, "EUR"}, // Should be €129.00, not €113.57
|
|
{499.00, 0.92, "EUR"}, // Should be €499.00 or €459.00
|
|
{999.00, 0.79, "GBP"}, // Should be £799.00, not £789.21
|
|
{29.99, 151.5, "JPY"}, // Should be ¥5,000, not ¥4,544
|
|
{1499.00, 1.0, "USD"}, // Should be $1,499.00
|
|
{1499.00, 0.92, "EUR"}, // Should be €1,499.00, not €1,379
|
|
}
|
|
|
|
for _, ex := range examples {
|
|
pretty := PrettyPrice(ex.USD, ex.Target, ex.Rate)
|
|
exactConverted := ex.USD * ex.Rate
|
|
|
|
display := FormatPrice(pretty, ex.Target, true)
|
|
|
|
fmt.Printf("$%.2f USD @ %.4f → EXACT: %.2f → PRETTY: %s\n",
|
|
ex.USD, ex.Rate, exactConverted, display)
|
|
}
|
|
}
|