Skip to content

Commit 86f3085

Browse files
authored
feat: add payment gateway to contra-account mapping (task 6) (#350)
1 parent 5ab8468 commit 86f3085

2 files changed

Lines changed: 641 additions & 0 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
// Package config provides configuration for the payment-order service.
2+
package config
3+
4+
import (
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"os"
9+
"strings"
10+
)
11+
12+
// GatewayAccountMapping defines the mapping between a payment gateway and its contra-account.
13+
// The contra-account is used for ledger postings when processing payments through this gateway.
14+
type GatewayAccountMapping struct {
15+
// GatewayID is the payment gateway identifier (e.g., "stripe", "adyen", "mock").
16+
GatewayID string `json:"gateway_id"`
17+
// ContraAccountID is the nostro/acquirer account ID for this gateway.
18+
ContraAccountID string `json:"contra_account_id"`
19+
// AccountType indicates the type of contra-account ("NOSTRO" or "ACQUIRER").
20+
AccountType string `json:"account_type"`
21+
}
22+
23+
// GatewayAccountConfig holds the configuration for all gateway-to-account mappings.
24+
type GatewayAccountConfig struct {
25+
// Mappings contains the gateway-to-account mappings keyed by GatewayID.
26+
Mappings map[string]*GatewayAccountMapping
27+
}
28+
29+
// Configuration errors
30+
var (
31+
// ErrNoGatewayMapping is returned when no mapping exists for the specified gateway.
32+
ErrNoGatewayMapping = errors.New("no contra-account mapping for gateway")
33+
// ErrEmptyConfig is returned when the configuration has no mappings.
34+
ErrEmptyConfig = errors.New("gateway account configuration is empty")
35+
// ErrInvalidAccountType is returned when the account type is not valid.
36+
ErrInvalidAccountType = errors.New("invalid account type: must be NOSTRO or ACQUIRER")
37+
// ErrEmptyGatewayID is returned when a gateway ID is empty.
38+
ErrEmptyGatewayID = errors.New("gateway ID must not be empty")
39+
// ErrEmptyContraAccountID is returned when a contra-account ID is empty.
40+
ErrEmptyContraAccountID = errors.New("contra-account ID must not be empty")
41+
// ErrGatewayIDMismatch is returned when the map key doesn't match the mapping's gateway ID.
42+
ErrGatewayIDMismatch = errors.New("gateway ID mismatch between key and mapping")
43+
)
44+
45+
// Valid account types
46+
const (
47+
AccountTypeNostro = "NOSTRO"
48+
AccountTypeAcquirer = "ACQUIRER"
49+
)
50+
51+
// GetContraAccount returns the contra-account ID for the specified gateway.
52+
// Returns ErrNoGatewayMapping if no mapping exists for the gateway.
53+
func (c *GatewayAccountConfig) GetContraAccount(gatewayID string) (string, error) {
54+
if c.Mappings == nil {
55+
return "", fmt.Errorf("%w: %s", ErrNoGatewayMapping, gatewayID)
56+
}
57+
58+
mapping, exists := c.Mappings[gatewayID]
59+
if !exists {
60+
return "", fmt.Errorf("%w: %s", ErrNoGatewayMapping, gatewayID)
61+
}
62+
63+
return mapping.ContraAccountID, nil
64+
}
65+
66+
// GetMapping returns the full mapping for the specified gateway.
67+
// Returns ErrNoGatewayMapping if no mapping exists for the gateway.
68+
func (c *GatewayAccountConfig) GetMapping(gatewayID string) (*GatewayAccountMapping, error) {
69+
if c.Mappings == nil {
70+
return nil, fmt.Errorf("%w: %s", ErrNoGatewayMapping, gatewayID)
71+
}
72+
73+
mapping, exists := c.Mappings[gatewayID]
74+
if !exists {
75+
return nil, fmt.Errorf("%w: %s", ErrNoGatewayMapping, gatewayID)
76+
}
77+
78+
return mapping, nil
79+
}
80+
81+
// Validate validates the configuration.
82+
func (c *GatewayAccountConfig) Validate() error {
83+
if len(c.Mappings) == 0 {
84+
return ErrEmptyConfig
85+
}
86+
87+
for gatewayID, mapping := range c.Mappings {
88+
if gatewayID == "" {
89+
return ErrEmptyGatewayID
90+
}
91+
if mapping.GatewayID == "" {
92+
return fmt.Errorf("%w: mapping key %s", ErrEmptyGatewayID, gatewayID)
93+
}
94+
if mapping.ContraAccountID == "" {
95+
return fmt.Errorf("%w: gateway %s", ErrEmptyContraAccountID, gatewayID)
96+
}
97+
if mapping.AccountType != AccountTypeNostro && mapping.AccountType != AccountTypeAcquirer {
98+
return fmt.Errorf("%w: gateway %s has type %s", ErrInvalidAccountType, gatewayID, mapping.AccountType)
99+
}
100+
// Verify the map key matches the mapping's GatewayID
101+
if gatewayID != mapping.GatewayID {
102+
return fmt.Errorf("%w: key %s does not match mapping gateway_id %s", ErrGatewayIDMismatch, gatewayID, mapping.GatewayID)
103+
}
104+
}
105+
106+
return nil
107+
}
108+
109+
// LoadGatewayAccountConfig loads the gateway account configuration from environment or file.
110+
//
111+
// The configuration can be loaded in two ways (in order of precedence):
112+
// 1. JSON file: Set GATEWAY_ACCOUNT_MAPPING_FILE to the path of a JSON config file
113+
// 2. Environment variables: Set GATEWAY_{ID}_ACCOUNT_ID and GATEWAY_{ID}_ACCOUNT_TYPE
114+
// for each gateway (e.g., GATEWAY_STRIPE_ACCOUNT_ID, GATEWAY_STRIPE_ACCOUNT_TYPE)
115+
//
116+
// Environment variable format for individual gateways:
117+
// - GATEWAY_{ID}_ACCOUNT_ID: The contra-account UUID
118+
// - GATEWAY_{ID}_ACCOUNT_TYPE: Either "NOSTRO" or "ACQUIRER"
119+
//
120+
// JSON file format:
121+
//
122+
// {
123+
// "stripe": {"gateway_id": "stripe", "contra_account_id": "uuid-1", "account_type": "NOSTRO"},
124+
// "mock": {"gateway_id": "mock", "contra_account_id": "uuid-2", "account_type": "ACQUIRER"}
125+
// }
126+
func LoadGatewayAccountConfig() (*GatewayAccountConfig, error) {
127+
// First, try loading from JSON file
128+
configFile := os.Getenv("GATEWAY_ACCOUNT_MAPPING_FILE")
129+
if configFile != "" {
130+
return loadFromFile(configFile)
131+
}
132+
133+
// Fall back to environment variables
134+
return loadFromEnv()
135+
}
136+
137+
// loadFromFile loads configuration from a JSON file.
138+
func loadFromFile(path string) (*GatewayAccountConfig, error) {
139+
data, err := os.ReadFile(path)
140+
if err != nil {
141+
return nil, fmt.Errorf("failed to read gateway account config file: %w", err)
142+
}
143+
144+
var mappings map[string]*GatewayAccountMapping
145+
if err := json.Unmarshal(data, &mappings); err != nil {
146+
return nil, fmt.Errorf("failed to parse gateway account config file: %w", err)
147+
}
148+
149+
config := &GatewayAccountConfig{
150+
Mappings: mappings,
151+
}
152+
153+
if err := config.Validate(); err != nil {
154+
return nil, fmt.Errorf("invalid gateway account config: %w", err)
155+
}
156+
157+
return config, nil
158+
}
159+
160+
// loadFromEnv loads configuration from environment variables.
161+
// Looks for GATEWAY_{ID}_ACCOUNT_ID and GATEWAY_{ID}_ACCOUNT_TYPE variables.
162+
func loadFromEnv() (*GatewayAccountConfig, error) {
163+
mappings := make(map[string]*GatewayAccountMapping)
164+
165+
// Scan environment variables for gateway configurations
166+
// Format: GATEWAY_{ID}_ACCOUNT_ID and GATEWAY_{ID}_ACCOUNT_TYPE
167+
for _, env := range os.Environ() {
168+
parts := strings.SplitN(env, "=", 2)
169+
if len(parts) != 2 {
170+
continue
171+
}
172+
key := parts[0]
173+
174+
// Look for GATEWAY_*_ACCOUNT_ID pattern
175+
if strings.HasPrefix(key, "GATEWAY_") && strings.HasSuffix(key, "_ACCOUNT_ID") {
176+
// Extract gateway ID: GATEWAY_STRIPE_ACCOUNT_ID -> STRIPE -> stripe
177+
gatewayID := extractGatewayID(key, "_ACCOUNT_ID")
178+
if gatewayID == "" {
179+
continue
180+
}
181+
182+
accountID := parts[1]
183+
accountType := os.Getenv(fmt.Sprintf("GATEWAY_%s_ACCOUNT_TYPE", strings.ToUpper(gatewayID)))
184+
if accountType == "" {
185+
accountType = AccountTypeNostro // Default to NOSTRO if not specified
186+
}
187+
188+
mappings[gatewayID] = &GatewayAccountMapping{
189+
GatewayID: gatewayID,
190+
ContraAccountID: accountID,
191+
AccountType: accountType,
192+
}
193+
}
194+
}
195+
196+
if len(mappings) == 0 {
197+
return nil, ErrEmptyConfig
198+
}
199+
200+
config := &GatewayAccountConfig{
201+
Mappings: mappings,
202+
}
203+
204+
if err := config.Validate(); err != nil {
205+
return nil, fmt.Errorf("invalid gateway account config: %w", err)
206+
}
207+
208+
return config, nil
209+
}
210+
211+
// extractGatewayID extracts the gateway ID from an environment variable key.
212+
// e.g., GATEWAY_STRIPE_ACCOUNT_ID with suffix _ACCOUNT_ID returns "stripe"
213+
func extractGatewayID(key, suffix string) string {
214+
// Remove "GATEWAY_" prefix and suffix
215+
trimmed := strings.TrimPrefix(key, "GATEWAY_")
216+
trimmed = strings.TrimSuffix(trimmed, suffix)
217+
if trimmed == "" {
218+
return ""
219+
}
220+
return strings.ToLower(trimmed)
221+
}
222+
223+
// NewGatewayAccountConfig creates a new GatewayAccountConfig with the given mappings.
224+
// This is useful for testing or programmatic configuration.
225+
func NewGatewayAccountConfig(mappings map[string]*GatewayAccountMapping) (*GatewayAccountConfig, error) {
226+
config := &GatewayAccountConfig{
227+
Mappings: mappings,
228+
}
229+
230+
if err := config.Validate(); err != nil {
231+
return nil, err
232+
}
233+
234+
return config, nil
235+
}

0 commit comments

Comments
 (0)