this repo has no description

:tada: first commit

tulkdan.dev 804724c4

+30
.gitignore
··· 1 + # Created by https://www.toptal.com/developers/gitignore/api/go 2 + # Edit at https://www.toptal.com/developers/gitignore?templates=go 3 + 4 + ### Go ### 5 + # If you prefer the allow list template instead of the deny list, see community template: 6 + # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 7 + # 8 + # Binaries for programs and plugins 9 + *.exe 10 + *.exe~ 11 + *.dll 12 + *.so 13 + *.dylib 14 + bin/ 15 + 16 + # Test binary, built with `go test -c` 17 + *.test 18 + 19 + # Output of the go coverage tool, specifically when used with LiteIDE 20 + *.out 21 + 22 + # Dependency directories (remove the comment below to include it) 23 + # vendor/ 24 + 25 + # Go workspace file 26 + go.work 27 + 28 + # End of https://www.toptal.com/developers/gitignore/api/go 29 + 30 + *.hurl
+27
cmd/app/main.go
··· 1 + package main 2 + 3 + import ( 4 + "log" 5 + "os" 6 + 7 + "github.com/Tulkdan/payment-gateway/internal/service" 8 + "github.com/Tulkdan/payment-gateway/internal/web" 9 + ) 10 + 11 + func getEnv(key, defaultValue string) string { 12 + if value := os.Getenv(key); value != "" { 13 + return value 14 + } 15 + return defaultValue 16 + } 17 + 18 + func main() { 19 + paymentsService := service.NewPaymentService() 20 + 21 + server := web.NewServer(paymentsService, "8000") 22 + server.ConfigureRouter() 23 + 24 + if err := server.Start(); err != nil { 25 + log.Fatal("Error starting server: ", err) 26 + } 27 + }
+5
go.mod
··· 1 + module github.com/Tulkdan/payment-gateway 2 + 3 + go 1.24.4 4 + 5 + require github.com/google/uuid v1.6.0
+2
go.sum
··· 1 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 2 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+371
internal/constants/iso4217.go
··· 1 + package constants 2 + 3 + var lookupTable = map[string]struct{}{ 4 + "AFN": {}, // Afghani 5 + "971": {}, // Afghani 6 + "EUR": {}, // Euro 7 + "978": {}, // Euro 8 + "ALL": {}, // Lek 9 + "008": {}, // Lek 10 + "DZD": {}, // Algerian Dinar 11 + "012": {}, // Algerian Dinar 12 + "USD": {}, // US Dollar 13 + "840": {}, // US Dollar 14 + "AOA": {}, // Kwanza 15 + "973": {}, // Kwanza 16 + "XCD": {}, // East Caribbean Dollar 17 + "951": {}, // East Caribbean Dollar 18 + "ARS": {}, // Argentine Peso 19 + "032": {}, // Argentine Peso 20 + "AMD": {}, // Armenian Dram 21 + "051": {}, // Armenian Dram 22 + "AWG": {}, // Aruban Florin 23 + "533": {}, // Aruban Florin 24 + "AUD": {}, // Australian Dollar 25 + "036": {}, // Australian Dollar 26 + "AZN": {}, // Azerbaijan Manat 27 + "944": {}, // Azerbaijan Manat 28 + "BSD": {}, // Bahamian Dollar 29 + "044": {}, // Bahamian Dollar 30 + "BHD": {}, // Bahraini Dinar 31 + "048": {}, // Bahraini Dinar 32 + "BDT": {}, // Taka 33 + "050": {}, // Taka 34 + "BBD": {}, // Barbados Dollar 35 + "052": {}, // Barbados Dollar 36 + "BYN": {}, // Belarusian Ruble 37 + "933": {}, // Belarusian Ruble 38 + "BZD": {}, // Belize Dollar 39 + "084": {}, // Belize Dollar 40 + "XOF": {}, // CFA Franc BCEAO 41 + "952": {}, // CFA Franc BCEAO 42 + "BMD": {}, // Bermudian Dollar 43 + "060": {}, // Bermudian Dollar 44 + "INR": {}, // Indian Rupee 45 + "356": {}, // Indian Rupee 46 + "BTN": {}, // Ngultrum 47 + "064": {}, // Ngultrum 48 + "BOB": {}, // Boliviano 49 + "068": {}, // Boliviano 50 + "BOV": {}, // Mvdol 51 + "984": {}, // Mvdol 52 + "BAM": {}, // Convertible Mark 53 + "977": {}, // Convertible Mark 54 + "BWP": {}, // Pula 55 + "072": {}, // Pula 56 + "NOK": {}, // Norwegian Krone 57 + "578": {}, // Norwegian Krone 58 + "BRL": {}, // Brazilian Real 59 + "986": {}, // Brazilian Real 60 + "BND": {}, // Brunei Dollar 61 + "096": {}, // Brunei Dollar 62 + "BGN": {}, // Bulgarian Lev 63 + "975": {}, // Bulgarian Lev 64 + "BIF": {}, // Burundi Franc 65 + "108": {}, // Burundi Franc 66 + "CVE": {}, // Cabo Verde Escudo 67 + "132": {}, // Cabo Verde Escudo 68 + "KHR": {}, // Riel 69 + "116": {}, // Riel 70 + "XAF": {}, // CFA Franc BEAC 71 + "950": {}, // CFA Franc BEAC 72 + "CAD": {}, // Canadian Dollar 73 + "124": {}, // Canadian Dollar 74 + "KYD": {}, // Cayman Islands Dollar 75 + "136": {}, // Cayman Islands Dollar 76 + "CLP": {}, // Chilean Peso 77 + "152": {}, // Chilean Peso 78 + "CLF": {}, // Unidad de Fomento 79 + "990": {}, // Unidad de Fomento 80 + "CNY": {}, // Yuan Renminbi 81 + "156": {}, // Yuan Renminbi 82 + "CNH": {}, // Yuan Fen 83 + "157": {}, // Yuan Fen 84 + "COP": {}, // Colombian Peso 85 + "170": {}, // Colombian Peso 86 + "COU": {}, // Unidad de Valor Real 87 + "970": {}, // Unidad de Valor Real 88 + "KMF": {}, // Comorian Franc 89 + "174": {}, // Comorian Franc 90 + "CDF": {}, // Congolese Franc 91 + "976": {}, // Congolese Franc 92 + "NZD": {}, // New Zealand Dollar 93 + "554": {}, // New Zealand Dollar 94 + "CRC": {}, // Costa Rican Colon 95 + "188": {}, // Costa Rican Colon 96 + "HRK": {}, // Kuna 97 + "191": {}, // Kuna 98 + "CUP": {}, // Cuban Peso 99 + "192": {}, // Cuban Peso 100 + "CUC": {}, // Peso Convertible 101 + "931": {}, // Peso Convertible 102 + "ANG": {}, // Netherlands Antillean Guilder 103 + "532": {}, // Netherlands Antillean Guilder 104 + "CZK": {}, // Czech Koruna 105 + "203": {}, // Czech Koruna 106 + "DKK": {}, // Danish Krone 107 + "208": {}, // Danish Krone 108 + "DJF": {}, // Djibouti Franc 109 + "262": {}, // Djibouti Franc 110 + "DOP": {}, // Dominican Peso 111 + "214": {}, // Dominican Peso 112 + "EGP": {}, // Egyptian Pound 113 + "818": {}, // Egyptian Pound 114 + "SVC": {}, // El Salvador Colon 115 + "222": {}, // El Salvador Colon 116 + "ERN": {}, // Nakfa 117 + "232": {}, // Nakfa 118 + "SZL": {}, // Lilangeni 119 + "748": {}, // Lilangeni 120 + "ETB": {}, // Ethiopian Birr 121 + "230": {}, // Ethiopian Birr 122 + "FKP": {}, // Falkland Islands Pound 123 + "238": {}, // Falkland Islands Pound 124 + "FJD": {}, // Fiji Dollar 125 + "242": {}, // Fiji Dollar 126 + "XPF": {}, // CFP Franc 127 + "953": {}, // CFP Franc 128 + "GMD": {}, // Dalasi 129 + "270": {}, // Dalasi 130 + "GEL": {}, // Lari 131 + "981": {}, // Lari 132 + "GHS": {}, // Ghana Cedi 133 + "936": {}, // Ghana Cedi 134 + "GIP": {}, // Gibraltar Pound 135 + "292": {}, // Gibraltar Pound 136 + "GTQ": {}, // Quetzal 137 + "320": {}, // Quetzal 138 + "GBP": {}, // Pound Sterling 139 + "826": {}, // Pound Sterling 140 + "GNF": {}, // Guinean Franc 141 + "324": {}, // Guinean Franc 142 + "GYD": {}, // Guyana Dollar 143 + "328": {}, // Guyana Dollar 144 + "HTG": {}, // Gourde 145 + "332": {}, // Gourde 146 + "HNL": {}, // Lempira 147 + "340": {}, // Lempira 148 + "HKD": {}, // Hong Kong Dollar 149 + "344": {}, // Hong Kong Dollar 150 + "HUF": {}, // Forint 151 + "348": {}, // Forint 152 + "ISK": {}, // Iceland Krona 153 + "352": {}, // Iceland Krona 154 + "IDR": {}, // Rupiah 155 + "360": {}, // Rupiah 156 + "XDR": {}, // SDR (Special Drawing Right) 157 + "960": {}, // SDR (Special Drawing Right) 158 + "IRR": {}, // Iranian Rial 159 + "364": {}, // Iranian Rial 160 + "IQD": {}, // Iraqi Dinar 161 + "368": {}, // Iraqi Dinar 162 + "ILS": {}, // New Israeli Sheqel 163 + "376": {}, // New Israeli Sheqel 164 + "JMD": {}, // Jamaican Dollar 165 + "388": {}, // Jamaican Dollar 166 + "JPY": {}, // Yen 167 + "392": {}, // Yen 168 + "JOD": {}, // Jordanian Dinar 169 + "400": {}, // Jordanian Dinar 170 + "KZT": {}, // Tenge 171 + "398": {}, // Tenge 172 + "KES": {}, // Kenyan Shilling 173 + "404": {}, // Kenyan Shilling 174 + "KPW": {}, // North Korean Won 175 + "408": {}, // North Korean Won 176 + "KRW": {}, // Won 177 + "410": {}, // Won 178 + "KWD": {}, // Kuwaiti Dinar 179 + "414": {}, // Kuwaiti Dinar 180 + "KGS": {}, // Som 181 + "417": {}, // Som 182 + "LAK": {}, // Lao Kip 183 + "418": {}, // Lao Kip 184 + "LBP": {}, // Lebanese Pound 185 + "422": {}, // Lebanese Pound 186 + "LSL": {}, // Loti 187 + "426": {}, // Loti 188 + "ZAR": {}, // Rand 189 + "710": {}, // Rand 190 + "LRD": {}, // Liberian Dollar 191 + "430": {}, // Liberian Dollar 192 + "LYD": {}, // Libyan Dinar 193 + "434": {}, // Libyan Dinar 194 + "CHF": {}, // Swiss Franc 195 + "756": {}, // Swiss Franc 196 + "MOP": {}, // Pataca 197 + "446": {}, // Pataca 198 + "MKD": {}, // Denar 199 + "807": {}, // Denar 200 + "MGA": {}, // Malagasy Ariary 201 + "969": {}, // Malagasy Ariary 202 + "MWK": {}, // Malawi Kwacha 203 + "454": {}, // Malawi Kwacha 204 + "MYR": {}, // Malaysian Ringgit 205 + "458": {}, // Malaysian Ringgit 206 + "MVR": {}, // Rufiyaa 207 + "462": {}, // Rufiyaa 208 + "MRU": {}, // Ouguiya 209 + "929": {}, // Ouguiya 210 + "MUR": {}, // Mauritius Rupee 211 + "480": {}, // Mauritius Rupee 212 + "XUA": {}, // ADB Unit of Account 213 + "965": {}, // ADB Unit of Account 214 + "MXN": {}, // Mexican Peso 215 + "484": {}, // Mexican Peso 216 + "MXV": {}, // Mexican Unidad de Inversion (UDI) 217 + "979": {}, // Mexican Unidad de Inversion (UDI) 218 + "MDL": {}, // Moldovan Leu 219 + "498": {}, // Moldovan Leu 220 + "MNT": {}, // Tugrik 221 + "496": {}, // Tugrik 222 + "MAD": {}, // Moroccan Dirham 223 + "504": {}, // Moroccan Dirham 224 + "MZN": {}, // Mozambique Metical 225 + "943": {}, // Mozambique Metical 226 + "MMK": {}, // Kyat 227 + "104": {}, // Kyat 228 + "NAD": {}, // Namibia Dollar 229 + "516": {}, // Namibia Dollar 230 + "NPR": {}, // Nepalese Rupee 231 + "524": {}, // Nepalese Rupee 232 + "NIO": {}, // Cordoba Oro 233 + "558": {}, // Cordoba Oro 234 + "NGN": {}, // Naira 235 + "566": {}, // Naira 236 + "OMR": {}, // Rial Omani 237 + "512": {}, // Rial Omani 238 + "PKR": {}, // Pakistan Rupee 239 + "586": {}, // Pakistan Rupee 240 + "PAB": {}, // Balboa 241 + "590": {}, // Balboa 242 + "PGK": {}, // Kina 243 + "598": {}, // Kina 244 + "PYG": {}, // Guarani 245 + "600": {}, // Guarani 246 + "PEN": {}, // Sol 247 + "604": {}, // Sol 248 + "PHP": {}, // Philippine Peso 249 + "608": {}, // Philippine Peso 250 + "PLN": {}, // Zloty 251 + "985": {}, // Zloty 252 + "QAR": {}, // Qatari Rial 253 + "634": {}, // Qatari Rial 254 + "RON": {}, // Romanian Leu 255 + "946": {}, // Romanian Leu 256 + "RUB": {}, // Russian Ruble 257 + "643": {}, // Russian Ruble 258 + "RWF": {}, // Rwanda Franc 259 + "646": {}, // Rwanda Franc 260 + "SHP": {}, // Saint Helena Pound 261 + "654": {}, // Saint Helena Pound 262 + "WST": {}, // Tala 263 + "882": {}, // Tala 264 + "STN": {}, // Dobra 265 + "930": {}, // Dobra 266 + "SAR": {}, // Saudi Riyal 267 + "682": {}, // Saudi Riyal 268 + "RSD": {}, // Serbian Dinar 269 + "941": {}, // Serbian Dinar 270 + "SCR": {}, // Seychelles Rupee 271 + "690": {}, // Seychelles Rupee 272 + "SLL": {}, // Leone 273 + "694": {}, // Leone 274 + "SGD": {}, // Singapore Dollar 275 + "702": {}, // Singapore Dollar 276 + "XSU": {}, // Sucre 277 + "994": {}, // Sucre 278 + "SBD": {}, // Solomon Islands Dollar 279 + "090": {}, // Solomon Islands Dollar 280 + "SOS": {}, // Somali Shilling 281 + "706": {}, // Somali Shilling 282 + "SSP": {}, // South Sudanese Pound 283 + "728": {}, // South Sudanese Pound 284 + "LKR": {}, // Sri Lanka Rupee 285 + "144": {}, // Sri Lanka Rupee 286 + "SDG": {}, // Sudanese Pound 287 + "938": {}, // Sudanese Pound 288 + "SRD": {}, // Surinam Dollar 289 + "968": {}, // Surinam Dollar 290 + "SEK": {}, // Swedish Krona 291 + "752": {}, // Swedish Krona 292 + "CHE": {}, // WIR Euro 293 + "947": {}, // WIR Euro 294 + "CHW": {}, // WIR Franc 295 + "948": {}, // WIR Franc 296 + "SYP": {}, // Syrian Pound 297 + "760": {}, // Syrian Pound 298 + "TWD": {}, // New Taiwan Dollar 299 + "901": {}, // New Taiwan Dollar 300 + "TJS": {}, // Somoni 301 + "972": {}, // Somoni 302 + "TZS": {}, // Tanzanian Shilling 303 + "834": {}, // Tanzanian Shilling 304 + "THB": {}, // Baht 305 + "764": {}, // Baht 306 + "TOP": {}, // Pa'anga 307 + "776": {}, // Pa'anga 308 + "TTD": {}, // Trinidad and Tobago Dollar 309 + "780": {}, // Trinidad and Tobago Dollar 310 + "TND": {}, // Tunisian Dinar 311 + "788": {}, // Tunisian Dinar 312 + "TRY": {}, // Turkish Lira 313 + "949": {}, // Turkish Lira 314 + "TMT": {}, // Turkmenistan New Manat 315 + "934": {}, // Turkmenistan New Manat 316 + "UGX": {}, // Uganda Shilling 317 + "800": {}, // Uganda Shilling 318 + "UAH": {}, // Hryvnia 319 + "980": {}, // Hryvnia 320 + "AED": {}, // UAE Dirham 321 + "784": {}, // UAE Dirham 322 + "USN": {}, // US Dollar (Next day) 323 + "997": {}, // US Dollar (Next day) 324 + "UYU": {}, // Peso Uruguayo 325 + "858": {}, // Peso Uruguayo 326 + "UYI": {}, // Uruguay Peso en Unidades Indexadas (UI) 327 + "940": {}, // Uruguay Peso en Unidades Indexadas (UI) 328 + "UYW": {}, // Unidad Previsional 329 + "927": {}, // Unidad Previsional 330 + "UZS": {}, // Uzbekistan Sum 331 + "860": {}, // Uzbekistan Sum 332 + "VUV": {}, // Vatu 333 + "548": {}, // Vatu 334 + "VES": {}, // Bolívar Soberano 335 + "928": {}, // Bolívar Soberano 336 + "VND": {}, // Dong 337 + "704": {}, // Dong 338 + "YER": {}, // Yemeni Rial 339 + "886": {}, // Yemeni Rial 340 + "ZMW": {}, // Zambian Kwacha 341 + "967": {}, // Zambian Kwacha 342 + "ZWL": {}, // Zimbabwe Dollar 343 + "932": {}, // Zimbabwe Dollar 344 + "ZWG": {}, // Zimbabwe Gold 345 + "924": {}, // Zimbabwe Gold 346 + "XBA": {}, // Bond Markets Unit European Composite Unit (EURCO) 347 + "955": {}, // Bond Markets Unit European Composite Unit (EURCO) 348 + "XBB": {}, // Bond Markets Unit European Monetary Unit (E.M.U.-6) 349 + "956": {}, // Bond Markets Unit European Monetary Unit (E.M.U.-6) 350 + "XBC": {}, // Bond Markets Unit European Unit of Account 9 (E.U.A.-9) 351 + "957": {}, // Bond Markets Unit European Unit of Account 9 (E.U.A.-9) 352 + "XBD": {}, // Bond Markets Unit European Unit of Account 17 (E.U.A.-17) 353 + "958": {}, // Bond Markets Unit European Unit of Account 17 (E.U.A.-17) 354 + "XTS": {}, // Codes specifically reserved for testing purposes 355 + "963": {}, // Codes specifically reserved for testing purposes 356 + "XXX": {}, // The codes assigned for transactions where no currency is involved 357 + "999": {}, // The codes assigned for transactions where no currency is involved 358 + "XAU": {}, // Gold 359 + "959": {}, // Gold 360 + "XPD": {}, // Palladium 361 + "964": {}, // Palladium 362 + "XPT": {}, // Platinum 363 + "962": {}, // Platinum 364 + "XAG": {}, // Silver 365 + "961": {}, // Silver 366 + } 367 + 368 + func Lookup(code string) bool { 369 + _, exists := lookupTable[code] 370 + return exists 371 + }
+72
internal/domain/payment.go
··· 1 + package domain 2 + 3 + import ( 4 + "errors" 5 + "regexp" 6 + "sync" 7 + "time" 8 + 9 + "github.com/Tulkdan/payment-gateway/internal/constants" 10 + ) 11 + 12 + type Status string 13 + 14 + const ( 15 + StatusPending Status = "pending" // when it needs to send payment to provider 16 + StatusApproved Status = "approved" // when provider successfully charged 17 + StatusRejected Status = "rejected" // when provider rejected payment 18 + StatusFailed Status = "failed" // when provider fails to charge 19 + ) 20 + 21 + type PaymentCard struct { 22 + Number string 23 + HolderName string 24 + CVV string 25 + ExpirationDate string 26 + Installments uint 27 + } 28 + 29 + type Payment struct { 30 + Amount uint 31 + Currency string 32 + Description string 33 + PaymentType string 34 + Card PaymentCard 35 + Status Status 36 + CreatedAt time.Time 37 + 38 + mu sync.Mutex 39 + } 40 + 41 + func NewPayment(amount uint, currency string, description string, paymentType string, card PaymentCard) (*Payment, error) { 42 + if paymentType != "card" { 43 + return nil, errors.New("We only accept payments with type 'card'") 44 + } 45 + 46 + isoFormatRgx := regexp.MustCompile(`^[A-Z]{3}$`) 47 + if !isoFormatRgx.Match([]byte(currency)) || !constants.Lookup(currency) { 48 + return nil, errors.New("Invalid Currency") 49 + } 50 + 51 + expirationDateRgx := regexp.MustCompile(`\d{2}\/\d{4}`) 52 + if !expirationDateRgx.Match([]byte(card.ExpirationDate)) { 53 + return nil, errors.New("Invalid ExpirationDate format") 54 + } 55 + 56 + return &Payment{ 57 + Amount: amount, 58 + Currency: currency, 59 + Description: description, 60 + PaymentType: paymentType, 61 + Card: card, 62 + Status: StatusPending, 63 + CreatedAt: time.Now(), 64 + }, nil 65 + } 66 + 67 + func (p *Payment) UpdateStatus(status Status) { 68 + p.mu.Lock() 69 + defer p.mu.Unlock() 70 + 71 + p.Status = status 72 + }
+65
internal/domain/payment_test.go
··· 1 + package domain_test 2 + 3 + import ( 4 + "reflect" 5 + "testing" 6 + "time" 7 + 8 + "github.com/Tulkdan/payment-gateway/internal/domain" 9 + ) 10 + 11 + func TestPayment(t *testing.T) { 12 + t.Run("should validate currency in type ISO 4217", func(t *testing.T) { 13 + currency := "R$" 14 + 15 + _, got := domain.NewPayment(1000, currency, "", "card", domain.PaymentCard{Number: "", HolderName: "", CVV: "", ExpirationDate: "02/2025", Installments: 1}) 16 + want := "Invalid Currency" 17 + 18 + if got.Error() != want { 19 + t.Errorf("got %q want %q given %q", got, want, currency) 20 + } 21 + }) 22 + 23 + t.Run("should validate paymentType received as 'card'", func(t *testing.T) { 24 + paymentType := "2025/02" 25 + 26 + _, got := domain.NewPayment(1000, "R$", "", paymentType, domain.PaymentCard{Number: "", HolderName: "", CVV: "", ExpirationDate: "02/2025", Installments: 1}) 27 + want := "We only accept payments with type 'card'" 28 + 29 + if got.Error() != want { 30 + t.Errorf("got %q want %q given %q", got, want, paymentType) 31 + } 32 + }) 33 + 34 + t.Run("should validate expirationDate from card to be in format DD/YYYY", func(t *testing.T) { 35 + expirationDate := "2025/02" 36 + 37 + _, got := domain.NewPayment(1000, "BRL", "", "card", domain.PaymentCard{Number: "", HolderName: "", CVV: "", ExpirationDate: expirationDate, Installments: 1}) 38 + want := "Invalid ExpirationDate format" 39 + 40 + if got.Error() != want { 41 + t.Errorf("got %q want %q given %q", got, want, expirationDate) 42 + } 43 + }) 44 + 45 + t.Run("should receive a Currency passing data", func(t *testing.T) { 46 + got, err := domain.NewPayment(1000, "BRL", "", "card", domain.PaymentCard{Number: "", HolderName: "", CVV: "", ExpirationDate: "02/2025", Installments: 1}) 47 + want := &domain.Payment{ 48 + Amount: 1000, 49 + Currency: "BRL", 50 + Description: "", 51 + PaymentType: "card", 52 + Card: domain.PaymentCard{Number: "", HolderName: "", CVV: "", ExpirationDate: "02/2025", Installments: 1}, 53 + Status: "pending", 54 + CreatedAt: time.Now(), 55 + } 56 + 57 + if err != nil { 58 + t.Fatal("got an error but didn't want one") 59 + } 60 + 61 + if reflect.DeepEqual(got, want) { 62 + t.Errorf("got %q want %q", got, want) 63 + } 64 + }) 65 + }
+15
internal/domain/provider.go
··· 1 + package domain 2 + 3 + import "github.com/google/uuid" 4 + 5 + type Provider struct { 6 + Id uuid.UUID 7 + CreatedAt string 8 + Status Status 9 + OriginalAmount uint 10 + CurrentAmount uint 11 + Currency string 12 + Description string 13 + PaymentMethod string 14 + CardId uuid.UUID 15 + }
+21
internal/dto/payments.go
··· 1 + package dto 2 + 3 + type PaymentCardInput struct { 4 + Number string `json:"number"` 5 + HolderName string `json:"holderName"` 6 + CVV string `json:"cvv"` 7 + ExpirationDate string `json:"expirationDate"` 8 + Installments uint `json:"installments"` 9 + } 10 + 11 + type PaymentInput struct { 12 + Amount uint `json:"amount"` 13 + Currency string `json:"currency"` 14 + Description string `json:"description"` 15 + PaymentType string `json:"paymentType"` 16 + Card PaymentCardInput `json:"card"` 17 + } 18 + 19 + type PaymentOutput struct { 20 + Message string `json:"message"` 21 + }
+110
internal/providers/braintree.go
··· 1 + package providers 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "net/http" 7 + 8 + "github.com/Tulkdan/payment-gateway/internal/domain" 9 + "github.com/google/uuid" 10 + ) 11 + 12 + type BraintreeProvider struct { 13 + Url string 14 + } 15 + 16 + func NewBraintreeProvider(url string) *BraintreeProvider { 17 + return &BraintreeProvider{Url: url} 18 + } 19 + 20 + type BraintreeChargeCard struct { 21 + Number string `json:"number"` 22 + HolderName string `json:"holderName"` 23 + CVV string `json:"cvv"` 24 + ExpirationDate string `json:"expirationDate"` 25 + Installments uint `json:"installments"` 26 + } 27 + 28 + type BraintreeChargePaymentMethod struct { 29 + Type string `json:"type"` 30 + Card BraintreeChargeCard `json:"card"` 31 + } 32 + 33 + type BraintreeCharge struct { 34 + Amount uint `json:"amount"` 35 + Currency string `json:"currency"` 36 + Description string `json:"description"` 37 + PaymentMethod BraintreeChargePaymentMethod `json:"paymentMethod"` 38 + } 39 + 40 + func (b BraintreeProvider) Charge(request *domain.Payment) (*domain.Provider, error) { 41 + body := b.createChargeBody(request) 42 + response, err := http.Post(b.Url, "application/json", bytes.NewBuffer(body)) 43 + if err != nil { 44 + return nil, err 45 + } 46 + 47 + return b.responseCharge(response) 48 + } 49 + 50 + func (b BraintreeProvider) createChargeBody(request *domain.Payment) []byte { 51 + toSend := &BraintreeCharge{ 52 + Amount: request.Amount, 53 + Currency: request.Currency, 54 + Description: request.Description, 55 + PaymentMethod: BraintreeChargePaymentMethod{ 56 + Type: request.PaymentType, 57 + Card: BraintreeChargeCard{ 58 + Number: request.Card.Number, 59 + HolderName: request.Card.HolderName, 60 + CVV: request.Card.CVV, 61 + ExpirationDate: request.Card.ExpirationDate, 62 + Installments: request.Card.Installments, 63 + }, 64 + }, 65 + } 66 + jsonValue, _ := json.Marshal(toSend) 67 + return jsonValue 68 + } 69 + 70 + type BraintreeChargeResponse struct { 71 + Id uuid.UUID `json:"id"` 72 + CreatedAt string `json:"createdAt"` 73 + Status string `json:"status"` // authorized failed refunded 74 + OriginalAmount uint `json:"originalAmount"` 75 + CurrentAmount uint `json:"currentAmount"` 76 + Currency string `json:"currency"` 77 + Description string `json:"description"` 78 + PaymentMethod string `json:"paymentMethod"` 79 + CardId uuid.UUID `json:"cardId"` 80 + } 81 + 82 + func (b BraintreeProvider) responseCharge(response *http.Response) (*domain.Provider, error) { 83 + var data BraintreeChargeResponse 84 + if err := json.NewDecoder(response.Body).Decode(&data); err != nil { 85 + return nil, err 86 + } 87 + 88 + var status domain.Status 89 + switch data.Status { 90 + case "authorized": 91 + status = domain.StatusApproved 92 + case "failed": 93 + status = domain.StatusFailed 94 + case "refunded": 95 + status = domain.StatusRejected 96 + } 97 + 98 + providerResponse := &domain.Provider{ 99 + Id: data.Id, 100 + CreatedAt: data.CreatedAt, 101 + OriginalAmount: data.OriginalAmount, 102 + CurrentAmount: data.CurrentAmount, 103 + Currency: data.Currency, 104 + Description: data.Description, 105 + PaymentMethod: data.PaymentMethod, 106 + CardId: data.CardId, 107 + Status: status, 108 + } 109 + return providerResponse, nil 110 + }
+74
internal/providers/braintree_test.go
··· 1 + package providers_test 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/http/httptest" 7 + "reflect" 8 + "testing" 9 + "time" 10 + 11 + "github.com/Tulkdan/payment-gateway/internal/domain" 12 + "github.com/Tulkdan/payment-gateway/internal/providers" 13 + "github.com/google/uuid" 14 + ) 15 + 16 + func TestBraintree(t *testing.T) { 17 + t.Run("should make request to url", func(t *testing.T) { 18 + id, _ := uuid.Parse("2ee70bcb-5cb9-4412-a35f-c2a15fb88ef1") 19 + cardId, _ := uuid.Parse("ed6ecd4c-81d5-4e63-bb12-99439ae559e7") 20 + serverResponse := &providers.BraintreeChargeResponse{ 21 + Id: id, 22 + CreatedAt: time.Now().Format("YYYY-MM-DD"), 23 + Status: "authorized", 24 + OriginalAmount: 1000, 25 + CurrentAmount: 1000, 26 + Currency: "BRL", 27 + Description: "", 28 + PaymentMethod: "card", 29 + CardId: cardId, 30 + } 31 + 32 + server := createServerBraintree(serverResponse) 33 + defer server.Close() 34 + 35 + charge := &domain.Payment{ 36 + Amount: 1000, 37 + Currency: "BRL", 38 + Description: "", 39 + PaymentType: "card", 40 + Card: domain.PaymentCard{Number: "", HolderName: "", CVV: "", ExpirationDate: "02/2025", Installments: 1}, 41 + Status: "pending", 42 + CreatedAt: time.Now(), 43 + } 44 + want := &domain.Provider{ 45 + Id: id, 46 + CreatedAt: time.Now().Format("YYYY-MM-DD"), 47 + Status: domain.StatusApproved, 48 + OriginalAmount: 1000, 49 + CurrentAmount: 1000, 50 + Currency: "BRL", 51 + Description: "", 52 + PaymentMethod: "card", 53 + CardId: cardId, 54 + } 55 + 56 + provider := providers.NewBraintreeProvider(server.URL) 57 + response, err := provider.Charge(charge) 58 + 59 + if err != nil { 60 + t.Fatalf("got an error but didn't want one %q", err) 61 + } 62 + 63 + if !reflect.DeepEqual(response, want) { 64 + t.Errorf("got %q want %q", response, want) 65 + } 66 + }) 67 + } 68 + 69 + func createServerBraintree(serverResponse *providers.BraintreeChargeResponse) *httptest.Server { 70 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 71 + w.WriteHeader(http.StatusOK) 72 + json.NewEncoder(w).Encode(serverResponse) 73 + })) 74 + }
+60
internal/providers/provider.go
··· 1 + package providers 2 + 3 + import ( 4 + "errors" 5 + "time" 6 + 7 + "github.com/Tulkdan/payment-gateway/internal/domain" 8 + ) 9 + 10 + var thirtySecondTimout = 30 * time.Second 11 + 12 + type Provider interface { 13 + Charge(request *domain.Payment) (*domain.Provider, error) 14 + } 15 + 16 + type UseProviders struct { 17 + providers []Provider 18 + timeout time.Duration 19 + } 20 + 21 + func NewUseProviders(providers []Provider) *UseProviders { 22 + return ConfigurableUseProvider(providers, thirtySecondTimout) 23 + } 24 + 25 + func ConfigurableUseProvider(providers []Provider, timeout time.Duration) *UseProviders { 26 + return &UseProviders{ 27 + providers: providers, 28 + timeout: timeout, 29 + } 30 + } 31 + 32 + func (p *UseProviders) Payment(payment *domain.Payment) (*domain.Provider, error) { 33 + var err error = nil 34 + 35 + for _, provider := range p.providers { 36 + select { 37 + case data := <-charge(payment, provider): 38 + return data, nil 39 + case <-time.After(p.timeout): 40 + err = errors.New("Timeout") 41 + continue 42 + } 43 + } 44 + 45 + return nil, err 46 + } 47 + 48 + func charge(charge *domain.Payment, provider Provider) chan *domain.Provider { 49 + ch := make(chan *domain.Provider) 50 + 51 + go func() { 52 + response, err := provider.Charge(charge) 53 + if err != nil { 54 + close(ch) 55 + } 56 + ch <- response 57 + }() 58 + 59 + return ch 60 + }
+103
internal/providers/provider_test.go
··· 1 + package providers_test 2 + 3 + import ( 4 + "testing" 5 + "time" 6 + 7 + "github.com/Tulkdan/payment-gateway/internal/domain" 8 + "github.com/Tulkdan/payment-gateway/internal/providers" 9 + ) 10 + 11 + type SpyProvider struct { 12 + Calls uint 13 + Timeout time.Duration 14 + Response *domain.Provider 15 + } 16 + 17 + func (s *SpyProvider) Charge(request *domain.Payment) (*domain.Provider, error) { 18 + time.Sleep(s.Timeout) 19 + s.Calls++ 20 + 21 + return s.Response, nil 22 + } 23 + 24 + func TestProvider(t *testing.T) { 25 + t.Run("should make request for first provider", func(t *testing.T) { 26 + spyFirst := &SpyProvider{Timeout: 10 * time.Millisecond, Response: &domain.Provider{Description: "First"}} 27 + spySecond := &SpyProvider{Timeout: 10 * time.Millisecond, Response: &domain.Provider{Description: "Second"}} 28 + 29 + payment, _ := domain.NewPayment(1000, "R$", "", "card", domain.PaymentCard{Number: "", HolderName: "", CVV: "", ExpirationDate: "02/2025", Installments: 1}) 30 + 31 + useProvider := providers.ConfigurableUseProvider([]providers.Provider{spyFirst, spySecond}, 15*time.Millisecond) 32 + data, err := useProvider.Payment(payment) 33 + 34 + if err != nil { 35 + t.Fatal("Got error. didn't want one") 36 + } 37 + 38 + if data != spyFirst.Response { 39 + t.Fatalf("expected data to be %q, got %q", spyFirst.Response, data) 40 + } 41 + 42 + assertSpyCalled(t, spyFirst, "spyFirst", 1) 43 + assertSpyNotCalled(t, spySecond, "spySecond") 44 + }) 45 + 46 + t.Run("should make request for second provider when first provider timeouts", func(t *testing.T) { 47 + spyFirst := &SpyProvider{Timeout: 20 * time.Millisecond, Response: &domain.Provider{Description: "First"}} 48 + spySecond := &SpyProvider{Timeout: 10 * time.Millisecond, Response: &domain.Provider{Description: "Second"}} 49 + 50 + payment, _ := domain.NewPayment(1000, "R$", "", "card", domain.PaymentCard{Number: "", HolderName: "", CVV: "", ExpirationDate: "02/2025", Installments: 1}) 51 + 52 + useProvider := providers.ConfigurableUseProvider([]providers.Provider{spyFirst, spySecond}, 15*time.Millisecond) 53 + data, err := useProvider.Payment(payment) 54 + 55 + if err != nil { 56 + t.Fatal("Got error. didn't want one") 57 + } 58 + 59 + if data != spySecond.Response { 60 + t.Fatalf("expected data to be %q, got %q", spySecond.Response, data) 61 + } 62 + 63 + assertSpyCalled(t, spyFirst, "spyFirst", 1) 64 + assertSpyCalled(t, spySecond, "spySecond", 1) 65 + }) 66 + 67 + t.Run("should return error when all providers timeout", func(t *testing.T) { 68 + spyFirst := &SpyProvider{Timeout: 20 * time.Millisecond, Response: &domain.Provider{Description: "First"}} 69 + spySecond := &SpyProvider{Timeout: 20 * time.Millisecond, Response: &domain.Provider{Description: "Second"}} 70 + 71 + payment, _ := domain.NewPayment(1000, "R$", "", "card", domain.PaymentCard{Number: "", HolderName: "", CVV: "", ExpirationDate: "02/2025", Installments: 1}) 72 + 73 + useProvider := providers.ConfigurableUseProvider([]providers.Provider{spyFirst, spySecond}, 5*time.Millisecond) 74 + data, err := useProvider.Payment(payment) 75 + 76 + if data != nil { 77 + t.Fatalf("Got data but didn't expected one, got %q", data) 78 + } 79 + 80 + if err.Error() != "Timeout" { 81 + t.Fatalf("expected error to be %s, got %s", "Timeout", err.Error()) 82 + } 83 + 84 + assertSpyNotCalled(t, spyFirst, "spyFirst") 85 + assertSpyNotCalled(t, spySecond, "spySecond") 86 + }) 87 + } 88 + 89 + func assertSpyCalled(t testing.TB, spy *SpyProvider, name string, wantTimes uint) { 90 + t.Helper() 91 + 92 + if spy.Calls != wantTimes { 93 + t.Fatalf("not enough calls to %s, want %d got %d", name, wantTimes, spy.Calls) 94 + } 95 + } 96 + 97 + func assertSpyNotCalled(t testing.TB, spy *SpyProvider, name string) { 98 + t.Helper() 99 + 100 + if spy.Calls != 0 { 101 + t.Fatalf("%s has been called, want 0 got %d", name, spy.Calls) 102 + } 103 + }
+104
internal/providers/stripe.go
··· 1 + package providers 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "net/http" 7 + 8 + "github.com/Tulkdan/payment-gateway/internal/domain" 9 + "github.com/google/uuid" 10 + ) 11 + 12 + type StripeProvider struct { 13 + Url string 14 + } 15 + 16 + func NewStripeProvider(url string) *StripeProvider { 17 + return &StripeProvider{Url: url} 18 + } 19 + 20 + type StripeChargeCard struct { 21 + Number string `json:"number"` 22 + HolderName string `json:"holder"` 23 + CVV string `json:"cvv"` 24 + ExpirationDate string `json:"expiration"` 25 + Installments uint `json:"installmentNumber"` 26 + } 27 + 28 + type StripeCharge struct { 29 + Amount uint `json:"amount"` 30 + Currency string `json:"currency"` 31 + Description string `json:"statementDescriptor"` 32 + PaymentType string `json:"paymentType"` 33 + Card StripeChargeCard `json:"card"` 34 + } 35 + 36 + func (b StripeProvider) Charge(request *domain.Payment) (*domain.Provider, error) { 37 + body := b.createChargeBody(request) 38 + response, err := http.Post(b.Url, "application/json", bytes.NewBuffer(body)) 39 + if err != nil { 40 + return nil, err 41 + } 42 + 43 + return b.responseCharge(response) 44 + } 45 + 46 + func (b StripeProvider) createChargeBody(request *domain.Payment) []byte { 47 + toSend := &StripeCharge{ 48 + Amount: request.Amount, 49 + Currency: request.Currency, 50 + Description: request.Description, 51 + PaymentType: request.PaymentType, 52 + Card: StripeChargeCard{ 53 + Number: request.Card.Number, 54 + HolderName: request.Card.HolderName, 55 + CVV: request.Card.CVV, 56 + ExpirationDate: request.Card.ExpirationDate, 57 + Installments: request.Card.Installments, 58 + }, 59 + } 60 + jsonValue, _ := json.Marshal(toSend) 61 + return jsonValue 62 + } 63 + 64 + type StripeChargeResponse struct { 65 + Id uuid.UUID `json:"id"` 66 + CreatedAt string `json:"date"` 67 + Status string `json:"status"` // paid failed voided 68 + OriginalAmount uint `json:"originalAmount"` 69 + CurrentAmount uint `json:"amount"` 70 + Currency string `json:"currency"` 71 + Description string `json:"statementDescriptor"` 72 + PaymentMethod string `json:"paymentMethod"` 73 + CardId uuid.UUID `json:"cardId"` 74 + } 75 + 76 + func (b StripeProvider) responseCharge(response *http.Response) (*domain.Provider, error) { 77 + var data StripeChargeResponse 78 + if err := json.NewDecoder(response.Body).Decode(&data); err != nil { 79 + return nil, err 80 + } 81 + 82 + var status domain.Status 83 + switch data.Status { 84 + case "paid": 85 + status = domain.StatusApproved 86 + case "failed": 87 + status = domain.StatusFailed 88 + case "voided": 89 + status = domain.StatusRejected 90 + } 91 + 92 + providerResponse := &domain.Provider{ 93 + Id: data.Id, 94 + CreatedAt: data.CreatedAt, 95 + OriginalAmount: data.OriginalAmount, 96 + CurrentAmount: data.CurrentAmount, 97 + Currency: data.Currency, 98 + Description: data.Description, 99 + PaymentMethod: data.PaymentMethod, 100 + CardId: data.CardId, 101 + Status: status, 102 + } 103 + return providerResponse, nil 104 + }
+74
internal/providers/stripe_test.go
··· 1 + package providers_test 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/http/httptest" 7 + "reflect" 8 + "testing" 9 + "time" 10 + 11 + "github.com/Tulkdan/payment-gateway/internal/domain" 12 + "github.com/Tulkdan/payment-gateway/internal/providers" 13 + "github.com/google/uuid" 14 + ) 15 + 16 + func TestStripe(t *testing.T) { 17 + t.Run("should make request to url", func(t *testing.T) { 18 + id, _ := uuid.Parse("2ee70bcb-5cb9-4412-a35f-c2a15fb88ef1") 19 + cardId, _ := uuid.Parse("ed6ecd4c-81d5-4e63-bb12-99439ae559e7") 20 + serverResponse := &providers.StripeChargeResponse{ 21 + Id: id, 22 + CreatedAt: time.Now().Format("YYYY-MM-DD"), 23 + Status: "paid", 24 + OriginalAmount: 1000, 25 + CurrentAmount: 1000, 26 + Currency: "BRL", 27 + Description: "", 28 + PaymentMethod: "card", 29 + CardId: cardId, 30 + } 31 + 32 + server := createServerStripe(serverResponse) 33 + defer server.Close() 34 + 35 + charge := &domain.Payment{ 36 + Amount: 1000, 37 + Currency: "BRL", 38 + Description: "", 39 + PaymentType: "card", 40 + Card: domain.PaymentCard{Number: "", HolderName: "", CVV: "", ExpirationDate: "02/2025", Installments: 1}, 41 + Status: "pending", 42 + CreatedAt: time.Now(), 43 + } 44 + want := &domain.Provider{ 45 + Id: id, 46 + CreatedAt: time.Now().Format("YYYY-MM-DD"), 47 + Status: domain.StatusApproved, 48 + OriginalAmount: 1000, 49 + CurrentAmount: 1000, 50 + Currency: "BRL", 51 + Description: "", 52 + PaymentMethod: "card", 53 + CardId: cardId, 54 + } 55 + 56 + provider := providers.NewStripeProvider(server.URL) 57 + response, err := provider.Charge(charge) 58 + 59 + if err != nil { 60 + t.Fatalf("got an error but didn't want one %q", err) 61 + } 62 + 63 + if !reflect.DeepEqual(response, want) { 64 + t.Errorf("got %q want %q", response, want) 65 + } 66 + }) 67 + } 68 + 69 + func createServerStripe(serverResponse *providers.StripeChargeResponse) *httptest.Server { 70 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 71 + w.WriteHeader(http.StatusOK) 72 + json.NewEncoder(w).Encode(serverResponse) 73 + })) 74 + }
+26
internal/service/payment_service.go
··· 1 + package service 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/Tulkdan/payment-gateway/internal/domain" 8 + "github.com/Tulkdan/payment-gateway/internal/dto" 9 + ) 10 + 11 + type PaymentService struct{} 12 + 13 + func NewPaymentService() *PaymentService { 14 + return &PaymentService{} 15 + } 16 + 17 + func (p *PaymentService) CreatePayment(ctx context.Context, input dto.PaymentInput) (*dto.PaymentOutput, error) { 18 + payment, err := domain.NewPayment(input.Amount, input.Currency, input.Description, input.PaymentType, domain.PaymentCard(input.Card)) 19 + if err != nil { 20 + return nil, err 21 + } 22 + 23 + fmt.Printf("%+v", payment) 24 + 25 + return &dto.PaymentOutput{Message: "Processed successfully"}, nil 26 + }
+37
internal/web/handler/payments_handler.go
··· 1 + package handler 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "github.com/Tulkdan/payment-gateway/internal/dto" 8 + "github.com/Tulkdan/payment-gateway/internal/service" 9 + ) 10 + 11 + type PaymentsHandler struct { 12 + paymentService *service.PaymentService 13 + } 14 + 15 + func NewPaymentsHandler(paymentsService *service.PaymentService) *PaymentsHandler { 16 + return &PaymentsHandler{ 17 + paymentService: paymentsService, 18 + } 19 + } 20 + 21 + func (p *PaymentsHandler) Create(w http.ResponseWriter, r *http.Request) { 22 + var body dto.PaymentInput 23 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 24 + http.Error(w, err.Error(), http.StatusBadRequest) 25 + return 26 + } 27 + 28 + response, err := p.paymentService.CreatePayment(r.Context(), body) 29 + if err != nil { 30 + http.Error(w, err.Error(), http.StatusBadRequest) 31 + return 32 + } 33 + 34 + w.Header().Set("Content-Type", "application/json") 35 + w.WriteHeader(http.StatusOK) 36 + json.NewEncoder(w).Encode(response) 37 + }
+46
internal/web/server.go
··· 1 + package web 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/Tulkdan/payment-gateway/internal/service" 7 + "github.com/Tulkdan/payment-gateway/internal/web/handler" 8 + ) 9 + 10 + type Server struct { 11 + port string 12 + router *http.ServeMux 13 + server *http.Server 14 + 15 + paymentsService *service.PaymentService 16 + } 17 + 18 + func NewServer(paymentsService *service.PaymentService, port string) *Server { 19 + return &Server{ 20 + port: port, 21 + paymentsService: paymentsService, 22 + } 23 + } 24 + 25 + func (s *Server) ConfigureRouter() { 26 + r := &http.ServeMux{} 27 + 28 + paymentsHandler := handler.NewPaymentsHandler(s.paymentsService) 29 + 30 + r.HandleFunc("POST /payments", paymentsHandler.Create) 31 + // r.HandleFunc("POST /refunds", func(http.ResponseWriter, *http.Request) {}) 32 + // r.HandleFunc("GET /payments/{id}", func(w http.ResponseWriter, r *http.Request) { 33 + // id := r.PathValue("id") 34 + // }) 35 + 36 + s.router = r 37 + } 38 + 39 + func (s *Server) Start() error { 40 + s.server = &http.Server{ 41 + Addr: ":" + s.port, 42 + Handler: s.router, 43 + } 44 + 45 + return s.server.ListenAndServe() 46 + }
+8
justfile
··· 1 + build: 2 + go build -o bin/gateway cmd/app/main.go 3 + 4 + run: build 5 + ./bin/gateway 6 + 7 + test: 8 + go test ./internal/...