Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions pkg/common/currency/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Currency Package

Convert costs between currencies in OpenCost plugins using live exchange rates.

## Quick Start

```go
import "github.com/opencost/opencost-plugins/pkg/common/currency"

config := currency.Config{
APIKey: "your-api-key",
CacheTTL: 24 * time.Hour,
}

converter, err := currency.NewConverter(config)
if err != nil {
log.Fatal(err)
}

// Convert 100 USD to EUR
amount, err := converter.Convert(100.0, "USD", "EUR")
```

## Setup

Get a free API key from [exchangerate-api.com](https://www.exchangerate-api.com/) (1,500 requests/month).

## How it Works

The package fetches exchange rates and caches them for 24 hours. This keeps API usage low - most plugins use under 50 requests per month.

Supports all ISO 4217 currencies (161 total). Thread-safe with automatic cache cleanup.

## MongoDB Atlas Example

```go
// Config
type AtlasConfig struct {
TargetCurrency string `json:"target_currency"`
ExchangeAPIKey string `json:"exchange_api_key"`
}

// Usage
if atlasConfig.ExchangeAPIKey != "" {
converter, _ := currency.NewConverter(currency.Config{
APIKey: atlasConfig.ExchangeAPIKey,
CacheTTL: 24 * time.Hour,
})
}

// Convert costs
if converter != nil {
cost, _ = converter.Convert(cost, "USD", targetCurrency)
}
```

## Testing

```bash
cd pkg/common/currency
go test -v
```

Tests use mocks - no API calls needed.
99 changes: 99 additions & 0 deletions pkg/common/currency/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package currency

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@malpou can we update this to remove currency conversion since we merged your PR upstream?

@malpou malpou Oct 4, 2025

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the long time of nothing happening been busy, @ameijer it should be good now 😄


import (
"sync"
"time"
)

type memoryCache struct {
mu sync.RWMutex
data map[string]*cachedRates
ttl time.Duration
janitor *time.Ticker
}

func newMemoryCache(ttl time.Duration) *memoryCache {
if ttl == 0 {
ttl = 24 * time.Hour
}

cache := &memoryCache{
data: make(map[string]*cachedRates),
ttl: ttl,
janitor: time.NewTicker(ttl / 2),
}

go cache.cleanup()

return cache
}

func (c *memoryCache) get(baseCurrency string) (*cachedRates, bool) {
c.mu.RLock()
defer c.mu.RUnlock()

rates, exists := c.data[baseCurrency]
if !exists {
return nil, false
}

if time.Now().After(rates.validUntil) {
return nil, false
}

return rates, true
}

func (c *memoryCache) set(baseCurrency string, rates *cachedRates) {
c.mu.Lock()
defer c.mu.Unlock()

rates.validUntil = rates.fetchedAt.Add(c.ttl)
c.data[baseCurrency] = rates
}

func (c *memoryCache) clear() {
c.mu.Lock()
defer c.mu.Unlock()

c.data = make(map[string]*cachedRates)
}

func (c *memoryCache) cleanup() {
for range c.janitor.C {
c.removeExpired()
}
}

func (c *memoryCache) removeExpired() {
c.mu.Lock()
defer c.mu.Unlock()

now := time.Now()
for key, rates := range c.data {
if now.After(rates.validUntil) {
delete(c.data, key)
}
}
}

func (c *memoryCache) stop() {
if c.janitor != nil {
c.janitor.Stop()
}
}

func (c *memoryCache) stats() (entries int, oldestEntry time.Time) {
c.mu.RLock()
defer c.mu.RUnlock()

entries = len(c.data)

for _, rates := range c.data {
if oldestEntry.IsZero() || rates.fetchedAt.Before(oldestEntry) {
oldestEntry = rates.fetchedAt
}
}

return entries, oldestEntry
}
170 changes: 170 additions & 0 deletions pkg/common/currency/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package currency

import (
"testing"
"time"
)

func TestMemoryCache_SetAndGet(t *testing.T) {
cache := newMemoryCache(1 * time.Hour)
defer cache.stop()

// Test setting and getting rates
rates := &cachedRates{
rates: map[string]float64{
"EUR": 0.85,
"GBP": 0.73,
},
baseCode: "USD",
fetchedAt: time.Now(),
}

cache.set("USD", rates)

// Test successful get
retrieved, found := cache.get("USD")
if !found {
t.Error("expected to find cached rates")
}

if retrieved.baseCode != "USD" {
t.Errorf("expected base code USD, got %s", retrieved.baseCode)
}

if len(retrieved.rates) != 2 {
t.Errorf("expected 2 rates, got %d", len(retrieved.rates))
}

// Test non-existent key
_, found = cache.get("EUR")
if found {
t.Error("expected not to find rates for EUR")
}
}

func TestMemoryCache_Expiration(t *testing.T) {
// Use short TTL for testing
cache := newMemoryCache(100 * time.Millisecond)
defer cache.stop()

rates := &cachedRates{
rates: map[string]float64{
"EUR": 0.85,
},
baseCode: "USD",
fetchedAt: time.Now(),
}

cache.set("USD", rates)

// Should find it immediately
_, found := cache.get("USD")
if !found {
t.Error("expected to find cached rates immediately")
}

// Wait for expiration
time.Sleep(150 * time.Millisecond)

// Should not find it after expiration
_, found = cache.get("USD")
if found {
t.Error("expected rates to be expired")
}
}

func TestMemoryCache_Clear(t *testing.T) {
cache := newMemoryCache(1 * time.Hour)
defer cache.stop()

// Add multiple entries
for _, base := range []string{"USD", "EUR", "GBP"} {
rates := &cachedRates{
rates: map[string]float64{"TEST": 1.0},
baseCode: base,
fetchedAt: time.Now(),
}
cache.set(base, rates)
}

// Verify all entries exist
for _, base := range []string{"USD", "EUR", "GBP"} {
_, found := cache.get(base)
if !found {
t.Errorf("expected to find rates for %s", base)
}
}

// Clear cache
cache.clear()

// Verify all entries are gone
for _, base := range []string{"USD", "EUR", "GBP"} {
_, found := cache.get(base)
if found {
t.Errorf("expected not to find rates for %s after clear", base)
}
}
}

func TestMemoryCache_Stats(t *testing.T) {
cache := newMemoryCache(1 * time.Hour)
defer cache.stop()

// Initially empty
entries, _ := cache.stats()
if entries != 0 {
t.Errorf("expected 0 entries, got %d", entries)
}

// Add entries
now := time.Now()
for i, base := range []string{"USD", "EUR", "GBP"} {
rates := &cachedRates{
rates: map[string]float64{"TEST": 1.0},
baseCode: base,
fetchedAt: now.Add(time.Duration(i) * time.Minute),
}
cache.set(base, rates)
}

entries, oldest := cache.stats()
if entries != 3 {
t.Errorf("expected 3 entries, got %d", entries)
}

// The oldest should be the first one we added (USD)
if !oldest.Equal(now) {
t.Errorf("expected oldest entry to be %v, got %v", now, oldest)
}
}

func TestMemoryCache_Cleanup(t *testing.T) {
// Use very short TTL for testing
cache := newMemoryCache(50 * time.Millisecond)
defer cache.stop()

// Add entry
rates := &cachedRates{
rates: map[string]float64{"EUR": 0.85},
baseCode: "USD",
fetchedAt: time.Now(),
}
cache.set("USD", rates)

// Verify it exists
entries, _ := cache.stats()
if entries != 1 {
t.Errorf("expected 1 entry, got %d", entries)
}

// Wait for cleanup cycle (janitor runs every TTL/2 = 25ms)
// Wait a bit longer to ensure cleanup has run
time.Sleep(100 * time.Millisecond)

// Verify it's been cleaned up
entries, _ = cache.stats()
if entries != 0 {
t.Errorf("expected 0 entries after cleanup, got %d", entries)
}
}
Loading