From f4256b779b935d0def3ac05dbda50675335b023e Mon Sep 17 00:00:00 2001 From: clD11 <23483715+clD11@users.noreply.github.com> Date: Sat, 10 Aug 2024 08:53:28 +0100 Subject: [PATCH] feat: handle radom webhook events --- services/skus/controllers.go | 42 ++ services/skus/radom/event.go | 177 ++++++ services/skus/radom/event_test.go | 892 +++++++++++++++++++++++++++ services/skus/radom/radom.go | 55 ++ services/skus/service.go | 124 ++++ services/skus/service_nonint_test.go | 9 + 6 files changed, 1299 insertions(+) create mode 100644 services/skus/radom/event.go create mode 100644 services/skus/radom/event_test.go diff --git a/services/skus/controllers.go b/services/skus/controllers.go index d04d1c5f2..dce5a72a4 100644 --- a/services/skus/controllers.go +++ b/services/skus/controllers.go @@ -12,6 +12,7 @@ import ( "strconv" "github.com/asaskevich/govalidator" + "github.com/brave-intl/bat-go/services/skus/radom" "github.com/go-chi/chi" "github.com/go-chi/cors" "github.com/go-playground/validator/v10" @@ -1193,6 +1194,47 @@ func handleRadomWebhook(_ *Service) handlers.AppHandler { } } +func handleRadomWebhookH(w http.ResponseWriter, r *http.Request, svc *Service) *handlers.AppError { + ctx := r.Context() + + l := logging.Logger(ctx, "skus").With().Str("func", "handleRadomWebhookH").Logger() + + if err := svc.radomAuth.Authenticate(ctx, r.Header.Get("radom-verification-key")); err != nil { + l.Error().Err(err).Msg("invalid request") + + return handlers.WrapError(err, "invalid request", http.StatusUnauthorized) + } + + b, err := io.ReadAll(io.LimitReader(r.Body, reqBodyLimit10MB)) + if err != nil { + l.Err(err).Msg("failed to read payload") + + return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK) + } + + event, err := radom.ParseEvent(b) + if err != nil { + l.Err(err).Msg("failed to parse radom event") + + return handlers.WrapError(err, "failed to parse radom event", http.StatusBadRequest) + } + + if err := svc.processRadomEvent(ctx, event); err != nil { + l.Err(err).Msg("failed to process radom event") + + return handlers.WrapError(model.ErrSomethingWentWrong, "something went wrong", http.StatusInternalServerError) + } + + msg := "skipped radom notification" + if event.ShouldProcess() { + msg = "processed radom notification" + } + + l.Info().Str("ntf_type", event.EventType).Str("ntf_effect", event.Effect()).Msg(msg) + + return nil +} + func handleStripeWebhook(svc *Service) handlers.AppHandler { return func(w http.ResponseWriter, r *http.Request) *handlers.AppError { ctx := r.Context() diff --git a/services/skus/radom/event.go b/services/skus/radom/event.go new file mode 100644 index 000000000..4b059d6f8 --- /dev/null +++ b/services/skus/radom/event.go @@ -0,0 +1,177 @@ +package radom + +import ( + "context" + "encoding/json" + + "github.com/brave-intl/bat-go/services/skus/model" + uuid "github.com/satori/go.uuid" +) + +const ( + ErrUnsupportedEvent = model.Error("radom: unsupported event type for brave order id") + ErrBraveOrderIDNotFound = model.Error("radom: brave order id not found") + ErrSubscriptionIDNotFound = model.Error("radom: subscription id not found") +) + +type Event struct { + EventType string `json:"eventType"` + EventData EventData `json:"eventData"` + RadomData RadData `json:"radomData"` +} + +func (e *Event) OrderID() (uuid.UUID, error) { + if e.EventData.NewSubscription == nil { + return uuid.Nil, ErrUnsupportedEvent + } + + mdata := e.RadomData.CheckoutSession.Metadata + + for i := range mdata { + d := mdata[i] + if d.Key == "brave_order_id" { + return uuid.FromString(d.Value) + } + } + + return uuid.Nil, ErrBraveOrderIDNotFound +} + +func (e *Event) SubID() (uuid.UUID, error) { + var subID uuid.UUID + + switch { + case e.EventData.NewSubscription != nil: + subID = e.EventData.NewSubscription.SubscriptionID + + case e.EventData.SubscriptionPayment != nil: + subID = e.EventData.SubscriptionPayment.RadomData.Subscription.SubscriptionID + + case e.EventData.SubscriptionCancelled != nil: + subID = e.EventData.SubscriptionCancelled.SubscriptionID + + case e.EventData.SubscriptionExpired != nil: + subID = e.EventData.SubscriptionExpired.SubscriptionID + } + + if uuid.Equal(subID, uuid.Nil) { + return uuid.Nil, ErrSubscriptionIDNotFound + } + + return subID, nil +} + +func (e *Event) IsNewSub() bool { + return e.EventData.NewSubscription != nil +} + +func (e *Event) ShouldRenew() bool { + return e.EventData.SubscriptionPayment != nil +} + +func (e *Event) ShouldCancel() bool { + return e.EventData.SubscriptionCancelled != nil || e.EventData.SubscriptionExpired != nil +} + +func (e *Event) ShouldProcess() bool { + return e.IsNewSub() || e.ShouldRenew() || e.ShouldCancel() +} + +func (e *Event) Effect() string { + switch { + case e.IsNewSub(): + return "new" + + case e.ShouldRenew(): + return "renew" + + case e.ShouldCancel(): + return "cancel" + + default: + return "skip" + } +} + +type EventData struct { + NewSubscription *NewSubscription `json:"newSubscription"` + SubscriptionPayment *SubscriptionPayment `json:"subscriptionPayment"` + SubscriptionCancelled *SubscriptionCancelled `json:"subscriptionCancelled"` + SubscriptionExpired *SubscriptionExpired `json:"subscriptionExpired"` +} + +type NewSubscription struct { + SubscriptionID uuid.UUID `json:"subscriptionId"` +} + +type SubscriptionPayment struct { + RadomData RadData `json:"radomData"` +} + +type SubscriptionCancelled struct { + SubscriptionID uuid.UUID `json:"subscriptionId"` +} + +type SubscriptionExpired struct { + SubscriptionID uuid.UUID `json:"subscriptionId"` +} + +type RadData struct { + CheckoutSession CheckoutSession `json:"checkoutSession"` + Subscription Subscription `json:"subscription"` +} + +type CheckoutSession struct { + CheckoutSessionID string `json:"checkoutSessionId"` + Metadata []Metadata `json:"metadata"` +} + +type Subscription struct { + SubscriptionID uuid.UUID `json:"subscriptionId"` +} + +func ParseEvent(b []byte) (Event, error) { + var event Event + if err := json.Unmarshal(b, &event); err != nil { + return Event{}, err + } + + return event, nil +} + +type MessageAuthConfig struct { + token string + enabled bool +} + +type MessageAuthenticator struct { + cfg MessageAuthConfig +} + +func NewMessageAuthenticator(cfg MessageAuthConfig) *MessageAuthenticator { + return &MessageAuthenticator{ + cfg: cfg, + } +} + +const ( + ErrDisabled = model.Error("radom: radom disabled") + ErrVerificationKeyEmpty = model.Error("radom: verification key is empty") + ErrVerificationKeyInvalid = model.Error("radom: verification key is invalid") +) + +func (r *MessageAuthenticator) Authenticate(_ context.Context, token string) error { + if !r.cfg.enabled { + return ErrDisabled + } + + if token == "" { + return ErrVerificationKeyEmpty + } + + if token != r.cfg.token { + return ErrVerificationKeyInvalid + } + + return nil +} diff --git a/services/skus/radom/event_test.go b/services/skus/radom/event_test.go new file mode 100644 index 000000000..746fd6a02 --- /dev/null +++ b/services/skus/radom/event_test.go @@ -0,0 +1,892 @@ +package radom + +import ( + "context" + "testing" + + uuid "github.com/satori/go.uuid" + + should "github.com/stretchr/testify/assert" + must "github.com/stretchr/testify/require" +) + +func TestParseEvent(t *testing.T) { + type tcGiven struct { + rawEvent string + } + + type tcExpected struct { + event Event + mustErr must.ErrorAssertionFunc + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "new_subscription", + given: tcGiven{ + rawEvent: `{ + "eventType": "newSubscription", + "eventData": { + "newSubscription": { + "subscriptionId": "54453f86-8cfa-4eee-8818-050fc61f560b" + } + }, + "radomData": { + "checkoutSession": { + "checkoutSessionId": "71da4f76-0ac7-47ee-bb51-9d9577232245", + "metadata": [ + { + "key": "brave_order_id", + "value": "b5269191-1d8d-4934-b105-3221da010222" + } + ] + } + } + }`, + }, + exp: tcExpected{ + event: Event{ + EventType: "newSubscription", + EventData: EventData{ + NewSubscription: &NewSubscription{ + SubscriptionID: uuid.FromStringOrNil("54453f86-8cfa-4eee-8818-050fc61f560b"), + }, + }, + RadomData: RadData{ + CheckoutSession: CheckoutSession{ + CheckoutSessionID: "71da4f76-0ac7-47ee-bb51-9d9577232245", + Metadata: []Metadata{ + { + Key: "brave_order_id", + Value: "b5269191-1d8d-4934-b105-3221da010222", + }, + }, + }, + }, + }, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "subscription_payment", + given: tcGiven{ + rawEvent: `{ + "eventType": "subscriptionPayment", + "eventData": { + "subscriptionPayment": { + "radomData": { + "subscription": { + "subscriptionId": "2eb2fcf0-8c73-4e2b-9e94-50f76f8caabe" + } + } + } + }, + "radomData": { + "subscription": { + "subscriptionId": "2eb2fcf0-8c73-4e2b-9e94-50f76f8caabe" + } + } + }`, + }, + exp: tcExpected{ + event: Event{ + EventType: "subscriptionPayment", + EventData: EventData{ + SubscriptionPayment: &SubscriptionPayment{ + RadomData: RadData{ + Subscription: Subscription{ + SubscriptionID: uuid.FromStringOrNil("2eb2fcf0-8c73-4e2b-9e94-50f76f8caabe"), + }, + }, + }, + }, + RadomData: RadData{ + Subscription: Subscription{ + SubscriptionID: uuid.FromStringOrNil("2eb2fcf0-8c73-4e2b-9e94-50f76f8caabe"), + }, + }, + }, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "subscription_cancelled", + given: tcGiven{ + rawEvent: `{ + "eventType": "subscriptionCancelled", + "eventData": { + "subscriptionCancelled": { + "subscriptionId": "56786d4e-a994-4392-952a-a648a0d2870a" + } + } + }`, + }, + exp: tcExpected{ + event: Event{ + EventType: "subscriptionCancelled", + EventData: EventData{ + SubscriptionCancelled: &SubscriptionCancelled{ + SubscriptionID: uuid.FromStringOrNil("56786d4e-a994-4392-952a-a648a0d2870a"), + }, + }, + }, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "subscription_expired", + given: tcGiven{ + rawEvent: `{ + "eventType": "subscriptionExpired", + "eventData": { + "subscriptionExpired": { + "subscriptionId": "56786d4e-a994-4392-952a-a648a0d2870a" + } + } + }`, + }, + exp: tcExpected{ + event: Event{ + EventType: "subscriptionExpired", + EventData: EventData{ + SubscriptionExpired: &SubscriptionExpired{ + SubscriptionID: uuid.FromStringOrNil("56786d4e-a994-4392-952a-a648a0d2870a"), + }, + }, + }, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "unknown_event", + given: tcGiven{ + rawEvent: `{ + "eventType": "unknownEvent", + "eventData": { + "unknownEvent": { + "subscriptionId": "56786d4e-a994-4392-952a-a648a0d2870a" + } + } + }`, + }, + exp: tcExpected{ + event: Event{ + EventType: "unknownEvent", + }, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual, err := ParseEvent([]byte(tc.given.rawEvent)) + tc.exp.mustErr(t, err) + + should.Equal(t, tc.exp.event, actual) + }) + } +} + +func TestEvent_OrderID(t *testing.T) { + type tcGiven struct { + event Event + } + + type tcExpected struct { + oid uuid.UUID + mustErr must.ErrorAssertionFunc + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "unsupported_type", + exp: tcExpected{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, ErrUnsupportedEvent) + }, + }, + }, + + { + name: "invalid_id", + given: tcGiven{ + event: Event{ + EventData: EventData{ + NewSubscription: &NewSubscription{}, + }, + RadomData: RadData{ + CheckoutSession: CheckoutSession{ + Metadata: []Metadata{ + { + Key: "brave_order_id", + Value: "invalid_uuid", + }, + }, + }, + }, + }, + }, + exp: tcExpected{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorContains(t, err, "incorrect UUID") + }, + }, + }, + + { + name: "order_id_found", + given: tcGiven{ + event: Event{ + EventData: EventData{ + NewSubscription: &NewSubscription{}, + }, + RadomData: RadData{ + CheckoutSession: CheckoutSession{ + Metadata: []Metadata{ + { + Key: "brave_order_id", + Value: "053e0244-4e37-48c3-8539-49952ec73f37", + }, + }, + }, + }, + }, + }, + exp: tcExpected{ + oid: uuid.FromStringOrNil("053e0244-4e37-48c3-8539-49952ec73f37"), + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "order_id_not_found", + given: tcGiven{ + event: Event{ + EventData: EventData{ + NewSubscription: &NewSubscription{}, + }, + RadomData: RadData{ + CheckoutSession: CheckoutSession{ + Metadata: []Metadata{ + { + Key: "some_key", + Value: "053e0244-4e37-48c3-8539-49952ec73f37", + }, + }, + }, + }, + }, + }, + exp: tcExpected{ + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, ErrBraveOrderIDNotFound) + }, + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + + actual, err := tc.given.event.OrderID() + tc.exp.mustErr(t, err) + + should.Equal(t, tc.exp.oid, actual) + }) + } +} + +func TestEvent_SubID(t *testing.T) { + type tcGiven struct { + event Event + } + + type tcExpected struct { + sid uuid.UUID + mustErr must.ErrorAssertionFunc + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "new_subscription", + given: tcGiven{ + event: Event{ + EventData: EventData{ + NewSubscription: &NewSubscription{ + SubscriptionID: uuid.FromStringOrNil("d14c5b2e-b719-4504-b034-86e74a932295"), + }, + }, + }, + }, + exp: tcExpected{ + sid: uuid.FromStringOrNil("d14c5b2e-b719-4504-b034-86e74a932295"), + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "new_subscription_nil", + given: tcGiven{ + event: Event{ + EventData: EventData{ + NewSubscription: &NewSubscription{}, + }, + }, + }, + exp: tcExpected{ + sid: uuid.Nil, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, ErrSubscriptionIDNotFound) + }, + }, + }, + + { + name: "subscription_payment", + given: tcGiven{ + event: Event{ + EventData: EventData{ + SubscriptionPayment: &SubscriptionPayment{ + RadomData: RadData{ + Subscription: Subscription{ + SubscriptionID: uuid.FromStringOrNil("d14c5b2e-b719-4504-b034-86e74a932295"), + }, + }, + }, + }, + }, + }, + exp: tcExpected{ + sid: uuid.FromStringOrNil("d14c5b2e-b719-4504-b034-86e74a932295"), + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "subscription_payment_nil", + given: tcGiven{ + event: Event{ + EventData: EventData{ + SubscriptionPayment: &SubscriptionPayment{}, + }, + }, + }, + exp: tcExpected{ + sid: uuid.Nil, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, ErrSubscriptionIDNotFound) + }, + }, + }, + + { + name: "subscription_cancelled", + given: tcGiven{ + event: Event{ + EventData: EventData{ + SubscriptionCancelled: &SubscriptionCancelled{ + SubscriptionID: uuid.FromStringOrNil("d14c5b2e-b719-4504-b034-86e74a932295"), + }, + }, + }, + }, + exp: tcExpected{ + sid: uuid.FromStringOrNil("d14c5b2e-b719-4504-b034-86e74a932295"), + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "subscription_cancelled_nil", + given: tcGiven{ + event: Event{ + EventData: EventData{ + SubscriptionCancelled: &SubscriptionCancelled{}, + }, + }, + }, + exp: tcExpected{ + sid: uuid.Nil, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, ErrSubscriptionIDNotFound) + }, + }, + }, + + { + name: "subscription_expired", + given: tcGiven{ + event: Event{ + EventData: EventData{ + SubscriptionExpired: &SubscriptionExpired{ + SubscriptionID: uuid.FromStringOrNil("d14c5b2e-b719-4504-b034-86e74a932295"), + }, + }, + }, + }, + exp: tcExpected{ + sid: uuid.FromStringOrNil("d14c5b2e-b719-4504-b034-86e74a932295"), + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.NoError(t, err) + }, + }, + }, + + { + name: "subscription_expired_nil", + given: tcGiven{ + event: Event{ + EventData: EventData{ + SubscriptionExpired: &SubscriptionExpired{}, + }, + }, + }, + exp: tcExpected{ + sid: uuid.Nil, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, ErrSubscriptionIDNotFound) + }, + }, + }, + + { + name: "subscription_id_not_found", + exp: tcExpected{ + sid: uuid.Nil, + mustErr: func(t must.TestingT, err error, i ...interface{}) { + must.ErrorIs(t, err, ErrSubscriptionIDNotFound) + }, + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + + actual, err := tc.given.event.SubID() + tc.exp.mustErr(t, err) + + should.Equal(t, tc.exp.sid, actual) + }) + } +} + +func TestEvent_IsNewSub(t *testing.T) { + type tcGiven struct { + event Event + } + + type testCase struct { + name string + given tcGiven + exp bool + } + + tests := []testCase{ + { + name: "new_subscription", + given: tcGiven{ + event: Event{ + EventData: EventData{ + NewSubscription: &NewSubscription{}, + }, + }, + }, + exp: true, + }, + + { + name: "not_new_subscription", + exp: false, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual := tc.given.event.IsNewSub() + should.Equal(t, tc.exp, actual) + }) + } +} + +func TestEvent_ShouldRenew(t *testing.T) { + type tcGiven struct { + event Event + } + + type testCase struct { + name string + given tcGiven + exp bool + } + + tests := []testCase{ + { + name: "subscription_payment", + given: tcGiven{ + event: Event{ + EventData: EventData{ + SubscriptionPayment: &SubscriptionPayment{}, + }, + }, + }, + exp: true, + }, + + { + name: "not_subscription_payment", + exp: false, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual := tc.given.event.ShouldRenew() + should.Equal(t, tc.exp, actual) + }) + } +} + +func TestEvent_ShouldCancel(t *testing.T) { + type tcGiven struct { + event Event + } + + type testCase struct { + name string + given tcGiven + exp bool + } + + tests := []testCase{ + { + name: "subscription_cancelled", + given: tcGiven{ + event: Event{ + EventData: EventData{ + SubscriptionCancelled: &SubscriptionCancelled{}, + }, + }, + }, + exp: true, + }, + + { + name: "not_subscription_cancelled", + exp: false, + }, + + { + name: "subscription_expired", + given: tcGiven{ + event: Event{ + EventData: EventData{ + SubscriptionExpired: &SubscriptionExpired{}, + }, + }, + }, + exp: true, + }, + + { + name: "not_subscription_expired", + exp: false, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual := tc.given.event.ShouldCancel() + should.Equal(t, tc.exp, actual) + }) + } +} + +func TestEvent_ShouldProcess(t *testing.T) { + type tcGiven struct { + event Event + } + + type testCase struct { + name string + given tcGiven + exp bool + } + + tests := []testCase{ + { + name: "should_process_new_subscription", + given: tcGiven{ + event: Event{ + EventData: EventData{ + NewSubscription: &NewSubscription{}, + }, + }, + }, + exp: true, + }, + + { + name: "should_process_subscription_payment", + given: tcGiven{ + event: Event{ + EventData: EventData{ + SubscriptionPayment: &SubscriptionPayment{}, + }, + }, + }, + exp: true, + }, + + { + name: "should_process_subscription_cancelled", + given: tcGiven{ + event: Event{ + EventData: EventData{ + SubscriptionCancelled: &SubscriptionCancelled{}, + }, + }, + }, + exp: true, + }, + + { + name: "should_process_subscription_expired", + given: tcGiven{ + event: Event{ + EventData: EventData{ + SubscriptionExpired: &SubscriptionExpired{}, + }, + }, + }, + exp: true, + }, + + { + name: "not_should_process", + exp: false, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual := tc.given.event.ShouldProcess() + should.Equal(t, tc.exp, actual) + }) + } +} + +func TestEvent_Effect(t *testing.T) { + type tcGiven struct { + event Event + } + + type testCase struct { + name string + given tcGiven + exp string + } + + tests := []testCase{ + { + name: "new", + given: tcGiven{ + event: Event{ + EventData: EventData{ + NewSubscription: &NewSubscription{}, + }, + }, + }, + exp: "new", + }, + + { + name: "renew", + given: tcGiven{ + event: Event{ + EventData: EventData{ + SubscriptionPayment: &SubscriptionPayment{}, + }, + }, + }, + exp: "renew", + }, + + { + name: "cancel", + given: tcGiven{ + event: Event{ + EventData: EventData{ + SubscriptionCancelled: &SubscriptionCancelled{}, + }, + }, + }, + exp: "cancel", + }, + + { + name: "expired", + given: tcGiven{ + event: Event{ + EventData: EventData{ + SubscriptionExpired: &SubscriptionExpired{}, + }, + }, + }, + exp: "cancel", + }, + + { + name: "skip", + exp: "skip", + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + actual := tc.given.event.Effect() + should.Equal(t, tc.exp, actual) + }) + } +} + +func TestMessageAuthenticator_Authenticate(t *testing.T) { + type tcGiven struct { + mAuth MessageAuthenticator + token string + } + + type tcExpected struct { + err error + } + + type testCase struct { + name string + given tcGiven + exp tcExpected + } + + tests := []testCase{ + { + name: "disabled", + given: tcGiven{ + mAuth: MessageAuthenticator{}, + }, + exp: tcExpected{ + err: ErrDisabled, + }, + }, + + { + name: "verification_key_empty", + given: tcGiven{ + mAuth: MessageAuthenticator{ + cfg: MessageAuthConfig{ + enabled: true, + token: "token", + }, + }, + }, + exp: tcExpected{ + err: ErrVerificationKeyEmpty, + }, + }, + + { + name: "verification_key_invalid", + given: tcGiven{ + mAuth: MessageAuthenticator{ + cfg: MessageAuthConfig{ + enabled: true, + token: "token_1", + }, + }, + token: "token_2", + }, + exp: tcExpected{ + err: ErrVerificationKeyInvalid, + }, + }, + + { + name: "success", + given: tcGiven{ + mAuth: MessageAuthenticator{ + cfg: MessageAuthConfig{ + enabled: true, + token: "token_1", + }, + }, + token: "token_1", + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + + actual := tc.given.mAuth.Authenticate(ctx, tc.given.token) + should.ErrorIs(t, tc.exp.err, actual) + }) + } +} diff --git a/services/skus/radom/radom.go b/services/skus/radom/radom.go index 018b31096..4fafff815 100644 --- a/services/skus/radom/radom.go +++ b/services/skus/radom/radom.go @@ -3,8 +3,11 @@ package radom import ( "context" "net/http" + "time" "github.com/brave-intl/bat-go/libs/clients" + "github.com/brave-intl/bat-go/services/skus/model" + uuid "github.com/satori/go.uuid" ) type Client struct { @@ -72,3 +75,55 @@ func (c *Client) CreateCheckoutSession(ctx context.Context, creq CheckoutSession return resp, nil } + +func (c *Client) GetSubscription(ctx context.Context, subID uuid.UUID) (SubscriptionResponse, error) { + req, err := c.client.NewRequest(ctx, http.MethodGet, "/subscription/"+subID.String(), nil, nil) + if err != nil { + return SubscriptionResponse{}, err + } + + req.Header.Add("Authorization", c.authToken) + + var resp SubscriptionResponse + if _, err := c.client.Do(ctx, req, &resp); err != nil { + return SubscriptionResponse{}, err + } + + return resp, nil +} + +type SubscriptionResponse struct { + ID string `json:"id"` + NextBillingDateAt string `json:"nextBillingDateAt"` + Payments []Payment `json:"payments"` +} + +type Payment struct { + Date string `json:"date"` +} + +// TODO set to UTC +func (s *SubscriptionResponse) NextBillingDate() (time.Time, error) { + return time.Parse(time.RFC3339, s.NextBillingDateAt) +} + +func (s *SubscriptionResponse) LastPaid() (time.Time, error) { + if len(s.Payments) <= 0 { + return time.Time{}, model.Error("radom: payments is empty") + } + + var paidAt time.Time + + for i := range s.Payments { + pat, err := time.Parse(time.RFC3339, s.Payments[i].Date) + if err != nil { + return time.Time{}, err + } + + if pat.After(paidAt) { + paidAt = pat + } + } + + return paidAt, nil +} diff --git a/services/skus/service.go b/services/skus/service.go index 8dcce8e5f..21e6ae4c2 100644 --- a/services/skus/service.go +++ b/services/skus/service.go @@ -121,6 +121,11 @@ type gpsMessageAuthenticator interface { type radomClient interface { CreateCheckoutSession(ctx context.Context, creq radom.CheckoutSessionRequest) (radom.CheckoutSessionResponse, error) + GetSubscription(ctx context.Context, subID uuid.UUID) (radom.SubscriptionResponse, error) +} + +type radomMessageAuthenticator interface { + Authenticate(ctx context.Context, token string) error } type Service struct { @@ -148,6 +153,7 @@ type Service struct { radomClient radomClient radomGateway radom.Gateway + radomAuth radomMessageAuthenticator vendorReceiptValid vendorReceiptValidator gpsAuth gpsMessageAuthenticator @@ -256,6 +262,9 @@ func InitService( } } + // TODO add secret + radAuth := radom.NewMessageAuthenticator(radom.MessageAuthConfig{}) + cbClient, err := cbr.New() if err != nil { return nil, err @@ -350,6 +359,7 @@ func InitService( radomClient: radomCl, radomGateway: radomGateway, + radomAuth: radAuth, vendorReceiptValid: rcptValidator, gpsAuth: newGPSNtfAuthenticator(gpsCfg, idv), @@ -2282,6 +2292,120 @@ func (s *Service) processSubmitReceipt(ctx context.Context, req model.ReceiptReq return rcpt, nil } +func (s *Service) processRadomEvent(ctx context.Context, event radom.Event) error { + if !event.ShouldProcess() { + return nil + } + + tx, err := s.Datastore.RawDB().BeginTxx(ctx, nil) // TODO + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if err := s.processRadomEventTx(ctx, tx, event); err != nil { + return err + } + + return tx.Commit() +} + +func (s *Service) processRadomEventTx(ctx context.Context, dbi sqlx.ExtContext, event radom.Event) error { + switch { + case event.IsNewSub(): + oid, err := event.OrderID() + if err != nil { + return err + } + + subID, err := event.SubID() + if err != nil { + return err + } + + rsub, err := s.radomClient.GetSubscription(ctx, subID) + if err != nil { + return err + } + + nxtB, err := rsub.NextBillingDate() + if err != nil { + return err + } + + expAt := nxtB.Add(24 * time.Hour) + + paidAt, err := rsub.LastPaid() + if err != nil { + return err + } + + if err := s.renewOrderWithExpPaidTimeTx(ctx, dbi, oid, expAt, paidAt); err != nil { + return err + } + + if err := s.orderRepo.AppendMetadata(ctx, dbi, oid, "externalID", subID.String()); err != nil { + return err + } + + if err := s.orderRepo.AppendMetadata(ctx, dbi, oid, "paymentProcessor", model.RadomPaymentMethod); err != nil { + return err + } + + case event.ShouldRenew(): + subID, err := event.SubID() + if err != nil { + return err + } + + ord, err := s.orderRepo.GetByExternalID(ctx, dbi, subID.String()) + if err != nil { + return err + } + + rsub, err := s.radomClient.GetSubscription(ctx, subID) + if err != nil { + return err + } + + nxtB, err := rsub.NextBillingDate() + if err != nil { + return err + } + + expAt := nxtB.Add(24 * time.Hour) + + paidAt, err := rsub.LastPaid() + if err != nil { + return err + } + + if err := s.renewOrderWithExpPaidTimeTx(ctx, dbi, ord.ID, expAt, paidAt); err != nil { + return err + } + + case event.ShouldCancel(): + subID, err := event.SubID() + if err != nil { + return err + } + + ord, err := s.orderRepo.GetByExternalID(ctx, dbi, subID.String()) + if err != nil { + return err + } + + if err := s.orderRepo.SetStatus(ctx, dbi, ord.ID, model.OrderStatusCanceled); err != nil { + return err + } + + default: + return model.Error("skus: unknown event type") // TODO better message + } + + return nil +} + func checkOrderReceipt(ctx context.Context, dbi sqlx.QueryerContext, repo orderStoreSvc, orderID uuid.UUID, extID string) error { ord, err := repo.GetByExternalID(ctx, dbi, extID) if err != nil { diff --git a/services/skus/service_nonint_test.go b/services/skus/service_nonint_test.go index 11daee509..4899b00a0 100644 --- a/services/skus/service_nonint_test.go +++ b/services/skus/service_nonint_test.go @@ -3045,6 +3045,7 @@ func Test_newRadomGateway(t *testing.T) { type mockRadomClient struct { fnCreateCheckoutSession func(ctx context.Context, creq radom.CheckoutSessionRequest) (radom.CheckoutSessionResponse, error) + fnGetSubscription func(ctx context.Context, subID uuid.UUID) (radom.SubscriptionResponse, error) } func (m *mockRadomClient) CreateCheckoutSession(ctx context.Context, creq radom.CheckoutSessionRequest) (radom.CheckoutSessionResponse, error) { @@ -3055,6 +3056,14 @@ func (m *mockRadomClient) CreateCheckoutSession(ctx context.Context, creq radom. return m.fnCreateCheckoutSession(ctx, creq) } +func (m *mockRadomClient) GetSubscription(ctx context.Context, subID uuid.UUID) (radom.SubscriptionResponse, error) { + if m.fnGetSubscription == nil { + return radom.SubscriptionResponse{}, nil + } + + return m.fnGetSubscription(ctx, subID) +} + type mockPaidOrderCreator struct { fnCreateOrderPremium func(ctx context.Context, req *model.CreateOrderRequestNew, ordNew *model.OrderNew, items []model.OrderItem) (*model.Order, error) fnRenewOrderWithExpPaidTime func(ctx context.Context, id uuid.UUID, expt, paidt time.Time) error