+5
.envrc
+5
.envrc
+1
-1
.gitignore
+1
-1
.gitignore
+47
README.org
+47
README.org
···
1
+
* Payment Gateway
2
+
3
+
4
+
** Getting started
5
+
6
+
*** Prerequisites
7
+
8
+
- [[https://hurl.dev][hurl]] :: CLI tool that runs http requests, similar to curl
9
+
- [[https://docs.docker.com/desktop/][docker]] :: Using docker to run project and mocks
10
+
- [[https://just.systems/][just]] :: CLI runner
11
+
- [[https://jqlang.org/][jq]] :: (Optional) CLI tool to query JSON data
12
+
13
+
If you're using nix, you can find a flake in the root of the project and run =nix develop= to download all the tools (or allow [[https://direnv.net/][direnv]] to auto import in your path)
14
+
15
+
*** Usage
16
+
17
+
Run docker compose to start mocks, the mocks will use ports =8002= and =8003= and the were created using [[https://github.com/mrak/stubby4node][stubby4node]]
18
+
19
+
#+begin_src shell
20
+
docker compose up
21
+
#+end_src
22
+
23
+
In another terminal, bulid and run the project with =just=, it will start the project in port =8000=
24
+
25
+
#+begin_src shell
26
+
just run
27
+
#+end_src
28
+
29
+
In other terminal, make the request with hurl
30
+
31
+
#+begin_src shell
32
+
hurl request.hurl
33
+
#+end_src
34
+
35
+
In case you want to see the response with json format, you can use jq
36
+
37
+
#+begin_src shell
38
+
hurl request.hurl | jq .
39
+
#+end_src
40
+
41
+
** Tests
42
+
43
+
To run all tests, you can run the command that it will run all tests from the project:
44
+
45
+
#+begin_src shell
46
+
just test
47
+
#+end_src
+3
-2
cmd/app/main.go
+3
-2
cmd/app/main.go
···
26
26
defer logger.Sync()
27
27
28
28
providers := providers.NewUseProviders([]providers.Provider{
29
-
providers.NewBraintreeProvider(getEnv("BRAINTREE_URL", "http://localhost:8001")),
30
-
providers.NewStripeProvider(getEnv("STRIPE_URL", "http://localhost:8002")),
29
+
providers.NewBraintreeProvider(getEnv("BRAINTREE_URL", "http://localhost:8001"), logger),
30
+
providers.NewStripeProvider(getEnv("STRIPE_URL", "http://localhost:8002"), logger),
31
31
}, logger)
32
32
paymentsService := service.NewPaymentService(providers)
33
33
···
37
37
38
38
srvErr := make(chan error, 1)
39
39
go func() {
40
+
logger.Info("Starting server", zap.String("port", port))
40
41
srvErr <- server.Start(ctx)
41
42
}()
42
43
-17
docker-compose.yml
-17
docker-compose.yml
···
1
1
services:
2
-
api-gateway:
3
-
build:
4
-
dockerfile: $PWD/Dockerfile
5
-
ports:
6
-
- "8000:8000"
7
-
environment:
8
-
PORT: 8000
9
-
BRAINTREE_URL: stripe
10
-
STRIPE_URL: braintree
11
-
depends_on:
12
-
- stripe
13
-
- braintree
14
-
15
2
stripe:
16
3
build:
17
4
dockerfile: $PWD/Dockerfile-stubby
18
5
ports:
19
6
- "8002:8882"
20
-
environment:
21
-
PORT: 8882
22
7
volumes:
23
8
- $PWD/mocks/stripe.yml:/app/mock.yml
24
9
···
27
12
dockerfile: $PWD/Dockerfile-stubby
28
13
ports:
29
14
- "8001:8882"
30
-
environment:
31
-
PORT: 8882
32
15
volumes:
33
16
- $PWD/mocks/braintree.yml:/app/mock.yml
+61
flake.lock
+61
flake.lock
···
1
+
{
2
+
"nodes": {
3
+
"flake-utils": {
4
+
"inputs": {
5
+
"systems": "systems"
6
+
},
7
+
"locked": {
8
+
"lastModified": 1731533236,
9
+
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
10
+
"owner": "numtide",
11
+
"repo": "flake-utils",
12
+
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
13
+
"type": "github"
14
+
},
15
+
"original": {
16
+
"owner": "numtide",
17
+
"repo": "flake-utils",
18
+
"type": "github"
19
+
}
20
+
},
21
+
"nixpkgs": {
22
+
"locked": {
23
+
"lastModified": 1752012998,
24
+
"narHash": "sha256-Q82Ms+FQmgOBkdoSVm+FBpuFoeUAffNerR5yVV7SgT8=",
25
+
"owner": "NixOS",
26
+
"repo": "nixpkgs",
27
+
"rev": "2a2130494ad647f953593c4e84ea4df839fbd68c",
28
+
"type": "github"
29
+
},
30
+
"original": {
31
+
"owner": "NixOS",
32
+
"ref": "nixpkgs-unstable",
33
+
"repo": "nixpkgs",
34
+
"type": "github"
35
+
}
36
+
},
37
+
"root": {
38
+
"inputs": {
39
+
"flake-utils": "flake-utils",
40
+
"nixpkgs": "nixpkgs"
41
+
}
42
+
},
43
+
"systems": {
44
+
"locked": {
45
+
"lastModified": 1681028828,
46
+
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
47
+
"owner": "nix-systems",
48
+
"repo": "default",
49
+
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
50
+
"type": "github"
51
+
},
52
+
"original": {
53
+
"owner": "nix-systems",
54
+
"repo": "default",
55
+
"type": "github"
56
+
}
57
+
}
58
+
},
59
+
"root": "root",
60
+
"version": 7
61
+
}
+28
flake.nix
+28
flake.nix
···
1
+
{
2
+
description = "A Nix-flake-based Golang development environment";
3
+
4
+
inputs = {
5
+
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
6
+
7
+
flake-utils.url = "github:numtide/flake-utils";
8
+
};
9
+
10
+
outputs = { self, nixpkgs, flake-utils }:
11
+
flake-utils.lib.eachDefaultSystem (system:
12
+
let
13
+
inherit (pkgs.lib) optional optionals;
14
+
15
+
pkgs = import nixpkgs { inherit system; };
16
+
in
17
+
{
18
+
devShells.default = pkgs.mkShell {
19
+
buildInputs = with pkgs; [
20
+
go
21
+
hurl
22
+
just
23
+
jq
24
+
];
25
+
};
26
+
}
27
+
);
28
+
}
+13
-1
internal/dto/payments.go
+13
-1
internal/dto/payments.go
···
1
1
package dto
2
2
3
+
import "github.com/google/uuid"
4
+
3
5
type PaymentCardInput struct {
4
6
Number string `json:"number"`
5
7
HolderName string `json:"holderName"`
···
17
19
}
18
20
19
21
type PaymentOutput struct {
20
-
Message string `json:"message"`
22
+
Id uuid.UUID `json:"id"`
23
+
CardId uuid.UUID `json:"cardId"`
24
+
CurrentAmount uint `json:"currentAmount"`
25
+
}
26
+
27
+
func NewPaymentOutput(id, cardId uuid.UUID, currentAmount uint) *PaymentOutput {
28
+
return &PaymentOutput{
29
+
Id: id,
30
+
CardId: cardId,
31
+
CurrentAmount: currentAmount,
32
+
}
21
33
}
+12
-3
internal/providers/braintree.go
+12
-3
internal/providers/braintree.go
···
8
8
9
9
"github.com/Tulkdan/payment-gateway/internal/domain"
10
10
"github.com/google/uuid"
11
+
"go.uber.org/zap"
11
12
)
12
13
13
14
type BraintreeProvider struct {
14
-
Url string
15
+
Url string
16
+
logger *zap.Logger
15
17
}
16
18
17
-
func NewBraintreeProvider(url string) *BraintreeProvider {
18
-
return &BraintreeProvider{Url: url}
19
+
func NewBraintreeProvider(url string, logger *zap.Logger) *BraintreeProvider {
20
+
return &BraintreeProvider{Url: url, logger: logger.Named("BraintreeProvider")}
19
21
}
20
22
21
23
type BraintreeChargeCard struct {
···
39
41
}
40
42
41
43
func (b *BraintreeProvider) Charge(ctx context.Context, request *domain.Payment) (*domain.Provider, error) {
44
+
url := b.Url + "/transactions"
45
+
46
+
b.logger.Debug("Making request to charge",
47
+
zap.String("url", url))
48
+
42
49
body := b.createChargeBody(request)
43
50
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.Url+"/charges", bytes.NewBuffer(body))
44
51
if err != nil {
···
52
59
if err != nil {
53
60
return nil, err
54
61
}
62
+
defer response.Body.Close()
63
+
55
64
return b.responseCharge(response)
56
65
}
57
66
+5
-1
internal/providers/braintree_test.go
+5
-1
internal/providers/braintree_test.go
···
12
12
"github.com/Tulkdan/payment-gateway/internal/domain"
13
13
"github.com/Tulkdan/payment-gateway/internal/providers"
14
14
"github.com/google/uuid"
15
+
"go.uber.org/zap"
15
16
)
16
17
17
18
func TestBraintree(t *testing.T) {
18
19
t.Run("should make request to url", func(t *testing.T) {
20
+
logger, _ := zap.NewDevelopment()
21
+
defer logger.Sync()
22
+
19
23
id, _ := uuid.Parse("2ee70bcb-5cb9-4412-a35f-c2a15fb88ef1")
20
24
cardId, _ := uuid.Parse("ed6ecd4c-81d5-4e63-bb12-99439ae559e7")
21
25
ctx := context.WithValue(t.Context(), "request-id", uuid.New().String())
···
56
60
CardId: cardId,
57
61
}
58
62
59
-
provider := providers.NewBraintreeProvider(server.URL)
63
+
provider := providers.NewBraintreeProvider(server.URL, logger)
60
64
response, err := provider.Charge(ctx, charge)
61
65
62
66
if err != nil {
+24
-10
internal/providers/provider.go
+24
-10
internal/providers/provider.go
···
29
29
func ConfigurableUseProvider(providers []Provider, logger *zap.Logger, timeout time.Duration) *UseProviders {
30
30
return &UseProviders{
31
31
providers: providers,
32
-
logger: logger,
32
+
logger: logger.Named("UseProviders"),
33
33
timeout: timeout,
34
34
}
35
35
}
36
36
37
37
func (p *UseProviders) Payment(ctx context.Context, payment *domain.Payment) (*domain.Provider, error) {
38
38
var err error = nil
39
-
attempts := 0
40
39
41
40
for _, provider := range p.providers {
41
+
requestCtx, cancel := context.WithTimeout(ctx, p.timeout)
42
+
defer cancel()
43
+
44
+
dataCh, errCh := p.charge(requestCtx, payment, provider)
42
45
select {
43
-
case data := <-p.charge(ctx, payment, provider):
46
+
case data := <-dataCh:
44
47
p.logger.Debug("[Payment] Received request successfully",
45
-
zap.String("provider", provider.GetName()),
46
-
zap.Int("attempt", attempts))
48
+
zap.String("provider", provider.GetName()))
47
49
48
50
return data, nil
51
+
case error := <-errCh:
52
+
p.logger.Error("[Payment] Received request with error",
53
+
zap.String("provider", provider.GetName()),
54
+
zap.String("error", error.Error()))
55
+
56
+
err = error
57
+
continue
49
58
case <-time.After(p.timeout):
50
59
p.logger.Error("[Payment] Timeout for provider to respond",
51
-
zap.String("provider", provider.GetName()),
52
-
zap.Int("attempt", attempts))
60
+
zap.String("provider", provider.GetName()))
61
+
62
+
cancel()
53
63
54
64
err = errors.New("Timeout")
55
65
continue
66
+
case <-ctx.Done():
67
+
cancel()
68
+
return nil, ctx.Err()
56
69
}
57
70
}
58
71
59
72
return nil, err
60
73
}
61
74
62
-
func (p *UseProviders) charge(ctx context.Context, charge *domain.Payment, provider Provider) chan *domain.Provider {
75
+
func (p *UseProviders) charge(ctx context.Context, charge *domain.Payment, provider Provider) (chan *domain.Provider, chan error) {
63
76
ch := make(chan *domain.Provider)
77
+
chError := make(chan error)
64
78
65
79
go func() {
66
80
response, err := provider.Charge(ctx, charge)
67
81
if err != nil {
68
-
close(ch)
82
+
chError <- err
69
83
return
70
84
}
71
85
ch <- response
72
86
}()
73
87
74
-
return ch
88
+
return ch, chError
75
89
}
+2
-2
internal/providers/provider_test.go
+2
-2
internal/providers/provider_test.go
···
53
53
})
54
54
55
55
t.Run("should make request for second provider when first provider timeouts", func(t *testing.T) {
56
-
logger, _ := zap.NewProduction()
56
+
logger, _ := zap.NewDevelopment()
57
57
defer logger.Sync()
58
58
59
59
spyFirst := &SpyProvider{Timeout: 20 * time.Millisecond, Response: &domain.Provider{Description: "First"}}
···
77
77
})
78
78
79
79
t.Run("should return error when all providers timeout", func(t *testing.T) {
80
-
logger, _ := zap.NewProduction()
80
+
logger, _ := zap.NewDevelopment()
81
81
defer logger.Sync()
82
82
83
83
spyFirst := &SpyProvider{Timeout: 20 * time.Millisecond, Response: &domain.Provider{Description: "First"}}
+17
-9
internal/providers/stripe.go
+17
-9
internal/providers/stripe.go
···
8
8
9
9
"github.com/Tulkdan/payment-gateway/internal/domain"
10
10
"github.com/google/uuid"
11
+
"go.uber.org/zap"
11
12
)
12
13
13
14
type StripeProvider struct {
14
-
Url string
15
+
Url string
16
+
logger *zap.Logger
15
17
}
16
18
17
-
func NewStripeProvider(url string) *StripeProvider {
18
-
return &StripeProvider{Url: url}
19
+
func NewStripeProvider(url string, logger *zap.Logger) *StripeProvider {
20
+
return &StripeProvider{Url: url, logger: logger.Named("StripeProvider")}
19
21
}
20
22
21
23
type StripeChargeCard struct {
···
34
36
Card StripeChargeCard `json:"card"`
35
37
}
36
38
37
-
func (b *StripeProvider) Charge(ctx context.Context, request *domain.Payment) (*domain.Provider, error) {
38
-
body := b.createChargeBody(request)
39
-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.Url+"/transactions", bytes.NewBuffer(body))
39
+
func (s *StripeProvider) Charge(ctx context.Context, request *domain.Payment) (*domain.Provider, error) {
40
+
url := s.Url + "/transactions"
41
+
42
+
s.logger.Debug("Making request to charge",
43
+
zap.String("url", url))
44
+
45
+
body := s.createChargeBody(request)
46
+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(body))
40
47
if err != nil {
41
48
return nil, err
42
49
}
···
48
55
if err != nil {
49
56
return nil, err
50
57
}
58
+
defer response.Body.Close()
51
59
52
-
return b.responseCharge(response)
60
+
return s.responseCharge(response)
53
61
}
54
62
55
-
func (b *StripeProvider) createChargeBody(request *domain.Payment) []byte {
63
+
func (s *StripeProvider) createChargeBody(request *domain.Payment) []byte {
56
64
toSend := &StripeCharge{
57
65
Amount: request.Amount,
58
66
Currency: request.Currency,
···
82
90
CardId uuid.UUID `json:"cardId"`
83
91
}
84
92
85
-
func (b *StripeProvider) responseCharge(response *http.Response) (*domain.Provider, error) {
93
+
func (s *StripeProvider) responseCharge(response *http.Response) (*domain.Provider, error) {
86
94
var data StripeChargeResponse
87
95
if err := json.NewDecoder(response.Body).Decode(&data); err != nil {
88
96
return nil, err
+5
-1
internal/providers/stripe_test.go
+5
-1
internal/providers/stripe_test.go
···
12
12
"github.com/Tulkdan/payment-gateway/internal/domain"
13
13
"github.com/Tulkdan/payment-gateway/internal/providers"
14
14
"github.com/google/uuid"
15
+
"go.uber.org/zap"
15
16
)
16
17
17
18
func TestStripe(t *testing.T) {
18
19
t.Run("should make request to url", func(t *testing.T) {
20
+
logger, _ := zap.NewDevelopment()
21
+
defer logger.Sync()
22
+
19
23
id, _ := uuid.Parse("2ee70bcb-5cb9-4412-a35f-c2a15fb88ef1")
20
24
cardId, _ := uuid.Parse("ed6ecd4c-81d5-4e63-bb12-99439ae559e7")
21
25
ctx := context.WithValue(t.Context(), "request-id", uuid.New().String())
···
56
60
CardId: cardId,
57
61
}
58
62
59
-
provider := providers.NewStripeProvider(server.URL)
63
+
provider := providers.NewStripeProvider(server.URL, logger)
60
64
response, err := provider.Charge(ctx, charge)
61
65
62
66
if err != nil {
+5
-2
internal/service/payment_service.go
+5
-2
internal/service/payment_service.go
···
22
22
return nil, err
23
23
}
24
24
25
-
_, err = p.providers.Payment(ctx, payment)
25
+
providerData, err := p.providers.Payment(ctx, payment)
26
26
if err != nil {
27
+
payment.UpdateStatus(domain.StatusRejected)
27
28
return nil, err
28
29
}
29
30
30
-
return &dto.PaymentOutput{Message: "Processed successfully"}, nil
31
+
payment.UpdateStatus(domain.StatusApproved)
32
+
33
+
return dto.NewPaymentOutput(providerData.Id, providerData.CardId, providerData.CurrentAmount), nil
31
34
}