From a2b0eb04676d35bc362c34573ab5924dead663a1 Mon Sep 17 00:00:00 2001 From: husobee Date: Wed, 24 May 2023 15:24:00 -0400 Subject: [PATCH 01/22] parse the request body from the ios hook appropriately (#1840) --- services/skus/input.go | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/services/skus/input.go b/services/skus/input.go index e25243ca0..cedf4c87b 100644 --- a/services/skus/input.go +++ b/services/skus/input.go @@ -9,6 +9,7 @@ import ( "github.com/asaskevich/govalidator" "github.com/awa/go-iap/appstore" + errorutils "github.com/brave-intl/bat-go/libs/errors" "github.com/brave-intl/bat-go/libs/inputs" "github.com/brave-intl/bat-go/libs/logging" "github.com/square/go-jose" @@ -23,22 +24,22 @@ type VerifyCredentialRequestV1 struct { Presentation string `json:"presentation" valid:"base64"` } -//GetSku - implement credential interface +// GetSku - implement credential interface func (vcr *VerifyCredentialRequestV1) GetSku(ctx context.Context) string { return vcr.SKU } -//GetType - implement credential interface +// GetType - implement credential interface func (vcr *VerifyCredentialRequestV1) GetType(ctx context.Context) string { return vcr.Type } -//GetMerchantID - implement credential interface +// GetMerchantID - implement credential interface func (vcr *VerifyCredentialRequestV1) GetMerchantID(ctx context.Context) string { return vcr.MerchantID } -//GetPresentation - implement credential interface +// GetPresentation - implement credential interface func (vcr *VerifyCredentialRequestV1) GetPresentation(ctx context.Context) string { return vcr.Presentation } @@ -51,12 +52,12 @@ type VerifyCredentialRequestV2 struct { CredentialOpaque *VerifyCredentialOpaque `json:"-" valid:"-"` } -//GetSku - implement credential interface +// GetSku - implement credential interface func (vcr *VerifyCredentialRequestV2) GetSku(ctx context.Context) string { return vcr.SKU } -//GetType - implement credential interface +// GetType - implement credential interface func (vcr *VerifyCredentialRequestV2) GetType(ctx context.Context) string { if vcr.CredentialOpaque == nil { return "" @@ -64,12 +65,12 @@ func (vcr *VerifyCredentialRequestV2) GetType(ctx context.Context) string { return vcr.CredentialOpaque.Type } -//GetMerchantID - implement credential interface +// GetMerchantID - implement credential interface func (vcr *VerifyCredentialRequestV2) GetMerchantID(ctx context.Context) string { return vcr.MerchantID } -//GetPresentation - implement credential interface +// GetPresentation - implement credential interface func (vcr *VerifyCredentialRequestV2) GetPresentation(ctx context.Context) string { if vcr.CredentialOpaque == nil { return "" @@ -317,7 +318,13 @@ func (iosn *IOSNotification) Decode(ctx context.Context, data []byte) error { logger := logging.Logger(ctx, "IOSNotification.Decode") logger.Debug().Msg("starting IOSNotification.Decode") - // parse the jws into payloadJWS + // json unmarshal the notification + if err := json.Unmarshal(data, iosn); err != nil { + logger.Error().Msg("failed to json unmarshal body") + return errorutils.Wrap(err, "error unmarshalling body") + } + + // parse the jws into payloadJWS from the signed payload payload, err := jose.ParseSigned(iosn.SignedPayload) if err != nil { return fmt.Errorf("failed to parse ios notification: %w", err) From 7d3177ce54d12ef3cc5380299c6d8b614d1c08e8 Mon Sep 17 00:00:00 2001 From: Pavel Brm <5097196+pavelbrm@users.noreply.github.com> Date: Thu, 25 May 2023 20:03:43 +1200 Subject: [PATCH 02/22] Fix panic with errors.As (#1844) --- libs/clients/gemini/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/clients/gemini/client.go b/libs/clients/gemini/client.go index e3ef02e98..384364ebd 100644 --- a/libs/clients/gemini/client.go +++ b/libs/clients/gemini/client.go @@ -398,7 +398,7 @@ func (c *HTTPClient) CheckTxStatus(ctx context.Context, APIKey string, clientID _, err = c.client.Do(ctx, req, &body) if err != nil { var eb *errorutils.ErrorBundle - if errors.As(err, eb) { + if errors.As(err, &eb) { if httpState, ok := eb.Data().(clients.HTTPState); ok { if httpState.Status == http.StatusNotFound { notFoundReason := "404 From Gemini" From dfb249de4e8ddb9669dc23ed3aa253bee898dfb4 Mon Sep 17 00:00:00 2001 From: husobee Date: Thu, 25 May 2023 15:55:39 -0400 Subject: [PATCH 03/22] parse the request body from the ios hook appropriately (#1847) fix processing of ios notifications fix ios notification processing --- services/skus/input.go | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/services/skus/input.go b/services/skus/input.go index cedf4c87b..7ade6c47a 100644 --- a/services/skus/input.go +++ b/services/skus/input.go @@ -384,20 +384,23 @@ func (iosn *IOSNotification) GetRenewalInfo(ctx context.Context) (*appstore.JWSR return nil, err } + // extract the payload from + payload, err := iosn.payloadJWS.Verify(pk) + if err != nil { + logger.Warn().Err(err).Msg("failed to verify the notification jws") + return nil, fmt.Errorf("failed to verify the notification JWS: %w", err) + } + logger.Debug().Msgf("raw payload: %s", string(payload)) + // first get the subscription notification payload decoded // req.payload is json serialized appstore.SubscriptionNotificationV2DecodedPayload var snv2dp = new(appstore.SubscriptionNotificationV2DecodedPayload) - if err := json.Unmarshal(iosn.payload, snv2dp); err != nil { + if err := json.Unmarshal(payload, snv2dp); err != nil { logger.Warn().Err(err).Msg("failed to unmarshal notification") return nil, fmt.Errorf("failed to unmarshal subscription notification v2 decoded: %w", err) } - // second base64 decode snv2dp.Data.SignedRenewalInfo and parse the resulting jws and verify the signature - signedRenewalInfoJWS, err := base64.StdEncoding.DecodeString(string(snv2dp.Data.SignedRenewalInfo)) - if err != nil { - logger.Warn().Err(err).Msg("failed to b64 decode signed info") - return nil, fmt.Errorf("failed to decode signed renewal info: %w", err) - } - signedRenewalInfo, err := jose.ParseSigned(string(signedRenewalInfoJWS)) + + signedRenewalInfo, err := jose.ParseSigned(string(snv2dp.Data.SignedRenewalInfo)) if err != nil { logger.Warn().Err(err).Msg("failed to parse jws") return nil, fmt.Errorf("failed to parse the Signed Renewal Info JWS: %w", err) @@ -448,6 +451,14 @@ func (iosn *IOSNotification) GetTransactionInfo(ctx context.Context) (*appstore. return nil, err } + // extract the payload from + payload, err := iosn.payloadJWS.Verify(pk) + if err != nil { + logger.Warn().Err(err).Msg("failed to verify the notification jws") + return nil, fmt.Errorf("failed to verify the notification JWS: %w", err) + } + logger.Debug().Msgf("raw payload: %s", string(payload)) + // first get the subscription notification payload decoded // req.payload is json serialized appstore.SubscriptionNotificationV2DecodedPayload var snv2dp = new(appstore.SubscriptionNotificationV2DecodedPayload) @@ -455,13 +466,9 @@ func (iosn *IOSNotification) GetTransactionInfo(ctx context.Context) (*appstore. logger.Warn().Err(err).Msg("failed to unmarshal notification") return nil, fmt.Errorf("failed to unmarshal subscription notification v2 decoded: %w", err) } - // second base64 decode snv2dp.Data.SignedTransactionInfo and parse the resulting jws and verify the signature - signedTransactionInfoJWS, err := base64.StdEncoding.DecodeString(string(snv2dp.Data.SignedTransactionInfo)) - if err != nil { - logger.Warn().Err(err).Msg("failed to b64 decode transaction blob") - return nil, fmt.Errorf("failed to decode signed transaction info: %w", err) - } - signedTransactionInfo, err := jose.ParseSigned(string(signedTransactionInfoJWS)) + + // verify the signed transaction jws + signedTransactionInfo, err := jose.ParseSigned(string(snv2dp.Data.SignedTransactionInfo)) if err != nil { logger.Warn().Err(err).Msg("failed to parse transaction jws") return nil, fmt.Errorf("failed to parse the Signed Transaction Info JWS: %w", err) From 7c2375297bf7bd6a18d12b71e61d9a2bd7dad02d Mon Sep 17 00:00:00 2001 From: clD11 <23483715+clD11@users.noreply.github.com> Date: Fri, 26 May 2023 17:48:18 +0100 Subject: [PATCH 04/22] add logging for wallet creation (#1850) --- services/wallet/service.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/services/wallet/service.go b/services/wallet/service.go index bfd75e2fd..17c270494 100644 --- a/services/wallet/service.go +++ b/services/wallet/service.go @@ -575,6 +575,8 @@ func (service *Service) DisconnectCustodianLink(ctx context.Context, custodian s // CreateRewardsWallet creates a brave rewards wallet and informs the reputation service. // If either the local transaction or call to the reputation service fails then the wallet is not created. func (service *Service) CreateRewardsWallet(ctx context.Context, publicKey string, geoCountry string) (*walletutils.Info, error) { + log := logging.Logger(ctx, "wallets.CreateRewardsWallet") + valid, err := service.geoValidator.Validate(ctx, geoCountry) if err != nil { return nil, fmt.Errorf("error validating geo country: %w", err) @@ -603,6 +605,10 @@ func (service *Service) CreateRewardsWallet(ctx context.Context, publicKey strin var pgErr *pq.Error if errors.As(err, &pgErr) { if pgErr.Code == "23505" { // unique constraint violation + if info != nil { + log.Error().Err(err).Interface("info", info). + Msg("error InsertWalletTx") + } return nil, errRewardsWalletAlreadyExists } } From ae2a5299af2e029f49f05d9c5f875dc262611275 Mon Sep 17 00:00:00 2001 From: husobee Date: Fri, 26 May 2023 14:31:46 -0400 Subject: [PATCH 05/22] Ios webhook parse request (#1851) * parse the request body from the ios hook appropriately fix processing of ios notifications fix ios notification processing * use original transaction id for webhook check * check if there is a revocation date or not --- services/skus/receipt.go | 2 +- services/skus/service.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/skus/receipt.go b/services/skus/receipt.go index bca100761..127c02ab0 100644 --- a/services/skus/receipt.go +++ b/services/skus/receipt.go @@ -127,7 +127,7 @@ func validateIOSReceipt(ctx context.Context, receipt interface{}) (string, error logger.Error().Msg("failed to verify receipt, no in app info") return "", fmt.Errorf("failed to verify receipt, no in app info in response") } - return resp.Receipt.InApp[0].TransactionID, nil + return resp.Receipt.InApp[0].OriginalTransactionID, nil } } logger.Error().Msg("client is not configured") diff --git a/services/skus/service.go b/services/skus/service.go index 1335004c2..3629a6ae4 100644 --- a/services/skus/service.go +++ b/services/skus/service.go @@ -1448,17 +1448,17 @@ func (s *Service) verifyIOSNotification(ctx context.Context, txInfo *appstore.JW // lookup the order based on the token as externalID o, err := s.Datastore.GetOrderByExternalID(txInfo.OriginalTransactionId) if err != nil { - return fmt.Errorf("failed to get order from db: %w", err) + return fmt.Errorf("failed to get order from db (%s): %w", txInfo.OriginalTransactionId, err) } if o == nil { - return fmt.Errorf("failed to get order from db: %w", errNotFound) + return fmt.Errorf("failed to get order from db (%s): %w", txInfo.OriginalTransactionId, errNotFound) } // check if we are past the expiration date on transaction or the order was revoked if time.Now().After(time.Unix(0, txInfo.ExpiresDate*int64(time.Millisecond))) || - time.Now().After(time.Unix(0, txInfo.RevocationDate*int64(time.Millisecond))) { + (txInfo.RevocationDate > 0 && time.Now().After(time.Unix(0, txInfo.RevocationDate*int64(time.Millisecond)))) { // past our tx expires/renewal time if err = s.CancelOrder(o.ID); err != nil { return fmt.Errorf("failed to cancel subscription in skus: %w", err) From 1d4fe95c59f9c5e7e0ef426a1ba527567d56bb04 Mon Sep 17 00:00:00 2001 From: Jackson Date: Wed, 31 May 2023 18:01:42 -0400 Subject: [PATCH 06/22] #1854 Fail permanently on Uphold 404 response on commit (#1855) --- tools/settlement/settlement.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/settlement/settlement.go b/tools/settlement/settlement.go index dacaa199a..63d9749a8 100644 --- a/tools/settlement/settlement.go +++ b/tools/settlement/settlement.go @@ -355,6 +355,10 @@ func ConfirmPreparedTransaction( logger.Error().Err(err).Msg("invalid destination, skipping") settlement.Status = "failed" return nil + } else if errorutils.IsErrNotFound(err) { + logger.Error().Err(err).Msg("transaction not found, skipping") + settlement.Status = "failed" + return nil } else if errorutils.IsErrAlreadyExists(err) { // NOTE we've observed the uphold API LB timing out while the request is eventually processed upholdInfo, err := settlementWallet.GetTransaction(ctx, settlement.ProviderID) From bd1ada4f390dc890b0394e3ef2904f149061c23e Mon Sep 17 00:00:00 2001 From: Nick von Pentz <12549658+nvonpentz@users.noreply.github.com> Date: Thu, 8 Jun 2023 12:00:19 -0400 Subject: [PATCH 07/22] Stripe Onramp integration for the Wallet (#1843) Add POST /v2/stripe/onramp_sessions to Ratios service that makes a call to Stripes servers to fetch a URL, and pass the URL back to the caller which is the wallet. --- Makefile | 4 + libs/clients/coingecko/client_test.go | 4 +- libs/clients/stripe/client.go | 144 +++++++++++++++ libs/clients/stripe/client_test.go | 94 ++++++++++ libs/clients/stripe/instrumented_client.go | 53 ++++++ libs/clients/stripe/mock/mock.go | 51 ++++++ libs/context/keys.go | 6 + libs/requestutils/requestutils.go | 4 + services/ratios/cmd/ratios.go | 9 +- services/ratios/cmd/rest_run.go | 7 + services/ratios/controllers.go | 135 ++++++++++++++ services/ratios/controllers_test.go | 193 +++++++++++++++++++-- services/ratios/service.go | 51 +++++- 13 files changed, 736 insertions(+), 19 deletions(-) create mode 100644 libs/clients/stripe/client.go create mode 100644 libs/clients/stripe/client_test.go create mode 100644 libs/clients/stripe/instrumented_client.go create mode 100644 libs/clients/stripe/mock/mock.go diff --git a/Makefile b/Makefile index 78503d2e6..fd7bf30e6 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,7 @@ mock: cd libs && mockgen -source=./clients/gemini/client.go -destination=clients/gemini/mock/mock.go -package=mock_gemini cd libs && mockgen -source=./clients/bitflyer/client.go -destination=clients/bitflyer/mock/mock.go -package=mock_bitflyer cd libs && mockgen -source=./clients/coingecko/client.go -destination=clients/coingecko/mock/mock.go -package=mock_coingecko + cd libs && mockgen -source=./clients/stripe/client.go -destination=clients/stripe/mock/mock.go -package=mock_stripe cd libs && mockgen -source=./backoff/retrypolicy/retrypolicy.go -destination=backoff/retrypolicy/mock/retrypolicy.go -package=mockretrypolicy cd libs && mockgen -source=./aws/s3.go -destination=aws/mock/mock.go -package=mockaws cd libs && mockgen -source=./kafka/dialer.go -destination=kafka/mock/dialer.go -package=mockdialer @@ -72,6 +73,8 @@ instrumented: sed -i'bak' 's/bitflyer.//g' libs/clients/bitflyer/instrumented_client.go cd libs && gowrap gen -p github.com/brave-intl/bat-go/libs/clients/coingecko -i Client -t ../.prom-gowrap.tmpl -o ./clients/coingecko/instrumented_client.go sed -i'bak' 's/coingecko.//g' libs/clients/coingecko/instrumented_client.go + cd libs && gowrap gen -p github.com/brave-intl/bat-go/libs/clients/stripe -i Client -t ../.prom-gowrap.tmpl -o ./clients/stripe/instrumented_client.go + sed -i'bak' 's/stripe.//g' libs/clients/stripe/instrumented_client.go # fix all instrumented cause the interfaces are all called "client" sed -i'bak' 's/client_duration_seconds/cbr_client_duration_seconds/g' libs/clients/cbr/instrumented_client.go sed -i'bak' 's/client_duration_seconds/ratios_client_duration_seconds/g' libs/clients/ratios/instrumented_client.go @@ -79,6 +82,7 @@ instrumented: sed -i'bak' 's/client_duration_seconds/gemini_client_duration_seconds/g' libs/clients/gemini/instrumented_client.go sed -i'bak' 's/client_duration_seconds/bitflyer_client_duration_seconds/g' libs/clients/bitflyer/instrumented_client.go sed -i'bak' 's/client_duration_seconds/coingecko_client_duration_seconds/g' libs/clients/coingecko/instrumented_client.go + sed -i'bak' 's/client_duration_seconds/stripe_client_duration_seconds/g' libs/clients/stripe/instrumented_client.go %-docker: docker docker build --build-arg COMMIT=$(GIT_COMMIT) --build-arg VERSION=$(GIT_VERSION) \ diff --git a/libs/clients/coingecko/client_test.go b/libs/clients/coingecko/client_test.go index 80df8a4d9..b26ae132e 100644 --- a/libs/clients/coingecko/client_test.go +++ b/libs/clients/coingecko/client_test.go @@ -152,8 +152,8 @@ func (suite *CoingeckoTestSuite) TestFetchCoinMarkets() { resp1, t1, err := suite.client.FetchCoinMarkets(suite.ctx, "usd", 10) suite.Require().NoError(err, "should be able to fetch the coin markets") suite.Require().Equal(10, len(*resp1), "should have a response length of 10 for limit=10") - suite.Require().Equal(t, t1, "the lastUpdated time should be equal because of cache usage") + suite.Require().Equal(t.Unix(), t1.Unix(), "the lastUpdated time should be equal because of cache usage") u, err := url.Parse((*resp1)[0].Image) suite.Require().NoError(err) - suite.Require().Equal(u.Host, "api.cgproxy.brave.com", "image host should be the brave proxy") + suite.Require().Equal(u.Host, "assets.cgproxy.brave.com", "image host should be the brave proxy") } diff --git a/libs/clients/stripe/client.go b/libs/clients/stripe/client.go new file mode 100644 index 000000000..c053279b4 --- /dev/null +++ b/libs/clients/stripe/client.go @@ -0,0 +1,144 @@ +package stripe + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" + "time" + + "github.com/brave-intl/bat-go/libs/clients" + appctx "github.com/brave-intl/bat-go/libs/context" + "github.com/google/go-querystring/query" +) + +// Client abstracts over the underlying client +type Client interface { + CreateOnrampSession( + ctx context.Context, + integrationMode string, + walletAddress string, + sourceCurrency string, + sourceExchangeAmount string, + destinationNetwork string, + destinationCurrency string, + supportedDestinationNetworks []string, + ) (*OnrampSessionResponse, error) +} + +// HTTPClient wraps http.Client for interacting with the Stripe server +type HTTPClient struct { + client *clients.SimpleHTTPClient +} + +// NewWithContext returns a new HTTPClient, retrieving the base URL from the context +func NewWithContext(ctx context.Context) (Client, error) { + // get the server url from context + serverURL, err := appctx.GetStringFromContext(ctx, appctx.StripeOnrampServerCTXKey) + if err != nil { + return nil, fmt.Errorf("failed to get StripeServer from context: %w", err) + } + + // get the server secretKey from context + secretKey, err := appctx.GetStringFromContext(ctx, appctx.StripeOnrampSecretKeyCTXKey) + if err != nil { + return nil, fmt.Errorf("failed to get StripeSecretKey from context: %w", err) + } + + client, err := clients.NewWithHTTPClient(serverURL, secretKey, &http.Client{ + Timeout: time.Second * 30, + }) + if err != nil { + return nil, err + } + + return NewClientWithPrometheus( + &HTTPClient{ + client: client, + }, "stripe_onramp_context_client"), nil +} + +// onrampSessionParams for fetching prices +type onrampSessionParams struct { + IntegrationMode string `url:"integration_mode"` + WalletAddress string `url:"-"` + SourceCurrency string `url:"transaction_details[source_currency],omitempty"` + SourceExchangeAmount string `url:"transaction_details[source_exchange_amount],omitempty"` + DestinationNetwork string `url:"transaction_details[destination_network],omitempty"` + DestinationCurrency string `url:"transaction_details[destination_currency],omitempty"` + SupportedDestinationNetworks []string `url:"-"` +} + +// GenerateQueryString - implement the QueryStringBody interface +func (p *onrampSessionParams) GenerateQueryString() (url.Values, error) { + values, err := query.Values(p) + if err != nil { + return nil, err + } + if p.WalletAddress != "" { + key := fmt.Sprintf("transaction_details[wallet_addresses][%s]", p.DestinationNetwork) + values.Add(key, p.WalletAddress) + } + + if len(p.SupportedDestinationNetworks) > 0 { + for i, network := range p.SupportedDestinationNetworks { + key := fmt.Sprintf("transaction_details[supported_destination_networks][%d]", i) + values.Add(key, network) + } + } + + return values, nil +} + +// OnrampSessionResponse represents the response received from Stripe +type OnrampSessionResponse struct { + RedirectURL string `json:"redirect_url"` +} + +// CreateOnrampSession creates a new onramp session +func (c *HTTPClient) CreateOnrampSession( + ctx context.Context, + integrationMode string, + walletAddress string, + sourceCurrency string, + sourceExchangeAmount string, + destinationNetwork string, + destinationCurrency string, + supportedDestinationNetworks []string, +) (*OnrampSessionResponse, error) { + url := "/v1/crypto/onramp_sessions" + + params := &onrampSessionParams{ + IntegrationMode: integrationMode, + WalletAddress: walletAddress, + SourceCurrency: sourceCurrency, + SourceExchangeAmount: sourceExchangeAmount, + DestinationNetwork: destinationNetwork, + DestinationCurrency: destinationCurrency, + SupportedDestinationNetworks: supportedDestinationNetworks, + } + + values, err := params.GenerateQueryString() + if err != nil { + return nil, err + } + + req, err := c.client.NewRequest(ctx, "POST", url, nil, nil) + if err != nil { + return nil, err + } + // Override request body after req has been created since our client + // implementation only supports JSON payloads. + req.Body = ioutil.NopCloser(strings.NewReader(values.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + var body OnrampSessionResponse + _, err = c.client.Do(ctx, req, &body) + if err != nil { + return nil, err + } + + return &body, nil +} diff --git a/libs/clients/stripe/client_test.go b/libs/clients/stripe/client_test.go new file mode 100644 index 000000000..2153aa046 --- /dev/null +++ b/libs/clients/stripe/client_test.go @@ -0,0 +1,94 @@ +//go:build integration && vpn +// +build integration,vpn + +package stripe_test + +import ( + "context" + "os" + "testing" + + "github.com/brave-intl/bat-go/libs/clients/stripe" + appctx "github.com/brave-intl/bat-go/libs/context" + logutils "github.com/brave-intl/bat-go/libs/logging" + "github.com/stretchr/testify/suite" +) + +type StripeTestSuite struct { + suite.Suite + client stripe.Client + ctx context.Context +} + +func TestStripeTestSuite(t *testing.T) { + if _, exists := os.LookupEnv("STRIPE_ONRAMP_SECRET_KEY"); !exists { + t.Skip("STRIPE_ONRAMP_SECRET_KEY is not found, skipping all tests in StripeTestSuite.") + } + + suite.Run(t, new(StripeTestSuite)) +} + +var ( + stripeService string = "https://api.stripe.com/" +) + +func (suite *StripeTestSuite) SetupTest() { + // setup the context + suite.ctx = context.Background() + suite.ctx = context.WithValue(suite.ctx, appctx.DebugLoggingCTXKey, false) + suite.ctx = context.WithValue(suite.ctx, appctx.LogLevelCTXKey, "info") + suite.ctx, _ = logutils.SetupLogger(suite.ctx) + + stripeKey := os.Getenv("STRIPE_ONRAMP_SECRET_KEY") + + // Set stripeKey and stripeService into context + suite.ctx = context.WithValue(suite.ctx, appctx.StripeOnrampServerCTXKey, stripeService) + suite.ctx = context.WithValue(suite.ctx, appctx.StripeOnrampSecretKeyCTXKey, stripeKey) + + var err error + suite.client, err = stripe.NewWithContext(suite.ctx) + suite.Require().NoError(err, "Must be able to correctly initialize the client") +} + +func (suite *StripeTestSuite) TestCreateOnrampSession() { + // Empty params should yield a redirect URL + var walletAddress string + var sourceCurrency string + var sourceExchangeAmount string + var destinationNetwork string + var destinationCurrency string + var supportedDestinationNetworks []string + + resp, err := suite.client.CreateOnrampSession( + suite.ctx, + "redirect", + walletAddress, + sourceCurrency, + sourceExchangeAmount, + destinationNetwork, + destinationCurrency, + supportedDestinationNetworks, + ) + suite.Require().NoError(err, "should be able to create an onramp session with no params") + suite.Require().NotEqual(resp.RedirectURL, "") + + // Filled out params should yield a redirect URL + walletAddress = "0xB00F0759DbeeF5E543Cc3E3B07A6442F5f3928a2" + sourceCurrency = "usd" + destinationCurrency = "eth" + destinationNetwork = "ethereum" + sourceExchangeAmount = "1" + supportedDestinationNetworks = []string{"ethereum", "polygon"} + resp, err = suite.client.CreateOnrampSession( + suite.ctx, + "redirect", + walletAddress, + sourceCurrency, + sourceExchangeAmount, + destinationNetwork, + destinationCurrency, + supportedDestinationNetworks, + ) + suite.Require().NoError(err, "should be able to create an onramp session with specific params") + suite.Require().NotEqual(resp.RedirectURL, "") +} diff --git a/libs/clients/stripe/instrumented_client.go b/libs/clients/stripe/instrumented_client.go new file mode 100644 index 000000000..2383ba792 --- /dev/null +++ b/libs/clients/stripe/instrumented_client.go @@ -0,0 +1,53 @@ +// Code generated by gowrap. DO NOT EDIT. +// template: ../../../.prom-gowrap.tmpl +// gowrap: http://github.com/hexdigest/gowrap + +package stripe + +//go:generate gowrap gen -p github.com/brave-intl/bat-go/libs/clients/-i Client -t ../../../.prom-gowrap.tmpl -o instrumented_client.go -l "" + +import ( + "context" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +// ClientWithPrometheus implements Client interface with all methods wrapped +// with Prometheus metrics +type ClientWithPrometheus struct { + base Client + instanceName string +} + +var clientDurationSummaryVec = promauto.NewSummaryVec( + prometheus.SummaryOpts{ + Name: "stripe_client_duration_seconds", + Help: "client runtime duration and result", + MaxAge: time.Minute, + Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, + }, + []string{"instance_name", "method", "result"}) + +// NewClientWithPrometheus returns an instance of the Client decorated with prometheus summary metric +func NewClientWithPrometheus(base Client, instanceName string) ClientWithPrometheus { + return ClientWithPrometheus{ + base: base, + instanceName: instanceName, + } +} + +// CreateOnrampSession implements Client +func (_d ClientWithPrometheus) CreateOnrampSession(ctx context.Context, integrationMode string, walletAddress string, sourceCurrency string, sourceExchangeAmount string, destinationNetwork string, destinationCurrency string, supportedDestinationNetworks []string) (op1 *OnrampSessionResponse, err error) { + _since := time.Now() + defer func() { + result := "ok" + if err != nil { + result = "error" + } + + clientDurationSummaryVec.WithLabelValues(_d.instanceName, "CreateOnrampSession", result).Observe(time.Since(_since).Seconds()) + }() + return _d.base.CreateOnrampSession(ctx, integrationMode, walletAddress, sourceCurrency, sourceExchangeAmount, destinationNetwork, destinationCurrency, supportedDestinationNetworks) +} diff --git a/libs/clients/stripe/mock/mock.go b/libs/clients/stripe/mock/mock.go new file mode 100644 index 000000000..997736114 --- /dev/null +++ b/libs/clients/stripe/mock/mock.go @@ -0,0 +1,51 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./clients/stripe/client.go + +// Package mock_stripe is a generated GoMock package. +package mock_stripe + +import ( + context "context" + reflect "reflect" + + stripe "github.com/brave-intl/bat-go/libs/clients/stripe" + gomock "github.com/golang/mock/gomock" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// CreateOnrampSession mocks base method. +func (m *MockClient) CreateOnrampSession(ctx context.Context, integrationMode, walletAddress, sourceCurrency, sourceExchangeAmount, destinationNetwork, destinationCurrency string, supportedDestinationNetworks []string) (*stripe.OnrampSessionResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateOnrampSession", ctx, integrationMode, walletAddress, sourceCurrency, sourceExchangeAmount, destinationNetwork, destinationCurrency, supportedDestinationNetworks) + ret0, _ := ret[0].(*stripe.OnrampSessionResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateOnrampSession indicates an expected call of CreateOnrampSession. +func (mr *MockClientMockRecorder) CreateOnrampSession(ctx, integrationMode, walletAddress, sourceCurrency, sourceExchangeAmount, destinationNetwork, destinationCurrency, supportedDestinationNetworks interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateOnrampSession", reflect.TypeOf((*MockClient)(nil).CreateOnrampSession), ctx, integrationMode, walletAddress, sourceCurrency, sourceExchangeAmount, destinationNetwork, destinationCurrency, supportedDestinationNetworks) +} diff --git a/libs/context/keys.go b/libs/context/keys.go index 8335ac0d3..28d414a49 100644 --- a/libs/context/keys.go +++ b/libs/context/keys.go @@ -189,6 +189,12 @@ const ( // ParametersTransitionCTXKey - the context key for getting the vbat deadline ParametersTransitionCTXKey CTXKey = "parameters_transition" + // StripeAccessTokenCTXKey - the context key for the Stripe secret key + StripeOnrampSecretKeyCTXKey CTXKey = "stripe_onramp_secret_key" + + // StripeServerCTXKey - the context key for the Stripe server + StripeOnrampServerCTXKey CTXKey = "stripe_onramp_server" + // Nitro // LogWriterCTXKey - the context key for getting the log writer LogWriterCTXKey CTXKey = "log_writer" diff --git a/libs/requestutils/requestutils.go b/libs/requestutils/requestutils.go index 05ab60614..41c14dc8e 100644 --- a/libs/requestutils/requestutils.go +++ b/libs/requestutils/requestutils.go @@ -3,6 +3,7 @@ package requestutils import ( "context" "encoding/json" + "errors" "io" "io/ioutil" "net/http" @@ -44,6 +45,9 @@ func Read(ctx context.Context, body io.Reader) ([]byte, error) { // ReadJSON reads a request body according to an interface and limits the size to 10MB func ReadJSON(ctx context.Context, body io.Reader, intr interface{}) error { logger := logging.Logger(ctx, "requestutils.ReadJSON") + if body == nil { + return errorutils.New(errors.New("body is nil"), "Error in request body", nil) + } jsonString, err := Read(ctx, body) if err != nil { return err diff --git a/services/ratios/cmd/ratios.go b/services/ratios/cmd/ratios.go index 03eef8975..8dfcf177a 100644 --- a/services/ratios/cmd/ratios.go +++ b/services/ratios/cmd/ratios.go @@ -19,7 +19,6 @@ func init() { cmd.ServeCmd.AddCommand(ratiosCmd) // setup the flags - ratiosCmd.PersistentFlags().String("coingecko-token", "", "the coingecko service token for this service") cmdutils.Must(viper.BindPFlag("coingecko-token", ratiosCmd.PersistentFlags().Lookup("coingecko-token"))) @@ -44,6 +43,14 @@ func init() { ratiosCmd.PersistentFlags().Int("rate-limit-per-min", 50, "rate limit per minute value") cmdutils.Must(viper.BindPFlag("rate-limit-per-min", ratiosCmd.PersistentFlags().Lookup("rate-limit-per-min"))) cmdutils.Must(viper.BindEnv("rate-limit-per-min", "RATE_LIMIT_PER_MIN")) + + ratiosCmd.PersistentFlags().String("stripe-onramp-secret-key", "", "the stripe service token for this service") + cmdutils.Must(viper.BindPFlag("stripe-onramp-secret-key", ratiosCmd.PersistentFlags().Lookup("stripe-onramp-secret-key"))) + cmdutils.Must(viper.BindEnv("stripe-onramp-secret-key", "STRIPE_ONRAMP_SECRET_KEY")) + + ratiosCmd.PersistentFlags().String("stripe-onramp-server", "https://api.stripe.com/", "the stripe service address") + cmdutils.Must(viper.BindPFlag("stripe-onramp-server", ratiosCmd.PersistentFlags().Lookup("stripe-onramp-server"))) + cmdutils.Must(viper.BindEnv("stripe-onramp-server", "STRIPE_ONRAMP_SERVER")) } var ( diff --git a/services/ratios/cmd/rest_run.go b/services/ratios/cmd/rest_run.go index 7f928047d..c6b315ef7 100644 --- a/services/ratios/cmd/rest_run.go +++ b/services/ratios/cmd/rest_run.go @@ -43,6 +43,9 @@ func RestRun(command *cobra.Command, args []string) { ctx = context.WithValue(ctx, appctx.RatiosRedisAddrCTXKey, viper.Get("redis-addr")) ctx = context.WithValue(ctx, appctx.RateLimitPerMinuteCTXKey, viper.GetInt("rate-limit-per-min")) + ctx = context.WithValue(ctx, appctx.StripeOnrampSecretKeyCTXKey, viper.Get("stripe-onramp-secret-key")) + ctx = context.WithValue(ctx, appctx.StripeOnrampServerCTXKey, viper.Get("stripe-onramp-server")) + // setup the service now ctx, s, err := ratios.InitService(ctx) if err != nil { @@ -60,6 +63,10 @@ func RestRun(command *cobra.Command, args []string) { r.Get("/v2/history/coingecko/{coinID}/{vsCurrency}/{duration}", middleware.InstrumentHandler("GetHistoryHandler", ratios.GetHistoryHandler(s)).ServeHTTP) r.Get("/v2/coinmap/provider/coingecko", middleware.InstrumentHandler("GetMappingHandler", ratios.GetMappingHandler(s)).ServeHTTP) r.Get("/v2/market/provider/coingecko", middleware.InstrumentHandler("GetCoinMarketsHandler", ratios.GetCoinMarketsHandler(s)).ServeHTTP) + r.Post("/v2/stripe/onramp_sessions", middleware.InstrumentHandler( + "StripeOnrampSessionsHandler", + ratios.CreateStripeOnrampSessionsHandler(s)).ServeHTTP, + ) err = cmd.SetupJobWorkers(command.Context(), s.Jobs()) if err != nil { diff --git a/services/ratios/controllers.go b/services/ratios/controllers.go index a28248f5f..d7798eb4b 100644 --- a/services/ratios/controllers.go +++ b/services/ratios/controllers.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" "net/http" + "strconv" + "strings" "time" "github.com/brave-intl/bat-go/libs/clients/coingecko" @@ -11,6 +13,7 @@ import ( "github.com/brave-intl/bat-go/libs/handlers" "github.com/brave-intl/bat-go/libs/inputs" "github.com/brave-intl/bat-go/libs/logging" + "github.com/brave-intl/bat-go/libs/requestutils" "github.com/go-chi/chi" ) @@ -314,3 +317,135 @@ func GetCoinMarketsHandler(service *Service) handlers.AppHandler { return handlers.RenderContent(ctx, data, w, http.StatusOK) }) } + +// StripeOnrampSessionRequest +type StripeOnrampSessionRequest struct { + WalletAddress string `json:"wallet_address"` + SourceCurrency string `json:"source_currency"` + SourceExchangeAmount string `json:"source_exchange_amount"` + DestinationNetwork string `json:"destination_network"` + DestinationCurrency string `json:"destination_currency"` + SupportedDestinationNetworks []string `json:"supported_destination_networks"` +} + +// CreateStripeOnrampSessionResponse is an HTTP response that includes the Stripe onramp redirect URL +type CreateStripeOnrampSessionResponse struct { + URL string `json:"url"` +} + +func CreateStripeOnrampSessionsHandler(service *Service) handlers.AppHandler { + return handlers.AppHandler(func(w http.ResponseWriter, r *http.Request) *handlers.AppError { + ctx := r.Context() + logger := logging.Logger(ctx, "ratios.CreateStripeOnrampSessionsHandler") + + // Parse the payload + var req StripeOnrampSessionRequest + err := requestutils.ReadJSON(r.Context(), r.Body, &req) + if err != nil { + return handlers.WrapError(err, "Error in request body", http.StatusBadRequest) + } + + // Parse SourceExchangeAmount to float64, if it's not empty + var sourceExchangeAmount float64 + if req.SourceExchangeAmount != "" { + var err error + sourceExchangeAmount, err = strconv.ParseFloat(req.SourceExchangeAmount, 64) + if err != nil { + return handlers.WrapError( + fmt.Errorf("SourceExchangeAmount must be a number"), + "SourceExchangeAmount must be a number", + http.StatusBadRequest, + ) + } + + if sourceExchangeAmount < 1 { + return handlers.WrapError( + fmt.Errorf("SourceExchangeAmount must be at least 1"), + "SourceExchangeAmount must be at least 1", + http.StatusBadRequest, + ) + } + + parts := strings.Split(req.SourceExchangeAmount, ".") + if len(parts) == 2 && len(parts[1]) > 2 { + return handlers.WrapError( + fmt.Errorf("SourceExchangeAmount must not include fractions of pennies"), + "SourceExchangeAmount must not include fractions of pennies", + http.StatusBadRequest, + ) + } + } + + // Validate the request payload + supportedDestinationNetworks := []string{"solana", "ethereum", "bitcoin", "polygon"} + supportedDestinationCurrencies := []string{"eth", "matic", "sol", "usdc", "btc"} + + // Check if requested DestinationNetwork is in the supported list + isValidNetwork := false + for _, network := range supportedDestinationNetworks { + if req.DestinationNetwork == network { + isValidNetwork = true + break + } + } + if !isValidNetwork { + return handlers.WrapError( + fmt.Errorf("Invalid destination network: %s", req.DestinationNetwork), + "Invalid destination network", + http.StatusBadRequest, + ) + } + + // Check if all SupportedDestinationNetworks in the request are in the supported list + for _, requestedNetwork := range req.SupportedDestinationNetworks { + isValidNetwork = false + for _, network := range supportedDestinationNetworks { + if requestedNetwork == network { + isValidNetwork = true + break + } + } + if !isValidNetwork { + return handlers.WrapError( + fmt.Errorf("Unsupported network in SupportedDestinationNetworks: %s", requestedNetwork), + "Unsupported network in SupportedDestinationNetworks", + http.StatusBadRequest, + ) + } + } + + // Check if requested DestinationCurrency is in the supported list + isValidCurrency := false + for _, currency := range supportedDestinationCurrencies { + if req.DestinationCurrency == currency { + isValidCurrency = true + break + } + } + if !isValidCurrency { + return handlers.WrapError( + fmt.Errorf("Invalid destination currency: %s", req.DestinationCurrency), + "Invalid destination currency", + http.StatusBadRequest, + ) + } + + // Create a session and retrieve a URL + urlString, err := service.CreateStripeOnrampSessionsHandler( + ctx, + req.WalletAddress, + req.SourceCurrency, + req.SourceExchangeAmount, + req.DestinationNetwork, + req.DestinationCurrency, + req.SupportedDestinationNetworks, + ) + if err != nil { + logger.Error().Err(err).Msg("failed to create on ramp session") + return handlers.WrapError(err, "Failed to create on ramp session", http.StatusInternalServerError) + } + + response := CreateStripeOnrampSessionResponse{URL: urlString} + return handlers.RenderContent(ctx, response, w, http.StatusOK) + }) +} diff --git a/services/ratios/controllers_test.go b/services/ratios/controllers_test.go index 395d4ab88..439e8244a 100644 --- a/services/ratios/controllers_test.go +++ b/services/ratios/controllers_test.go @@ -4,12 +4,15 @@ package ratios_test import ( + "bytes" "context" "encoding/json" "github.com/asaskevich/govalidator" "github.com/brave-intl/bat-go/libs/clients/coingecko" mockcoingecko "github.com/brave-intl/bat-go/libs/clients/coingecko/mock" ratiosclient "github.com/brave-intl/bat-go/libs/clients/ratios" + "github.com/brave-intl/bat-go/libs/clients/stripe" + mockstripe "github.com/brave-intl/bat-go/libs/clients/stripe/mock" appctx "github.com/brave-intl/bat-go/libs/context" logutils "github.com/brave-intl/bat-go/libs/logging" "github.com/brave-intl/bat-go/services/ratios" @@ -30,10 +33,11 @@ import ( type ControllersTestSuite struct { suite.Suite - ctx context.Context - service *ratios.Service - mockCtrl *gomock.Controller - mockClient *mockcoingecko.MockClient + ctx context.Context + service *ratios.Service + mockCtrl *gomock.Controller + mockCoingeckoClient *mockcoingecko.MockClient + mockStripeClient *mockstripe.MockClient } func TestControllersTestSuite(t *testing.T) { @@ -93,10 +97,13 @@ func (suite *ControllersTestSuite) BeforeTest(sn, tn string) { conn := redis.Get() err = conn.Err() suite.Require().NoError(err, "failed to setup redis conn") - client := mockcoingecko.NewMockClient(suite.mockCtrl) - suite.mockClient = client + coingecko := mockcoingecko.NewMockClient(suite.mockCtrl) + suite.mockCoingeckoClient = coingecko - suite.service = ratios.NewService(suite.ctx, client, redis) + stripe := mockstripe.NewMockClient(suite.mockCtrl) + suite.mockStripeClient = stripe + + suite.service = ratios.NewService(suite.ctx, coingecko, stripe, redis) suite.Require().NoError(err, "failed to setup ratios service") } @@ -159,7 +166,7 @@ func (suite *ControllersTestSuite) TestGetHistoryHandler() { suite.Require().Empty(rr.Header().Get("Cache-Control")) // Test success with 1h duration - suite.mockClient.EXPECT(). + suite.mockCoingeckoClient.EXPECT(). FetchMarketChart(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(&coingecko.MarketChartResponse{ Prices: [][]decimal.Decimal{ @@ -190,7 +197,7 @@ func (suite *ControllersTestSuite) TestGetHistoryHandler() { suite.Require().LessOrEqual(maxAge, 150, "Invalid max-age parameter in Cache-Control header") // Test success with 1d duration - suite.mockClient.EXPECT(). + suite.mockCoingeckoClient.EXPECT(). FetchMarketChart(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(&coingecko.MarketChartResponse{ Prices: [][]decimal.Decimal{ @@ -219,7 +226,7 @@ func (suite *ControllersTestSuite) TestGetHistoryHandler() { suite.Require().LessOrEqual(maxAge, 3600, "Invalid max-age parameter in Cache-Control header") // Test success with 1w duration - suite.mockClient.EXPECT(). + suite.mockCoingeckoClient.EXPECT(). FetchMarketChart(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(&coingecko.MarketChartResponse{ Prices: [][]decimal.Decimal{ @@ -252,7 +259,7 @@ func (suite *ControllersTestSuite) TestGetHistoryHandler() { durations := []string{"1m", "3m", "1y", "all"} for _, duration := range durations { - suite.mockClient.EXPECT(). + suite.mockCoingeckoClient.EXPECT(). FetchMarketChart(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(&coingecko.MarketChartResponse{ Prices: [][]decimal.Decimal{ @@ -365,7 +372,7 @@ func (suite *ControllersTestSuite) TestGetRelativeHandler() { respy := coingecko.SimplePriceResponse(map[string]map[string]decimal.Decimal{ "basic-attention-token": map[string]decimal.Decimal{"usd": decimal.Zero}, }) - suite.mockClient.EXPECT(). + suite.mockCoingeckoClient.EXPECT(). FetchSimplePrice(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return( &respy, nil) @@ -427,7 +434,7 @@ func (suite *ControllersTestSuite) TestGetCoinMarketsHandler() { }, }, ) - suite.mockClient.EXPECT(). + suite.mockCoingeckoClient.EXPECT(). FetchCoinMarkets(gomock.Any(), gomock.Any(), gomock.Any()). Return(&coingeckoResp, time.Now(), nil) req, err := http.NewRequest("GET", "/v2/market/provider/coingecko?vsCurrency=usd&limit=1", nil) @@ -452,3 +459,163 @@ func (suite *ControllersTestSuite) TestGetCoinMarketsHandler() { suite.Require().Greater(maxAge, 0, "Invalid max-age parameter in Cache-Control header") suite.Require().LessOrEqual(maxAge, 3600, "Invalid max-age parameter in Cache-Control header") } + +func (suite *ControllersTestSuite) TestCreateStripeOnrampSessionsHandler() { + handler := ratios.CreateStripeOnrampSessionsHandler(suite.service) + // Missing payload results in 400 + { + req, err := http.NewRequest("POST", "/v2/stripe/onramp_sessions", nil) + suite.Require().NoError(err) + rctx := chi.NewRouteContext() + req = req.WithContext(suite.ctx) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + suite.Require().Equal(http.StatusBadRequest, rr.Code) + } + + // SourceExchangeAmount less than 1 results in 400 + { + payload := &ratios.StripeOnrampSessionRequest{ + WalletAddress: "0x123abc456def", + SourceCurrency: "usd", + SourceExchangeAmount: "0.5", + DestinationNetwork: "ethereum", + DestinationCurrency: "eth", + SupportedDestinationNetworks: []string{"ethereum", "bitcoin", "solana", "polygon"}, + } + payloadBytes, err := json.Marshal(payload) + suite.Require().NoError(err) + req, err := http.NewRequest("POST", "/v2/stripe/onramp_sessions", bytes.NewBuffer(payloadBytes)) + suite.Require().NoError(err) + rctx := chi.NewRouteContext() + req = req.WithContext(suite.ctx) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + suite.Require().Equal(http.StatusBadRequest, rr.Code) + } + + // SourceExchangeAmount includes fractions of pennies results in 400 + { + payload := &ratios.StripeOnrampSessionRequest{ + WalletAddress: "0x123abc456def", + SourceCurrency: "usd", + SourceExchangeAmount: "1000.001", + DestinationNetwork: "ethereum", + DestinationCurrency: "eth", + SupportedDestinationNetworks: []string{"ethereum", "bitcoin", "solana", "polygon"}, + } + payloadBytes, err := json.Marshal(payload) + suite.Require().NoError(err) + req, err := http.NewRequest("POST", "/v2/stripe/onramp_sessions", bytes.NewBuffer(payloadBytes)) + suite.Require().NoError(err) + rctx := chi.NewRouteContext() + req = req.WithContext(suite.ctx) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + suite.Require().Equal(http.StatusBadRequest, rr.Code) + } + + // Invalid DestinationNetwork results in 400 + { + payload := &ratios.StripeOnrampSessionRequest{ + WalletAddress: "0x123abc456def", + SourceCurrency: "USD", + SourceExchangeAmount: "1000.00", + DestinationNetwork: "unsupportedNetwork", + DestinationCurrency: "ETH", + SupportedDestinationNetworks: []string{"ethereum", "bitcoin", "solana", "polygon"}, + } + payloadBytes, err := json.Marshal(payload) + suite.Require().NoError(err) + req, err := http.NewRequest("POST", "/v2/stripe/onramp_sessions", bytes.NewBuffer(payloadBytes)) + suite.Require().NoError(err) + rctx := chi.NewRouteContext() + req = req.WithContext(suite.ctx) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + suite.Require().Equal(http.StatusBadRequest, rr.Code) + } + + // Unsupported network in SupportedDestinationNetworks results in 400 + { + payload := &ratios.StripeOnrampSessionRequest{ + WalletAddress: "0x123abc456def", + SourceCurrency: "usd", + SourceExchangeAmount: "1000.00", + DestinationNetwork: "ethereum", + DestinationCurrency: "eth", + SupportedDestinationNetworks: []string{"ethereum", "binance", "cardano"}, + } + payloadBytes, err := json.Marshal(payload) + suite.Require().NoError(err) + req, err := http.NewRequest("POST", "/v2/stripe/onramp_sessions", bytes.NewBuffer(payloadBytes)) + suite.Require().NoError(err) + rctx := chi.NewRouteContext() + req = req.WithContext(suite.ctx) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + suite.Require().Equal(http.StatusBadRequest, rr.Code) + } + + // Invalid DestinationCurrency results in 400 + { + payload := &ratios.StripeOnrampSessionRequest{ + WalletAddress: "0x123abc456def", + SourceCurrency: "usd", + SourceExchangeAmount: "1000.00", + DestinationNetwork: "ethereum", + DestinationCurrency: "unsupportedCurrency", + SupportedDestinationNetworks: []string{"ethereum", "bitcoin", "solana", "polygon"}, + } + payloadBytes, err := json.Marshal(payload) + suite.Require().NoError(err) + req, err := http.NewRequest("POST", "/v2/stripe/onramp_sessions", bytes.NewBuffer(payloadBytes)) + suite.Require().NoError(err) + rctx := chi.NewRouteContext() + req = req.WithContext(suite.ctx) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + suite.Require().Equal(http.StatusBadRequest, rr.Code) + } + + // Valid request yields 200 + { + stripeResp := stripe.OnrampSessionResponse{ + RedirectURL: "https://example.com", + } + suite.mockStripeClient.EXPECT(). + CreateOnrampSession( + gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), + gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), + ). + Return(&stripeResp, nil) + payload := &ratios.StripeOnrampSessionRequest{ + WalletAddress: "0x123abc456def", + SourceCurrency: "usd", + SourceExchangeAmount: "1000.00", + DestinationNetwork: "ethereum", + DestinationCurrency: "eth", + SupportedDestinationNetworks: []string{"ethereum", "solana", "bitcoin"}, + } + payloadBytes, err := json.Marshal(payload) + suite.Require().NoError(err) + req, err := http.NewRequest("POST", "/v2/stripe/onramp_sessions", bytes.NewBuffer(payloadBytes)) + suite.Require().NoError(err) + rctx := chi.NewRouteContext() + req = req.WithContext(suite.ctx) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + suite.Require().Equal(http.StatusOK, rr.Code) + var ratiosResp = new(ratios.CreateStripeOnrampSessionResponse) + err = json.Unmarshal(rr.Body.Bytes(), ratiosResp) + suite.Require().NoError(err) + suite.Require().Equal(ratiosResp.URL, "https://example.com") + } +} diff --git a/services/ratios/service.go b/services/ratios/service.go index ac7d20ede..1e81ebf4f 100644 --- a/services/ratios/service.go +++ b/services/ratios/service.go @@ -8,6 +8,7 @@ import ( "github.com/brave-intl/bat-go/libs/clients/coingecko" ratiosclient "github.com/brave-intl/bat-go/libs/clients/ratios" + "github.com/brave-intl/bat-go/libs/clients/stripe" appctx "github.com/brave-intl/bat-go/libs/context" "github.com/brave-intl/bat-go/libs/logging" logutils "github.com/brave-intl/bat-go/libs/logging" @@ -17,10 +18,16 @@ import ( ) // NewService - create a new ratios service structure -func NewService(ctx context.Context, coingecko coingecko.Client, redis *redis.Pool) *Service { +func NewService( + ctx context.Context, + coingecko coingecko.Client, + stripe stripe.Client, + redis *redis.Pool, +) *Service { return &Service{ jobs: []srv.Job{}, coingecko: coingecko, + stripe: stripe, redis: redis, } } @@ -30,6 +37,7 @@ type Service struct { jobs []srv.Job // coingecko client coingecko coingecko.Client + stripe stripe.Client redis *redis.Pool } @@ -69,12 +77,19 @@ func InitService(ctx context.Context) (context.Context, *Service, error) { return ctx, nil, fmt.Errorf("failed to initialize redis client: %w", err) } - client, err := coingecko.NewWithContext(ctx, redis) + coingecko, err := coingecko.NewWithContext(ctx, redis) if err != nil { logger.Error().Err(err).Msg("failed to initialize the coingecko client") return ctx, nil, fmt.Errorf("failed to initialize coingecko client: %w", err) } - service := NewService(ctx, client, redis) + + stripe, err := stripe.NewWithContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to initialize the stripe client") + return ctx, nil, fmt.Errorf("failed to initialize stripe client: %w", err) + } + + service := NewService(ctx, coingecko, stripe, redis) ctx, err = service.initializeCoingeckoCurrencies(ctx) if err != nil { @@ -264,3 +279,33 @@ func (s *Service) GetCoinMarkets( LastUpdated: updated, }, nil } + +// CreateStripeOnrampSessionsHandler - respond to caller with an onramp URL +func (s *Service) CreateStripeOnrampSessionsHandler( + ctx context.Context, + walletAddress string, + sourceCurrency string, + sourceExchangeAmount string, + destinationNetwork string, + destinationCurrency string, + supportedDestinationNetworks []string, +) (string, error) { + logger := logging.Logger(ctx, "ratios.CreateStripeOnrampSessionsHandler") + payload, err := s.stripe.CreateOnrampSession( + ctx, + "redirect", + walletAddress, + sourceCurrency, + sourceExchangeAmount, + destinationNetwork, + destinationCurrency, + supportedDestinationNetworks, + ) + + if err != nil { + logger.Error().Err(err).Msg("failed to create onramp session with stripe") + return "", fmt.Errorf("error creating onramp session with stripe: %w", err) + } + + return payload.RedirectURL, nil +} From 6164140dc823b61512cd57fa70d4faebc63cc5c1 Mon Sep 17 00:00:00 2001 From: Pavel Brm <5097196+pavelbrm@users.noreply.github.com> Date: Fri, 9 Jun 2023 22:01:07 +1200 Subject: [PATCH 08/22] Decouple order database operations from the rest (#1825) --- libs/datastore/models.go | 8 + services/grant/cmd/grant.go | 10 +- services/skus/controllers_test.go | 13 +- services/skus/credentials_test.go | 4 +- services/skus/datastore.go | 577 +++++++----------- services/skus/datastore_test.go | 13 +- services/skus/key_test.go | 18 +- services/skus/model/model.go | 285 +++++++++ services/skus/order.go | 199 +----- services/skus/order_test.go | 11 +- services/skus/service.go | 109 ++-- .../skus/storage/repository/order_history.go | 35 ++ .../skus/storage/repository/order_item.go | 81 +++ .../storage/repository/order_item_test.go | 230 +++++++ .../skus/storage/repository/repository.go | 252 ++++++++ .../storage/repository/repository_test.go | 362 +++++++++++ services/skus/vote_test.go | 6 +- 17 files changed, 1589 insertions(+), 624 deletions(-) create mode 100644 services/skus/model/model.go create mode 100644 services/skus/storage/repository/order_history.go create mode 100644 services/skus/storage/repository/order_item.go create mode 100644 services/skus/storage/repository/order_item_test.go create mode 100644 services/skus/storage/repository/repository.go create mode 100644 services/skus/storage/repository/repository_test.go diff --git a/libs/datastore/models.go b/libs/datastore/models.go index ec67e1b07..1176bb901 100644 --- a/libs/datastore/models.go +++ b/libs/datastore/models.go @@ -25,6 +25,14 @@ func (m *Metadata) Scan(value interface{}) error { if !ok { return errors.New("failed to scan Metadata, not byte slice") } + + // BUG: numbers stored in jsonb become float64 when retrieved. + // + // If there was an integer stored as jsonb on a table, + // when fetched, it will appear as float64 in the map. + // + // This is due to how Go treats JSON numbers when the destination is interface{}. + // See docs for [Unmarshal]](https://pkg.go.dev/encoding/json#Unmarshal). return json.Unmarshal(b, &m) } diff --git a/services/grant/cmd/grant.go b/services/grant/cmd/grant.go index 285f866e9..a7bc8db09 100644 --- a/services/grant/cmd/grant.go +++ b/services/grant/cmd/grant.go @@ -29,6 +29,7 @@ import ( "github.com/brave-intl/bat-go/services/grant" "github.com/brave-intl/bat-go/services/promotion" "github.com/brave-intl/bat-go/services/skus" + "github.com/brave-intl/bat-go/services/skus/storage/repository" "github.com/brave-intl/bat-go/services/wallet" sentry "github.com/getsentry/sentry-go" "github.com/go-chi/chi" @@ -380,7 +381,11 @@ func setupRouter(ctx context.Context, logger *zerolog.Logger) (context.Context, // temporarily house batloss events in promotion to avoid widespread conflicts later r.Mount("/v1/wallets", promotion.WalletEventRouter(promotionService)) - skusPG, err := skus.NewPostgres("", true, "skus_db") + skuOrderRepo := repository.NewOrder() + skuOrderItemRepo := repository.NewOrderItem() + skuOrderPayHistRepo := repository.NewOrderPayHistory() + + skusPG, err := skus.NewPostgres(skuOrderRepo, skuOrderItemRepo, skuOrderPayHistRepo, "", true, "skus_db") if err != nil { sentry.CaptureException(err) logger.Panic().Err(err).Msg("Must be able to init postgres connection to start") @@ -414,11 +419,12 @@ func setupRouter(ctx context.Context, logger *zerolog.Logger) (context.Context, r.Mount("/v1/votes", skus.VoteRouter(skusService, middleware.InstrumentHandler)) if os.Getenv("FEATURE_MERCHANT") != "" { - skusDB, err := skus.NewPostgres("", true, "merch_skus_db") + skusDB, err := skus.NewPostgres(skuOrderRepo, skuOrderItemRepo, skuOrderPayHistRepo, "", true, "merch_skus_db") if err != nil { sentry.CaptureException(err) logger.Panic().Err(err).Msg("Must be able to init postgres connection to start") } + skusService, err := skus.InitService(ctx, skusDB, walletService) if err != nil { sentry.CaptureException(err) diff --git a/services/skus/controllers_test.go b/services/skus/controllers_test.go index 2eb527ac4..dc3b1d30b 100644 --- a/services/skus/controllers_test.go +++ b/services/skus/controllers_test.go @@ -47,6 +47,8 @@ import ( uuid "github.com/satori/go.uuid" "github.com/shopspring/decimal" "github.com/stretchr/testify/suite" + + "github.com/brave-intl/bat-go/services/skus/storage/repository" ) var ( @@ -88,7 +90,8 @@ func (suite *ControllersTestSuite) SetupSuite() { retryPolicy = retrypolicy.NoRetry // set this so we fail fast for cbr http requests govalidator.SetFieldsRequiredByDefault(true) - storage, _ := NewPostgres("", false, "") + storage, _ := NewPostgres(repository.NewOrder(), repository.NewOrderItem(), repository.NewOrderPayHistory(), "", false, "") + suite.storage = storage AnonCardC := macaroon.Caveats{ @@ -215,7 +218,7 @@ func (suite *ControllersTestSuite) SetupSuite() { } func (suite *ControllersTestSuite) BeforeTest(sn, tn string) { - pg, err := NewPostgres("", false, "") + pg, err := NewPostgres(repository.NewOrder(), repository.NewOrderItem(), repository.NewOrderPayHistory(), "", false, "") suite.Require().NoError(err, "Failed to get postgres conn") suite.mockCtrl = gomock.NewController(suite.T()) @@ -500,7 +503,7 @@ func (suite *ControllersTestSuite) TestGetMissingOrder() { } func (suite *ControllersTestSuite) TestE2EOrdersGeminiTransactions() { - pg, err := NewPostgres("", false, "") + pg, err := NewPostgres(repository.NewOrder(), repository.NewOrderItem(), repository.NewOrderPayHistory(), "", false, "") suite.Require().NoError(err, "Failed to get postgres conn") service := &Service{ @@ -1309,7 +1312,7 @@ func (suite *ControllersTestSuite) TestDeleteKey() { } func (suite *ControllersTestSuite) TestGetKeys() { - pg, err := NewPostgres("", false, "") + pg, err := NewPostgres(repository.NewOrder(), repository.NewOrderItem(), repository.NewOrderPayHistory(), "", false, "") suite.Require().NoError(err, "Failed to get postgres conn") // Delete transactions so we don't run into any validation errors @@ -1339,7 +1342,7 @@ func (suite *ControllersTestSuite) TestGetKeys() { } func (suite *ControllersTestSuite) TestGetKeysFiltered() { - pg, err := NewPostgres("", false, "") + pg, err := NewPostgres(repository.NewOrder(), repository.NewOrderItem(), repository.NewOrderPayHistory(), "", false, "") suite.Require().NoError(err, "Failed to get postgres conn") // Delete transactions so we don't run into any validation errors diff --git a/services/skus/credentials_test.go b/services/skus/credentials_test.go index 9d7ff3752..da2034d93 100644 --- a/services/skus/credentials_test.go +++ b/services/skus/credentials_test.go @@ -29,6 +29,8 @@ import ( "github.com/segmentio/kafka-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/brave-intl/bat-go/services/skus/storage/repository" ) type CredentialsTestSuite struct { @@ -42,7 +44,7 @@ func TestCredentialsTestSuite(t *testing.T) { func (suite *CredentialsTestSuite) SetupSuite() { skustest.Migrate(suite.T()) - storage, _ := NewPostgres("", false, "") + storage, _ := NewPostgres(repository.NewOrder(), repository.NewOrderItem(), repository.NewOrderPayHistory(), "", false, "") suite.storage = storage } diff --git a/services/skus/datastore.go b/services/skus/datastore.go index a53710ccd..b13585b72 100644 --- a/services/skus/datastore.go +++ b/services/skus/datastore.go @@ -6,24 +6,30 @@ import ( "encoding/json" "errors" "fmt" - "strconv" "time" + // needed for magic migration + _ "github.com/golang-migrate/migrate/v4/source/file" + + "github.com/getsentry/sentry-go" + "github.com/jmoiron/sqlx" + uuid "github.com/satori/go.uuid" "github.com/segmentio/kafka-go" + "github.com/shopspring/decimal" "github.com/brave-intl/bat-go/libs/datastore" "github.com/brave-intl/bat-go/libs/inputs" "github.com/brave-intl/bat-go/libs/jsonutils" "github.com/brave-intl/bat-go/libs/logging" "github.com/brave-intl/bat-go/libs/ptr" - "github.com/getsentry/sentry-go" - "github.com/jmoiron/sqlx" - "github.com/lib/pq" - uuid "github.com/satori/go.uuid" - "github.com/shopspring/decimal" - // needed for magic migration - _ "github.com/golang-migrate/migrate/v4/source/file" + "github.com/brave-intl/bat-go/services/skus/model" +) + +const ( + signingRequestBatchSize = 10 + + errNotFound = model.Error("not found") ) // Datastore abstracts over the underlying datastore @@ -92,9 +98,39 @@ type Datastore interface { ExternalIDExists(context.Context, string) (bool, error) } -const signingRequestBatchSize = 10 +type orderStore interface { + Get(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID) (*model.Order, error) + GetByExternalID(ctx context.Context, dbi sqlx.QueryerContext, extID string) (*model.Order, error) + Create( + ctx context.Context, + dbi sqlx.QueryerContext, + totalPrice decimal.Decimal, + merchantID, status, currency, location string, + paymentMethods *model.Methods, + validFor *time.Duration, + ) (*model.Order, error) + SetLastPaidAt(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, when time.Time) error + SetTrialDays(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID, ndays int64) (*model.Order, error) + SetStatus(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, status string) error + GetTimeBounds(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID) (model.OrderTimeBounds, error) + SetExpiresAt(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, when time.Time) error + UpdateMetadata(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, data datastore.Metadata) error + AppendMetadata(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, key, val string) error + AppendMetadataInt(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, key string, val int) error + GetExpiredStripeCheckoutSessionID(ctx context.Context, dbi sqlx.QueryerContext, orderID uuid.UUID) (string, error) + HasExternalID(ctx context.Context, dbi sqlx.QueryerContext, extID string) (bool, error) + GetMetadata(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID) (datastore.Metadata, error) +} + +type orderItemStore interface { + Get(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID) (*model.OrderItem, error) + FindByOrderID(ctx context.Context, dbi sqlx.QueryerContext, orderID uuid.UUID) ([]model.OrderItem, error) + InsertMany(ctx context.Context, dbi sqlx.ExtContext, items ...model.OrderItem) ([]model.OrderItem, error) +} -var errNotFound = errors.New("not found") +type orderPayHistoryStore interface { + Insert(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, when time.Time) error +} // VoteRecord - how the ac votes are stored in the queue type VoteRecord struct { @@ -109,17 +145,41 @@ type VoteRecord struct { // Postgres is a Datastore wrapper around a postgres database type Postgres struct { datastore.Postgres + + orderRepo orderStore + orderItemRepo orderItemStore + orderPayHistory orderPayHistoryStore } -// NewPostgres creates a new Postgres Datastore -func NewPostgres(databaseURL string, performMigration bool, migrationTrack string, dbStatsPrefix ...string) (Datastore, error) { +// NewPostgres creates a new Postgres Datastore. +func NewPostgres( + orderRepo orderStore, + orderItemRepo orderItemStore, + orderPayHistory orderPayHistoryStore, + databaseURL string, + performMigration bool, + migrationTrack string, + dbStatsPrefix ...string, +) (Datastore, error) { + pg, err := newPostgres(databaseURL, performMigration, migrationTrack, dbStatsPrefix...) + if err != nil { + return nil, err + } + + pg.orderRepo = orderRepo + pg.orderItemRepo = orderItemRepo + pg.orderPayHistory = orderPayHistory + + return &DatastoreWithPrometheus{base: pg, instanceName: "payment_datastore"}, nil +} + +func newPostgres(databaseURL string, performMigration bool, migrationTrack string, dbStatsPrefix ...string) (*Postgres, error) { pg, err := datastore.NewPostgres(databaseURL, performMigration, migrationTrack, dbStatsPrefix...) - if pg != nil { - return &DatastoreWithPrometheus{ - base: &Postgres{*pg}, instanceName: "payment_datastore", - }, err + if err != nil { + return nil, err } - return nil, err + + return &Postgres{Postgres: *pg}, nil } // CreateKey creates an encrypted key in the database based on the merchant @@ -206,7 +266,7 @@ func (pg *Postgres) GetKey(id uuid.UUID, showExpired bool) (*Key, error) { return &key, nil } -// SetOrderTrialDays - set the number of days of free trial for this order +// SetOrderTrialDays sets the number of days of free trial for this order and returns the updated result. func (pg *Postgres) SetOrderTrialDays(ctx context.Context, orderID *uuid.UUID, days int64) (*Order, error) { tx, err := pg.RawDB().BeginTxx(ctx, nil) if err != nil { @@ -214,132 +274,80 @@ func (pg *Postgres) SetOrderTrialDays(ctx context.Context, orderID *uuid.UUID, d } defer pg.RollbackTx(tx) - order := Order{} - - // update the order with the right expires at - err = tx.Get(&order, ` - UPDATE orders - SET - trial_days = $1, - updated_at = now() - WHERE - id = $2 - RETURNING - id, created_at, currency, updated_at, total_price, - merchant_id, location, status, allowed_payment_methods, - metadata, valid_for, last_paid_at, expires_at, trial_days - `, days, orderID) - + result, err := pg.orderRepo.SetTrialDays(ctx, tx, *orderID, days) if err != nil { return nil, fmt.Errorf("failed to execute tx: %w", err) } - foundOrderItems := []OrderItem{} - statement := ` - SELECT id, order_id, sku, created_at, updated_at, currency, quantity, price, (quantity * price) as subtotal, location, description, credential_type,metadata, valid_for_iso - FROM order_items WHERE order_id = $1` - err = tx.Select(&foundOrderItems, statement, orderID) - - order.Items = foundOrderItems + result.Items, err = pg.orderItemRepo.FindByOrderID(ctx, tx, *orderID) if err != nil { return nil, err } - return &order, tx.Commit() + if err := tx.Commit(); err != nil { + return nil, err + } + + return result, nil } -// CreateOrder creates orders given the total price, merchant ID, status and items of the order +// CreateOrder creates an order with the given total price, merchant ID, status and orderItems. func (pg *Postgres) CreateOrder(totalPrice decimal.Decimal, merchantID, status, currency, location string, validFor *time.Duration, orderItems []OrderItem, allowedPaymentMethods *Methods) (*Order, error) { - tx := pg.RawDB().MustBegin() + tx, err := pg.RawDB().Beginx() + if err != nil { + return nil, err + } + defer pg.RollbackTx(tx) - var order Order - err := tx.Get(&order, ` - INSERT INTO orders (total_price, merchant_id, status, currency, location, allowed_payment_methods, valid_for) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, created_at, currency, updated_at, total_price, merchant_id, location, status, allowed_payment_methods, valid_for - `, - totalPrice, merchantID, status, currency, location, pq.Array(*allowedPaymentMethods), validFor) + ctx := context.TODO() + result, err := pg.orderRepo.Create(ctx, tx, totalPrice, merchantID, status, currency, location, allowedPaymentMethods, validFor) if err != nil { return nil, err } if status == OrderStatusPaid { - // record the order payment - if err := recordOrderPayment(context.Background(), tx, order.ID, time.Now()); err != nil { + if err := pg.recordOrderPayment(ctx, tx, result.ID, time.Now()); err != nil { return nil, fmt.Errorf("failed to record order payment: %w", err) } } - // TODO: We should make a generalized helper to handle bulk inserts - query := ` - insert into order_items - (order_id, sku, quantity, price, currency, subtotal, location, description, credential_type, metadata, valid_for, valid_for_iso, issuance_interval) - values ` - params := []interface{}{} - for i := 0; i < len(orderItems); i++ { - // put all our params together - params = append(params, - order.ID, orderItems[i].SKU, orderItems[i].Quantity, - orderItems[i].Price, orderItems[i].Currency, orderItems[i].Subtotal, - orderItems[i].Location, orderItems[i].Description, - orderItems[i].CredentialType, orderItems[i].Metadata, orderItems[i].ValidFor, - orderItems[i].ValidForISO, - orderItems[i].IssuanceIntervalISO, - ) - numFields := 13 // the number of fields you are inserting - n := i * numFields + model.OrderItemList(orderItems).SetOrderID(result.ID) - query += `(` - for j := 0; j < numFields; j++ { - query += `$` + strconv.Itoa(n+j+1) + `,` - } - query = query[:len(query)-1] + `),` - } - query = query[:len(query)-1] // remove the trailing comma - query += ` RETURNING id, order_id, sku, created_at, updated_at, currency, quantity, price, location, description, credential_type, (quantity * price) as subtotal, metadata, valid_for` - - order.Items = []OrderItem{} - - err = tx.Select(&order.Items, query, params...) + result.Items, err = pg.orderItemRepo.InsertMany(ctx, tx, orderItems...) if err != nil { return nil, err } - err = tx.Commit() - if err != nil { + + if err := tx.Commit(); err != nil { return nil, err } - return &order, nil + return result, nil } -// GetOrderByExternalID by the external id from the purchase vendor +// GetOrderByExternalID returns an order by the external id from the purchase vendor. func (pg *Postgres) GetOrderByExternalID(externalID string) (*Order, error) { - statement := ` - SELECT - id, created_at, currency, updated_at, total_price, - merchant_id, location, status, allowed_payment_methods, - metadata, valid_for, last_paid_at, expires_at, trial_days - FROM orders WHERE metadata->>'externalID' = $1` - order := Order{} - err := pg.RawDB().Get(&order, statement, externalID) - if err == sql.ErrNoRows { - return nil, nil - } else if err != nil { + ctx := context.TODO() + dbi := pg.RawDB() + + result, err := pg.orderRepo.GetByExternalID(ctx, dbi, externalID) + if err != nil { + // Preserve the legacy behaviour. + // TODO: Propagate the sentinel error, and handle in the business logic properly. + if errors.Is(err, model.ErrOrderNotFound) { + return nil, nil + } + return nil, err } - foundOrderItems := []OrderItem{} - statement = ` - SELECT id, order_id, sku, created_at, updated_at, currency, quantity, price, (quantity * price) as subtotal, location, description, credential_type,metadata, valid_for_iso, issuance_interval - FROM order_items WHERE order_id = $1` - err = pg.RawDB().Select(&foundOrderItems, statement, order.ID) - - order.Items = foundOrderItems + result.Items, err = pg.orderItemRepo.FindByOrderID(ctx, dbi, result.ID) if err != nil { return nil, err } - return &order, nil + + return result, nil } // GetOutboxMovAvgDurationSeconds - get the number of seconds it takes to clear the last 20 outbox messages @@ -361,46 +369,46 @@ func (pg *Postgres) GetOutboxMovAvgDurationSeconds() (int64, error) { return seconds, nil } -// GetOrder queries the database and returns an order +// GetOrder returns an order from the database. func (pg *Postgres) GetOrder(orderID uuid.UUID) (*Order, error) { - statement := ` - SELECT - id, created_at, currency, updated_at, total_price, - merchant_id, location, status, allowed_payment_methods, - metadata, valid_for, last_paid_at, expires_at, trial_days - FROM orders WHERE id = $1` - order := Order{} - err := pg.RawDB().Get(&order, statement, orderID) - if err == sql.ErrNoRows { - return nil, nil - } else if err != nil { + ctx := context.TODO() + dbi := pg.RawDB() + + result, err := pg.orderRepo.Get(ctx, dbi, orderID) + if err != nil { + // Preserve the legacy behaviour. + // TODO: Propagate the sentinel error, and handle in the business logic properly. + if errors.Is(err, model.ErrOrderNotFound) { + return nil, nil + } + return nil, err } - foundOrderItems := []OrderItem{} - statement = ` - SELECT id, order_id, sku, created_at, updated_at, currency, quantity, price, (quantity * price) as subtotal, location, description, credential_type,metadata, valid_for_iso, issuance_interval - FROM order_items WHERE order_id = $1` - err = pg.RawDB().Select(&foundOrderItems, statement, orderID) - - order.Items = foundOrderItems + result.Items, err = pg.orderItemRepo.FindByOrderID(ctx, dbi, orderID) if err != nil { return nil, err } - return &order, nil + + return result, nil } // GetOrderItem retrieves the order item for the given identifier. -// This function will return sql.ErrNoRows if the result set is empty. +// +// It returns sql.ErrNoRows if the item is not found. func (pg *Postgres) GetOrderItem(ctx context.Context, itemID uuid.UUID) (*OrderItem, error) { - var orderItem OrderItem - err := pg.GetContext(ctx, &orderItem, `SELECT id, order_id, sku, created_at, updated_at, currency, quantity, price, - (quantity * price) as subtotal, location, description, credential_type,metadata, valid_for_iso, issuance_interval - from order_items where id = $1`, itemID) + result, err := pg.orderItemRepo.Get(ctx, pg.RawDB(), itemID) if err != nil { + // Preserve the legacy behaviour. + // TODO: Propagate the sentinel error, and handle in the business logic properly. + if errors.Is(err, model.ErrOrderItemNotFound) { + return nil, sql.ErrNoRows + } + return nil, err } - return &orderItem, nil + + return result, nil } // GetPagedMerchantTransactions - get a paginated list of transactions for a merchant @@ -504,215 +512,78 @@ func (pg *Postgres) GetTransaction(externalTransactionID string) (*Transaction, return &transaction, nil } -// CheckExpiredCheckoutSession - check order metadata for an expired checkout session id +// CheckExpiredCheckoutSession indicates whether a Stripe checkout session is expired with its id for the given orderID. +// +// TODO(pavelb): The boolean return value is unnecessary, and can be removed. +// If there is experied session, the session id is present. +// If there is no session, or it has not expired, the result is the same – no session id. +// It's the caller's responsibility (the business logic layer) to interpret the result. func (pg *Postgres) CheckExpiredCheckoutSession(orderID uuid.UUID) (bool, string, error) { - var ( - // can be nil in db - checkoutSession *string - err error - ) + ctx := context.TODO() - err = pg.RawDB().Get(&checkoutSession, ` - SELECT metadata->>'stripeCheckoutSessionId' as checkout_session - FROM orders - WHERE id = $1 - AND metadata is not null - AND status='pending' - AND updated_at>'externalID' = $1 AND metadata is not null - `, externalID) - - if errors.Is(err, sql.ErrNoRows) { - return false, nil - } - - return ok, err + return pg.orderRepo.HasExternalID(ctx, pg.RawDB(), externalID) } -// IsStripeSub - is this order related to a stripe subscription, if so, true, subscription id returned +// IsStripeSub reports whether the order is associated with a stripe subscription, if true, subscription id is returned. +// +// TODO(pavelb): This is a piece of business logic that leaked to the storage layer. +// Also, it unsuccessfully mimics the Go comma, ok idiom – bool and string should be swapped. +// But that's not necessary. +// If metadata was found, but there was no stripeSubscriptionId, it's known not to be a Stripe order. func (pg *Postgres) IsStripeSub(orderID uuid.UUID) (bool, string, error) { - var ( - ok bool - md datastore.Metadata - err error - ) - - err = pg.RawDB().Get(&md, ` - SELECT metadata - FROM orders - WHERE id = $1 AND metadata is not null - `, orderID) - - if err == nil { - if v, ok := md["stripeSubscriptionId"].(string); ok { - return ok, v, err - } - } - return ok, "", err -} - -// UpdateOrderExpiresAt - set the expires_at attribute of the order, based on now (or last paid_at if exists) and valid_for from db -func (pg *Postgres) updateOrderExpiresAt(ctx context.Context, tx *sqlx.Tx, orderID uuid.UUID) error { - if tx == nil { - return fmt.Errorf("need to pass in tx to update order expiry") - } - - // how long should the order be valid for? - var orderTimeBounds = struct { - ValidFor *time.Duration `db:"valid_for"` - LastPaid sql.NullTime `db:"last_paid_at"` - }{} - - err := tx.GetContext(ctx, &orderTimeBounds, ` - SELECT valid_for, last_paid_at - FROM orders - WHERE id = $1 - `, orderID) - if err != nil { - return fmt.Errorf("unable to get order time bounds: %w", err) - } - - // default to last paid now - lastPaid := time.Now() - - // if there is a valid last paid, use that from the order - if orderTimeBounds.LastPaid.Valid { - lastPaid = orderTimeBounds.LastPaid.Time - } - - var expiresAt time.Time - - if orderTimeBounds.ValidFor != nil { - // compute expiry based on valid for - expiresAt = lastPaid.Add(*orderTimeBounds.ValidFor) - } - - // update the order with the right expires at - result, err := tx.ExecContext(ctx, ` - UPDATE orders - SET - updated_at = CURRENT_TIMESTAMP, - expires_at = $1 - WHERE - id = $2 - `, expiresAt, orderID) - - if err != nil { - return err - } - - rowsAffected, err := result.RowsAffected() - if rowsAffected == 0 || err != nil { - return errors.New("no rows updated") - } - - return nil -} - -func recordOrderPayment(ctx context.Context, tx *sqlx.Tx, id uuid.UUID, t time.Time) error { - - // record the order payment - // on renewal and initial payment - result, err := tx.ExecContext(ctx, ` - INSERT INTO order_payment_history - (order_id, last_paid) - VALUES - ( $1, $2 ) - `, id, t) + ctx := context.TODO() + data, err := pg.orderRepo.GetMetadata(ctx, pg.RawDB(), orderID) if err != nil { - return err + return false, "", err } - rowsAffected, err := result.RowsAffected() - if rowsAffected == 0 || err != nil { - return errors.New("no rows updated") - } + sid, ok := data["stripeSubscriptionId"].(string) - if err != nil { - return err - } - - // record on order as well - result, err = tx.ExecContext(ctx, ` - update orders set last_paid_at = $1 - where id = $2 - `, t, id) - - if err != nil { - return err - } - - rowsAffected, err = result.RowsAffected() - if rowsAffected == 0 || err != nil { - return errors.New("no rows updated") - } - - if err != nil { - return err - } - return nil + return ok, sid, nil } // UpdateOrder updates the orders status. // -// Status should either be one of pending, paid, fulfilled, or canceled. +// Status should either be one of pending, paid, fulfilled, or canceled. +// +// TODO: rename it to better reflect the behaviour. func (pg *Postgres) UpdateOrder(orderID uuid.UUID, status string) error { ctx := context.Background() - // create tx + tx, err := pg.RawDB().BeginTxx(ctx, nil) if err != nil { return err } defer pg.RollbackTx(tx) - result, err := tx.Exec(`UPDATE orders set status = $1, updated_at = CURRENT_TIMESTAMP where id = $2`, status, orderID) - - if err != nil { + if err := pg.orderRepo.SetStatus(ctx, tx, orderID, status); err != nil { return err } - rowsAffected, err := result.RowsAffected() - if rowsAffected == 0 || err != nil { - return errors.New("no rows updated") - } - if status == OrderStatusPaid { - // record the order payment - if err := recordOrderPayment(ctx, tx, orderID, time.Now()); err != nil { + if err := pg.recordOrderPayment(ctx, tx, orderID, time.Now()); err != nil { return fmt.Errorf("failed to record order payment: %w", err) } - // set the expires at value - err = pg.updateOrderExpiresAt(ctx, tx, orderID) - if err != nil { + if err := pg.updateOrderExpiresAt(ctx, tx, orderID); err != nil { return fmt.Errorf("failed to set order expires_at: %w", err) } } @@ -1082,27 +953,11 @@ func (pg *Postgres) InsertVote(ctx context.Context, vr VoteRecord) error { return nil } -// UpdateOrderMetadata sets a key value pair to an order's metadata +// UpdateOrderMetadata sets the order's metadata to the key and value. func (pg *Postgres) UpdateOrderMetadata(orderID uuid.UUID, key string, value string) error { - // create order - om := datastore.Metadata{ - key: value, - } - - stmt := `update orders set metadata = $1, updated_at = current_timestamp where id = $2` + data := datastore.Metadata{key: value} - result, err := pg.RawDB().Exec(stmt, om, orderID.String()) - - if err != nil { - return err - } - - rowsAffected, err := result.RowsAffected() - if rowsAffected == 0 || err != nil { - return errors.New("No rows updated") - } - - return nil + return pg.orderRepo.UpdateMetadata(context.TODO(), pg.RawDB(), orderID, data) } // TimeLimitedV2Creds represent all the @@ -1239,7 +1094,7 @@ type SigningOrderRequestOutbox struct { func (pg *Postgres) GetSigningOrderRequestOutboxByOrder(ctx context.Context, orderID uuid.UUID) ([]SigningOrderRequestOutbox, error) { var signingRequestOutbox []SigningOrderRequestOutbox err := pg.RawDB().SelectContext(ctx, &signingRequestOutbox, - `select request_id, order_id, item_id, completed_at, message_data + `select request_id, order_id, item_id, completed_at, message_data from signing_order_request_outbox where order_id = $1`, orderID) if err != nil { return nil, fmt.Errorf("error retrieving signing request from outbox: %w", err) @@ -1252,7 +1107,7 @@ func (pg *Postgres) GetSigningOrderRequestOutboxByOrder(ctx context.Context, ord func (pg *Postgres) GetSigningOrderRequestOutboxByOrderItem(ctx context.Context, itemID uuid.UUID) ([]SigningOrderRequestOutbox, error) { var signingRequestOutbox []SigningOrderRequestOutbox err := pg.RawDB().SelectContext(ctx, &signingRequestOutbox, - `select request_id, order_id, item_id, completed_at, message_data + `select request_id, order_id, item_id, completed_at, message_data from signing_order_request_outbox where item_id = $1`, itemID) if err != nil { return nil, fmt.Errorf("error retrieving signing requests from outbox: %w", err) @@ -1265,7 +1120,7 @@ func (pg *Postgres) GetSigningOrderRequestOutboxByOrderItem(ctx context.Context, func (pg *Postgres) GetSigningOrderRequestOutboxByRequestID(ctx context.Context, requestID uuid.UUID) (*SigningOrderRequestOutbox, error) { var signingRequestOutbox SigningOrderRequestOutbox err := pg.RawDB().GetContext(ctx, &signingRequestOutbox, - `select request_id, order_id, item_id, completed_at, message_data + `select request_id, order_id, item_id, completed_at, message_data from signing_order_request_outbox where request_id = $1`, requestID) if err != nil { return nil, fmt.Errorf("error retrieving signing request from outbox: %w", err) @@ -1278,7 +1133,7 @@ func (pg *Postgres) GetSigningOrderRequestOutboxByRequestID(ctx context.Context, func (pg *Postgres) GetSigningOrderRequestOutboxByRequestIDTx(ctx context.Context, tx *sqlx.Tx, requestID uuid.UUID) (*SigningOrderRequestOutbox, error) { var signingRequestOutbox SigningOrderRequestOutbox err := tx.GetContext(ctx, &signingRequestOutbox, - `select request_id, order_id, item_id, completed_at, message_data + `select request_id, order_id, item_id, completed_at, message_data from signing_order_request_outbox where request_id = $1 for update`, requestID) if err != nil { return nil, fmt.Errorf("error retrieving signing request from outbox: %w", err) @@ -1305,7 +1160,7 @@ func (pg *Postgres) InsertSigningOrderRequestOutbox(ctx context.Context, request return fmt.Errorf("error marshalling signing order request: %w", err) } - _, err = pg.ExecContext(ctx, `insert into signing_order_request_outbox(request_id, order_id, item_id, message_data) + _, err = pg.ExecContext(ctx, `insert into signing_order_request_outbox(request_id, order_id, item_id, message_data) values ($1, $2, $3, $4)`, requestID, orderID, itemID, message) if err != nil { return fmt.Errorf("error inserting order request outbox row: %w", err) @@ -1335,8 +1190,8 @@ func (pg *Postgres) SendSigningRequest(ctx context.Context, signingRequestWriter defer rollback() var soro []SigningOrderRequestOutbox - err = tx.SelectContext(ctx, &soro, `select request_id, order_id, item_id, message_data from signing_order_request_outbox - where submitted_at is null order by created_at asc + err = tx.SelectContext(ctx, &soro, `select request_id, order_id, item_id, message_data from signing_order_request_outbox + where submitted_at is null order by created_at asc for update skip locked limit $1`, signingRequestBatchSize) if err != nil { return fmt.Errorf("error could not get signing order request outbox: %w", err) @@ -1374,7 +1229,7 @@ func (pg *Postgres) SendSigningRequest(ctx context.Context, signingRequestWriter soroIDs[i] = soro[i].RequestID } - qry, args, err := sqlx.In(`update signing_order_request_outbox + qry, args, err := sqlx.In(`update signing_order_request_outbox set submitted_at = now() where request_id IN (?)`, soroIDs) if err != nil { return fmt.Errorf("error creating sql update statement: %w", err) @@ -1518,80 +1373,72 @@ func (pg *Postgres) InsertSignedOrderCredentialsTx(ctx context.Context, tx *sqlx return nil } -// AppendOrderMetadataInt appends a key value pair to an order's metadata +// AppendOrderMetadataInt appends the key and int value to an order's metadata. func (pg *Postgres) AppendOrderMetadataInt(ctx context.Context, orderID *uuid.UUID, key string, value int) error { - // get the db tx from context if exists, if not create it _, tx, rollback, commit, err := datastore.GetTx(ctx, pg) - defer rollback() if err != nil { return err } - stmt := `update orders set metadata = coalesce(metadata||jsonb_build_object($1::text, $2::integer), metadata, jsonb_build_object($1::text, $2::integer)), updated_at = current_timestamp where id = $3` + defer rollback() - result, err := tx.Exec(stmt, key, value, orderID.String()) - if err != nil { + if err := pg.orderRepo.AppendMetadataInt(ctx, tx, *orderID, key, value); err != nil { return fmt.Errorf("error updating order metadata %s: %w", orderID, err) } - rowsAffected, err := result.RowsAffected() - if rowsAffected == 0 || err != nil { - return errors.New("no rows updated") - } - return commit() } -// AppendOrderMetadata appends a key value pair to an order's metadata +// AppendOrderMetadata appends the key and string value to an order's metadata. func (pg *Postgres) AppendOrderMetadata(ctx context.Context, orderID *uuid.UUID, key string, value string) error { - // get the db tx from context if exists, if not create it _, tx, rollback, commit, err := datastore.GetTx(ctx, pg) - defer rollback() if err != nil { return err } - stmt := `update orders set metadata = coalesce(metadata||jsonb_build_object($1::text, $2::text), metadata, jsonb_build_object($1::text, $2::text)), updated_at = current_timestamp where id = $3` + defer rollback() - result, err := tx.Exec(stmt, key, value, orderID.String()) - if err != nil { + if err := pg.orderRepo.AppendMetadata(ctx, tx, *orderID, key, value); err != nil { return fmt.Errorf("error updating order metadata %s: %w", orderID, err) } - rowsAffected, err := result.RowsAffected() - if rowsAffected == 0 || err != nil { - return errors.New("no rows updated") - } - return commit() } -// SetOrderPaid - set the order as paid +// SetOrderPaid sets status to paid for the order, updates last paid and expiration. func (pg *Postgres) SetOrderPaid(ctx context.Context, orderID *uuid.UUID) error { _, tx, rollback, commit, err := datastore.GetTx(ctx, pg) - defer rollback() // doesnt hurt to rollback incase we panic if err != nil { return fmt.Errorf("failed to get db transaction: %w", err) } + defer rollback() - result, err := tx.Exec(`UPDATE orders set status = $1, updated_at = CURRENT_TIMESTAMP where id = $2`, OrderStatusPaid, *orderID) - if err != nil { + if err := pg.orderRepo.SetStatus(ctx, tx, *orderID, OrderStatusPaid); err != nil { return fmt.Errorf("error updating order %s: %w", orderID, err) } - rowsAffected, err := result.RowsAffected() - if rowsAffected == 0 || err != nil { - return errors.New("no rows updated") - } - - // record the order payment - if err := recordOrderPayment(ctx, tx, *orderID, time.Now()); err != nil { + if err := pg.recordOrderPayment(ctx, tx, *orderID, time.Now()); err != nil { return fmt.Errorf("failed to record order payment: %w", err) } - // set the expires at value - err = pg.updateOrderExpiresAt(ctx, tx, *orderID) - if err != nil { + if err := pg.updateOrderExpiresAt(ctx, tx, *orderID); err != nil { return fmt.Errorf("failed to set order expires_at: %w", err) } return commit() } + +func (pg *Postgres) recordOrderPayment(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, when time.Time) error { + if err := pg.orderPayHistory.Insert(ctx, dbi, id, when); err != nil { + return err + } + + return pg.orderRepo.SetLastPaidAt(ctx, dbi, id, when) +} + +func (pg *Postgres) updateOrderExpiresAt(ctx context.Context, dbi sqlx.ExtContext, orderID uuid.UUID) error { + orderTimeBounds, err := pg.orderRepo.GetTimeBounds(ctx, dbi, orderID) + if err != nil { + return fmt.Errorf("unable to get order time bounds: %w", err) + } + + return pg.orderRepo.SetExpiresAt(ctx, dbi, orderID, orderTimeBounds.ExpiresAt()) +} diff --git a/services/skus/datastore_test.go b/services/skus/datastore_test.go index e4b98e9a5..4fb58497f 100644 --- a/services/skus/datastore_test.go +++ b/services/skus/datastore_test.go @@ -24,6 +24,8 @@ import ( "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + + "github.com/brave-intl/bat-go/services/skus/storage/repository" ) type PostgresTestSuite struct { @@ -37,7 +39,7 @@ func TestPostgresTestSuite(t *testing.T) { func (suite *PostgresTestSuite) SetupSuite() { skustest.Migrate(suite.T()) - storage, _ := NewPostgres("", false, "") + storage, _ := NewPostgres(repository.NewOrder(), repository.NewOrderItem(), repository.NewOrderPayHistory(), "", false, "") suite.storage = storage } @@ -59,8 +61,13 @@ func TestGetPagedMerchantTransactions(t *testing.T) { } } }() - // inject our mock db into our postgres - pg := &Postgres{Postgres: datastore.Postgres{DB: sqlx.NewDb(mockDB, "sqlmock")}} + + pg := &Postgres{ + Postgres: datastore.Postgres{DB: sqlx.NewDb(mockDB, "sqlmock")}, + orderRepo: repository.NewOrder(), + orderItemRepo: repository.NewOrderItem(), + orderPayHistory: repository.NewOrderPayHistory(), + } // setup inputs merchantID := uuid.NewV4() diff --git a/services/skus/key_test.go b/services/skus/key_test.go index 84e0f586f..3827b6f8f 100644 --- a/services/skus/key_test.go +++ b/services/skus/key_test.go @@ -13,13 +13,15 @@ import ( "time" sqlmock "github.com/DATA-DOG/go-sqlmock" + "github.com/jmoiron/sqlx" + uuid "github.com/satori/go.uuid" + "github.com/stretchr/testify/assert" + "github.com/brave-intl/bat-go/libs/cryptography" "github.com/brave-intl/bat-go/libs/datastore" "github.com/brave-intl/bat-go/libs/httpsignature" "github.com/brave-intl/bat-go/libs/middleware" - "github.com/jmoiron/sqlx" - uuid "github.com/satori/go.uuid" - "github.com/stretchr/testify/assert" + "github.com/brave-intl/bat-go/services/skus/storage/repository" ) func TestGenerateSecret(t *testing.T) { @@ -127,9 +129,12 @@ func TestMerchantSignedMiddleware(t *testing.T) { service := Service{} service.Datastore = Datastore( &Postgres{ - datastore.Postgres{ + Postgres: datastore.Postgres{ DB: sqlx.NewDb(db, "postgres"), }, + orderRepo: repository.NewOrder(), + orderItemRepo: repository.NewOrderItem(), + orderPayHistory: repository.NewOrderPayHistory(), }, ) @@ -252,9 +257,12 @@ func TestValidateOrderMerchantAndCaveats(t *testing.T) { service := Service{} service.Datastore = Datastore( &Postgres{ - datastore.Postgres{ + Postgres: datastore.Postgres{ DB: sqlx.NewDb(db, "postgres"), }, + orderRepo: repository.NewOrder(), + orderItemRepo: repository.NewOrderItem(), + orderPayHistory: repository.NewOrderPayHistory(), }, ) expectedOrderID := uuid.NewV4() diff --git a/services/skus/model/model.go b/services/skus/model/model.go new file mode 100644 index 000000000..a9c1cd053 --- /dev/null +++ b/services/skus/model/model.go @@ -0,0 +1,285 @@ +// Package model provides data that the SKUs service operates on. +package model + +import ( + "database/sql" + "database/sql/driver" + "fmt" + "reflect" + "sort" + "strings" + "time" + + "github.com/lib/pq" + uuid "github.com/satori/go.uuid" + "github.com/shopspring/decimal" + "github.com/stripe/stripe-go/v72" + "github.com/stripe/stripe-go/v72/checkout/session" + "github.com/stripe/stripe-go/v72/customer" + + "github.com/brave-intl/bat-go/libs/datastore" +) + +const ( + ErrOrderNotFound Error = "model: order not found" + ErrOrderItemNotFound Error = "model: order item not found" + ErrNoRowsChangedOrder Error = "model: no rows changed in orders" + ErrNoRowsChangedOrderPayHistory Error = "model: no rows changed in order_payment_history" + ErrExpiredStripeCheckoutSessionIDNotFound Error = "model: expired stripeCheckoutSessionId not found" +) + +const ( + StripePaymentMethod = "stripe" + + // OrderStatus* represent order statuses at runtime and in db. + OrderStatusCanceled = "canceled" + OrderStatusPaid = "paid" + OrderStatusPending = "pending" +) + +var ( + emptyCreateCheckoutSessionResp CreateCheckoutSessionResponse + emptyOrderTimeBounds OrderTimeBounds +) + +// Order represents an individual order. +type Order struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + Currency string `json:"currency" db:"currency"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + TotalPrice decimal.Decimal `json:"totalPrice" db:"total_price"` + MerchantID string `json:"merchantId" db:"merchant_id"` + Location datastore.NullString `json:"location" db:"location"` + Status string `json:"status" db:"status"` + Items []OrderItem `json:"items"` + AllowedPaymentMethods Methods `json:"allowedPaymentMethods" db:"allowed_payment_methods"` + Metadata datastore.Metadata `json:"metadata" db:"metadata"` + LastPaidAt *time.Time `json:"lastPaidAt" db:"last_paid_at"` + ExpiresAt *time.Time `json:"expiresAt" db:"expires_at"` + ValidFor *time.Duration `json:"validFor" db:"valid_for"` + TrialDays *int64 `json:"-" db:"trial_days"` +} + +// IsStripePayable returns true if every item is payable by Stripe. +func (o *Order) IsStripePayable() bool { + // TODO: if not we need to look into subscription trials: + // -> https://stripe.com/docs/billing/subscriptions/trials + + return strings.Contains(strings.Join(o.AllowedPaymentMethods, ","), StripePaymentMethod) +} + +// CreateStripeCheckoutSession creats a Stripe checkout session for the order. +func (o *Order) CreateStripeCheckoutSession( + email, successURI, cancelURI string, + freeTrialDays int64, +) (CreateCheckoutSessionResponse, error) { + var custID string + if email != "" { + // find the existing customer by email + // so we can use the customer id instead of a customer email + i := customer.List(&stripe.CustomerListParams{ + Email: stripe.String(email), + }) + + for i.Next() { + custID = i.Customer().ID + } + } + + sd := &stripe.CheckoutSessionSubscriptionDataParams{} + // If a free trial is set, apply it. + if freeTrialDays > 0 { + sd.TrialPeriodDays = &freeTrialDays + } + + params := &stripe.CheckoutSessionParams{ + PaymentMethodTypes: stripe.StringSlice([]string{ + "card", + }), + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), + SuccessURL: stripe.String(successURI), + CancelURL: stripe.String(cancelURI), + ClientReferenceID: stripe.String(o.ID.String()), + SubscriptionData: sd, + LineItems: OrderItemList(o.Items).stripeLineItems(), + } + + if custID != "" { + // try to use existing customer we found by email + params.Customer = stripe.String(custID) + } else if email != "" { + // if we dont have an existing customer, this CustomerEmail param will create a new one + params.CustomerEmail = stripe.String(email) + } + // else we have no record of this email for this checkout session + // the user will be asked for the email, we cannot send an empty customer email as a param + + params.SubscriptionData.AddMetadata("orderID", o.ID.String()) + params.AddExtra("allow_promotion_codes", "true") + + session, err := session.New(params) + if err != nil { + return EmptyCreateCheckoutSessionResponse(), fmt.Errorf("failed to create stripe session: %w", err) + } + + return CreateCheckoutSessionResponse{SessionID: session.ID}, nil +} + +// IsPaid returns true if the order is paid. +func (o *Order) IsPaid() bool { + switch o.Status { + case OrderStatusPaid: + // The order is paid if the status is paid. + return true + case OrderStatusCanceled: + // Check to make sure that expires_a is after now, if order is cancelled. + if o.ExpiresAt == nil { + return false + } + + return o.ExpiresAt.After(time.Now()) + default: + return false + } +} + +func (o *Order) GetTrialDays() int64 { + if o.TrialDays == nil { + return 0 + } + + return *o.TrialDays +} + +// OrderItem represents a particular order item. +type OrderItem struct { + ID uuid.UUID `json:"id" db:"id"` + OrderID uuid.UUID `json:"orderId" db:"order_id"` + SKU string `json:"sku" db:"sku"` + CreatedAt *time.Time `json:"createdAt" db:"created_at"` + UpdatedAt *time.Time `json:"updatedAt" db:"updated_at"` + Currency string `json:"currency" db:"currency"` + Quantity int `json:"quantity" db:"quantity"` + Price decimal.Decimal `json:"price" db:"price"` + Subtotal decimal.Decimal `json:"subtotal" db:"subtotal"` + Location datastore.NullString `json:"location" db:"location"` + Description datastore.NullString `json:"description" db:"description"` + CredentialType string `json:"credentialType" db:"credential_type"` + ValidFor *time.Duration `json:"validFor" db:"valid_for"` + ValidForISO *string `json:"validForIso" db:"valid_for_iso"` + EachCredentialValidForISO *string `json:"-" db:"each_credential_valid_for_iso"` + Metadata datastore.Metadata `json:"metadata" db:"metadata"` + IssuanceIntervalISO *string `json:"issuanceInterval" db:"issuance_interval"` +} + +// Methods represents payment methods. +type Methods []string + +// Equal checks if m equals m2. +func (m *Methods) Equal(m2 *Methods) bool { + s1 := []string(*m) + s2 := []string(*m2) + sort.Strings(s1) + sort.Strings(s2) + + return reflect.DeepEqual(s1, s2) +} + +// Scan scans the raw src value into m as JSONStringArray. +func (m *Methods) Scan(src interface{}) error { + var x []sql.NullString + if err := pq.Array(&x).Scan(src); err != nil { + return err + } + + for i := range x { + if x[i].Valid { + *m = append(*m, x[i].String) + } + } + + return nil +} + +// Value satisifies the drive.Valuer interface. +func (m *Methods) Value() (driver.Value, error) { + return pq.Array(m), nil +} + +// CreateCheckoutSessionResponse represents a checkout session response. +type CreateCheckoutSessionResponse struct { + SessionID string `json:"checkoutSessionId"` +} + +func EmptyCreateCheckoutSessionResponse() CreateCheckoutSessionResponse { + return emptyCreateCheckoutSessionResp +} + +type OrderItemList []OrderItem + +func (l OrderItemList) SetOrderID(orderID uuid.UUID) { + for i := range l { + l[i].OrderID = orderID + } +} + +func (l OrderItemList) stripeLineItems() []*stripe.CheckoutSessionLineItemParams { + result := make([]*stripe.CheckoutSessionLineItemParams, 0, len(l)) + + for _, item := range l { + // Obtain the item id from the metadata. + priceID, ok := item.Metadata["stripe_item_id"].(string) + if !ok { + continue + } + + // Assume that the stripe product is embedded in macaroon as metadata + // because a stripe line item is being created. + result = append(result, &stripe.CheckoutSessionLineItemParams{ + Price: stripe.String(priceID), + Quantity: stripe.Int64(int64(item.Quantity)), + }) + } + + return result +} + +type Error string + +func (e Error) Error() string { + return string(e) +} + +type OrderTimeBounds struct { + ValidFor *time.Duration `db:"valid_for"` + LastPaid sql.NullTime `db:"last_paid_at"` +} + +func EmptyOrderTimeBounds() OrderTimeBounds { + return emptyOrderTimeBounds +} + +// ExpiresAt computes expiry time, and uses now if last paid was not set before. +func (x *OrderTimeBounds) ExpiresAt() time.Time { + // Default to last paid now. + return x.ExpiresAtWithFallback(time.Now()) +} + +// ExpiresAtWithFallback computes expiry time, and uses fallback for last paid, if it was not set before. +func (x *OrderTimeBounds) ExpiresAtWithFallback(fallback time.Time) time.Time { + // Default to fallback. + // Use valid last paid from order, if available. + lastPaid := fallback + if x.LastPaid.Valid { + lastPaid = x.LastPaid.Time + } + + var expiresAt time.Time + if x.ValidFor != nil { + // Compute expiry based on valid for. + expiresAt = lastPaid.Add(*x.ValidFor) + } + + return expiresAt +} diff --git a/services/skus/order.go b/services/skus/order.go index 3bbf591b5..6968bc107 100644 --- a/services/skus/order.go +++ b/services/skus/order.go @@ -2,27 +2,21 @@ package skus import ( "context" - "database/sql" - "database/sql/driver" "encoding/json" "errors" "fmt" - "reflect" - "sort" "strconv" "strings" "time" - "github.com/brave-intl/bat-go/libs/datastore" "github.com/brave-intl/bat-go/libs/logging" timeutils "github.com/brave-intl/bat-go/libs/time" - "github.com/lib/pq" uuid "github.com/satori/go.uuid" "github.com/shopspring/decimal" "github.com/stripe/stripe-go/v72" - "github.com/stripe/stripe-go/v72/checkout/session" - "github.com/stripe/stripe-go/v72/customer" "gopkg.in/macaroon.v2" + + "github.com/brave-intl/bat-go/services/skus/model" ) const ( @@ -33,9 +27,10 @@ const ( AndroidPaymentMethod = "android" ) -//StripePaymentMethod - the label for stripe payment method const ( - StripePaymentMethod = "stripe" + // TODO(pavelb): Gradually replace it everywhere. + StripePaymentMethod = model.StripePaymentMethod + StripeInvoiceUpdated = "invoice.updated" StripeInvoicePaid = "invoice.paid" StripeCustomerSubscriptionDeleted = "customer.subscription.deleted" @@ -46,86 +41,15 @@ var ( ErrInvalidSKU = errors.New("Invalid SKU Token provided in request") ) -// Methods type is a string slice holding payments -type Methods []string - -// Equal - check equality -func (pm *Methods) Equal(b *Methods) bool { - s1 := []string(*pm) - s2 := []string(*b) - sort.Strings(s1) - sort.Strings(s2) - return reflect.DeepEqual(s1, s2) -} - -// Scan the src sql type into the passed JSONStringArray -func (pm *Methods) Scan(src interface{}) error { - var x []sql.NullString - var v = pq.Array(&x) - - if err := v.Scan(src); err != nil { - return err - } - for i := 0; i < len(x); i++ { - if x[i].Valid { - *pm = append(*pm, x[i].String) - } - } +// TODO(pavelb): Gradually replace it everywhere. - return nil -} - -// Value the driver.Value representation -func (pm *Methods) Value() (driver.Value, error) { - return pq.Array(pm), nil -} +type Methods = model.Methods -// Order includes information about a particular order -type Order struct { - ID uuid.UUID `json:"id" db:"id"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - Currency string `json:"currency" db:"currency"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - TotalPrice decimal.Decimal `json:"totalPrice" db:"total_price"` - MerchantID string `json:"merchantId" db:"merchant_id"` - Location datastore.NullString `json:"location" db:"location"` - Status string `json:"status" db:"status"` - Items []OrderItem `json:"items"` - AllowedPaymentMethods Methods `json:"allowedPaymentMethods" db:"allowed_payment_methods"` - Metadata datastore.Metadata `json:"metadata" db:"metadata"` - LastPaidAt *time.Time `json:"lastPaidAt" db:"last_paid_at"` - ExpiresAt *time.Time `json:"expiresAt" db:"expires_at"` - ValidFor *time.Duration `json:"validFor" db:"valid_for"` - TrialDays *int64 `json:"-" db:"trial_days"` -} +type Order = model.Order -func (order *Order) getTrialDays() int64 { - if order.TrialDays == nil { - return 0 - } - return *order.TrialDays -} +type OrderItem = model.OrderItem -// OrderItem includes information about a particular order item -type OrderItem struct { - ID uuid.UUID `json:"id" db:"id"` - OrderID uuid.UUID `json:"orderId" db:"order_id"` - SKU string `json:"sku" db:"sku"` - CreatedAt *time.Time `json:"createdAt" db:"created_at"` - UpdatedAt *time.Time `json:"updatedAt" db:"updated_at"` - Currency string `json:"currency" db:"currency"` - Quantity int `json:"quantity" db:"quantity"` - Price decimal.Decimal `json:"price" db:"price"` - Subtotal decimal.Decimal `json:"subtotal" db:"subtotal"` - Location datastore.NullString `json:"location" db:"location"` - Description datastore.NullString `json:"description" db:"description"` - CredentialType string `json:"credentialType" db:"credential_type"` - ValidFor *time.Duration `json:"validFor" db:"valid_for"` - ValidForISO *string `json:"validForIso" db:"valid_for_iso"` - EachCredentialValidForISO *string `json:"-" db:"each_credential_valid_for_iso"` - Metadata datastore.Metadata `json:"metadata" db:"metadata"` - IssuanceIntervalISO *string `json:"issuanceInterval" db:"issuance_interval"` -} +type CreateCheckoutSessionResponse = model.CreateCheckoutSessionResponse func decodeAndUnmarshalSku(sku string) (*macaroon.Macaroon, error) { macBytes, err := macaroon.Base64Decode([]byte(sku)) @@ -268,18 +192,6 @@ func (s *Service) CreateOrderItemFromMacaroon(ctx context.Context, sku string, q return &orderItem, allowedPaymentMethods, issuerConfig, nil } -// IsStripePayable returns true if every item is payable by Stripe -func (order Order) IsStripePayable() bool { - // TODO: if not we need to look into subscription trials: - /// -> https://stripe.com/docs/billing/subscriptions/trials - return strings.Contains(strings.Join(order.AllowedPaymentMethods, ","), StripePaymentMethod) -} - -// CreateCheckoutSessionResponse - the structure of a checkout session response -type CreateCheckoutSessionResponse struct { - SessionID string `json:"checkoutSessionId"` -} - func getEmailFromCheckoutSession(stripeSession *stripe.CheckoutSession) string { // has an existing checkout session var email string @@ -298,97 +210,6 @@ func getEmailFromCheckoutSession(stripeSession *stripe.CheckoutSession) string { return email } -// CreateStripeCheckoutSession - Create a Stripe Checkout Session for an Order -func (order Order) CreateStripeCheckoutSession(email, successURI, cancelURI string, freeTrialDays int64) (CreateCheckoutSessionResponse, error) { - - var custID string - - if email != "" { - // find the existing customer by email - // so we can use the customer id instead of a customer email - i := customer.List(&stripe.CustomerListParams{ - Email: stripe.String(email), - }) - - for i.Next() { - custID = i.Customer().ID - } - } - - var sd = &stripe.CheckoutSessionSubscriptionDataParams{} - - // if a free trial is set, apply it - if freeTrialDays > 0 { - sd.TrialPeriodDays = &freeTrialDays - } - - params := &stripe.CheckoutSessionParams{ - PaymentMethodTypes: stripe.StringSlice([]string{ - "card", - }), - Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), - SuccessURL: stripe.String(successURI), - CancelURL: stripe.String(cancelURI), - ClientReferenceID: stripe.String(order.ID.String()), - SubscriptionData: sd, - LineItems: order.CreateStripeLineItems(), - } - - if custID != "" { - // try to use existing customer we found by email - params.Customer = stripe.String(custID) - } else if email != "" { - // if we dont have an existing customer, this CustomerEmail param will create a new one - params.CustomerEmail = stripe.String(email) - } - // else we have no record of this email for this checkout session - // the user will be asked for the email, we cannot send an empty customer email as a param - - params.SubscriptionData.AddMetadata("orderID", order.ID.String()) - params.AddExtra("allow_promotion_codes", "true") - session, err := session.New(params) - if err != nil { - return CreateCheckoutSessionResponse{}, fmt.Errorf("failed to create stripe session: %w", err) - } - - data := CreateCheckoutSessionResponse{ - SessionID: session.ID, - } - return data, nil -} - -// CreateStripeLineItems - create line items for a checkout session with stripe -func (order Order) CreateStripeLineItems() []*stripe.CheckoutSessionLineItemParams { - lineItems := make([]*stripe.CheckoutSessionLineItemParams, len(order.Items)) - for index, item := range order.Items { - // get the item id from the metadata - priceID, ok := item.Metadata["stripe_item_id"].(string) - if !ok { - continue - } - // since we are creating stripe line item, we can assume - // that the stripe product is embedded in macaroon as metadata - lineItems[index] = &stripe.CheckoutSessionLineItemParams{ - Price: stripe.String(priceID), - Quantity: stripe.Int64(int64(item.Quantity)), - } - } - return lineItems -} - -// IsPaid returns true if the order is paid -func (order Order) IsPaid() bool { - // if the order status is paid it is paid. - // if the order is cancelled, check to make sure that expires at is after now - if order.Status == OrderStatusPaid { - return true - } else if order.Status == OrderStatusCanceled && order.ExpiresAt != nil { - expires := *order.ExpiresAt - return expires.After(time.Now()) - } - return false -} - // RenewOrder updates the orders status to paid and paid at time, inserts record of this order // Status should either be one of pending, paid, fulfilled, or canceled. func (s *Service) RenewOrder(ctx context.Context, orderID uuid.UUID) error { diff --git a/services/skus/order_test.go b/services/skus/order_test.go index 01e7424e1..2b3905d21 100644 --- a/services/skus/order_test.go +++ b/services/skus/order_test.go @@ -9,13 +9,14 @@ import ( "strings" "testing" - "github.com/brave-intl/bat-go/libs/test" - "github.com/asaskevich/govalidator" + "github.com/stretchr/testify/suite" + appctx "github.com/brave-intl/bat-go/libs/context" "github.com/brave-intl/bat-go/libs/cryptography" + "github.com/brave-intl/bat-go/libs/test" + "github.com/brave-intl/bat-go/services/skus/storage/repository" macarooncmd "github.com/brave-intl/bat-go/tools/macaroon/cmd" - "github.com/stretchr/testify/suite" ) type OrderTestSuite struct { @@ -29,7 +30,7 @@ func TestOrderTestSuite(t *testing.T) { func (suite *OrderTestSuite) SetupSuite() { govalidator.SetFieldsRequiredByDefault(true) - pg, err := NewPostgres("", false, "") + pg, err := NewPostgres(repository.NewOrder(), repository.NewOrderItem(), repository.NewOrderPayHistory(), "", false, "") suite.Require().NoError(err, "Failed to get postgres conn") m, err := pg.NewMigrate() @@ -59,7 +60,7 @@ func (suite *OrderTestSuite) TearDownTest() { func (suite *OrderTestSuite) CleanDB() { tables := []string{"api_keys"} - pg, err := NewPostgres("", false, "") + pg, err := NewPostgres(repository.NewOrder(), repository.NewOrderItem(), repository.NewOrderPayHistory(), "", false, "") suite.Require().NoError(err, "Failed to get postgres conn") for _, table := range tables { diff --git a/services/skus/service.go b/services/skus/service.go index 3629a6ae4..b598976ec 100644 --- a/services/skus/service.go +++ b/services/skus/service.go @@ -2,7 +2,6 @@ package skus import ( "context" - "database/sql" "encoding/base64" "encoding/json" "errors" @@ -14,37 +13,36 @@ import ( "sync" "time" - "github.com/getsentry/sentry-go" - "github.com/asaskevich/govalidator" - "github.com/brave-intl/bat-go/libs/backoff" - "github.com/awa/go-iap/appstore" + "github.com/brave-intl/bat-go/libs/backoff" + "github.com/getsentry/sentry-go" + "github.com/linkedin/goavro" + uuid "github.com/satori/go.uuid" + "github.com/segmentio/kafka-go" + "github.com/shopspring/decimal" + "github.com/stripe/stripe-go/v72" + "github.com/stripe/stripe-go/v72/checkout/session" + "github.com/stripe/stripe-go/v72/client" + "github.com/stripe/stripe-go/v72/sub" + "github.com/brave-intl/bat-go/libs/clients/cbr" + "github.com/brave-intl/bat-go/libs/clients/gemini" + appctx "github.com/brave-intl/bat-go/libs/context" "github.com/brave-intl/bat-go/libs/cryptography" "github.com/brave-intl/bat-go/libs/datastore" + errorutils "github.com/brave-intl/bat-go/libs/errors" "github.com/brave-intl/bat-go/libs/handlers" + kafkautils "github.com/brave-intl/bat-go/libs/kafka" "github.com/brave-intl/bat-go/libs/logging" srv "github.com/brave-intl/bat-go/libs/service" timeutils "github.com/brave-intl/bat-go/libs/time" + walletutils "github.com/brave-intl/bat-go/libs/wallet" "github.com/brave-intl/bat-go/libs/wallet/provider" "github.com/brave-intl/bat-go/libs/wallet/provider/uphold" "github.com/brave-intl/bat-go/services/wallet" - "github.com/linkedin/goavro" - "github.com/brave-intl/bat-go/libs/clients/cbr" - "github.com/brave-intl/bat-go/libs/clients/gemini" - appctx "github.com/brave-intl/bat-go/libs/context" - errorutils "github.com/brave-intl/bat-go/libs/errors" - kafkautils "github.com/brave-intl/bat-go/libs/kafka" - walletutils "github.com/brave-intl/bat-go/libs/wallet" - uuid "github.com/satori/go.uuid" - "github.com/segmentio/kafka-go" - "github.com/shopspring/decimal" - "github.com/stripe/stripe-go/v72" - "github.com/stripe/stripe-go/v72/checkout/session" - "github.com/stripe/stripe-go/v72/client" - "github.com/stripe/stripe-go/v72/sub" + "github.com/brave-intl/bat-go/services/skus/model" ) var ( @@ -64,12 +62,14 @@ var ( ) const ( + // TODO(pavelb): Gradually replace it everywhere. + // // OrderStatusCanceled - string literal used in db for canceled status - OrderStatusCanceled = "canceled" + OrderStatusCanceled = model.OrderStatusCanceled // OrderStatusPaid - string literal used in db for canceled status - OrderStatusPaid = "paid" + OrderStatusPaid = model.OrderStatusPaid // OrderStatusPending - string literal used in db for pending status - OrderStatusPending = "pending" + OrderStatusPending = model.OrderStatusPending ) // Default issuer V3 config default values @@ -343,7 +343,7 @@ func (s *Service) CreateOrderFromRequest(ctx context.Context, req CreateOrderReq req.Email, parseURLAddOrderIDParam(stripeSuccessURI, order.ID), parseURLAddOrderIDParam(stripeCancelURI, order.ID), - order.getTrialDays(), + order.GetTrialDays(), ) if err != nil { return nil, fmt.Errorf("failed to create checkout session: %w", err) @@ -393,9 +393,8 @@ func (s *Service) GetOrder(orderID uuid.UUID) (*Order, error) { } -// TransformStripeOrder - update checkout session if expired, check the status of the checkout session +// TransformStripeOrder updates checkout session if expired, checks the status of the checkout session. func (s *Service) TransformStripeOrder(order *Order) (*Order, error) { - ctx := context.Background() // check if this order has an expired checkout session @@ -414,7 +413,7 @@ func (s *Service) TransformStripeOrder(order *Order) (*Order, error) { checkoutSession, err := order.CreateStripeCheckoutSession( getEmailFromCheckoutSession(stripeSession), stripeSession.SuccessURL, stripeSession.CancelURL, - order.getTrialDays(), + order.GetTrialDays(), ) if err != nil { return nil, fmt.Errorf("failed to create checkout session: %w", err) @@ -446,6 +445,9 @@ func (s *Service) TransformStripeOrder(order *Order) (*Order, error) { if err != nil { return nil, fmt.Errorf("failed to update order to add the subscription id") } + + // TODO(pavelb): Duplicate calls. Remove one. + // set paymentProcessor as stripe err = s.Datastore.AppendOrderMetadata(context.Background(), &order.ID, paymentProcessor, StripePaymentMethod) if err != nil { @@ -469,35 +471,46 @@ func (s *Service) TransformStripeOrder(order *Order) (*Order, error) { return order, nil } -// CancelOrder - cancels an order, propagates to stripe if needed +// CancelOrder cancels an order, propagates to stripe if needed. +// +// TODO(pavelb): Refactor and make it precise. +// Currently, this method does something weird for the case when the order was not found in the DB. +// If we have an order id, but ended up without the order, that means either the id is wrong, +// or we somehow lost data. The latter is less likely. +// Yet we allow non-existing order ids to be searched for in Stripe, which is strange. func (s *Service) CancelOrder(orderID uuid.UUID) error { - // check the order, do we have a stripe subscription? + // Check the order, do we have a stripe subscription? ok, subID, err := s.Datastore.IsStripeSub(orderID) - if err != nil && err != sql.ErrNoRows { + if err != nil && !errors.Is(err, model.ErrOrderNotFound) { return fmt.Errorf("failed to check stripe subscription: %w", err) } + if ok && subID != "" { - // cancel the stripe subscription + // Cancel the stripe subscription. if _, err := sub.Cancel(subID, nil); err != nil { return fmt.Errorf("failed to cancel stripe subscription: %w", err) } - } else { - // last ditch, ask stripe if we can find one - params := &stripe.SubscriptionSearchParams{} - params.Query = *stripe.String(fmt.Sprintf( - "status:'active' AND metadata['orderID']:'%s'", - orderID.String())) // orderID is already checked as uuid - iter := sub.Search(params) - for iter.Next() { - // we have a result, fix the stripe sub on the db record, and then cancel sub - subscription := iter.Subscription() - // cancel the stripe subscription - if _, err := sub.Cancel(subscription.ID, nil); err != nil { - return fmt.Errorf("failed to cancel stripe subscription: %w", err) - } - if err := s.Datastore.AppendOrderMetadata(context.Background(), &orderID, "stripeSubscriptionId", subscription.ID); err != nil { - return fmt.Errorf("failed to update order metadata with subscription id: %w", err) - } + + return s.Datastore.UpdateOrder(orderID, OrderStatusCanceled) + } + + // Try to find order in Stripe. + params := &stripe.SubscriptionSearchParams{} + params.Query = *stripe.String(fmt.Sprintf( + "status:'active' AND metadata['orderID']:'%s'", + orderID.String(), // orderID is already checked as uuid + )) + + iter := sub.Search(params) + for iter.Next() { + // we have a result, fix the stripe sub on the db record, and then cancel sub + subscription := iter.Subscription() + // cancel the stripe subscription + if _, err := sub.Cancel(subscription.ID, nil); err != nil { + return fmt.Errorf("failed to cancel stripe subscription: %w", err) + } + if err := s.Datastore.AppendOrderMetadata(context.Background(), &orderID, "stripeSubscriptionId", subscription.ID); err != nil { + return fmt.Errorf("failed to update order metadata with subscription id: %w", err) } } @@ -527,7 +540,7 @@ func (s *Service) SetOrderTrialDays(ctx context.Context, orderID *uuid.UUID, day checkoutSession, err := order.CreateStripeCheckoutSession( getEmailFromCheckoutSession(stripeSession), stripeSession.SuccessURL, stripeSession.CancelURL, - order.getTrialDays(), + order.GetTrialDays(), ) if err != nil { return fmt.Errorf("failed to create checkout session: %w", err) diff --git a/services/skus/storage/repository/order_history.go b/services/skus/storage/repository/order_history.go new file mode 100644 index 000000000..63dd3eba8 --- /dev/null +++ b/services/skus/storage/repository/order_history.go @@ -0,0 +1,35 @@ +package repository + +import ( + "context" + "time" + + "github.com/jmoiron/sqlx" + uuid "github.com/satori/go.uuid" + + "github.com/brave-intl/bat-go/services/skus/model" +) + +type OrderPayHistory struct{} + +func NewOrderPayHistory() *OrderPayHistory { return &OrderPayHistory{} } + +func (r *OrderPayHistory) Insert(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, when time.Time) error { + const q = `INSERT INTO order_payment_history (order_id, last_paid) VALUES ($1, $2)` + + result, err := dbi.ExecContext(ctx, q, id, when) + if err != nil { + return err + } + + numAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if numAffected == 0 { + return model.ErrNoRowsChangedOrderPayHistory + } + + return nil +} diff --git a/services/skus/storage/repository/order_item.go b/services/skus/storage/repository/order_item.go new file mode 100644 index 000000000..d6a706ffb --- /dev/null +++ b/services/skus/storage/repository/order_item.go @@ -0,0 +1,81 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + + "github.com/jmoiron/sqlx" + uuid "github.com/satori/go.uuid" + + "github.com/brave-intl/bat-go/services/skus/model" +) + +type OrderItem struct{} + +func NewOrderItem() *OrderItem { return &OrderItem{} } + +// Get retrieves the order item by the given id. +func (r *OrderItem) Get(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID) (*model.OrderItem, error) { + const q = ` + SELECT + id, order_id, sku, created_at, updated_at, currency, + quantity, price, (quantity * price) as subtotal, + location, description, credential_type,metadata, valid_for_iso, issuance_interval + FROM order_items WHERE id = $1` + + result := &model.OrderItem{} + if err := sqlx.GetContext(ctx, dbi, result, q, id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, model.ErrOrderItemNotFound + } + + return nil, err + } + + return result, nil +} + +// FindByOrderID returns order items for the given orderID. +func (r *OrderItem) FindByOrderID(ctx context.Context, dbi sqlx.QueryerContext, orderID uuid.UUID) ([]model.OrderItem, error) { + const q = ` + SELECT + id, order_id, sku, created_at, updated_at, currency, + quantity, price, (quantity * price) as subtotal, + location, description, credential_type, metadata, valid_for_iso, issuance_interval + FROM order_items WHERE order_id = $1` + + result := make([]model.OrderItem, 0) + if err := sqlx.SelectContext(ctx, dbi, &result, q, orderID); err != nil { + return nil, err + } + + return result, nil +} + +// InsertMany inserts given items and returns the result. +func (r *OrderItem) InsertMany(ctx context.Context, dbi sqlx.ExtContext, items ...model.OrderItem) ([]model.OrderItem, error) { + if len(items) == 0 { + return []model.OrderItem{}, nil + } + + const q = ` + INSERT INTO order_items ( + order_id, sku, quantity, price, currency, subtotal, location, description, credential_type, metadata, valid_for, valid_for_iso, issuance_interval + ) VALUES ( + :order_id, :sku, :quantity, :price, :currency, :subtotal, :location, :description, :credential_type, :metadata, :valid_for, :valid_for_iso, :issuance_interval + ) RETURNING id, order_id, sku, created_at, updated_at, currency, quantity, price, location, description, credential_type, (quantity * price) as subtotal, metadata, valid_for` + + rows, err := sqlx.NamedQueryContext(ctx, dbi, q, items) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + result := make([]model.OrderItem, 0, len(items)) + if err := sqlx.StructScan(rows, &result); err != nil { + return nil, err + } + + return result, nil +} diff --git a/services/skus/storage/repository/order_item_test.go b/services/skus/storage/repository/order_item_test.go new file mode 100644 index 000000000..f0d06facd --- /dev/null +++ b/services/skus/storage/repository/order_item_test.go @@ -0,0 +1,230 @@ +//go:build integration + +package repository_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/jmoiron/sqlx" + uuid "github.com/satori/go.uuid" + "github.com/shopspring/decimal" + should "github.com/stretchr/testify/assert" + must "github.com/stretchr/testify/require" + + "github.com/brave-intl/bat-go/libs/datastore" + + "github.com/brave-intl/bat-go/services/skus/model" + "github.com/brave-intl/bat-go/services/skus/storage/repository" +) + +func TestOrderItem_InsertMany(t *testing.T) { + dbi, err := setupDBI() + must.Equal(t, nil, err) + + defer func() { + _, _ = dbi.Exec("TRUNCATE_TABLE order_items, orders;") + }() + + type testCase struct { + name string + given []model.OrderItem + exp []model.OrderItem + } + + tests := []testCase{ + { + name: "empty_input", + exp: []model.OrderItem{}, + }, + + { + name: "one_item", + given: []model.OrderItem{ + { + SKU: "sku_01_01", + Quantity: 1, + Price: mustDecimalFromString("2"), + Currency: "USD", + Subtotal: mustDecimalFromString("2"), + CredentialType: "something", + }, + }, + + exp: []model.OrderItem{ + { + SKU: "sku_01_01", + Quantity: 1, + Price: mustDecimalFromString("2"), + Currency: "USD", + Subtotal: mustDecimalFromString("2"), + CredentialType: "something", + }, + }, + }, + + { + name: "two_items", + given: []model.OrderItem{ + { + SKU: "sku_02_01", + Quantity: 2, + Price: mustDecimalFromString("3"), + Currency: "USD", + Subtotal: mustDecimalFromString("6"), + CredentialType: "something", + }, + + { + SKU: "sku_02_02", + Quantity: 3, + Price: mustDecimalFromString("4"), + Currency: "USD", + Subtotal: mustDecimalFromString("12"), + CredentialType: "something", + }, + }, + + exp: []model.OrderItem{ + { + SKU: "sku_02_01", + Quantity: 2, + Price: mustDecimalFromString("3"), + Currency: "USD", + Subtotal: mustDecimalFromString("6"), + CredentialType: "something", + }, + + { + SKU: "sku_02_02", + Quantity: 3, + Price: mustDecimalFromString("4"), + Currency: "USD", + Subtotal: mustDecimalFromString("12"), + CredentialType: "something", + }, + }, + }, + } + + orepo := repository.NewOrder() + iorepo := repository.NewOrderItem() + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + ctx := context.TODO() + + tx, err := dbi.BeginTxx(ctx, &sql.TxOptions{Isolation: sql.LevelReadUncommitted}) + must.Equal(t, nil, err) + + t.Cleanup(func() { _ = tx.Rollback() }) + + order, err := createOrderForTest(ctx, tx, orepo) + must.Equal(t, nil, err) + + model.OrderItemList(tc.given).SetOrderID(order.ID) + + actual, err := iorepo.InsertMany(ctx, tx, tc.given...) + must.Equal(t, nil, err) + + must.Equal(t, len(tc.exp), len(actual)) + + // Check each item manually as ids are generated. + for j := range tc.exp { + should.NotEqual(t, uuid.Nil, actual[j].ID) + should.Equal(t, order.ID, actual[j].OrderID) + should.Equal(t, tc.exp[j].SKU, actual[j].SKU) + should.Equal(t, tc.exp[j].Quantity, actual[j].Quantity) + should.Equal(t, tc.exp[j].Price.String(), actual[j].Price.String()) + should.Equal(t, tc.exp[j].Currency, actual[j].Currency) + should.Equal(t, tc.exp[j].Subtotal.String(), actual[j].Subtotal.String()) + should.Equal(t, tc.exp[j].CredentialType, actual[j].CredentialType) + } + }) + } +} + +func setupDBI() (*sqlx.DB, error) { + pg, err := datastore.NewPostgres("", false, "") + if err != nil { + return nil, err + } + + mg, err := pg.NewMigrate() + if err != nil { + return nil, err + } + + ver, dirty, err := mg.Version() + if err != nil { + return nil, err + } + + if dirty { + if err := mg.Force(int(ver)); err != nil { + return nil, err + } + } + + if ver > 0 { + if err := mg.Down(); err != nil { + return nil, err + } + } + + if err := pg.Migrate(); err != nil { + return nil, err + } + + return pg.RawDB(), nil +} + +type orderCreator interface { + Create( + ctx context.Context, + dbi sqlx.QueryerContext, + totalPrice decimal.Decimal, + merchantID, status, currency, location string, + paymentMethods *model.Methods, + validFor *time.Duration, + ) (*model.Order, error) +} + +func createOrderForTest(ctx context.Context, dbi sqlx.QueryerContext, repo orderCreator) (*model.Order, error) { + price, err := decimal.NewFromString("187") + if err != nil { + return nil, err + } + + methods := model.Methods{"stripe"} + + result, err := repo.Create( + ctx, + dbi, + price, + "brave.com", + "pending", + "USD", + "somelocation", + &methods, + nil, + ) + if err != nil { + return nil, err + } + + return result, nil +} + +func mustDecimalFromString(v string) decimal.Decimal { + result, err := decimal.NewFromString(v) + if err != nil { + panic(err) + } + + return result +} diff --git a/services/skus/storage/repository/repository.go b/services/skus/storage/repository/repository.go new file mode 100644 index 000000000..4a300341f --- /dev/null +++ b/services/skus/storage/repository/repository.go @@ -0,0 +1,252 @@ +// Package repository provides access to data available in SQL-based data store. +package repository + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/jmoiron/sqlx" + "github.com/lib/pq" + uuid "github.com/satori/go.uuid" + "github.com/shopspring/decimal" + + "github.com/brave-intl/bat-go/libs/datastore" + + "github.com/brave-intl/bat-go/services/skus/model" +) + +type Order struct{} + +func NewOrder() *Order { return &Order{} } + +// Get retrieves the order for the given id. +func (r *Order) Get(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID) (*model.Order, error) { + const q = `SELECT + id, created_at, currency, updated_at, total_price, + merchant_id, location, status, allowed_payment_methods, + metadata, valid_for, last_paid_at, expires_at, trial_days + FROM orders WHERE id = $1` + + result := &model.Order{} + if err := sqlx.GetContext(ctx, dbi, result, q, id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, model.ErrOrderNotFound + } + + return nil, err + } + + return result, nil +} + +// GetByExternalID retrieves the order by extID in metadata.externalID. +func (r *Order) GetByExternalID(ctx context.Context, dbi sqlx.QueryerContext, extID string) (*model.Order, error) { + const q = `SELECT + id, created_at, currency, updated_at, total_price, + merchant_id, location, status, allowed_payment_methods, + metadata, valid_for, last_paid_at, expires_at, trial_days + FROM orders WHERE metadata->>'externalID' = $1` + + result := &model.Order{} + if err := sqlx.GetContext(ctx, dbi, result, q, extID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, model.ErrOrderNotFound + } + + return nil, err + } + + return result, nil +} + +// Create creates an order with the given inputs. +func (r *Order) Create( + ctx context.Context, + dbi sqlx.QueryerContext, + totalPrice decimal.Decimal, + merchantID, status, currency, location string, + paymentMethods *model.Methods, + validFor *time.Duration, +) (*model.Order, error) { + const q = `INSERT INTO orders + (total_price, merchant_id, status, currency, location, allowed_payment_methods, valid_for) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, created_at, currency, updated_at, total_price, merchant_id, location, status, allowed_payment_methods, valid_for` + + result := &model.Order{} + if err := dbi.QueryRowxContext( + ctx, + q, + totalPrice, + merchantID, + status, + currency, + location, + pq.Array(*paymentMethods), + validFor, + ).StructScan(result); err != nil { + return nil, err + } + + return result, nil +} + +// SetLastPaidAt sets last_paid_at to when. +func (r *Order) SetLastPaidAt(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, when time.Time) error { + const q = `UPDATE orders SET last_paid_at = $2 WHERE id = $1` + + return r.execUpdate(ctx, dbi, q, id, when) +} + +// SetTrialDays sets trial_days to ndays. +func (r *Order) SetTrialDays(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID, ndays int64) (*model.Order, error) { + const q = `UPDATE orders + SET trial_days = $2, updated_at = now() + WHERE id = $1 + RETURNING id, created_at, currency, updated_at, total_price, merchant_id, location, status, allowed_payment_methods, metadata, valid_for, last_paid_at, expires_at, trial_days` + + result := &model.Order{} + if err := dbi.QueryRowxContext(ctx, q, id, ndays).StructScan(result); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, model.ErrOrderNotFound + } + + return nil, err + } + + return result, nil +} + +// SetStatus sets status to status. +func (r *Order) SetStatus(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, status string) error { + const q = `UPDATE orders SET status = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1` + + return r.execUpdate(ctx, dbi, q, id, status) +} + +// GetTimeBounds returns valid_for and last_paid_at for the order. +func (r *Order) GetTimeBounds(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID) (model.OrderTimeBounds, error) { + const q = `SELECT valid_for, last_paid_at FROM orders WHERE id = $1` + + var result model.OrderTimeBounds + if err := sqlx.GetContext(ctx, dbi, &result, q, id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return model.EmptyOrderTimeBounds(), model.ErrOrderNotFound + } + + return model.EmptyOrderTimeBounds(), err + } + + return result, nil +} + +// SetExpiresAt sets expires_at. +func (r *Order) SetExpiresAt(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, when time.Time) error { + const q = `UPDATE orders SET updated_at = CURRENT_TIMESTAMP, expires_at = $2 WHERE id = $1` + + return r.execUpdate(ctx, dbi, q, id, when) +} + +// UpdateMetadata _sets_ metadata to data. +func (r *Order) UpdateMetadata(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, data datastore.Metadata) error { + const q = `UPDATE orders SET metadata = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1` + + return r.execUpdate(ctx, dbi, q, id, data) +} + +// AppendMetadata sets value by key to order's metadata, and might create metadata if it was missing. +func (r *Order) AppendMetadata(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, key, val string) error { + const q = `UPDATE orders + SET metadata = COALESCE(metadata||jsonb_build_object($2::text, $3::text), metadata, jsonb_build_object($2::text, $3::text)), + updated_at = CURRENT_TIMESTAMP WHERE id = $1` + + return r.execUpdate(ctx, dbi, q, id, key, val) +} + +// AppendMetadataInt sets int value by key to order's metadata, and might create metadata if it was missing. +func (r *Order) AppendMetadataInt(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, key string, val int) error { + const q = `UPDATE orders + SET metadata = COALESCE(metadata||jsonb_build_object($2::text, $3::integer), metadata, jsonb_build_object($2::text, $3::integer)), + updated_at = CURRENT_TIMESTAMP where id = $1` + + return r.execUpdate(ctx, dbi, q, id, key, val) +} + +// GetExpiredStripeCheckoutSessionID returns stripeCheckoutSessionId if it's found and expired. +func (r *Order) GetExpiredStripeCheckoutSessionID(ctx context.Context, dbi sqlx.QueryerContext, orderID uuid.UUID) (string, error) { + const q = `SELECT metadata->>'stripeCheckoutSessionId' AS checkout_session + FROM orders + WHERE id = $1 AND metadata IS NOT NULL AND status='pending' AND updated_at>'externalID' = $1 AND metadata IS NOT NULL` + + var result bool + if err := sqlx.GetContext(ctx, dbi, &result, q, extID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + + return false, err + } + + return result, nil +} + +// GetMetadata returns metadata of the order. +func (r *Order) GetMetadata(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID) (datastore.Metadata, error) { + const q = `SELECT metadata + FROM orders + WHERE id = $1 AND metadata IS NOT NULL` + + result := datastore.Metadata{} + if err := sqlx.GetContext(ctx, dbi, &result, q, id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, model.ErrOrderNotFound + } + + return nil, err + } + + return result, nil +} + +func (r *Order) execUpdate(ctx context.Context, dbi sqlx.ExecerContext, q string, args ...interface{}) error { + result, err := dbi.ExecContext(ctx, q, args...) + if err != nil { + return err + } + + numAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if numAffected == 0 { + return model.ErrNoRowsChangedOrder + } + + return nil +} diff --git a/services/skus/storage/repository/repository_test.go b/services/skus/storage/repository/repository_test.go new file mode 100644 index 000000000..62da44ef1 --- /dev/null +++ b/services/skus/storage/repository/repository_test.go @@ -0,0 +1,362 @@ +//go:build integration + +package repository_test + +import ( + "context" + "database/sql" + "errors" + "testing" + + uuid "github.com/satori/go.uuid" + should "github.com/stretchr/testify/assert" + must "github.com/stretchr/testify/require" + + "github.com/brave-intl/bat-go/libs/datastore" + + "github.com/brave-intl/bat-go/services/skus/model" + "github.com/brave-intl/bat-go/services/skus/storage/repository" +) + +func TestOrder_SetTrialDays(t *testing.T) { + dbi, err := setupDBI() + must.Equal(t, nil, err) + + defer func() { + _, _ = dbi.Exec("TRUNCATE_TABLE orders;") + }() + + type tcExpected struct { + ndays int64 + err error + } + + type testCase struct { + name string + given int64 + exp tcExpected + } + + tests := []testCase{ + { + name: "not_found", + exp: tcExpected{ + err: model.ErrOrderNotFound, + }, + }, + + { + name: "no_changes", + }, + + { + name: "updated_value", + given: 4, + exp: tcExpected{ndays: 4}, + }, + } + + repo := repository.NewOrder() + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + ctx := context.TODO() + + tx, err := dbi.BeginTxx(ctx, &sql.TxOptions{Isolation: sql.LevelReadUncommitted}) + must.Equal(t, nil, err) + + t.Cleanup(func() { _ = tx.Rollback() }) + + order, err := createOrderForTest(ctx, tx, repo) + must.Equal(t, nil, err) + + id := order.ID + if tc.exp.err == model.ErrOrderNotFound { + // Use any id for testing the not found case. + id = uuid.NamespaceDNS + } + + actual, err := repo.SetTrialDays(ctx, tx, id, tc.given) + must.Equal(t, true, errors.Is(err, tc.exp.err)) + + if tc.exp.err != nil { + return + } + + should.Equal(t, tc.exp.ndays, actual.GetTrialDays()) + }) + } +} + +func TestOrder_AppendMetadata(t *testing.T) { + dbi, err := setupDBI() + must.Equal(t, nil, err) + + defer func() { + _, _ = dbi.Exec("TRUNCATE_TABLE orders;") + }() + + type tcGiven struct { + data datastore.Metadata + key string + val string + } + + type tcExpected struct { + data datastore.Metadata + err error + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "not_found", + exp: tcExpected{ + err: model.ErrNoRowsChangedOrder, + }, + }, + + { + name: "no_previous_metadata", + given: tcGiven{ + key: "key_01_01", + val: "value_01_01", + }, + exp: tcExpected{ + data: datastore.Metadata{"key_01_01": "value_01_01"}, + }, + }, + + { + name: "no_changes", + given: tcGiven{ + data: datastore.Metadata{"key_02_01": "value_02_01"}, + key: "key_02_01", + val: "value_02_01", + }, + exp: tcExpected{ + data: datastore.Metadata{"key_02_01": "value_02_01"}, + }, + }, + + { + name: "updates_the_only_key", + given: tcGiven{ + data: datastore.Metadata{"key_03_01": "value_03_01"}, + key: "key_03_01", + val: "value_03_01_UPDATED", + }, + exp: tcExpected{ + data: datastore.Metadata{"key_03_01": "value_03_01_UPDATED"}, + }, + }, + + { + name: "updates_one_from_many", + given: tcGiven{ + data: datastore.Metadata{ + "key_04_01": "value_04_01", + "key_04_02": "value_04_02", + "key_04_03": "value_04_03", + }, + key: "key_04_02", + val: "value_04_02_UPDATED", + }, + exp: tcExpected{ + data: datastore.Metadata{ + "key_04_01": "value_04_01", + "key_04_02": "value_04_02_UPDATED", + "key_04_03": "value_04_03", + }, + }, + }, + } + + repo := repository.NewOrder() + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + ctx := context.TODO() + + tx, err := dbi.BeginTxx(ctx, &sql.TxOptions{Isolation: sql.LevelReadUncommitted}) + must.Equal(t, nil, err) + + t.Cleanup(func() { _ = tx.Rollback() }) + + order, err := createOrderForTest(ctx, tx, repo) + must.Equal(t, nil, err) + + id := order.ID + if tc.exp.err == model.ErrNoRowsChangedOrder { + // Use any id for testing the not found case. + id = uuid.NamespaceDNS + } + + if tc.given.data != nil { + err := repo.UpdateMetadata(ctx, tx, id, tc.given.data) + must.Equal(t, nil, err) + } + + { + err := repo.AppendMetadata(ctx, tx, id, tc.given.key, tc.given.val) + must.Equal(t, true, errors.Is(err, tc.exp.err)) + } + + if tc.exp.err != nil { + return + } + + actual, err := repo.Get(ctx, tx, id) + must.Equal(t, nil, err) + + should.Equal(t, tc.exp.data, actual.Metadata) + }) + } +} + +func TestOrder_AppendMetadataInt(t *testing.T) { + dbi, err := setupDBI() + must.Equal(t, nil, err) + + defer func() { + _, _ = dbi.Exec("TRUNCATE_TABLE orders;") + }() + + type tcGiven struct { + data datastore.Metadata + key string + val int + } + + type tcExpected struct { + data datastore.Metadata + err error + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "not_found", + exp: tcExpected{ + err: model.ErrNoRowsChangedOrder, + }, + }, + + { + name: "no_previous_metadata", + given: tcGiven{ + key: "key_01_01", + val: 101, + }, + exp: tcExpected{ + data: datastore.Metadata{"key_01_01": float64(101)}, + }, + }, + + { + name: "no_changes", + given: tcGiven{ + data: datastore.Metadata{"key_02_01": 201}, + key: "key_02_01", + val: 201, + }, + exp: tcExpected{ + data: datastore.Metadata{"key_02_01": float64(201)}, + }, + }, + + { + name: "updates_the_only_key", + given: tcGiven{ + data: datastore.Metadata{"key_03_01": float64(301)}, + key: "key_03_01", + val: 30101, + }, + exp: tcExpected{ + data: datastore.Metadata{"key_03_01": float64(30101)}, + }, + }, + + { + name: "updates_one_from_many", + given: tcGiven{ + data: datastore.Metadata{ + "key_04_01": "key_04_01", + "key_04_02": float64(402), + "key_04_03": float64(403), + }, + key: "key_04_02", + val: 40201, + }, + exp: tcExpected{ + data: datastore.Metadata{ + "key_04_01": "key_04_01", + "key_04_02": float64(40201), + "key_04_03": float64(403), + }, + }, + }, + } + + repo := repository.NewOrder() + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + ctx := context.TODO() + + tx, err := dbi.BeginTxx(ctx, &sql.TxOptions{Isolation: sql.LevelReadUncommitted}) + must.Equal(t, nil, err) + + t.Cleanup(func() { _ = tx.Rollback() }) + + order, err := createOrderForTest(ctx, tx, repo) + must.Equal(t, nil, err) + + id := order.ID + if tc.exp.err == model.ErrNoRowsChangedOrder { + // Use any id for testing the not found case. + id = uuid.NamespaceDNS + } + + if tc.given.data != nil { + err := repo.UpdateMetadata(ctx, tx, id, tc.given.data) + must.Equal(t, nil, err) + } + + { + err := repo.AppendMetadataInt(ctx, tx, id, tc.given.key, tc.given.val) + must.Equal(t, true, errors.Is(err, tc.exp.err)) + } + + if tc.exp.err != nil { + return + } + + actual, err := repo.Get(ctx, tx, id) + must.Equal(t, nil, err) + + // This is currently failing. + // The expectation is that data fetched from the store would be int. + // It, however, is float64. + // + // Temporary defining expectations as float64 so that tests pass. + should.Equal(t, tc.exp.data, actual.Metadata) + }) + } +} diff --git a/services/skus/vote_test.go b/services/skus/vote_test.go index 3de5a6a9d..43912474c 100644 --- a/services/skus/vote_test.go +++ b/services/skus/vote_test.go @@ -14,6 +14,7 @@ import ( "github.com/brave-intl/bat-go/libs/clients/cbr" "github.com/brave-intl/bat-go/libs/datastore" kafkautils "github.com/brave-intl/bat-go/libs/kafka" + "github.com/brave-intl/bat-go/services/skus/storage/repository" ) type BytesContains []byte @@ -59,9 +60,12 @@ func TestVoteAnonCard(t *testing.T) { } s.Datastore = Datastore( &Postgres{ - datastore.Postgres{ + Postgres: datastore.Postgres{ DB: sqlx.NewDb(db, "postgres"), }, + orderRepo: repository.NewOrder(), + orderItemRepo: repository.NewOrderItem(), + orderPayHistory: repository.NewOrderPayHistory(), }, ) From 4a3b5b79971c7d2e6aa233e8b1c52853ddc0c77d Mon Sep 17 00:00:00 2001 From: clD11 <23483715+clD11@users.noreply.github.com> Date: Tue, 20 Jun 2023 16:47:01 +0100 Subject: [PATCH 09/22] payment tool fixes (#1853) * payment tool fixes * payment tool fixes --- tools/payments/client.go | 79 ++++++++++--------- tools/payments/cmd/authorize/main.go | 71 ++++++++++------- tools/payments/cmd/prepare/main.go | 50 ++++++++---- tools/payments/cmd/validate/main.go | 4 +- tools/payments/cryptography.go | 25 +++--- tools/payments/custodian.go | 13 --- tools/payments/report.go | 32 +++++--- .../payments/test/attested-report-s3-download | 1 + .../{redistest => }/test/attested-report.json | 0 .../{redistest => }/test/bootstrap.json | 0 .../payments/{redistest => }/test/private.pem | 0 .../redis}/docker-compose.redis.yml | 0 .../{redistest => }/test/redis/tls/ca.crt | 0 .../{redistest => }/test/redis/tls/ca.key | 0 .../{redistest => }/test/redis/tls/ca.txt | 0 .../test/redis/tls/openssl.cnf | 0 .../{redistest => }/test/redis/tls/redis.crt | 0 .../{redistest => }/test/redis/tls/redis.csr | 0 .../{redistest => }/test/redis/tls/redis.dh | 0 .../{redistest => }/test/redis/tls/redis.key | 0 .../{redistest => }/test/redis/tls/sans.conf | 0 .../{redistest => }/test/redis/tls/users.acl | 0 .../payments/{redistest => }/test/report.json | 9 ++- tools/payments/transaction.go | 21 +++-- 24 files changed, 171 insertions(+), 134 deletions(-) create mode 100644 tools/payments/test/attested-report-s3-download rename tools/payments/{redistest => }/test/attested-report.json (100%) rename tools/payments/{redistest => }/test/bootstrap.json (100%) rename tools/payments/{redistest => }/test/private.pem (100%) rename tools/payments/{redistest => test/redis}/docker-compose.redis.yml (100%) rename tools/payments/{redistest => }/test/redis/tls/ca.crt (100%) rename tools/payments/{redistest => }/test/redis/tls/ca.key (100%) rename tools/payments/{redistest => }/test/redis/tls/ca.txt (100%) rename tools/payments/{redistest => }/test/redis/tls/openssl.cnf (100%) rename tools/payments/{redistest => }/test/redis/tls/redis.crt (100%) rename tools/payments/{redistest => }/test/redis/tls/redis.csr (100%) rename tools/payments/{redistest => }/test/redis/tls/redis.dh (100%) rename tools/payments/{redistest => }/test/redis/tls/redis.key (100%) rename tools/payments/{redistest => }/test/redis/tls/sans.conf (100%) rename tools/payments/{redistest => }/test/redis/tls/users.acl (100%) rename tools/payments/{redistest => }/test/report.json (79%) diff --git a/tools/payments/client.go b/tools/payments/client.go index 7ebfdd2f1..2ba9be152 100644 --- a/tools/payments/client.go +++ b/tools/payments/client.go @@ -3,7 +3,6 @@ package payments import ( "bytes" "context" - "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/json" @@ -21,23 +20,25 @@ import ( const ( preparePrefix = "prepare-" - submitPrefix = "authorize-" + submitPrefix = "submit-" // headers - hostHeader = "Host" - digestHeader = "Digest" + hostHeader = "Host" + digestHeader = "Digest" + // dateHeader needs to be lowercase to pass the signing verifier validation. + dateHeader = "date" contentLengthHeader = "Content-Length" - contentTypeHeader = "Content-type" + contentTypeHeader = "Content-Type" signatureHeader = "Signature" ) var ( payout = strconv.FormatInt(time.Now().Unix(), 10) - prepareStream = preparePrefix + payout - submitStream = preparePrefix + payout + PrepareStream = preparePrefix + payout + SubmitStream = submitPrefix + payout - prepareConfigStream = preparePrefix + "configure" - submitConfigStream = submitPrefix + "configure" + PrepareConfigStream = preparePrefix + "config" + SubmitConfigStream = submitPrefix + "config" ) // redisClient is an implementation of settlement client using clustered redis client @@ -46,9 +47,8 @@ type redisClient struct { redis *redis.ClusterClient } -func newRedisClient(ctx context.Context, env, addrs, pass, username string) (*redisClient, error) { +func newRedisClient(env, addrs, pass, username string) (*redisClient, error) { tlsConfig := &tls.Config{ - ServerName: "redis", MinVersion: tls.VersionTLS12, ClientAuth: 0, } @@ -79,10 +79,6 @@ func newRedisClient(ctx context.Context, env, addrs, pass, username string) (*re TLSConfig: tlsConfig, }), } - err := rc.redis.Ping(ctx).Err() - if err != nil { - return nil, fmt.Errorf("failed to ping redis: %w", err) - } return rc, nil } @@ -99,7 +95,7 @@ func (rc *redisClient) ConfigureWorker(ctx context.Context, stream string, confi Body: string(body), } - _, err := rc.redis.XAdd( + _, err = rc.redis.XAdd( ctx, &redis.XAddArgs{ Stream: stream, Values: map[string]interface{}{ @@ -129,16 +125,19 @@ func (rc *redisClient) PrepareTransactions(ctx context.Context, t ...*PrepareTx) } // add to stream - _, err = pipe.XAdd( + pipe.XAdd( ctx, &redis.XAddArgs{ - Stream: prepareStream, + Stream: PrepareStream, Values: map[string]interface{}{ "data": message}}, - ).Result() - if err != nil { - return fmt.Errorf("failed to prepare transaction: %w", err) - } + ) } + + _, err := pipe.Exec(ctx) + if err != nil { + return fmt.Errorf("failed to exec prepare transaction commands: %w", err) + } + return nil } @@ -147,20 +146,22 @@ func (rc *redisClient) SubmitTransactions(ctx context.Context, signer httpsignat pipe := rc.redis.Pipeline() for _, v := range at { + buf := bytes.NewBuffer([]byte{}) err := json.NewEncoder(buf).Encode(v) body := buf.Bytes() if err != nil { return fmt.Errorf("failed to marshal attested transaction body: %w", err) } - // create a request - req, err := http.NewRequest("POST", rc.env+"/authorize", buf) + + // Create a request and set the headers we require for signing. The Digest header is added + // during the signing call and the request.Host is set during the new request creation so, + // we don't need to explicitly set them here. + req, err := http.NewRequest(http.MethodPost, rc.env+"/v1/payments/submit", buf) if err != nil { return fmt.Errorf("failed to create request to sign: %w", err) } - // we will be signing, need all these headers for it to go through - req.Header.Set(hostHeader, rc.env) - req.Header.Set(digestHeader, fmt.Sprintf("%x", sha256.Sum256(body))) + req.Header.Set(dateHeader, time.Now().Format(time.RFC1123)) req.Header.Set(contentLengthHeader, fmt.Sprintf("%d", len(body))) req.Header.Set(contentTypeHeader, "application/json") @@ -175,7 +176,8 @@ func (rc *redisClient) SubmitTransactions(ctx context.Context, signer httpsignat ID: uuid.New(), Timestamp: time.Now(), Headers: map[string]string{ - hostHeader: req.Header.Get(hostHeader), + hostHeader: req.Host, + dateHeader: req.Header.Get(dateHeader), digestHeader: req.Header.Get(digestHeader), signatureHeader: req.Header.Get(signatureHeader), contentLengthHeader: req.Header.Get(contentLengthHeader), @@ -184,16 +186,19 @@ func (rc *redisClient) SubmitTransactions(ctx context.Context, signer httpsignat Body: string(body), } - _, err = pipe.XAdd( + pipe.XAdd( ctx, &redis.XAddArgs{ - Stream: submitStream, + Stream: SubmitStream, Values: map[string]interface{}{ "data": message}}, - ).Result() - if err != nil { - return fmt.Errorf("failed to submit transaction: %w", err) - } + ) } + + _, err := pipe.Exec(ctx) + if err != nil { + return fmt.Errorf("failed to exec submit transaction commands: %w", err) + } + return nil } @@ -204,7 +209,7 @@ type SettlementClient interface { SubmitTransactions(context.Context, httpsignature.ParameterizedSignator, ...*AttestedTx) error } -// NewSettlementClient instanciates a new SettlementClient for use by tooling -func NewSettlementClient(ctx context.Context, env string, config map[string]string) (SettlementClient, error) { - return newRedisClient(ctx, env, config["addrs"], config["pass"], config["username"]) +// NewSettlementClient instantiates a new SettlementClient for use by tooling +func NewSettlementClient(env string, config map[string]string) (SettlementClient, error) { + return newRedisClient(env, config["addrs"], config["pass"], config["username"]) } diff --git a/tools/payments/cmd/authorize/main.go b/tools/payments/cmd/authorize/main.go index c47193dba..7ecc1992a 100644 --- a/tools/payments/cmd/authorize/main.go +++ b/tools/payments/cmd/authorize/main.go @@ -34,6 +34,7 @@ import ( "os" "github.com/brave-intl/bat-go/tools/payments" + uuid "github.com/satori/go.uuid" ) func main() { @@ -72,45 +73,61 @@ func main() { if *verbose { // print out the configuration log.Printf("Operator Key File Location: %s\n", *key) - log.Printf("Redis: %s, %s\n", redisAddrs, redisUser) + log.Printf("Redis: %s, %s\n", *redisAddrs, *redisUser) } // setup the settlement redis client - client, err := payments.NewSettlementClient(ctx, *env, map[string]string{ + client, err := payments.NewSettlementClient(*env, map[string]string{ "addrs": *redisAddrs, "pass": *redisPass, "username": *redisUser, // client specific configurations }) if err != nil { log.Fatalf("failed to create settlement client: %v\n", err) } - for _, fname := range files { - f, err := os.Open(fname) - if err != nil { - log.Fatalf("failed to open report file: %v\n", err) - } - defer f.Close() - - report := payments.AttestedReport{} - if err := payments.ReadReport(&report, f); err != nil { - log.Fatalf("failed to read report from stdin: %v\n", err) - } - - if *verbose { - log.Printf("report stats: %d transactions; %s total bat\n", - len(report), payments.SumBAT(report...)) - } - - priv, err := payments.GetOperatorPrivateKey(*key) - if err != nil { - log.Fatalf("failed to parse operator key file: %v\n", err) - } - - if err := report.Submit(ctx, priv, client); err != nil { - log.Fatalf("failed to submit report: %v\n", err) - } + wc := &payments.WorkerConfig{ + PayoutID: uuid.NewV4().String(), + ConsumerGroup: payments.SubmitStream + "-cg", + Stream: payments.SubmitStream, + Count: 0, + } + + for _, name := range files { + func() { + f, err := os.Open(name) + if err != nil { + log.Fatalf("failed to open report file: %v\n", err) + } + defer f.Close() + + var report payments.AttestedReport + if err := payments.ReadReport(&report, f); err != nil { + log.Fatalf("failed to read report from stdin: %v\n", err) + } + + wc.Count += len(report) + + if *verbose { + log.Printf("report stats: %d transactions; %s total bat\n", len(report), report.SumBAT()) + } + + priv, err := payments.GetOperatorPrivateKey(*key) + if err != nil { + log.Fatalf("failed to parse operator key file: %v\n", err) + } + + if err := report.Submit(ctx, priv, client); err != nil { + log.Fatalf("failed to submit report: %v\n", err) + } + }() + } + + err = client.ConfigureWorker(ctx, payments.SubmitConfigStream, wc) + if err != nil { + log.Fatalf("failed to write to submit config stream: %v\n", err) } if *verbose { + log.Printf("submit transactions loaded for %+v\n", wc) log.Println("completed report submission") } } diff --git a/tools/payments/cmd/prepare/main.go b/tools/payments/cmd/prepare/main.go index 87728901c..0b50bd335 100644 --- a/tools/payments/cmd/prepare/main.go +++ b/tools/payments/cmd/prepare/main.go @@ -33,6 +33,7 @@ import ( "os" "github.com/brave-intl/bat-go/tools/payments" + uuid "github.com/satori/go.uuid" ) func main() { @@ -69,31 +70,48 @@ func main() { } // setup the settlement redis client - client, err := payments.NewSettlementClient(ctx, *env, map[string]string{ + client, err := payments.NewSettlementClient(*env, map[string]string{ "addrs": *redisAddrs, "pass": *redisPass, "username": *redisUser, // client specific configurations }) if err != nil { log.Fatalf("failed to create settlement client: %v\n", err) } - for _, fname := range files { - f, err := os.Open(fname) - if err != nil { - log.Fatalf("failed to open report file: %v\n", err) - } - defer f.Close() - - report := payments.PreparedReport{} - if err := payments.ReadReport(&report, f); err != nil { - log.Fatalf("failed to read report from stdin: %v\n", err) - } - - if err := report.Prepare(ctx, client); err != nil { - log.Fatalf("failed to read report from stdin: %v\n", err) - } + wc := &payments.WorkerConfig{ + PayoutID: uuid.NewV4().String(), + ConsumerGroup: payments.PrepareStream + "-cg", + Stream: payments.PrepareStream, + Count: 0, + } + + for _, name := range files { + func() { + f, err := os.Open(name) + if err != nil { + log.Fatalf("failed to open report file: %v\n", err) + } + defer f.Close() + + report := payments.PreparedReport{} + if err := payments.ReadReport(&report, f); err != nil { + log.Fatalf("failed to read report from stdin: %v\n", err) + } + + wc.Count += len(report) + + if err := report.Prepare(ctx, client); err != nil { + log.Fatalf("failed to read report from stdin: %v\n", err) + } + }() + } + + err = client.ConfigureWorker(ctx, payments.PrepareConfigStream, wc) + if err != nil { + log.Fatalf("failed to write to prepare config stream: %v\n", err) } if *verbose { + log.Printf("prepare transactions loaded for %+v\n", wc) log.Println("completed report preparation") } } diff --git a/tools/payments/cmd/validate/main.go b/tools/payments/cmd/validate/main.go index 52d340a83..8a38fb674 100644 --- a/tools/payments/cmd/validate/main.go +++ b/tools/payments/cmd/validate/main.go @@ -78,9 +78,9 @@ func main() { if *verbose { log.Printf("attested report stats: %d transactions; %s total bat\n", - len(attestedReport), payments.SumBAT(attestedReport...)) + len(attestedReport), attestedReport.SumBAT()) log.Printf("prepared report stats: %d transactions; %s total bat\n", - len(preparedReport), payments.SumBAT(preparedReport...)) + len(preparedReport), preparedReport.SumBAT()) } // check that the report is actually nitro attested diff --git a/tools/payments/cryptography.go b/tools/payments/cryptography.go index df2812baa..63b4cd53f 100644 --- a/tools/payments/cryptography.go +++ b/tools/payments/cryptography.go @@ -2,21 +2,13 @@ package payments import ( "crypto/ed25519" - "encoding/asn1" + "crypto/x509" "encoding/pem" "fmt" "io" "os" ) -type ed25519PrivKey struct { - Version int - ObjectIdentifier struct { - ObjectIdentifier asn1.ObjectIdentifier - } - PrivateKey []byte -} - // GetOperatorPrivateKey - get the private key from the file specified func GetOperatorPrivateKey(filename string) (ed25519.PrivateKey, error) { f, err := os.Open(filename) @@ -29,13 +21,16 @@ func GetOperatorPrivateKey(filename string) (ed25519.PrivateKey, error) { return nil, fmt.Errorf("failed to read key file: %w", err) } - var block *pem.Block - block, _ = pem.Decode(privateKeyPEM) + p, _ := pem.Decode(privateKeyPEM) + key, err := x509.ParsePKCS8PrivateKey(p.Bytes) + if err != nil { + return nil, err + } - var asn1PrivKey ed25519PrivKey - if _, err := asn1.Unmarshal(block.Bytes, &asn1PrivKey); err != nil { - return nil, fmt.Errorf("failed to unmarshal pem key file: %w", err) + edKey, ok := key.(ed25519.PrivateKey) + if !ok { + return nil, fmt.Errorf("key is not ed25519 key") } - return ed25519.NewKeyFromSeed(asn1PrivKey.PrivateKey[2:]), nil + return edKey, nil } diff --git a/tools/payments/custodian.go b/tools/payments/custodian.go index 8e649e18f..d3f868254 100644 --- a/tools/payments/custodian.go +++ b/tools/payments/custodian.go @@ -2,7 +2,6 @@ package payments import ( "github.com/google/uuid" - "github.com/shopspring/decimal" ) // idempotencyNamespace is a uuidv5 namespace for creating transaction idempotency keys @@ -15,15 +14,3 @@ type Custodian string func (c Custodian) String() string { return string(c) } - -const ( - uphold Custodian = "uphold" - gemini = "gemini" - bitflyer = "bitflyer" -) - -// custodianStats is a structure which contains total amount of bat, and total number of transactions -type custodianStats struct { - Transactions uint64 - AmountBAT decimal.Decimal -} diff --git a/tools/payments/report.go b/tools/payments/report.go index 503a308e9..3774ae644 100644 --- a/tools/payments/report.go +++ b/tools/payments/report.go @@ -18,20 +18,30 @@ import ( nitrodoc "github.com/veracruz-project/go-nitro-enclave-attestation-document" ) -func SumBAT[T isTransaction](txs ...T) decimal.Decimal { +// AttestedReport is the report of payouts after being prepared +type AttestedReport []*AttestedTx + +// SumBAT sums the total amount of BAT in the report. +func (ar AttestedReport) SumBAT() decimal.Decimal { total := decimal.Zero - for _, v := range txs { + for _, v := range ar { total = total.Add(v.GetAmount()) } return total } -// AttestedReport is the report of payouts after being prepared -type AttestedReport []*AttestedTx - // PreparedReport is the report of payouts prior to being prepared type PreparedReport []*PrepareTx +// SumBAT sums the total amount of BAT in the report. +func (r PreparedReport) SumBAT() decimal.Decimal { + total := decimal.Zero + for _, v := range r { + total = total.Add(v.GetAmount()) + } + return total +} + // ReadReport reads a report from the reader func ReadReport(report any, reader io.Reader) error { if err := json.NewDecoder(reader).Decode(report); err != nil { @@ -94,9 +104,14 @@ func Compare(pr PreparedReport, ar AttestedReport) error { if len(pr) != len(ar) { return fmt.Errorf("number of transactions do not match - attested: %d; prepared: %d", len(ar), len(pr)) } - if !SumBAT(pr...).Equal(SumBAT(ar...)) { - return fmt.Errorf("sum of BAT do not match - attested: %s; prepared: %s", SumBAT(ar...).String(), SumBAT(pr...).String()) + + p := pr.SumBAT() + a := ar.SumBAT() + + if !p.Equal(a) { + return fmt.Errorf("sum of BAT do not match - prepared: %s; attested: %s", p.String(), a.String()) } + return nil } @@ -109,6 +124,7 @@ func (ar AttestedReport) Submit(ctx context.Context, key ed25519.PrivateKey, cli Headers: []string{ "(request-target)", "host", + "date", "digest", "content-length", "content-type", @@ -121,8 +137,6 @@ func (ar AttestedReport) Submit(ctx context.Context, key ed25519.PrivateKey, cli return client.SubmitTransactions(ctx, signer, ar...) } -const prepareWorkerCount = 1000 - // Prepare performs a preparation of transactions for a payout to the settlement client func (r PreparedReport) Prepare(ctx context.Context, client SettlementClient) error { return client.PrepareTransactions(ctx, r...) diff --git a/tools/payments/test/attested-report-s3-download b/tools/payments/test/attested-report-s3-download new file mode 100644 index 000000000..c4825d834 --- /dev/null +++ b/tools/payments/test/attested-report-s3-download @@ -0,0 +1 @@ +["{\"idempotencyKey\":\"562cbc9e-32c6-5877-bced-9fe43f358b61\",\"custodian\":\"bitflyer\",\"to\":\"003694c1-def6-48c4-8a45-ca8c2fce50f3\",\"amount\":\"1.971713439895352832\",\"documentId\":\"\",\"version\":\"\",\"state\":\"\",\"attestationDocument\":\"\"}","{\"idempotencyKey\":\"775fd54b-36fe-5167-9eb8-85eddccac76f\",\"custodian\":\"uphold\",\"to\":\"a6a5ff0c-f45e-40ac-8ed3-b2bc32454066\",\"amount\":\"3.27000000000003\",\"documentId\":\"\",\"version\":\"\",\"state\":\"\",\"attestationDocument\":\"\"}","{\"idempotencyKey\":\"bfda3e71-c051-5822-b549-5d523889bd46\",\"custodian\":\"gemini\",\"to\":\"002399e3-6eaa-47ce-bf92-7f531bb6a971\",\"amount\":\"5.779826652242246656\",\"documentId\":\"\",\"version\":\"\",\"state\":\"\",\"attestationDocument\":\"\"}"] \ No newline at end of file diff --git a/tools/payments/redistest/test/attested-report.json b/tools/payments/test/attested-report.json similarity index 100% rename from tools/payments/redistest/test/attested-report.json rename to tools/payments/test/attested-report.json diff --git a/tools/payments/redistest/test/bootstrap.json b/tools/payments/test/bootstrap.json similarity index 100% rename from tools/payments/redistest/test/bootstrap.json rename to tools/payments/test/bootstrap.json diff --git a/tools/payments/redistest/test/private.pem b/tools/payments/test/private.pem similarity index 100% rename from tools/payments/redistest/test/private.pem rename to tools/payments/test/private.pem diff --git a/tools/payments/redistest/docker-compose.redis.yml b/tools/payments/test/redis/docker-compose.redis.yml similarity index 100% rename from tools/payments/redistest/docker-compose.redis.yml rename to tools/payments/test/redis/docker-compose.redis.yml diff --git a/tools/payments/redistest/test/redis/tls/ca.crt b/tools/payments/test/redis/tls/ca.crt similarity index 100% rename from tools/payments/redistest/test/redis/tls/ca.crt rename to tools/payments/test/redis/tls/ca.crt diff --git a/tools/payments/redistest/test/redis/tls/ca.key b/tools/payments/test/redis/tls/ca.key similarity index 100% rename from tools/payments/redistest/test/redis/tls/ca.key rename to tools/payments/test/redis/tls/ca.key diff --git a/tools/payments/redistest/test/redis/tls/ca.txt b/tools/payments/test/redis/tls/ca.txt similarity index 100% rename from tools/payments/redistest/test/redis/tls/ca.txt rename to tools/payments/test/redis/tls/ca.txt diff --git a/tools/payments/redistest/test/redis/tls/openssl.cnf b/tools/payments/test/redis/tls/openssl.cnf similarity index 100% rename from tools/payments/redistest/test/redis/tls/openssl.cnf rename to tools/payments/test/redis/tls/openssl.cnf diff --git a/tools/payments/redistest/test/redis/tls/redis.crt b/tools/payments/test/redis/tls/redis.crt similarity index 100% rename from tools/payments/redistest/test/redis/tls/redis.crt rename to tools/payments/test/redis/tls/redis.crt diff --git a/tools/payments/redistest/test/redis/tls/redis.csr b/tools/payments/test/redis/tls/redis.csr similarity index 100% rename from tools/payments/redistest/test/redis/tls/redis.csr rename to tools/payments/test/redis/tls/redis.csr diff --git a/tools/payments/redistest/test/redis/tls/redis.dh b/tools/payments/test/redis/tls/redis.dh similarity index 100% rename from tools/payments/redistest/test/redis/tls/redis.dh rename to tools/payments/test/redis/tls/redis.dh diff --git a/tools/payments/redistest/test/redis/tls/redis.key b/tools/payments/test/redis/tls/redis.key similarity index 100% rename from tools/payments/redistest/test/redis/tls/redis.key rename to tools/payments/test/redis/tls/redis.key diff --git a/tools/payments/redistest/test/redis/tls/sans.conf b/tools/payments/test/redis/tls/sans.conf similarity index 100% rename from tools/payments/redistest/test/redis/tls/sans.conf rename to tools/payments/test/redis/tls/sans.conf diff --git a/tools/payments/redistest/test/redis/tls/users.acl b/tools/payments/test/redis/tls/users.acl similarity index 100% rename from tools/payments/redistest/test/redis/tls/users.acl rename to tools/payments/test/redis/tls/users.acl diff --git a/tools/payments/redistest/test/report.json b/tools/payments/test/report.json similarity index 79% rename from tools/payments/redistest/test/report.json rename to tools/payments/test/report.json index de09a8126..2c1e360a3 100644 --- a/tools/payments/redistest/test/report.json +++ b/tools/payments/test/report.json @@ -4,20 +4,23 @@ "probi": "3270000000000030000", "publisher": "wallet:ab7198e8-5ee3-4626-8315-c7f2ace8f1c2", "transactionId":"8d2c3616-d582-4d00-9d7d-a300a8f041d6", - "walletProvider": "uphold" + "walletProvider": "uphold", + "dryRun": "success" }, { "address": "002399e3-6eaa-47ce-bf92-7f531bb6a971", "probi": "5779826652242246656", "publisher": "wallet:cc57f134-9f16-4b44-904c-027c10d1157c", "transactionId":"8d2c3616-d582-4d00-9d7d-a300a8f041d6", - "walletProvider": "gemini" + "walletProvider": "gemini", + "dryRun": "success" }, { "address": "003694c1-def6-48c4-8a45-ca8c2fce50f3", "probi": "1971713439895352832", "publisher": "wallet:01b3b403-38bf-4a54-826e-5af2f8f1d40a", "transactionId":"8d2c3616-d582-4d00-9d7d-a300a8f041d6", - "walletProvider": "bitflyer" + "walletProvider": "bitflyer", + "dryRun": "success" } ] diff --git a/tools/payments/transaction.go b/tools/payments/transaction.go index ab1776159..1a9f845ac 100644 --- a/tools/payments/transaction.go +++ b/tools/payments/transaction.go @@ -26,11 +26,7 @@ type Tx struct { Amount decimal.Decimal `json:"amount"` ID string `json:"idempotencyKey"` Custodian Custodian `json:"custodian"` -} - -type isTransaction interface { - GetCustodian() Custodian - GetAmount() decimal.Decimal + DryRun *string `json:"dryRun" ion:"-"` } // GetCustodian returns the custodian of the transaction @@ -54,7 +50,7 @@ func (t *Tx) MarshalJSON() ([]byte, error) { } // UnmarshalJSON implements custom json unmarshaling (convert altcurrency) for Tx -func (t *PrepareTx) UnmarshalJSON(data []byte) error { +func (pt *PrepareTx) UnmarshalJSON(data []byte) error { type TxAlias Tx aux := &struct { *TxAlias @@ -64,20 +60,20 @@ func (t *PrepareTx) UnmarshalJSON(data []byte) error { BatchID string `json:"transactionId"` Custodian string `json:"walletProvider"` }{ - TxAlias: (*TxAlias)(t), + TxAlias: (*TxAlias)(pt), } if err := json.Unmarshal(data, aux); err != nil { return err } - t.Amount = altcurrency.BAT.FromProbi(aux.Amount) - t.To = aux.To - t.Custodian = Custodian(aux.Custodian) + pt.Amount = altcurrency.BAT.FromProbi(aux.Amount) + pt.To = aux.To + pt.Custodian = Custodian(aux.Custodian) - // uuidv5 with settlement namespace to get the idemptotency key for this publisher/transactionId + // uuidV5 with settlement namespace to get the idempotent key for this publisher/transactionId // transactionId is the settlement batch identifier, and publisher is the identifier of the recipient - t.ID = uuid.NewSHA1( + pt.ID = uuid.NewSHA1( idempotencyNamespace, []byte(aux.BatchID+aux.Publisher)).String() return nil @@ -107,6 +103,7 @@ func (at *AttestedTx) UnmarshalJSON(data []byte) error { at.ID = aux.ID at.Version = aux.Version at.State = aux.State + at.DocumentID = aux.DocumentID at.AttestationDocument = aux.AttestationDocument return nil From b28963ede6c6f81df576224821556f18859e1de6 Mon Sep 17 00:00:00 2001 From: clD11 <23483715+clD11@users.noreply.github.com> Date: Thu, 22 Jun 2023 15:51:45 +0100 Subject: [PATCH 10/22] payment tooling fixes (#1871) * payment tooling fixes added dryrun to unmarshall * payment tooling fixes added dryrun to unmarshall --- .../payments/test/attested-report-s3-download | 1 - tools/payments/test/attested-report.json | 27 ------------------- tools/payments/test/report-attested.json | 1 + tools/payments/transaction.go | 1 + 4 files changed, 2 insertions(+), 28 deletions(-) delete mode 100644 tools/payments/test/attested-report-s3-download delete mode 100644 tools/payments/test/attested-report.json create mode 100644 tools/payments/test/report-attested.json diff --git a/tools/payments/test/attested-report-s3-download b/tools/payments/test/attested-report-s3-download deleted file mode 100644 index c4825d834..000000000 --- a/tools/payments/test/attested-report-s3-download +++ /dev/null @@ -1 +0,0 @@ -["{\"idempotencyKey\":\"562cbc9e-32c6-5877-bced-9fe43f358b61\",\"custodian\":\"bitflyer\",\"to\":\"003694c1-def6-48c4-8a45-ca8c2fce50f3\",\"amount\":\"1.971713439895352832\",\"documentId\":\"\",\"version\":\"\",\"state\":\"\",\"attestationDocument\":\"\"}","{\"idempotencyKey\":\"775fd54b-36fe-5167-9eb8-85eddccac76f\",\"custodian\":\"uphold\",\"to\":\"a6a5ff0c-f45e-40ac-8ed3-b2bc32454066\",\"amount\":\"3.27000000000003\",\"documentId\":\"\",\"version\":\"\",\"state\":\"\",\"attestationDocument\":\"\"}","{\"idempotencyKey\":\"bfda3e71-c051-5822-b549-5d523889bd46\",\"custodian\":\"gemini\",\"to\":\"002399e3-6eaa-47ce-bf92-7f531bb6a971\",\"amount\":\"5.779826652242246656\",\"documentId\":\"\",\"version\":\"\",\"state\":\"\",\"attestationDocument\":\"\"}"] \ No newline at end of file diff --git a/tools/payments/test/attested-report.json b/tools/payments/test/attested-report.json deleted file mode 100644 index 273cd319b..000000000 --- a/tools/payments/test/attested-report.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "to":"a6a5ff0c-f45e-40ac-8ed3-b2bc32454066", - "amount":"3.27000000000003", - "idempotencyKey":"1d691291-f376-5591-8ab8-43db145d0e5e", - "custodian":"uphold", - "documentId":"1234", - "attestationDocument": "" // todo - generate real attestation document for test - }, - { - "to":"002399e3-6eaa-47ce-bf92-7f531bb6a971", - "amount":"5.779826652242246656", - "idempotencyKey":"11705191-50e4-5401-ab9b-9fd8d88d934a", - "custodian":"gemini", - "documentId":"2345", - "attestationDocument": "" // todo - generate real attestation document for test - }, - { - "to":"003694c1-def6-48c4-8a45-ca8c2fce50f3", - "amount":"1.971713439895352832", - "idempotencyKey":"431bd316-91e7-519c-9588-7fe6ff7408bb", - "custodian":"bitflyer", - "documentId":"3456", - "attestationDocument": "" // todo - generate real attestation document for test - } -] - diff --git a/tools/payments/test/report-attested.json b/tools/payments/test/report-attested.json new file mode 100644 index 000000000..280865cc3 --- /dev/null +++ b/tools/payments/test/report-attested.json @@ -0,0 +1 @@ +[{"idempotencyKey":"562cbc9e-32c6-5877-bced-9fe43f358b61","custodian":"bitflyer","to":"003694c1-def6-48c4-8a45-ca8c2fce50f3","amount":"1.971713439895352832","documentId":"8dd8365f-9ad6-4562-85cc-b05fcc46a15d","version":"","state":"","attestationDocument":"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","dryRun":"success"},{"idempotencyKey":"775fd54b-36fe-5167-9eb8-85eddccac76f","custodian":"uphold","to":"a6a5ff0c-f45e-40ac-8ed3-b2bc32454066","amount":"3.27000000000003","documentId":"7ec65eb7-cdab-4ed2-bc86-2086a618f713","version":"","state":"","attestationDocument":"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","dryRun":"success"},{"idempotencyKey":"bfda3e71-c051-5822-b549-5d523889bd46","custodian":"gemini","to":"002399e3-6eaa-47ce-bf92-7f531bb6a971","amount":"5.779826652242246656","documentId":"74aebf31-b47c-46f7-bfa4-6df3b5ec9c1d","version":"","state":"","attestationDocument":"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","dryRun":"success"}] diff --git a/tools/payments/transaction.go b/tools/payments/transaction.go index 1a9f845ac..c96f85507 100644 --- a/tools/payments/transaction.go +++ b/tools/payments/transaction.go @@ -105,6 +105,7 @@ func (at *AttestedTx) UnmarshalJSON(data []byte) error { at.State = aux.State at.DocumentID = aux.DocumentID at.AttestationDocument = aux.AttestationDocument + at.DryRun = aux.DryRun return nil } From b90fb3bd42d95ace6419a587a73f67db537cc464 Mon Sep 17 00:00:00 2001 From: Pavel Brm <5097196+pavelbrm@users.noreply.github.com> Date: Fri, 23 Jun 2023 14:50:40 +1200 Subject: [PATCH 11/22] Speed up linters locally (#1867) --- Makefile | 16 +++++++++------- serverless/email/status/go.mod | 2 +- serverless/email/unsubscribe/go.mod | 12 ++++++------ serverless/email/webhook/go.mod | 12 ++++++------ 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/Makefile b/Makefile index fd7bf30e6..e763e8957 100644 --- a/Makefile +++ b/Makefile @@ -186,11 +186,13 @@ format: format-lint: make format && make lint + lint: - docker run --rm -v "$$(pwd):/app" --workdir /app/libs golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... - docker run --rm -v "$$(pwd):/app" --workdir /app/services golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... - docker run --rm -v "$$(pwd):/app" --workdir /app/tools golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... - docker run --rm -v "$$(pwd):/app" --workdir /app/cmd golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... - docker run --rm -v "$$(pwd):/app" --workdir /app/main golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... - docker run --rm -v "$$(pwd):/app" --workdir /app/serverless/email/webhook golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... - docker run --rm -v "$$(pwd):/app" --workdir /app/serverless/email/unsubscribe golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... + docker volume create batgo_lint_gomod + docker run --rm -v "$$(pwd):/app" -v batgo_lint_gomod:/go/pkg --workdir /app/main golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... + docker run --rm -v "$$(pwd):/app" -v batgo_lint_gomod:/go/pkg --workdir /app/cmd golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... + docker run --rm -v "$$(pwd):/app" -v batgo_lint_gomod:/go/pkg --workdir /app/libs golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... + docker run --rm -v "$$(pwd):/app" -v batgo_lint_gomod:/go/pkg --workdir /app/services golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... + docker run --rm -v "$$(pwd):/app" -v batgo_lint_gomod:/go/pkg --workdir /app/tools golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... + docker run --rm -v "$$(pwd):/app" -v batgo_lint_gomod:/go/pkg --workdir /app/serverless/email/webhook golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... + docker run --rm -v "$$(pwd):/app" -v batgo_lint_gomod:/go/pkg --workdir /app/serverless/email/unsubscribe golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... diff --git a/serverless/email/status/go.mod b/serverless/email/status/go.mod index ddbfaabb2..024d0434e 100644 --- a/serverless/email/status/go.mod +++ b/serverless/email/status/go.mod @@ -1,4 +1,4 @@ -module notification +module github.com/brave-intl/bat-go/serverless/email/status go 1.18 diff --git a/serverless/email/unsubscribe/go.mod b/serverless/email/unsubscribe/go.mod index 562077df4..06ec54ff1 100644 --- a/serverless/email/unsubscribe/go.mod +++ b/serverless/email/unsubscribe/go.mod @@ -1,3 +1,9 @@ +module github.com/brave-intl/bat-go/serverless/email/unsubscribe + +go 1.18 + +replace gopkg.in/yaml.v2 => gopkg.in/yaml.v2 v2.2.8 + require ( github.com/aws/aws-lambda-go v1.34.1 github.com/aws/aws-sdk-go-v2 v1.17.1 @@ -40,9 +46,3 @@ require ( golang.org/x/sys v0.1.0 // indirect google.golang.org/protobuf v1.28.1 // indirect ) - -replace gopkg.in/yaml.v2 => gopkg.in/yaml.v2 v2.2.8 - -module webhook - -go 1.18 diff --git a/serverless/email/webhook/go.mod b/serverless/email/webhook/go.mod index 2cc8063d2..2c0b74311 100644 --- a/serverless/email/webhook/go.mod +++ b/serverless/email/webhook/go.mod @@ -1,3 +1,9 @@ +module github.com/brave-intl/bat-go/serverless/email/webhook + +go 1.18 + +replace gopkg.in/yaml.v2 => gopkg.in/yaml.v2 v2.2.8 + require ( github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d github.com/aws/aws-lambda-go v1.34.1 @@ -46,9 +52,3 @@ require ( google.golang.org/protobuf v1.28.1 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) - -replace gopkg.in/yaml.v2 => gopkg.in/yaml.v2 v2.2.8 - -module webhook - -go 1.18 From fe73b73f81c4732697c776d5b3e45d43af92d132 Mon Sep 17 00:00:00 2001 From: Pavel Brm <5097196+pavelbrm@users.noreply.github.com> Date: Fri, 23 Jun 2023 20:19:14 +1200 Subject: [PATCH 12/22] Update Settings For CI (#1873) --- .github/workflows/ci.yml | 17 +++++++++-------- .github/workflows/codeql-analysis.yml | 18 +++++++++--------- Makefile | 16 ++++++++++++++-- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7dee64689..09b298f8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,28 +44,29 @@ jobs: matrix: goversion: - 1.18 + steps: - - name: Check out code into the Go module directory - uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f + - name: Checkout repository + uses: actions/checkout@v3 - - name: Set up Go 1.x - uses: actions/setup-go@37335c7bb261b353407cff977110895fa0b4f7d8 + - name: Set up Go + uses: actions/setup-go@v4 with: - go-version: ${{matrix.goversion}} + go-version: ${{ matrix.goversion }} - - name: Docker Compose Install + - name: Install Docker Compose uses: KengoTODA/actions-setup-docker-compose@92cbaf8ac8c113c35e1cedd1182f217043fbdd00 with: version: '1.25.4' - run: docker-compose pull - - name: Vault + - name: Start Vault run: | docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d vault; sleep 3; - - name: Test + - name: Run Tests run: | export VAULT_TOKEN=$(docker logs grant-vault 2>&1 | grep "Root Token" | tail -1 | cut -d ' ' -f 3 ); docker-compose -f docker-compose.yml -f docker-compose.dev.yml run --rm dev make; diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d1f3d0aa8..48fcf92b5 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -35,11 +35,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -49,8 +49,12 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 + # - name: Autobuild + # uses: github/codeql-action/autobuild@v2 + + - name: Download Modules and Build + run: | + make codeql # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -59,9 +63,5 @@ jobs: # and modify them (or add more) to build your code if your project # uses a compiled language - #- run: | - # make bootstrap - # make release - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 diff --git a/Makefile b/Makefile index e763e8957..ea8587d09 100644 --- a/Makefile +++ b/Makefile @@ -16,13 +16,15 @@ ifdef TEST_RUN TEST_FLAGS = --tags=$(TEST_TAGS) $(TEST_PKG) --run=$(TEST_RUN) endif -.PHONY: all buildcmd docker test create-json-schema lint clean +.PHONY: all buildcmd docker test create-json-schema lint clean download-mod all: test create-json-schema buildcmd .DEFAULT: buildcmd +codeql: download-mod buildcmd + buildcmd: - cd main && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -v -ldflags "-w -s -X main.version=${GIT_VERSION} -X main.buildTime=${BUILD_TIME} -X main.commit=${GIT_COMMIT}" -o ${OUTPUT}/bat-go main.go + cd main && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags "-w -s -X main.version=${GIT_VERSION} -X main.buildTime=${BUILD_TIME} -X main.commit=${GIT_COMMIT}" -o ${OUTPUT}/bat-go main.go mock: cd services && mockgen -source=./promotion/claim.go -destination=promotion/mockclaim.go -package=promotion @@ -196,3 +198,13 @@ lint: docker run --rm -v "$$(pwd):/app" -v batgo_lint_gomod:/go/pkg --workdir /app/tools golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... docker run --rm -v "$$(pwd):/app" -v batgo_lint_gomod:/go/pkg --workdir /app/serverless/email/webhook golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... docker run --rm -v "$$(pwd):/app" -v batgo_lint_gomod:/go/pkg --workdir /app/serverless/email/unsubscribe golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... + +download-mod: + cd ./cmd && go mod download && cd .. + cd ./libs && go mod download && cd .. + cd ./main && go mod download && cd .. + cd ./services && go mod download && cd .. + cd ./tools && go mod download && cd .. + cd ./serverless/email/status && go mod download && cd ../../.. + cd ./serverless/email/unsubscribe && go mod download && cd ../../.. + cd ./serverless/email/webhook && go mod download && cd ../../.. From a1656ddde5d11ecf4b7f677cc03e2369697e120d Mon Sep 17 00:00:00 2001 From: Pavel Brm <5097196+pavelbrm@users.noreply.github.com> Date: Mon, 26 Jun 2023 18:21:30 +1200 Subject: [PATCH 13/22] Calculate expires_at Using valid_for_iso (#1874) --- services/skus/datastore.go | 6 +- .../skus/storage/repository/repository.go | 22 +++ .../storage/repository/repository_test.go | 176 ++++++++++++++++++ 3 files changed, 201 insertions(+), 3 deletions(-) diff --git a/services/skus/datastore.go b/services/skus/datastore.go index b13585b72..d24b9c5d6 100644 --- a/services/skus/datastore.go +++ b/services/skus/datastore.go @@ -112,7 +112,7 @@ type orderStore interface { SetLastPaidAt(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, when time.Time) error SetTrialDays(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID, ndays int64) (*model.Order, error) SetStatus(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, status string) error - GetTimeBounds(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID) (model.OrderTimeBounds, error) + GetExpiresAtAfterISOPeriod(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID) (time.Time, error) SetExpiresAt(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, when time.Time) error UpdateMetadata(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, data datastore.Metadata) error AppendMetadata(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, key, val string) error @@ -1435,10 +1435,10 @@ func (pg *Postgres) recordOrderPayment(ctx context.Context, dbi sqlx.ExecerConte } func (pg *Postgres) updateOrderExpiresAt(ctx context.Context, dbi sqlx.ExtContext, orderID uuid.UUID) error { - orderTimeBounds, err := pg.orderRepo.GetTimeBounds(ctx, dbi, orderID) + expiresAt, err := pg.orderRepo.GetExpiresAtAfterISOPeriod(ctx, dbi, orderID) if err != nil { return fmt.Errorf("unable to get order time bounds: %w", err) } - return pg.orderRepo.SetExpiresAt(ctx, dbi, orderID, orderTimeBounds.ExpiresAt()) + return pg.orderRepo.SetExpiresAt(ctx, dbi, orderID, expiresAt) } diff --git a/services/skus/storage/repository/repository.go b/services/skus/storage/repository/repository.go index 4a300341f..520cab025 100644 --- a/services/skus/storage/repository/repository.go +++ b/services/skus/storage/repository/repository.go @@ -142,6 +142,28 @@ func (r *Order) GetTimeBounds(ctx context.Context, dbi sqlx.QueryerContext, id u return result, nil } +// GetExpiresAtAfterISOPeriod returns a new value for expires_at that is last_paid_at plus ISO period. +// +// It falls back to now() when last_paid_at is NULL. +// It uses the maximum of the order items' valid_for_iso as inverval, and falls back to 1 month. +func (r *Order) GetExpiresAtAfterISOPeriod(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID) (time.Time, error) { + const q = `SELECT COALESCE(last_paid_at, now()) + + (SELECT COALESCE(MAX(valid_for_iso::interval), interval '1 month') FROM order_items WHERE order_id = $2) + AS expires_at + FROM orders WHERE id = $1` + + var result time.Time + if err := sqlx.GetContext(ctx, dbi, &result, q, id, id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return time.Time{}, model.ErrOrderNotFound + } + + return time.Time{}, err + } + + return result, nil +} + // SetExpiresAt sets expires_at. func (r *Order) SetExpiresAt(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, when time.Time) error { const q = `UPDATE orders SET updated_at = CURRENT_TIMESTAMP, expires_at = $2 WHERE id = $1` diff --git a/services/skus/storage/repository/repository_test.go b/services/skus/storage/repository/repository_test.go index 62da44ef1..10893f2ac 100644 --- a/services/skus/storage/repository/repository_test.go +++ b/services/skus/storage/repository/repository_test.go @@ -7,6 +7,7 @@ import ( "database/sql" "errors" "testing" + "time" uuid "github.com/satori/go.uuid" should "github.com/stretchr/testify/assert" @@ -360,3 +361,178 @@ func TestOrder_AppendMetadataInt(t *testing.T) { }) } } + +func TestOrder_GetExpiresAtAfterISOPeriod(t *testing.T) { + dbi, err := setupDBI() + must.Equal(t, nil, err) + + defer func() { + _, _ = dbi.Exec("TRUNCATE_TABLE orders;") + }() + + type tcGiven struct { + lastPaidAt time.Time + items []model.OrderItem + } + + type tcExpected struct { + expiresAt time.Time + err error + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "no_last_paid_no_items", + }, + + { + name: "20230202_no_items", + given: tcGiven{ + lastPaidAt: time.Date(2023, time.February, 2, 1, 0, 0, 0, time.UTC), + }, + exp: tcExpected{ + expiresAt: time.Date(2023, time.March, 2, 1, 0, 0, 0, time.UTC), + }, + }, + + { + name: "20230202_1_item", + given: tcGiven{ + lastPaidAt: time.Date(2023, time.February, 2, 1, 0, 0, 0, time.UTC), + items: []model.OrderItem{ + { + SKU: "sku_01_01", + Quantity: 1, + Price: mustDecimalFromString("2"), + Currency: "USD", + Subtotal: mustDecimalFromString("2"), + CredentialType: "something", + ValidForISO: ptrString("P1M"), + }, + }, + }, + exp: tcExpected{ + expiresAt: time.Date(2023, time.March, 2, 1, 0, 0, 0, time.UTC), + }, + }, + + { + name: "20230331_2_items", + given: tcGiven{ + lastPaidAt: time.Date(2023, time.March, 31, 1, 0, 0, 0, time.UTC), + items: []model.OrderItem{ + { + SKU: "sku_02_01", + Quantity: 2, + Price: mustDecimalFromString("3"), + Currency: "USD", + Subtotal: mustDecimalFromString("6"), + CredentialType: "something", + ValidForISO: ptrString("P1M"), + }, + + { + SKU: "sku_02_02", + Quantity: 3, + Price: mustDecimalFromString("4"), + Currency: "USD", + Subtotal: mustDecimalFromString("12"), + CredentialType: "something", + ValidForISO: ptrString("P2M"), + }, + }, + }, + exp: tcExpected{ + expiresAt: time.Date(2023, time.May, 31, 1, 0, 0, 0, time.UTC), + }, + }, + + { + name: "20230331_2_items_no_iso", + given: tcGiven{ + lastPaidAt: time.Date(2023, time.March, 31, 1, 0, 0, 0, time.UTC), + items: []model.OrderItem{ + { + SKU: "sku_02_01", + Quantity: 2, + Price: mustDecimalFromString("3"), + Currency: "USD", + Subtotal: mustDecimalFromString("6"), + CredentialType: "something", + }, + + { + SKU: "sku_02_02", + Quantity: 3, + Price: mustDecimalFromString("4"), + Currency: "USD", + Subtotal: mustDecimalFromString("12"), + CredentialType: "something", + }, + }, + }, + exp: tcExpected{ + expiresAt: time.Date(2023, time.April, 30, 1, 0, 0, 0, time.UTC), + }, + }, + } + + repo := repository.NewOrder() + iorepo := repository.NewOrderItem() + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + ctx := context.TODO() + + tx, err := dbi.BeginTxx(ctx, &sql.TxOptions{Isolation: sql.LevelReadUncommitted}) + must.Equal(t, nil, err) + + t.Cleanup(func() { _ = tx.Rollback() }) + + order, err := createOrderForTest(ctx, tx, repo) + must.Equal(t, nil, err) + + if !tc.given.lastPaidAt.IsZero() { + err := repo.SetLastPaidAt(ctx, tx, order.ID, tc.given.lastPaidAt) + must.Equal(t, nil, err) + } + + if len(tc.given.items) > 0 { + model.OrderItemList(tc.given.items).SetOrderID(order.ID) + + _, err := iorepo.InsertMany(ctx, tx, tc.given.items...) + must.Equal(t, nil, err) + } + + actual, err := repo.GetExpiresAtAfterISOPeriod(ctx, tx, order.ID) + must.Equal(t, nil, err) + + // Handle the special case where last_paid_at was not set. + // The time is generated by the database, so it is non-deterministic. + // The result should not be too far from time.Now()+1 month. + if tc.given.lastPaidAt.IsZero() { + now := time.Now() + future := time.Date(now.Year(), now.Month()+1, now.Day(), now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), now.Location()) + + should.Equal(t, true, future.Sub(actual) < time.Duration(12*time.Hour)) + return + } + + // TODO(pavelb): update local and testing containers to use Go 1.20+. + // Then switch to tc.exp.expiresAt.Compare(actual) == 0. + should.Equal(t, true, tc.exp.expiresAt.Sub(actual) == 0) + }) + } +} + +func ptrString(s string) *string { + return &s +} From dc492526bba9195ccef847cf3e0087e0d4fd7483 Mon Sep 17 00:00:00 2001 From: husobee Date: Mon, 26 Jun 2023 09:35:58 -0400 Subject: [PATCH 14/22] updates to deps (#1872) * updates to deps * tidy deps --- main/go.mod | 14 +++++++------- main/go.sum | 28 ++++++++++++++-------------- services/go.mod | 8 ++++---- services/go.sum | 16 ++++++++-------- tools/go.mod | 14 +++++++------- tools/go.sum | 28 ++++++++++++++-------------- tools/payments/cmd/create/go.mod | 2 +- tools/payments/cmd/create/go.sum | 4 ++-- 8 files changed, 57 insertions(+), 57 deletions(-) diff --git a/main/go.mod b/main/go.mod index aedb1dc08..0c964eea5 100644 --- a/main/go.mod +++ b/main/go.mod @@ -130,9 +130,9 @@ require ( github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/hashicorp/hcl v1.0.1-vault-5 // indirect github.com/hashicorp/hcp-sdk-go v0.23.0 // indirect - github.com/hashicorp/vault v1.12.5 // indirect + github.com/hashicorp/vault v1.12.7 // indirect github.com/hashicorp/vault/api v1.8.1 // indirect - github.com/hashicorp/vault/sdk v0.6.1-0.20230302210543-38f40f637f4f // indirect + github.com/hashicorp/vault/sdk v0.6.1-0.20230427140652-b4b396ffc14f // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/huandu/xstrings v1.3.2 // indirect github.com/iancoleman/orderedmap v0.2.0 // indirect @@ -202,13 +202,13 @@ require ( go.mongodb.org/mongo-driver v1.10.3 // indirect go.opencensus.io v0.24.0 // indirect go.uber.org/atomic v1.10.0 // indirect - golang.org/x/crypto v0.6.0 // indirect + golang.org/x/crypto v0.8.0 // indirect golang.org/x/mod v0.8.0 // indirect - golang.org/x/net v0.7.0 // indirect + golang.org/x/net v0.9.0 // indirect golang.org/x/oauth2 v0.5.0 // indirect - golang.org/x/sys v0.5.0 // indirect - golang.org/x/term v0.5.0 // indirect - golang.org/x/text v0.7.0 // indirect + golang.org/x/sys v0.7.0 // indirect + golang.org/x/term v0.7.0 // indirect + golang.org/x/text v0.9.0 // indirect golang.org/x/time v0.1.0 // indirect golang.org/x/tools v0.6.0 // indirect google.golang.org/api v0.110.0 // indirect diff --git a/main/go.sum b/main/go.sum index f26c32f99..740e3a550 100644 --- a/main/go.sum +++ b/main/go.sum @@ -919,12 +919,12 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= -github.com/hashicorp/vault v1.12.5 h1:c5wNu14Lp/UOTEROVWy74lTIaZO/5+dnmzaoUflN8dw= -github.com/hashicorp/vault v1.12.5/go.mod h1:35dTzdQDYFI83xdVUfDC4o0a1WZmMpfr6F3+XRTV+2k= +github.com/hashicorp/vault v1.12.7 h1:T+nWB2Ihe6xiNelLfC1BMJhV0dgJngDgRW8EiG6/em8= +github.com/hashicorp/vault v1.12.7/go.mod h1:TkP77qkpNyb7kXeZlLLsj0luGitsq5BzRtaBoXgSCs4= github.com/hashicorp/vault/api v1.8.1 h1:bMieWIe6dAlqAAPReZO/8zYtXaWUg/21umwqGZpEjCI= github.com/hashicorp/vault/api v1.8.1/go.mod h1:uJrw6D3y9Rv7hhmS17JQC50jbPDAZdjZoTtrCCxxs7E= -github.com/hashicorp/vault/sdk v0.6.1-0.20230302210543-38f40f637f4f h1:bdWa/SckATQyKiElmR/TDPNfRILakE9RvFFrH6sefY8= -github.com/hashicorp/vault/sdk v0.6.1-0.20230302210543-38f40f637f4f/go.mod h1:XduFY2J0HMoM4mt4kkxlrrkF8bYowzUc2Gog6epWVsA= +github.com/hashicorp/vault/sdk v0.6.1-0.20230427140652-b4b396ffc14f h1:0KmxboDYCgT0rssFOTOkqVkLGbueORiGpkfVA6r5LQs= +github.com/hashicorp/vault/sdk v0.6.1-0.20230427140652-b4b396ffc14f/go.mod h1:XduFY2J0HMoM4mt4kkxlrrkF8bYowzUc2Gog6epWVsA= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -1625,8 +1625,8 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= -golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1752,8 +1752,8 @@ golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220706163947-c90051bbdb60/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/oauth2 v0.0.0-20180227000427-d7d64896b5ff/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -1930,16 +1930,16 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1950,8 +1950,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/services/go.mod b/services/go.mod index 70043f13c..49274788e 100644 --- a/services/go.mod +++ b/services/go.mod @@ -41,7 +41,7 @@ require ( github.com/square/go-jose v2.6.0+incompatible github.com/stretchr/testify v1.8.1 github.com/stripe/stripe-go/v72 v72.122.0 - golang.org/x/crypto v0.6.0 + golang.org/x/crypto v0.8.0 golang.org/x/exp v0.0.0-20230223210539-50820d90acfd gopkg.in/macaroon.v2 v2.1.0 gopkg.in/square/go-jose.v2 v2.6.0 @@ -119,11 +119,11 @@ require ( github.com/x448/float16 v0.8.4 // indirect go.opencensus.io v0.24.0 // indirect go.uber.org/atomic v1.10.0 // indirect - golang.org/x/net v0.7.0 // indirect + golang.org/x/net v0.9.0 // indirect golang.org/x/oauth2 v0.5.0 // indirect golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.5.0 // indirect - golang.org/x/text v0.7.0 // indirect + golang.org/x/sys v0.7.0 // indirect + golang.org/x/text v0.9.0 // indirect google.golang.org/api v0.110.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230221151758-ace64dc21148 // indirect diff --git a/services/go.sum b/services/go.sum index 63aa90bab..d9a328b9f 100644 --- a/services/go.sum +++ b/services/go.sum @@ -1392,8 +1392,8 @@ golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= -golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1516,8 +1516,8 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220706163947-c90051bbdb60/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/oauth2 v0.0.0-20180227000427-d7d64896b5ff/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -1684,8 +1684,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -1702,8 +1702,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/tools/go.mod b/tools/go.mod index 69a5f3b8a..ed71bf9b3 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -11,9 +11,9 @@ require ( github.com/gocarina/gocsv v0.0.0-20220927221512-ad3251f9fa25 github.com/golang/mock v1.6.0 github.com/google/uuid v1.3.0 - github.com/hashicorp/vault v1.12.5 + github.com/hashicorp/vault v1.12.7 github.com/hashicorp/vault/api v1.8.1 - github.com/hashicorp/vault/sdk v0.6.1-0.20230302210543-38f40f637f4f + github.com/hashicorp/vault/sdk v0.6.1-0.20230427140652-b4b396ffc14f github.com/keybase/go-crypto v0.0.0-20200123153347-de78d2cb44f4 github.com/rs/zerolog v1.28.0 github.com/satori/go.uuid v1.2.0 @@ -23,8 +23,8 @@ require ( github.com/spf13/cobra v1.6.1 github.com/spf13/viper v1.13.0 github.com/stretchr/testify v1.8.1 - golang.org/x/crypto v0.6.0 - golang.org/x/term v0.5.0 + golang.org/x/crypto v0.8.0 + golang.org/x/term v0.7.0 gopkg.in/macaroon.v2 v2.1.0 gopkg.in/yaml.v2 v2.4.0 gotest.tools v2.2.0+incompatible @@ -196,10 +196,10 @@ require ( go.opencensus.io v0.24.0 // indirect go.uber.org/atomic v1.10.0 // indirect golang.org/x/mod v0.8.0 // indirect - golang.org/x/net v0.7.0 // indirect + golang.org/x/net v0.9.0 // indirect golang.org/x/oauth2 v0.5.0 // indirect - golang.org/x/sys v0.5.0 // indirect - golang.org/x/text v0.7.0 // indirect + golang.org/x/sys v0.7.0 // indirect + golang.org/x/text v0.9.0 // indirect golang.org/x/time v0.1.0 // indirect golang.org/x/tools v0.6.0 // indirect google.golang.org/api v0.110.0 // indirect diff --git a/tools/go.sum b/tools/go.sum index 733145ac4..e3746ce88 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -917,12 +917,12 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= -github.com/hashicorp/vault v1.12.5 h1:c5wNu14Lp/UOTEROVWy74lTIaZO/5+dnmzaoUflN8dw= -github.com/hashicorp/vault v1.12.5/go.mod h1:35dTzdQDYFI83xdVUfDC4o0a1WZmMpfr6F3+XRTV+2k= +github.com/hashicorp/vault v1.12.7 h1:T+nWB2Ihe6xiNelLfC1BMJhV0dgJngDgRW8EiG6/em8= +github.com/hashicorp/vault v1.12.7/go.mod h1:TkP77qkpNyb7kXeZlLLsj0luGitsq5BzRtaBoXgSCs4= github.com/hashicorp/vault/api v1.8.1 h1:bMieWIe6dAlqAAPReZO/8zYtXaWUg/21umwqGZpEjCI= github.com/hashicorp/vault/api v1.8.1/go.mod h1:uJrw6D3y9Rv7hhmS17JQC50jbPDAZdjZoTtrCCxxs7E= -github.com/hashicorp/vault/sdk v0.6.1-0.20230302210543-38f40f637f4f h1:bdWa/SckATQyKiElmR/TDPNfRILakE9RvFFrH6sefY8= -github.com/hashicorp/vault/sdk v0.6.1-0.20230302210543-38f40f637f4f/go.mod h1:XduFY2J0HMoM4mt4kkxlrrkF8bYowzUc2Gog6epWVsA= +github.com/hashicorp/vault/sdk v0.6.1-0.20230427140652-b4b396ffc14f h1:0KmxboDYCgT0rssFOTOkqVkLGbueORiGpkfVA6r5LQs= +github.com/hashicorp/vault/sdk v0.6.1-0.20230427140652-b4b396ffc14f/go.mod h1:XduFY2J0HMoM4mt4kkxlrrkF8bYowzUc2Gog6epWVsA= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -1603,8 +1603,8 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= -golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1729,8 +1729,8 @@ golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/oauth2 v0.0.0-20180227000427-d7d64896b5ff/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -1907,16 +1907,16 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1927,8 +1927,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/tools/payments/cmd/create/go.mod b/tools/payments/cmd/create/go.mod index ca9b25c7c..b53b4c875 100644 --- a/tools/payments/cmd/create/go.mod +++ b/tools/payments/cmd/create/go.mod @@ -6,7 +6,7 @@ go 1.20 require ( filippo.io/age v1.1.1 - github.com/hashicorp/vault v1.13.1 + github.com/hashicorp/vault v1.13.3 ) require ( diff --git a/tools/payments/cmd/create/go.sum b/tools/payments/cmd/create/go.sum index 84b5e2b50..2b9a8b00c 100644 --- a/tools/payments/cmd/create/go.sum +++ b/tools/payments/cmd/create/go.sum @@ -1,7 +1,7 @@ filippo.io/age v1.1.1 h1:pIpO7l151hCnQ4BdyBujnGP2YlUo0uj6sAVNHGBvXHg= filippo.io/age v1.1.1/go.mod h1:l03SrzDUrBkdBx8+IILdnn2KZysqQdbEBUQ4p3sqEQE= -github.com/hashicorp/vault v1.13.1 h1:4Q31hCWCwD2XRCwGL+9gsrCwxn6+ngToBgjT0FCTG+M= -github.com/hashicorp/vault v1.13.1/go.mod h1:aD5a/VHOADe8hiMHx4rYJwv6W6+1WYlco/hf8MWbYaw= +github.com/hashicorp/vault v1.13.3 h1:zmKMhLBMotUy4//Vdx2+Sa/U0epEy8LMtdQBGQOMLS8= +github.com/hashicorp/vault v1.13.3/go.mod h1:+tySoVOldtS+rQfvOh0nqY67YjnkkiTTSLQvwaBKR0w= golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= From 308b4488ede01cfc1e299f58eb5c598469574517 Mon Sep 17 00:00:00 2001 From: Harold Spencer Jr Date: Mon, 26 Jun 2023 06:36:27 -0700 Subject: [PATCH 15/22] Updated aws-actions/configure-aws-credentials to v2 (https://github.com/brave-intl/bsg-infra/issues/647) (#1856) --- .github/workflows/generalized-deployments.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/generalized-deployments.yaml b/.github/workflows/generalized-deployments.yaml index 4fc8b781e..a831c5106 100644 --- a/.github/workflows/generalized-deployments.yaml +++ b/.github/workflows/generalized-deployments.yaml @@ -14,7 +14,7 @@ jobs: - name: Checkout uses: actions/checkout@v2 - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v1 + uses: aws-actions/configure-aws-credentials@v2 with: aws-access-key-id: ${{ secrets.GDBP_AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.GDBP_AWS_SECRET_ACCESS_KEY }} From d3ce2734a953f04ea16b28919ac24c9214bb03be Mon Sep 17 00:00:00 2001 From: Pavel Brm <5097196+pavelbrm@users.noreply.github.com> Date: Tue, 27 Jun 2023 01:37:38 +1200 Subject: [PATCH 16/22] Use Go module cache for tests in CI (#1875) --- .github/workflows/ci.yml | 23 +++++++++++++++++++++-- Dockerfile | 20 +++++++------------- Makefile | 6 ++++-- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09b298f8d..05723ea36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,9 +50,19 @@ jobs: uses: actions/checkout@v3 - name: Set up Go + id: setup-go uses: actions/setup-go@v4 with: go-version: ${{ matrix.goversion }} + cache-dependency-path: "**/go.sum" + + - name: Ensure Module Path + run: mkdir -p /opt/go/pkg/mod + + - name: Copy From Module Cache + if: steps.setup-go.outputs.cache-hit == 'true' + run: | + rsync -au "/home/runner/go/pkg/" "/opt/go/pkg" - name: Install Docker Compose uses: KengoTODA/actions-setup-docker-compose@92cbaf8ac8c113c35e1cedd1182f217043fbdd00 @@ -64,9 +74,18 @@ jobs: - name: Start Vault run: | docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d vault; - sleep 3; + sleep 3 - name: Run Tests run: | export VAULT_TOKEN=$(docker logs grant-vault 2>&1 | grep "Root Token" | tail -1 | cut -d ' ' -f 3 ); - docker-compose -f docker-compose.yml -f docker-compose.dev.yml run --rm dev make; + docker-compose -f docker-compose.yml -f docker-compose.dev.yml run --rm -v /opt/go/pkg:/go/pkg dev make + + - name: Ensure Module Directory + if: steps.setup-go.outputs.cache-hit != 'true' + run: mkdir -p /home/runner/go/pkg + + - name: Copy To Module Cache + run: | + sudo rsync -au "/opt/go/pkg/" "/home/runner/go/pkg" + sudo chown -R runner:runner /home/runner/go/pkg diff --git a/Dockerfile b/Dockerfile index 0e1aa7eba..0ae3bec81 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,9 @@ FROM golang:1.18-alpine as builder - -# put certs in builder image +# Put certs in builder image. RUN apk update RUN apk add -U --no-cache ca-certificates && update-ca-certificates -RUN apk add make -RUN apk add build-base -RUN apk add git -RUN apk add bash +RUN apk add make build-base git bash ARG VERSION ARG BUILD_TIME @@ -15,19 +11,18 @@ ARG COMMIT WORKDIR /src COPY . ./ -RUN chown -R nobody:nobody /src/ -RUN mkdir /.cache -RUN chown -R nobody:nobody /.cache + +RUN chown -R nobody:nobody /src/ && mkdir /.cache && chown -R nobody:nobody /.cache USER nobody -RUN cd main && go mod download -RUN cd main && CGO_ENABLED=0 GOOS=linux go build \ +RUN cd main && go mod download && CGO_ENABLED=0 GOOS=linux go build \ -ldflags "-w -s -X main.version=${VERSION} -X main.buildTime=${BUILD_TIME} -X main.commit=${COMMIT}" \ -o bat-go main.go FROM alpine:3.15 as base -# put certs in artifact from builder + +# Put certs in artifact from builder. COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /src/main/bat-go /bin/ @@ -38,4 +33,3 @@ FROM base as artifact COPY --from=builder /src/migrations/ /migrations/ EXPOSE 3333 CMD ["bat-go", "serve", "grant", "--enable-job-workers", "true"] - diff --git a/Makefile b/Makefile index ea8587d09..0aa9e7826 100644 --- a/Makefile +++ b/Makefile @@ -189,8 +189,7 @@ format: format-lint: make format && make lint -lint: - docker volume create batgo_lint_gomod +lint: ensure-gomod-volume docker run --rm -v "$$(pwd):/app" -v batgo_lint_gomod:/go/pkg --workdir /app/main golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... docker run --rm -v "$$(pwd):/app" -v batgo_lint_gomod:/go/pkg --workdir /app/cmd golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... docker run --rm -v "$$(pwd):/app" -v batgo_lint_gomod:/go/pkg --workdir /app/libs golangci/golangci-lint:v1.49.0 golangci-lint run -v ./... @@ -208,3 +207,6 @@ download-mod: cd ./serverless/email/status && go mod download && cd ../../.. cd ./serverless/email/unsubscribe && go mod download && cd ../../.. cd ./serverless/email/webhook && go mod download && cd ../../.. + +ensure-gomod-volume: + docker volume create batgo_lint_gomod From 735b61a3ca2d03822ccf6f4f05f12f2bce80a354 Mon Sep 17 00:00:00 2001 From: Pavel Brm <5097196+pavelbrm@users.noreply.github.com> Date: Wed, 28 Jun 2023 14:06:04 +1200 Subject: [PATCH 17/22] Wait for dependencies in compose (#1877) --- docker-compose.yml | 205 ++++++++++++++++++++++++++++----------------- 1 file changed, 129 insertions(+), 76 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 6d5754d24..2144c2def 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,73 +21,77 @@ services: COMMIT: "${COMMIT}" BUILD_TIME: "${BUILD_TIME}" environment: - - PPROF_ENABLED=true - - ENABLE_LINKING_DRAINING=true - - DRAIN_RETRY_JOB_ENABLED=true - - ENV=local - - DEBUG=1 + - "ALLOWED_ORIGINS=http://localhost:8080" + - AWS_ACCESS_KEY_ID=dummy + - AWS_SECRET_ACCESS_KEY=dummy + - AWS_REGION=us-west-2 - BAT_SETTLEMENT_ADDRESS + - BITFLYER_CLIENT_ID + - BITFLYER_CLIENT_SECRET + - BITFLYER_EXTRA_CLIENT_SECRET + - BITFLYER_DRYRUN + - BITFLYER_SERVER + - BITFLYER_SOURCE_FROM + - BITFLYER_TOKEN - BRAVE_TRANSFER_PROMOTION_ID - - COINGECKO_SERVICE - - COINGECKO_APIKEY - CHALLENGE_BYPASS_SERVER=http://challenge-bypass:2416 - CHALLENGE_BYPASS_TOKEN + - COINGECKO_APIKEY + - COINGECKO_SERVICE - "DATABASE_MIGRATIONS_URL=file:///src/migrations" - "DATABASE_URL=postgres://grants:password@postgres/grants?sslmode=disable" + - DEBUG=1 - DONOR_WALLET_CARD_ID - DONOR_WALLET_PRIVATE_KEY - DONOR_WALLET_PUBLIC_KEY + - DRAIN_RETRY_JOB_ENABLED=true + - "DYNAMODB_ENDPOINT=http://dynamodb:8000" + - ENABLE_LINKING_DRAINING=true + - ENV=local + - GEMINI_API_KEY + - GEMINI_API_SECRET + - GEMINI_BROWSER_CLIENT_ID + - GEMINI_CLIENT_ID + - GEMINI_CLIENT_SECRET + - GEMINI_SERVER + - GEMINI_TEST_DESTINATION_ID + - GRANT_CBP_SIGN_CONSUMER_TOPIC=sign.consumer # unsigned order creds request + - GRANT_CBP_SIGN_CONSUMER_TOPIC_DLQ=sign.consumer.dlq # unsigned order creds request dlq + - GRANT_CBP_SIGN_PRODUCER_TOPIC=sign.producer # signed orders creds result - GRANT_SIGNATOR_PUBLIC_KEY - GRANT_WALLET_CARD_ID - - REPUTATION_SERVER - - REPUTATION_TOKEN - GRANT_WALLET_PRIVATE_KEY - GRANT_WALLET_PUBLIC_KEY - - TEST_PKG - - TEST_RUN - KAFKA_BROKERS=kafka:19092 + - KAFKA_CONSUMER_GROUP_PROMOTIONS=grant-bat-promotions-local + - KAFKA_CONSUMER_GROUP_SIGNED_ORDER_CREDENTIALS=grant-bat-skus-local + - KAFKA_REQUIRED_ACKS=1 - KAFKA_SSL_CA_LOCATION=/etc/kafka/secrets/snakeoil-ca-1.crt - KAFKA_SSL_CERTIFICATE_LOCATION=/etc/kafka/secrets/consumer-ca1-signed.pem - KAFKA_SSL_KEY_LOCATION=/etc/kafka/secrets/consumer.client.key - - KAFKA_REQUIRED_ACKS=1 - - KAFKA_CONSUMER_GROUP_PROMOTIONS=grant-bat-promotions-local - - KAFKA_CONSUMER_GROUP_SIGNED_ORDER_CREDENTIALS=grant-bat-skus-local - - GRANT_CBP_SIGN_CONSUMER_TOPIC=sign.consumer # unsigned order creds request - - GRANT_CBP_SIGN_CONSUMER_TOPIC_DLQ=sign.consumer.dlq # unsigned order creds request dlq - - GRANT_CBP_SIGN_PRODUCER_TOPIC=sign.producer # signed orders creds result - - TOKEN_LIST - - UPHOLD_ACCESS_TOKEN + - PPROF_ENABLED=true - "RATIOS_SERVICE=https://ratios.rewards.bravesoftware.com" - RATIOS_TOKEN - - GEMINI_SERVER - - GEMINI_CLIENT_ID - - GEMINI_CLIENT_SECRET - - GEMINI_BROWSER_CLIENT_ID - - GEMINI_API_KEY - - GEMINI_API_SECRET - - GEMINI_TEST_DESTINATION_ID - - BITFLYER_SERVER - - BITFLYER_SOURCE_FROM - - BITFLYER_DRYRUN - - BITFLYER_CLIENT_ID - - BITFLYER_CLIENT_SECRET - - BITFLYER_EXTRA_CLIENT_SECRET - - BITFLYER_TOKEN - - "ALLOWED_ORIGINS=http://localhost:8080" + - REPUTATION_SERVER + - REPUTATION_TOKEN - SKUS_WHITELIST - - "DYNAMODB_ENDPOINT=http://dynamodb:8000" - - AWS_ACCESS_KEY_ID=dummy - - AWS_SECRET_ACCESS_KEY=dummy - - AWS_REGION=us-west-2 + - TEST_PKG + - TEST_RUN + - TOKEN_LIST + - UPHOLD_ACCESS_TOKEN volumes: - ./test/secrets:/etc/kafka/secrets - ./migrations:/src/migrations - "out:/out" depends_on: - - redis - - kafka - - postgres - - challenge-bypass + redis: + condition: service_healthy + postgres: + condition: service_healthy + challenge-bypass: + condition: service_started + kafka: + condition: service_healthy networks: - grant @@ -106,64 +110,74 @@ services: security_opt: - no-new-privileges:true environment: - - OUTPUT_DIR="/out" - - "DYNAMODB_ENDPOINT=http://dynamodb:8000" - AWS_ACCESS_KEY_ID=dummy - AWS_SECRET_ACCESS_KEY=dummy - AWS_REGION=us-west-2 - - PPROF_ENABLED=true - - ENABLE_LINKING_DRAINING=true - - DRAIN_RETRY_JOB_ENABLED=true - - ENV=local - - PKG - - RUN - - DEBUG=1 - BAT_SETTLEMENT_ADDRESS + - BITFLYER_CLIENT_ID + - BITFLYER_CLIENT_SECRET + - BITFLYER_EXTRA_CLIENT_SECRET + - BITFLYER_DRYRUN + - BITFLYER_SERVER + - BITFLYER_SOURCE_FROM + - BITFLYER_TOKEN - CHALLENGE_BYPASS_SERVER=http://challenge-bypass:2416 - CHALLENGE_BYPASS_TOKEN + - COINGECKO_APIKEY + - COINGECKO_SERVICE - "DATABASE_MIGRATIONS_URL=file:///src/migrations" - "DATABASE_URL=postgres://grants:password@postgres/grants?sslmode=disable" + - DEBUG=1 - DONOR_WALLET_CARD_ID - DONOR_WALLET_PRIVATE_KEY - DONOR_WALLET_PUBLIC_KEY + - DRAIN_RETRY_JOB_ENABLED=true + - "DYNAMODB_ENDPOINT=http://dynamodb:8000" + - ENABLE_LINKING_DRAINING=true - ENCRYPTION_KEY + - ENV=local - FEATURE_MERCHANT + - GEMINI_API_KEY + - GEMINI_API_SECRET + - GEMINI_BROWSER_CLIENT_ID + - GEMINI_CLIENT_ID + - GEMINI_CLIENT_SECRET + - GEMINI_SERVER + - GEMINI_TEST_DESTINATION_ID + - GRANT_CBP_SIGN_CONSUMER_TOPIC=sign.consumer # unsigned order creds request + - GRANT_CBP_SIGN_CONSUMER_TOPIC_DLQ=sign.consumer.dlq # unsigned order creds request dlq + - GRANT_CBP_SIGN_PRODUCER_TOPIC=sign.producer # signed orders creds result - GRANT_SIGNATOR_PUBLIC_KEY - GRANT_WALLET_CARD_ID - GRANT_WALLET_PRIVATE_KEY - GRANT_WALLET_PUBLIC_KEY - - TEST_PKG - - TEST_RUN - KAFKA_BROKERS=kafka:19092 + - KAFKA_CONSUMER_GROUP_PROMOTIONS=grant-bat-promotions-local + - KAFKA_CONSUMER_GROUP_SIGNED_ORDER_CREDENTIALS=grant-bat-skus-local + - KAFKA_REQUIRED_ACKS=1 - KAFKA_SSL_CA_LOCATION=/etc/kafka/secrets/snakeoil-ca-1.crt - KAFKA_SSL_CERTIFICATE_LOCATION=/etc/kafka/secrets/consumer-ca1-signed.pem - KAFKA_SSL_KEY_LOCATION=/etc/kafka/secrets/consumer.client.key - - KAFKA_REQUIRED_ACKS=1 - - KAFKA_CONSUMER_GROUP_PROMOTIONS=grant-bat-promotions-local + - OUTPUT_DIR="/out" + - PKG + - PPROF_ENABLED=true + - RUN + - TEST_PKG + - TEST_RUN - TOKEN_LIST - UPHOLD_ACCESS_TOKEN - - GEMINI_SERVER - - GEMINI_CLIENT_ID - - GEMINI_CLIENT_SECRET - - GEMINI_BROWSER_CLIENT_ID - - GEMINI_API_KEY - - GEMINI_API_SECRET - - GEMINI_TEST_DESTINATION_ID - - BITFLYER_SERVER - - BITFLYER_SOURCE_FROM - - BITFLYER_DRYRUN - - BITFLYER_CLIENT_ID - - BITFLYER_CLIENT_SECRET - - BITFLYER_EXTRA_CLIENT_SECRET - - BITFLYER_TOKEN volumes: - ./test/secrets:/etc/kafka/secrets - ./migrations:/src/migrations depends_on: - - redis - - kafka - - postgres - - challenge-bypass + redis: + condition: service_healthy + postgres: + condition: service_healthy + challenge-bypass: + condition: service_started + kafka: + condition: service_healthy networks: - grant @@ -174,6 +188,12 @@ services: - "6379:6379" networks: - grant + healthcheck: + test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 5s postgres: container_name: grant-postgres @@ -187,6 +207,12 @@ services: networks: - grant command: ["postgres", "-c", "log_statement=all"] + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 5s challenge-bypass-postgres: container_name: challenge-bypass-postgres @@ -197,6 +223,12 @@ services: - "TZ=UTC" networks: - grant + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 5s challenge-bypass: container_name: challenge-bypass @@ -225,8 +257,10 @@ services: - AWS_SECRET_ACCESS_KEY=dummy - AWS_REGION=us-west-2 depends_on: - - challenge-bypass-postgres - - dynamodb + challenge-bypass-postgres: + condition: service_healthy + dynamodb: + condition: service_healthy volumes: - ./test/secrets:/etc/kafka/secrets networks: @@ -241,6 +275,12 @@ services: - "2181:2181" networks: - grant + healthcheck: + test: ["CMD-SHELL", "nc -z localhost 2181 || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s kafka: container_name: grant-kafka @@ -264,9 +304,16 @@ services: volumes: - ./test/secrets:/etc/kafka/secrets depends_on: - - zookeeper + zookeeper: + condition: service_healthy networks: - grant + healthcheck: + test: ["CMD-SHELL", "nc -z localhost 29092 || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s dynamodb: container_name: dynamodb @@ -275,3 +322,9 @@ services: - grant ports: - "8000:8000" + healthcheck: + test: ["CMD-SHELL", "curl -Is http://localhost:8000/shell/ | grep HTTP || exit 1"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 5s From 31d02534aa26bd1aec9b7d1dede9ef8493b355b6 Mon Sep 17 00:00:00 2001 From: husobee Date: Wed, 28 Jun 2023 07:57:41 -0400 Subject: [PATCH 18/22] add xyzabc as custodian for wallet linking (#1876) * add xyzabc as custodian for wallet linking * Patches For 1876. (#1879) * Implement my review suggestions * Continue after checking errors --------- Co-authored-by: Pavel Brm <5097196+pavelbrm@users.noreply.github.com> --- libs/context/keys.go | 4 + services/grant/cmd/grant.go | 9 ++ services/wallet/controllers_v3.go | 52 +++++++++++ services/wallet/controllers_v3_test.go | 124 +++++++++++++++++++++++++ services/wallet/inputs.go | 54 ++++++++++- services/wallet/service.go | 84 ++++++++++++++++- 6 files changed, 321 insertions(+), 6 deletions(-) diff --git a/libs/context/keys.go b/libs/context/keys.go index 28d414a49..eacdec568 100644 --- a/libs/context/keys.go +++ b/libs/context/keys.go @@ -53,6 +53,10 @@ const ( BuildTimeCTXKey CTXKey = "build_time" // ReputationClientCTXKey - context key for the build time of code ReputationClientCTXKey CTXKey = "reputation_client" + // XyzAbcLinkingKeyCTXKey - context key for the build time of code + XyzAbcLinkingKeyCTXKey CTXKey = "xyzabc_linking_key" + // DisableXyzAbcLinkingCTXKey - context key for the build time of code + DisableXyzAbcLinkingCTXKey CTXKey = "disable_xyzabc_linking" // GeminiClientCTXKey - context key for the build time of code GeminiClientCTXKey CTXKey = "gemini_client" // GeminiBrowserClientIDCTXKey - context key for the gemini browser client id diff --git a/services/grant/cmd/grant.go b/services/grant/cmd/grant.go index a7bc8db09..e39206911 100644 --- a/services/grant/cmd/grant.go +++ b/services/grant/cmd/grant.go @@ -208,6 +208,11 @@ func init() { Bind("gemini-client-secret"). Env("GEMINI_CLIENT_SECRET") + flagBuilder.Flag().String("xyzabc-linking-key", "", + "the linking key for xyzabc custodian"). + Bind("xyzabc-linking-key"). + Env("XYZABC_LINKING_KEY") + // bitflyer credentials flagBuilder.Flag().String("bitflyer-client-id", "", "tells bitflyer what the client id is during token generation"). @@ -532,7 +537,11 @@ func GrantServer( ctx = context.WithValue(ctx, appctx.GeminiClientIDCTXKey, viper.GetString("gemini-client-id")) ctx = context.WithValue(ctx, appctx.GeminiClientSecretCTXKey, viper.GetString("gemini-client-secret")) + // xyzabc wallet linking signing key + ctx = context.WithValue(ctx, appctx.XyzAbcLinkingKeyCTXKey, viper.GetString("xyzabc-linking-key")) + // linking variables + ctx = context.WithValue(ctx, appctx.DisableXyzAbcLinkingCTXKey, viper.GetBool("disable-xyzabc-linking")) ctx = context.WithValue(ctx, appctx.DisableUpholdLinkingCTXKey, viper.GetBool("disable-uphold-linking")) ctx = context.WithValue(ctx, appctx.DisableGeminiLinkingCTXKey, viper.GetBool("disable-gemini-linking")) ctx = context.WithValue(ctx, appctx.DisableBitflyerLinkingCTXKey, viper.GetBool("disable-bitflyer-linking")) diff --git a/services/wallet/controllers_v3.go b/services/wallet/controllers_v3.go index c157c8bb1..07a998f77 100644 --- a/services/wallet/controllers_v3.go +++ b/services/wallet/controllers_v3.go @@ -211,6 +211,58 @@ func LinkBitFlyerDepositAccountV3(s *Service) func(w http.ResponseWriter, r *htt } } +// LinkXyzAbcDepositAccountV3 returns a handler which handles deposit account linking of xyzabc wallets. +func LinkXyzAbcDepositAccountV3(s *Service) func(w http.ResponseWriter, r *http.Request) *handlers.AppError { + return func(w http.ResponseWriter, r *http.Request) *handlers.AppError { + ctx := r.Context() + + // Check whether it's disabled. + if disable, ok := ctx.Value(appctx.DisableXyzAbcLinkingCTXKey).(bool); ok && disable { + const msg = "Connecting Brave Rewards to XyzAbc is temporarily unavailable. Please try again later" + return handlers.ValidationError(msg, nil) + } + + id := &inputs.ID{} + logger := logging.Logger(ctx, "wallet.LinkXyzAbcDepositAccountV3") + + if err := inputs.DecodeAndValidateString(ctx, id, chi.URLParam(r, "paymentID")); err != nil { + logger.Warn().Str("paymentID", err.Error()).Msg("failed to decode and validate paymentID from url") + + const msg = "error validating paymentID url parameter" + return handlers.ValidationError(msg, map[string]interface{}{"paymentID": err.Error()}) + } + + // Check that payment id matches what was in the http signature. + signatureID, err := middleware.GetKeyID(ctx) + if err != nil { + const msg = "error validating paymentID url parameter" + return handlers.ValidationError(msg, map[string]interface{}{"paymentID": err.Error()}) + } + + if id.String() != signatureID { + const msg = "paymentId from URL does not match paymentId in http signature" + return handlers.ValidationError(msg, map[string]interface{}{ + "paymentID": "does not match http signature id", + }) + } + + xalr := &XyzAbcLinkingRequest{} + if err := inputs.DecodeAndValidateReader(ctx, xalr, r.Body); err != nil { + return HandleErrorsXyzAbc(err) + } + + if err := s.LinkXyzAbcWallet(ctx, *id.UUID(), xalr.VerificationToken, xalr.DepositID); err != nil { + if errors.Is(err, errorutils.ErrInvalidCountry) { + return handlers.WrapError(err, "region not supported", http.StatusBadRequest) + } + + return handlers.WrapError(err, "error linking wallet", http.StatusBadRequest) + } + + return handlers.RenderContent(ctx, map[string]interface{}{}, w, http.StatusOK) + } +} + // LinkGeminiDepositAccountV3 - produces an http handler for the service s which handles deposit account linking of uphold wallets func LinkGeminiDepositAccountV3(s *Service) func(w http.ResponseWriter, r *http.Request) *handlers.AppError { return func(w http.ResponseWriter, r *http.Request) *handlers.AppError { diff --git a/services/wallet/controllers_v3_test.go b/services/wallet/controllers_v3_test.go index 1c7cc8278..4354dc80f 100644 --- a/services/wallet/controllers_v3_test.go +++ b/services/wallet/controllers_v3_test.go @@ -7,6 +7,7 @@ import ( "crypto/ed25519" "crypto/sha256" "database/sql" + "encoding/base64" "encoding/hex" "fmt" "io/ioutil" @@ -689,6 +690,129 @@ func TestLinkGeminiWalletV3FirstLinking(t *testing.T) { } } +func TestLinkXyzAbcWalletV3(t *testing.T) { + wallet.VerifiedWalletEnable = true + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + // setup jwt token for the test + var secret = []byte("a jwt secret") + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: secret}, (&jose.SignerOptions{}).WithType("JWT")) + if err != nil { + panic(err) + } + + var ( + // setup test variables + idFrom = uuid.NewV4() + ctx = middleware.AddKeyID(context.Background(), idFrom.String()) + accountID = uuid.NewV4() + idTo = accountID + + // setup db mocks + db, mock, _ = sqlmock.New() + datastore = wallet.Datastore( + &wallet.Postgres{ + datastoreutils.Postgres{ + DB: sqlx.NewDb(db, "postgres"), + }, + }) + roDatastore = wallet.ReadOnlyDatastore( + &wallet.Postgres{ + datastoreutils.Postgres{ + DB: sqlx.NewDb(db, "postgres"), + }, + }) + + // setup mock clients + mockReputationClient = mockreputation.NewMockClient(mockCtrl) + + s, _ = wallet.InitService(datastore, nil, nil, nil, nil, nil) + handler = wallet.LinkXyzAbcDepositAccountV3(s) + w = httptest.NewRecorder() + ) + + ctx = context.WithValue(ctx, appctx.DatastoreCTXKey, datastore) + ctx = context.WithValue(ctx, appctx.RODatastoreCTXKey, roDatastore) + ctx = context.WithValue(ctx, appctx.ReputationClientCTXKey, mockReputationClient) + ctx = context.WithValue(ctx, appctx.NoUnlinkPriorToDurationCTXKey, "-P1D") + ctx = context.WithValue(ctx, appctx.XyzAbcLinkingKeyCTXKey, base64.StdEncoding.EncodeToString(secret)) + + linkingInfo, err := jwt.Signed(sig).Claims(map[string]interface{}{ + "accountId": accountID, "deposit_id": idTo, + }).CompactSerialize() + if err != nil { + panic(err) + } + + // this is our main request + r := httptest.NewRequest( + "POST", + fmt.Sprintf("/v3/wallet/xyzabc/%s/claim", idFrom), + bytes.NewBufferString(fmt.Sprintf(` + { + "linking_info": "%s", + "deposit_id": "%s" + }`, linkingInfo, idTo)), + ) + + mockReputationClient.EXPECT().IsLinkingReputable( + gomock.Any(), // ctx + gomock.Any(), // wallet id + gomock.Any(), // country + ).Return( + true, + []int{}, + nil, + ) + + // begin linking tx + mock.ExpectBegin() + + // make sure old linking id matches new one for same custodian + linkingID := uuid.NewV5(wallet.ClaimNamespace, idTo.String()) + var linkingIDRows = sqlmock.NewRows([]string{"linking_id"}).AddRow(linkingID) + + // acquire lock for linkingID + mock.ExpectExec("^SELECT pg_advisory_xact_lock\\(hashtext(.+)\\)").WithArgs(linkingID.String()). + WillReturnResult(sqlmock.NewResult(1, 1)) + + mock.ExpectQuery("^select linking_id from (.+)").WithArgs(idFrom, "xyzabc").WillReturnRows(linkingIDRows) + + // updates the link to the wallet_custodian record in wallets + mock.ExpectExec("^update wallet_custodian (.+)").WithArgs(idFrom).WillReturnResult(sqlmock.NewResult(1, 1)) + + // this wallet has been linked prior, with the same linking id that the request is with + // SHOULD SKIP THE linking limit checks + clRows := sqlmock.NewRows([]string{"created_at", "linked_at"}). + AddRow(time.Now(), time.Now()) + + // insert into wallet custodian + mock.ExpectQuery("^insert into wallet_custodian (.+)").WithArgs(idFrom, "xyzabc", uuid.NewV5(wallet.ClaimNamespace, accountID.String())).WillReturnRows(clRows) + + // updates the link to the wallet_custodian record in wallets + mock.ExpectExec("^update wallets (.+)").WithArgs(idTo, linkingID, "xyzabc", idFrom).WillReturnResult(sqlmock.NewResult(1, 1)) + + mock.ExpectExec("^insert into (.+)").WithArgs(idFrom, true).WillReturnResult(sqlmock.NewResult(1, 1)) + + // commit transaction + mock.ExpectCommit() + + r = r.WithContext(ctx) + + router := chi.NewRouter() + router.Post("/v3/wallet/xyzabc/{paymentID}/claim", handlers.AppHandler(handler).ServeHTTP) + router.ServeHTTP(w, r) + + if resp := w.Result(); resp.StatusCode != http.StatusOK { + t.Logf("%+v\n", resp) + body, err := ioutil.ReadAll(resp.Body) + t.Logf("%s, %+v\n", body, err) + must(t, "invalid response", fmt.Errorf("expected %d, got %d", http.StatusOK, resp.StatusCode)) + } +} + func TestLinkGeminiWalletV3(t *testing.T) { wallet.VerifiedWalletEnable = true diff --git a/services/wallet/inputs.go b/services/wallet/inputs.go index a14e18996..965be5201 100644 --- a/services/wallet/inputs.go +++ b/services/wallet/inputs.go @@ -29,7 +29,8 @@ var ( // ErrInvalidJSON - the input json is invalid ErrInvalidJSON = errors.New("invalid json") // ErrMissingLinkingInfo - required parameter missing from request - ErrMissingLinkingInfo = errors.New("missing linking information") + ErrMissingLinkingInfo = errors.New("missing linking information") + ErrXyzAbcInvalidVrfToken = errors.New("failed to validate 'linking_info': must not be empty") ) // CustodianName - input validation for custodian name @@ -271,6 +272,57 @@ func (lbdar *LinkBraveDepositAccountRequest) HandleErrors(err error) *handlers.A return handlers.ValidationError("brave link wallet request validation errors", issues) } +// XyzAbcLinkingRequest holds info needed to link xyzabc account. +type XyzAbcLinkingRequest struct { + VerificationToken string `json:"linking_info"` + DepositID string `json:"deposit_id"` +} + +// Validate implements DecodeValidate interface. +func (r *XyzAbcLinkingRequest) Validate(ctx context.Context) error { + if r.VerificationToken == "" { + return ErrXyzAbcInvalidVrfToken + } + + return nil +} + +// Decode implements DecodeValidate interface. +func (r *XyzAbcLinkingRequest) Decode(ctx context.Context, v []byte) error { + if err := inputs.DecodeJSON(ctx, v, r); err != nil { + return fmt.Errorf("failed to decode json: %w", err) + } + + return nil +} + +// HandleErrorsXyzAbc returns an AppError for the given err. +func HandleErrorsXyzAbc(err error) *handlers.AppError { + issues := make(map[string]string) + if errors.Is(err, ErrInvalidJSON) { + issues["invalidJSON"] = err.Error() + } + + var merr *errorutils.MultiError + if errors.As(err, &merr) { + for _, e := range merr.Errs { + msg := e.Error() + + if strings.Contains(msg, "failed decoding") { + issues["decoding"] = msg + continue + } + + if strings.Contains(msg, "failed validation") { + issues["validation"] = msg + continue + } + } + } + + return handlers.ValidationError("xyzabc wallet linking request validation errors", issues) +} + // GeminiLinkingRequest holds info needed to link gemini account type GeminiLinkingRequest struct { VerificationToken string `json:"linking_info"` diff --git a/services/wallet/service.go b/services/wallet/service.go index 17c270494..285ecf251 100644 --- a/services/wallet/service.go +++ b/services/wallet/service.go @@ -3,6 +3,7 @@ package wallet import ( "context" "database/sql" + "encoding/base64" "encoding/hex" "errors" "fmt" @@ -12,6 +13,13 @@ import ( "sync" "time" + "github.com/go-chi/chi" + "github.com/go-jose/go-jose/v3/jwt" + "github.com/lib/pq" + uuid "github.com/satori/go.uuid" + "github.com/shopspring/decimal" + "github.com/spf13/viper" + "github.com/brave-intl/bat-go/libs/altcurrency" appaws "github.com/brave-intl/bat-go/libs/aws" "github.com/brave-intl/bat-go/libs/backoff" @@ -30,11 +38,6 @@ import ( "github.com/brave-intl/bat-go/libs/wallet/provider" "github.com/brave-intl/bat-go/libs/wallet/provider/uphold" "github.com/brave-intl/bat-go/services/cmd" - "github.com/go-chi/chi" - "github.com/lib/pq" - uuid "github.com/satori/go.uuid" - "github.com/shopspring/decimal" - "github.com/spf13/viper" ) // ReputationGeoEnable - enable geo reputation check @@ -272,6 +275,8 @@ func RegisterRoutes(ctx context.Context, s *Service, r *chi.Mux) *chi.Mux { "LinkBitFlyerDepositAccount", LinkBitFlyerDepositAccountV3(s))).ServeHTTP) r.Post("/gemini/{paymentID}/claim", middleware.HTTPSignedOnly(s)(middleware.InstrumentHandlerFunc( "LinkGeminiDepositAccount", LinkGeminiDepositAccountV3(s))).ServeHTTP) + r.Post("/xyzabc/{paymentID}/claim", middleware.HTTPSignedOnly(s)(middleware.InstrumentHandlerFunc( + "LinkXyzAbcDepositAccount", LinkXyzAbcDepositAccountV3(s))).ServeHTTP) // disconnect verified custodial wallet if !disableDisconnect { // if disable-disconnect is false then add this route r.Delete("/{custodian}/{paymentID}/claim", middleware.HTTPSignedOnly(s)(middleware.InstrumentHandlerFunc( @@ -285,6 +290,8 @@ func RegisterRoutes(ctx context.Context, s *Service, r *chi.Mux) *chi.Mux { "LinkBitFlyerDepositAccount", LinkBitFlyerDepositAccountV3(s))).ServeHTTP) r.Post("/gemini/{paymentID}/connect", middleware.HTTPSignedOnly(s)(middleware.InstrumentHandlerFunc( "LinkGeminiDepositAccount", LinkGeminiDepositAccountV3(s))).ServeHTTP) + r.Post("/xyzabc/{paymentID}/connect", middleware.HTTPSignedOnly(s)(middleware.InstrumentHandlerFunc( + "LinkXyzAbcDepositAccount", LinkXyzAbcDepositAccountV3(s))).ServeHTTP) // disconnect verified custodial wallet if !disableDisconnect { // if disable-disconnect is false then add this route r.Delete("/{custodian}/{paymentID}/connect", middleware.HTTPSignedOnly(s)(middleware.InstrumentHandlerFunc( @@ -402,6 +409,73 @@ func (service *Service) LinkBitFlyerWallet(ctx context.Context, walletID uuid.UU return nil } +// LinkXyzAbcWallet links a wallet and transfers funds to newly linked wallet. +func (service *Service) LinkXyzAbcWallet(ctx context.Context, walletID uuid.UUID, verificationToken, depositID string) error { + // Get xyzabc linking_info signing key. + linkingKeyB64, ok := ctx.Value(appctx.XyzAbcLinkingKeyCTXKey).(string) + if !ok { + const msg = "xyzabc linking validation misconfigured" + return handlers.WrapError(appctx.ErrNotInContext, msg, http.StatusInternalServerError) + } + + // Decode base64 encoded jwt key. + decodedJWTKey, err := base64.StdEncoding.DecodeString(linkingKeyB64) + if err != nil { + const msg = "xyzabc linking validation misconfigured" + return handlers.WrapError(appctx.ErrNotInContext, msg, http.StatusInternalServerError) + } + + // Parse the signed verification token from input. + tok, err := jwt.ParseSigned(verificationToken) + if err != nil { + const msg = "xyzabc linking info parsing failed" + return handlers.WrapError(appctx.ErrNotInContext, msg, http.StatusBadRequest) + } + + // Create the jwt claims and get them (verified) from the token. + claims := make(map[string]interface{}) + if err := tok.Claims(decodedJWTKey, &claims); err != nil { + const msg = "xyzabc linking info validation failed" + return handlers.WrapError(appctx.ErrNotInContext, msg, http.StatusBadRequest) + } + + // Make sure deposit id matches claims. + if dID, ok := claims["depositId"].(string); ok && dID != depositID { + const msg = "xyzabc deposit id does not match token" + return handlers.WrapError(appctx.ErrNotInContext, msg, http.StatusBadRequest) + } + + // Get the account id. + accountID, ok := claims["accountId"].(string) + if !ok || accountID == "" { + const msg = "xyzabc account id invalid in token" + return handlers.WrapError(appctx.ErrNotInContext, msg, http.StatusBadRequest) + } + + providerLinkingID := uuid.NewV5(ClaimNamespace, accountID) + + // tx.Destination will be stored as UserDepositDestination in the wallet info upon linking. + // FIXME + if err := service.Datastore.LinkWallet(ctx, walletID.String(), depositID, providerLinkingID, nil, "xyzabc", "US"); err != nil { + if errors.Is(err, ErrUnusualActivity) { + return handlers.WrapError(err, "unable to link - unusual activity", http.StatusBadRequest) + } + + if errors.Is(err, ErrGeoResetDifferent) { + return handlers.WrapError(err, "mismatched provider account regions", http.StatusBadRequest) + } + + status := http.StatusInternalServerError + if errors.Is(err, ErrTooManyCardsLinked) { + status = http.StatusConflict + } + + return handlers.WrapError(err, "unable to link gemini wallets", status) + } + + return nil +} + // LinkGeminiWallet links a wallet and transfers funds to newly linked wallet func (service *Service) LinkGeminiWallet(ctx context.Context, walletID uuid.UUID, verificationToken, depositID string) error { // get gemini client from context From 105497c4686ed2365a9f3f0d5484ac4db6dbdcfd Mon Sep 17 00:00:00 2001 From: Pavel Brm <5097196+pavelbrm@users.noreply.github.com> Date: Thu, 29 Jun 2023 14:52:45 +1200 Subject: [PATCH 19/22] Use only one Postgres container (#1878) --- create_dbs.sh | 37 +++++++++++++++++++++++++++++++++++++ docker-compose.dev.yml | 2 +- docker-compose.yml | 25 +++++++------------------ 3 files changed, 45 insertions(+), 19 deletions(-) create mode 100755 create_dbs.sh diff --git a/create_dbs.sh b/create_dbs.sh new file mode 100755 index 000000000..63bf67e1b --- /dev/null +++ b/create_dbs.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -eu + +function create_database_and_user() { + local database=$1 + local user=$2 + local password=$3 + + echo "Creating database with user: $database $user" + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL +CREATE USER $user WITH PASSWORD '$password'; +CREATE DATABASE $database; +GRANT ALL PRIVILEGES ON DATABASE $database TO $user; +EOSQL +} + +if [ -n $POSTGRES_EXTRA_DATABASES ]; then + echo "Creating multiple databases and users: $POSTGRES_EXTRA_DATABASES" + for dup in $(echo $POSTGRES_EXTRA_DATABASES | tr ',' ' '); do + db=$(echo $dup | awk -F":" '{print $1}') + user=$(echo $dup | awk -F":" '{print $2}') + password=$(echo $dup | awk -F":" '{print $3}') + + if [ -z "$user"]; then + user=$db + fi + + if [ -z "$password" ]; then + password=$user + fi + + create_database_and_user $db $user $password + done + + echo "Created multiple databases" +fi diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6857ede33..d5b93339e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -15,7 +15,7 @@ services: - no-new-privileges:true environment: - OUTPUT_DIR="/out" - - "CHALLENGE_BYPASS_DATABASE_URL=postgres://btokens:password@challenge-bypass-postgres/btokens?sslmode=disable" + - "CHALLENGE_BYPASS_DATABASE_URL=postgres://btokens:password@grant-postgres/btokens?sslmode=disable" - "VAULT_ADDR=http://vault:8200" - TEST_TAGS - VAULT_TOKEN diff --git a/docker-compose.yml b/docker-compose.yml index 2144c2def..042b38844 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ version: "3.4" volumes: + pg_data: out: driver_opts: type: tmpfs @@ -203,26 +204,14 @@ services: environment: - "POSTGRES_USER=grants" - "POSTGRES_PASSWORD=password" + - "POSTGRES_EXTRA_DATABASES=btokens:btokens:password" - "TZ=UTC" networks: - grant command: ["postgres", "-c", "log_statement=all"] - healthcheck: - test: ["CMD-SHELL", "pg_isready"] - interval: 5s - timeout: 5s - retries: 5 - start_period: 5s - - challenge-bypass-postgres: - container_name: challenge-bypass-postgres - image: postgres:14 - environment: - - "POSTGRES_USER=btokens" - - "POSTGRES_PASSWORD=password" - - "TZ=UTC" - networks: - - grant + volumes: + - pg_data:/var/lib/postgresql/data + - ./create_dbs.sh:/docker-entrypoint-initdb.d/00_create_dbs.sh healthcheck: test: ["CMD-SHELL", "pg_isready"] interval: 5s @@ -239,7 +228,7 @@ services: environment: - "ENV=devtest" - "SENTRY_DSN" - - "DATABASE_URL=postgres://btokens:password@challenge-bypass-postgres/btokens?sslmode=disable" + - "DATABASE_URL=postgres://btokens:password@grant-postgres/btokens?sslmode=disable" - "DATABASE_MIGRATIONS_URL=file:///src/migrations" - KAFKA_BROKERS=kafka:19092 - KAFKA_SSL_CA_LOCATION=/etc/kafka/secrets/snakeoil-ca-1.crt @@ -257,7 +246,7 @@ services: - AWS_SECRET_ACCESS_KEY=dummy - AWS_REGION=us-west-2 depends_on: - challenge-bypass-postgres: + postgres: condition: service_healthy dynamodb: condition: service_healthy From f78ecbe400070dd5465aca0456dbaa0cb70c433f Mon Sep 17 00:00:00 2001 From: Pavel Brm <5097196+pavelbrm@users.noreply.github.com> Date: Fri, 30 Jun 2023 00:54:10 +1200 Subject: [PATCH 20/22] Allow running with external network (#1880) --- Makefile | 7 +++++++ docker-compose.ext.yml | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 docker-compose.ext.yml diff --git a/Makefile b/Makefile index 0aa9e7826..ddcece1d1 100644 --- a/Makefile +++ b/Makefile @@ -208,5 +208,12 @@ download-mod: cd ./serverless/email/unsubscribe && go mod download && cd ../../.. cd ./serverless/email/webhook && go mod download && cd ../../.. +docker-up-ext: ensure-shared-net + $(eval VAULT_TOKEN = $(shell docker logs grant-vault 2>&1 | grep "Root Token" | tail -1 | cut -d ' ' -f 3 )) + VAULT_TOKEN=$(VAULT_TOKEN) docker-compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.ext.yml run --rm -p 3333:3333 dev /bin/bash + ensure-gomod-volume: docker volume create batgo_lint_gomod + +ensure-shared-net: + if [ -z $$(docker network ls -q -f "name=brave_shared_net") ]; then docker network create brave_shared_net; fi diff --git a/docker-compose.ext.yml b/docker-compose.ext.yml new file mode 100644 index 000000000..da09ac48a --- /dev/null +++ b/docker-compose.ext.yml @@ -0,0 +1,17 @@ +version: "3.4" + +networks: + brave_shared: + name: brave_shared_net + external: true + +services: + dev: + networks: + - grant + - brave_shared + + web: + networks: + - grant + - brave_shared From edc7b85918540f45f3f41f2732ef0be0a43c9de7 Mon Sep 17 00:00:00 2001 From: husobee Date: Thu, 6 Jul 2023 08:25:55 -0400 Subject: [PATCH 21/22] xyzabc fix linking payload (#1881) * remove deposit id from linking for xyzabc, fix json for linkingInfo in payload typo * Fix tests (#1882) --------- Co-authored-by: Pavel Brm <5097196+pavelbrm@users.noreply.github.com> --- services/wallet/controllers_v3.go | 2 +- services/wallet/controllers_v3_test.go | 11 +++++------ services/wallet/inputs.go | 3 +-- services/wallet/service.go | 9 +++++---- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/services/wallet/controllers_v3.go b/services/wallet/controllers_v3.go index 07a998f77..6594714f9 100644 --- a/services/wallet/controllers_v3.go +++ b/services/wallet/controllers_v3.go @@ -251,7 +251,7 @@ func LinkXyzAbcDepositAccountV3(s *Service) func(w http.ResponseWriter, r *http. return HandleErrorsXyzAbc(err) } - if err := s.LinkXyzAbcWallet(ctx, *id.UUID(), xalr.VerificationToken, xalr.DepositID); err != nil { + if err := s.LinkXyzAbcWallet(ctx, *id.UUID(), xalr.VerificationToken); err != nil { if errors.Is(err, errorutils.ErrInvalidCountry) { return handlers.WrapError(err, "region not supported", http.StatusBadRequest) } diff --git a/services/wallet/controllers_v3_test.go b/services/wallet/controllers_v3_test.go index 4354dc80f..7f92712e2 100644 --- a/services/wallet/controllers_v3_test.go +++ b/services/wallet/controllers_v3_test.go @@ -740,7 +740,7 @@ func TestLinkXyzAbcWalletV3(t *testing.T) { ctx = context.WithValue(ctx, appctx.XyzAbcLinkingKeyCTXKey, base64.StdEncoding.EncodeToString(secret)) linkingInfo, err := jwt.Signed(sig).Claims(map[string]interface{}{ - "accountId": accountID, "deposit_id": idTo, + "accountId": accountID, "depositId": idTo, }).CompactSerialize() if err != nil { panic(err) @@ -750,11 +750,10 @@ func TestLinkXyzAbcWalletV3(t *testing.T) { r := httptest.NewRequest( "POST", fmt.Sprintf("/v3/wallet/xyzabc/%s/claim", idFrom), - bytes.NewBufferString(fmt.Sprintf(` - { - "linking_info": "%s", - "deposit_id": "%s" - }`, linkingInfo, idTo)), + bytes.NewBufferString(fmt.Sprintf( + `{"linkingInfo": "%s"}`, + linkingInfo, + )), ) mockReputationClient.EXPECT().IsLinkingReputable( diff --git a/services/wallet/inputs.go b/services/wallet/inputs.go index 965be5201..ea78e3479 100644 --- a/services/wallet/inputs.go +++ b/services/wallet/inputs.go @@ -274,8 +274,7 @@ func (lbdar *LinkBraveDepositAccountRequest) HandleErrors(err error) *handlers.A // XyzAbcLinkingRequest holds info needed to link xyzabc account. type XyzAbcLinkingRequest struct { - VerificationToken string `json:"linking_info"` - DepositID string `json:"deposit_id"` + VerificationToken string `json:"linkingInfo"` } // Validate implements DecodeValidate interface. diff --git a/services/wallet/service.go b/services/wallet/service.go index 285ecf251..d037585f7 100644 --- a/services/wallet/service.go +++ b/services/wallet/service.go @@ -410,7 +410,7 @@ func (service *Service) LinkBitFlyerWallet(ctx context.Context, walletID uuid.UU } // LinkXyzAbcWallet links a wallet and transfers funds to newly linked wallet. -func (service *Service) LinkXyzAbcWallet(ctx context.Context, walletID uuid.UUID, verificationToken, depositID string) error { +func (service *Service) LinkXyzAbcWallet(ctx context.Context, walletID uuid.UUID, verificationToken string) error { // Get xyzabc linking_info signing key. linkingKeyB64, ok := ctx.Value(appctx.XyzAbcLinkingKeyCTXKey).(string) if !ok { @@ -439,8 +439,9 @@ func (service *Service) LinkXyzAbcWallet(ctx context.Context, walletID uuid.UUID return handlers.WrapError(appctx.ErrNotInContext, msg, http.StatusBadRequest) } - // Make sure deposit id matches claims. - if dID, ok := claims["depositId"].(string); ok && dID != depositID { + // Make sure deposit id exists + depositID, ok := claims["depositId"].(string) + if !ok || depositID == "" { const msg = "xyzabc deposit id does not match token" return handlers.WrapError(appctx.ErrNotInContext, msg, http.StatusBadRequest) } @@ -455,7 +456,7 @@ func (service *Service) LinkXyzAbcWallet(ctx context.Context, walletID uuid.UUID providerLinkingID := uuid.NewV5(ClaimNamespace, accountID) // tx.Destination will be stored as UserDepositDestination in the wallet info upon linking. - // FIXME + // FIXME - correct country if err := service.Datastore.LinkWallet(ctx, walletID.String(), depositID, providerLinkingID, nil, "xyzabc", "US"); err != nil { if errors.Is(err, ErrUnusualActivity) { return handlers.WrapError(err, "unable to link - unusual activity", http.StatusBadRequest) From cd02710dc81d50ccc3e50e0e3f03176d2e8b794f Mon Sep 17 00:00:00 2001 From: husobee Date: Fri, 7 Jul 2023 11:52:31 -0400 Subject: [PATCH 22/22] Cust linking xyzabc 3 (#1886) * fix request payload for xyzabc custodian linking * fix xyzabc country code * Fix tests * Add xyzabc to the list of custodians --------- Co-authored-by: PavelBrm --- libs/datastore/postgres.go | 2 +- migrations/0061_wallet_custodian_check_custodian.down.sql | 5 +++++ migrations/0061_wallet_custodian_check_custodian.up.sql | 5 +++++ services/wallet/controllers_v3_test.go | 2 +- services/wallet/datastore.go | 6 +++--- services/wallet/inputs.go | 2 +- services/wallet/service.go | 5 ++--- 7 files changed, 18 insertions(+), 9 deletions(-) create mode 100644 migrations/0061_wallet_custodian_check_custodian.down.sql create mode 100644 migrations/0061_wallet_custodian_check_custodian.up.sql diff --git a/libs/datastore/postgres.go b/libs/datastore/postgres.go index 2a0fc4abe..ef3709b05 100644 --- a/libs/datastore/postgres.go +++ b/libs/datastore/postgres.go @@ -41,7 +41,7 @@ var ( } dbs = map[string]*sqlx.DB{} // CurrentMigrationVersion holds the default migration version - CurrentMigrationVersion = uint(60) + CurrentMigrationVersion = uint(61) // MigrationTracks holds the migration version for a given track (eyeshade, promotion, wallet) MigrationTracks = map[string]uint{ "eyeshade": 20, diff --git a/migrations/0061_wallet_custodian_check_custodian.down.sql b/migrations/0061_wallet_custodian_check_custodian.down.sql new file mode 100644 index 000000000..92050e4ed --- /dev/null +++ b/migrations/0061_wallet_custodian_check_custodian.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE wallet_custodian +DROP CONSTRAINT IF EXISTS check_custodian, +ADD CONSTRAINT check_custodian CHECK ( + custodian IN ('brave', 'uphold', 'bitflyer', 'gemini') +); diff --git a/migrations/0061_wallet_custodian_check_custodian.up.sql b/migrations/0061_wallet_custodian_check_custodian.up.sql new file mode 100644 index 000000000..c68990df3 --- /dev/null +++ b/migrations/0061_wallet_custodian_check_custodian.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE wallet_custodian +DROP CONSTRAINT IF EXISTS check_custodian, +ADD CONSTRAINT check_custodian CHECK ( + custodian IN ('brave', 'uphold', 'bitflyer', 'gemini', 'xyzabc') +); diff --git a/services/wallet/controllers_v3_test.go b/services/wallet/controllers_v3_test.go index 7f92712e2..5772c3109 100644 --- a/services/wallet/controllers_v3_test.go +++ b/services/wallet/controllers_v3_test.go @@ -751,7 +751,7 @@ func TestLinkXyzAbcWalletV3(t *testing.T) { "POST", fmt.Sprintf("/v3/wallet/xyzabc/%s/claim", idFrom), bytes.NewBufferString(fmt.Sprintf( - `{"linkingInfo": "%s"}`, + `{"linking_info": "%s"}`, linkingInfo, )), ) diff --git a/services/wallet/datastore.go b/services/wallet/datastore.go index 59241c2ae..e8921f69a 100644 --- a/services/wallet/datastore.go +++ b/services/wallet/datastore.go @@ -973,7 +973,7 @@ func (pg *Postgres) ConnectCustodialWallet(ctx context.Context, cl *CustodianLin ) values ( $1, $2, $3 ) - on conflict (wallet_id, custodian, linking_id) + on conflict (wallet_id, custodian, linking_id) do update set updated_at=now(), disconnected_at=null, unlinked_at=null, linked_at=now() returning * ` @@ -1019,7 +1019,7 @@ func (pg *Postgres) ConnectCustodialWallet(ctx context.Context, cl *CustodianLin // InsertVerifiedWalletOutboxTx inserts a verifiedWalletOutbox for processing. func (pg *Postgres) InsertVerifiedWalletOutboxTx(ctx context.Context, tx *sqlx.Tx, walletID uuid.UUID, verifiedWallet bool) error { - _, err := tx.ExecContext(ctx, `insert into verified_wallet_outbox(payment_id, verified_wallet) + _, err := tx.ExecContext(ctx, `insert into verified_wallet_outbox(payment_id, verified_wallet) values ($1, $2)`, walletID, verifiedWallet) if err != nil { return fmt.Errorf("error inserting values into vefified wallet outbox: %w", err) @@ -1041,7 +1041,7 @@ func (pg *Postgres) SendVerifiedWalletOutbox(ctx context.Context, client reputat } defer rollback() - err = tx.Get(&vw, `select id, payment_id, verified_wallet from verified_wallet_outbox + err = tx.Get(&vw, `select id, payment_id, verified_wallet from verified_wallet_outbox order by created_at asc for update skip locked limit 1`) if err != nil { return false, fmt.Errorf("error get verified wallet: %w", err) diff --git a/services/wallet/inputs.go b/services/wallet/inputs.go index ea78e3479..c8ebf9448 100644 --- a/services/wallet/inputs.go +++ b/services/wallet/inputs.go @@ -274,7 +274,7 @@ func (lbdar *LinkBraveDepositAccountRequest) HandleErrors(err error) *handlers.A // XyzAbcLinkingRequest holds info needed to link xyzabc account. type XyzAbcLinkingRequest struct { - VerificationToken string `json:"linkingInfo"` + VerificationToken string `json:"linking_info"` } // Validate implements DecodeValidate interface. diff --git a/services/wallet/service.go b/services/wallet/service.go index d037585f7..c1e5a2a60 100644 --- a/services/wallet/service.go +++ b/services/wallet/service.go @@ -456,8 +456,7 @@ func (service *Service) LinkXyzAbcWallet(ctx context.Context, walletID uuid.UUID providerLinkingID := uuid.NewV5(ClaimNamespace, accountID) // tx.Destination will be stored as UserDepositDestination in the wallet info upon linking. - // FIXME - correct country - if err := service.Datastore.LinkWallet(ctx, walletID.String(), depositID, providerLinkingID, nil, "xyzabc", "US"); err != nil { + if err := service.Datastore.LinkWallet(ctx, walletID.String(), depositID, providerLinkingID, nil, "xyzabc", "IN"); err != nil { if errors.Is(err, ErrUnusualActivity) { return handlers.WrapError(err, "unable to link - unusual activity", http.StatusBadRequest) } @@ -471,7 +470,7 @@ func (service *Service) LinkXyzAbcWallet(ctx context.Context, walletID uuid.UUID status = http.StatusConflict } - return handlers.WrapError(err, "unable to link gemini wallets", status) + return handlers.WrapError(err, "unable to link xyzabc wallets", status) } return nil