From a9081eac6c76489f63e681af9a0059c503efbab5 Mon Sep 17 00:00:00 2001 From: Imre Nagi Date: Sun, 7 Jun 2020 11:31:56 +0700 Subject: [PATCH] Add functionality to stop, resume and pause a susbcription Signed-off-by: Imre Nagi --- example/server/server.go | 3 + example/server/subscription.yaml | 7 - gateway/xendit/gateway.go | 3 + manage/manage.go | 28 ++- manage/manager.go | 58 ++++++ manage/subscription.go | 34 ++++ manage/xendit.go | 2 +- server/server.go | 42 ++++ subscription/interface.go | 17 ++ subscription/mocks/controller.go | 96 +++++++++ subscription/subscription.go | 112 +++++++++-- subscription/subscription_test.go | 312 +++++++++++++++++++++++++----- 12 files changed, 627 insertions(+), 87 deletions(-) delete mode 100644 example/server/subscription.yaml create mode 100644 subscription/mocks/controller.go diff --git a/example/server/server.go b/example/server/server.go index 1edd6c9..0e94389 100644 --- a/example/server/server.go +++ b/example/server/server.go @@ -90,4 +90,7 @@ func (s srv) routes() { s.Router.HandleFunc("/payment/xendit/dana/callback", s.paymentSrv.XenditDanaCallbackHandler()).Methods("POST") s.Router.HandleFunc("/payment/xendit/linkaja/callback", s.paymentSrv.XenditLinkAjaCallbackHandler()).Methods("POST") s.Router.HandleFunc("/payment/subscriptions", s.paymentSrv.CreateSubscriptionHandler()).Methods("POST") + s.Router.HandleFunc("/payment/subscriptions/{subscription_number}/pause", s.paymentSrv.PauseSubscriptionHandler()).Methods("POST", "PUT") + s.Router.HandleFunc("/payment/subscriptions/{subscription_number}/stop", s.paymentSrv.StopSubscriptionHandler()).Methods("POST", "PUT") + s.Router.HandleFunc("/payment/subscriptions/{subscription_number}/resume", s.paymentSrv.ResumeSubscriptionHandler()).Methods("POST", "PUT") } diff --git a/example/server/subscription.yaml b/example/server/subscription.yaml deleted file mode 100644 index 4b7911e..0000000 --- a/example/server/subscription.yaml +++ /dev/null @@ -1,7 +0,0 @@ -subscriptions: - - gateway: midtrans - in_use: false - send_email: true - - gateway: xendit - in_use: true - send_email: true diff --git a/gateway/xendit/gateway.go b/gateway/xendit/gateway.go index 6ec6f6f..781e9e8 100644 --- a/gateway/xendit/gateway.go +++ b/gateway/xendit/gateway.go @@ -52,4 +52,7 @@ type xInvoice interface { type xRecurring interface { CreateWithContext(ctx context.Context, data *recurring.CreateParams) (*xgo.RecurringPayment, *xendit.Error) + PauseWithContext(ctx context.Context, data *recurring.PauseParams) (*xgo.RecurringPayment, *xendit.Error) + ResumeWithContext(ctx context.Context, data *recurring.ResumeParams) (*xgo.RecurringPayment, *xendit.Error) + StopWithContext(ctx context.Context, data *recurring.StopParams) (*xgo.RecurringPayment, *xendit.Error) } diff --git a/manage/manage.go b/manage/manage.go index ea53515..394b918 100644 --- a/manage/manage.go +++ b/manage/manage.go @@ -50,6 +50,7 @@ type FailInvoiceRequest struct { Reason string `json:"reason"` } +// CreateSubscriptionRequest contains data for creating subscription type CreateSubscriptionRequest struct { Name string `json:"name"` Description string `json:"description"` @@ -96,11 +97,12 @@ func (csr CreateSubscriptionRequest) ToSubscription() *subscription.Subscription s.TotalReccurence = csr.TotalReccurence s.CardToken = csr.CardToken s.ChargeImmediately = csr.ChargeImmediately - s.Schedule = subscription.Schedule{ - Interval: csr.Schedule.Interval, - IntervalUnit: subscription.NewIntervalUnit(csr.Schedule.IntervalUnit), - StartAt: csr.StartAt(), - } + schedule := subscription.NewSchedule( + csr.Schedule.Interval, + subscription.NewIntervalUnit(csr.Schedule.IntervalUnit), + csr.StartAt(), + ) + s.Schedule = *schedule return s } @@ -116,9 +118,14 @@ func (csr CreateSubscriptionRequest) StartAt() *time.Time { // Interface payment management interface type Interface interface { + invoiceI + subscriptionI + // return the payment methods available in payment service GetPaymentMethods(ctx context.Context, opts ...payment.Option) (*PaymentMethodList, error) +} +type invoiceI interface { // return invoice given its invoice number GetInvoice(ctx context.Context, number string) (*invoice.Invoice, error) @@ -133,9 +140,20 @@ type Interface interface { // FailInvoice make the invoice failed FailInvoice(ctx context.Context, fir *FailInvoiceRequest) (*invoice.Invoice, error) +} +type subscriptionI interface { // CreateSubscription creates new subscription CreateSubscription(ctx context.Context, csr *CreateSubscriptionRequest) (*subscription.Subscription, error) + + // PauseSubscription pause active subscription + PauseSubscription(ctx context.Context, subsNumber string) (*subscription.Subscription, error) + + // ResumeSubscription resume paused subscription + ResumeSubscription(ctx context.Context, subsNumber string) (*subscription.Subscription, error) + + // StopSubscription stop subscription + StopSubscription(ctx context.Context, subsNumber string) (*subscription.Subscription, error) } // XenditProcessor callback handler for xendit diff --git a/manage/manager.go b/manage/manager.go index 5e2ac3e..cbdc842 100644 --- a/manage/manager.go +++ b/manage/manager.go @@ -261,6 +261,64 @@ func (m *Manager) CreateSubscription(ctx context.Context, csr *CreateSubscriptio return s, nil } +// PauseSubscription pause active subscription +func (m *Manager) PauseSubscription(ctx context.Context, subsNumber string) (*subscription.Subscription, error) { + + sub, err := m.subscriptionRepository.FindByNumber(ctx, subsNumber) + if err != nil { + return nil, err + } + + if err := sub.Pause(ctx, m.subscriptionController(payment.GatewayXendit)); err != nil { + return nil, err + } + + if err := m.subscriptionRepository.Save(ctx, sub); err != nil { + return nil, err + } + + return sub, nil + +} + +// ResumeSubscription resume paused subscription +func (m *Manager) ResumeSubscription(ctx context.Context, subsNumber string) (*subscription.Subscription, error) { + + sub, err := m.subscriptionRepository.FindByNumber(ctx, subsNumber) + if err != nil { + return nil, err + } + + if err := sub.Resume(ctx, m.subscriptionController(payment.GatewayXendit)); err != nil { + return nil, err + } + + if err := m.subscriptionRepository.Save(ctx, sub); err != nil { + return nil, err + } + + return sub, nil +} + +// StopSubscription stop subscription +func (m *Manager) StopSubscription(ctx context.Context, subsNumber string) (*subscription.Subscription, error) { + + sub, err := m.subscriptionRepository.FindByNumber(ctx, subsNumber) + if err != nil { + return nil, err + } + + if err := sub.Stop(ctx, m.subscriptionController(payment.GatewayXendit)); err != nil { + return nil, err + } + + if err := m.subscriptionRepository.Save(ctx, sub); err != nil { + return nil, err + } + + return sub, nil +} + func (m Manager) subscriptionController(gateway payment.Gateway) subscription.Controller { return &xenditSubscriptionController{ XenditGateway: m.xenditGateway, diff --git a/manage/subscription.go b/manage/subscription.go index c000430..5e99ee9 100644 --- a/manage/subscription.go +++ b/manage/subscription.go @@ -13,6 +13,7 @@ import ( "github.com/imrenagi/go-payment/subscription" goxendit "github.com/xendit/xendit-go" + xrecurring "github.com/xendit/xendit-go/recurringpayment" ) type xenditSubscriptionController struct { @@ -44,6 +45,39 @@ func (sc xenditSubscriptionController) Create(ctx context.Context, sub *subscrip }, nil } +func (sc xenditSubscriptionController) Resume(ctx context.Context, sub *subscription.Subscription) error { + _, err := sc.XenditGateway.Recurring.ResumeWithContext(ctx, &xrecurring.ResumeParams{ + ID: sub.GatewayRecurringID, + }) + var xError *goxendit.Error + if ok := errors.As(err, &xError); ok && xError != nil { + return xError + } + return nil +} + +func (sc xenditSubscriptionController) Stop(ctx context.Context, sub *subscription.Subscription) error { + _, err := sc.XenditGateway.Recurring.StopWithContext(ctx, &xrecurring.StopParams{ + ID: sub.GatewayRecurringID, + }) + var xError *goxendit.Error + if ok := errors.As(err, &xError); ok && xError != nil { + return xError + } + return nil +} + +func (sc xenditSubscriptionController) Pause(ctx context.Context, sub *subscription.Subscription) error { + _, err := sc.XenditGateway.Recurring.PauseWithContext(ctx, &xrecurring.PauseParams{ + ID: sub.GatewayRecurringID, + }) + var xError *goxendit.Error + if ok := errors.As(err, &xError); ok && xError != nil { + return xError + } + return nil +} + func (sc xenditSubscriptionController) Gateway() payment.Gateway { return payment.GatewayXendit } diff --git a/manage/xendit.go b/manage/xendit.go index 062e95e..ac67674 100644 --- a/manage/xendit.go +++ b/manage/xendit.go @@ -227,7 +227,7 @@ func (m Manager) processXenditRecurringTransactionCallback(ctx context.Context, } } - if err := subs.Record(inv); err != nil { + if err := subs.Save(inv); err != nil { return err } diff --git a/server/server.go b/server/server.go index a392988..1d3ab85 100644 --- a/server/server.go +++ b/server/server.go @@ -110,6 +110,48 @@ func (s Server) CreateSubscriptionHandler() http.HandlerFunc { } } +// PauseSubscriptionHandler returns handler for pausing subscription +func (s Server) PauseSubscriptionHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + subscriptionNumber := vars["subscription_number"] + subs, err := s.Manager.PauseSubscription(r.Context(), subscriptionNumber) + if err != nil { + WriteFailResponseFromError(w, err) + return + } + WriteSuccessResponse(w, http.StatusOK, subs, nil) + } +} + +// StopSubscriptionHandler returns stop subscription handler +func (s Server) StopSubscriptionHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + subscriptionNumber := vars["subscription_number"] + subs, err := s.Manager.StopSubscription(r.Context(), subscriptionNumber) + if err != nil { + WriteFailResponseFromError(w, err) + return + } + WriteSuccessResponse(w, http.StatusOK, subs, nil) + } +} + +// ResumeSubscriptionHandler returns resume susbcription handler +func (s Server) ResumeSubscriptionHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + subscriptionNumber := vars["subscription_number"] + subs, err := s.Manager.ResumeSubscription(r.Context(), subscriptionNumber) + if err != nil { + WriteFailResponseFromError(w, err) + return + } + WriteSuccessResponse(w, http.StatusOK, subs, nil) + } +} + // MidtransTransactionCallbackHandler handles incoming notification about payment status from midtrans. func (s *Server) MidtransTransactionCallbackHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/subscription/interface.go b/subscription/interface.go index 311c898..d03dd75 100644 --- a/subscription/interface.go +++ b/subscription/interface.go @@ -1,3 +1,5 @@ +//go:generate mockery -dir . -name Controller -output ./mocks -filename controller.go + package subscription import ( @@ -15,7 +17,22 @@ type creator interface { Create(ctx context.Context, sub *Subscription) (*CreateResponse, error) } +type pauser interface { + Pause(ctx context.Context, sub *Subscription) error +} + +type stopper interface { + Stop(ctx context.Context, stop *Subscription) error +} + +type resumer interface { + Resume(ctx context.Context, sub *Subscription) error +} + // Controller is payment gateway interface for subscription handling type Controller interface { creator + pauser + stopper + resumer } diff --git a/subscription/mocks/controller.go b/subscription/mocks/controller.go new file mode 100644 index 0000000..349a9a5 --- /dev/null +++ b/subscription/mocks/controller.go @@ -0,0 +1,96 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + payment "github.com/imrenagi/go-payment" + mock "github.com/stretchr/testify/mock" + + subscription "github.com/imrenagi/go-payment/subscription" +) + +// Controller is an autogenerated mock type for the Controller type +type Controller struct { + mock.Mock +} + +// Create provides a mock function with given fields: ctx, sub +func (_m *Controller) Create(ctx context.Context, sub *subscription.Subscription) (*subscription.CreateResponse, error) { + ret := _m.Called(ctx, sub) + + var r0 *subscription.CreateResponse + if rf, ok := ret.Get(0).(func(context.Context, *subscription.Subscription) *subscription.CreateResponse); ok { + r0 = rf(ctx, sub) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*subscription.CreateResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *subscription.Subscription) error); ok { + r1 = rf(ctx, sub) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Gateway provides a mock function with given fields: +func (_m *Controller) Gateway() payment.Gateway { + ret := _m.Called() + + var r0 payment.Gateway + if rf, ok := ret.Get(0).(func() payment.Gateway); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(payment.Gateway) + } + + return r0 +} + +// Pause provides a mock function with given fields: ctx, sub +func (_m *Controller) Pause(ctx context.Context, sub *subscription.Subscription) error { + ret := _m.Called(ctx, sub) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *subscription.Subscription) error); ok { + r0 = rf(ctx, sub) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Resume provides a mock function with given fields: ctx, sub +func (_m *Controller) Resume(ctx context.Context, sub *subscription.Subscription) error { + ret := _m.Called(ctx, sub) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *subscription.Subscription) error); ok { + r0 = rf(ctx, sub) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Stop provides a mock function with given fields: ctx, stop +func (_m *Controller) Stop(ctx context.Context, stop *subscription.Subscription) error { + ret := _m.Called(ctx, stop) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *subscription.Subscription) error); ok { + r0 = rf(ctx, stop) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/subscription/subscription.go b/subscription/subscription.go index 26db72f..bd79d5c 100644 --- a/subscription/subscription.go +++ b/subscription/subscription.go @@ -77,6 +77,12 @@ func (s *Subscription) Start(ctx context.Context, c creator) error { s.Status = res.Status s.LastCreatedInvoice = res.LastCreatedInvoiceURL + s.Schedule.PreviousExecutionAt = s.Schedule.StartAt + if s.TotalReccurence == 0 || s.TotalReccurence > 1 { + next := s.Schedule.NextSince(*s.Schedule.PreviousExecutionAt) + s.Schedule.NextExecutionAt = &next + } + return nil } @@ -84,34 +90,79 @@ func (s Subscription) recurrenceProgress() int { return len(s.Invoices) } -func (s *Subscription) Stop() error { +// Pause change the subscription status to paused and stop the schedule +func (s *Subscription) Pause(ctx context.Context, p pauser) error { + + if s.Status != StatusActive { + return fmt.Errorf("can't pause subscription if it is not in active state: %w", payment.ErrCantProceed) + } + + if err := p.Pause(ctx, s); err != nil { + return err + } + s.Status = StatusPaused return nil } -func (s *Subscription) Pause() error { +// Resume ... +func (s *Subscription) Resume(ctx context.Context, r resumer) error { + + if s.Status != StatusPaused { + return fmt.Errorf("can't resume subscription if it is not in paused state: %w", payment.ErrCantProceed) + } + + if err := r.Resume(ctx, s); err != nil { + return err + } + + s.Schedule.NextExecutionAt = s.Schedule.NextAfterPause() + s.Status = StatusActive return nil } -func (s *Subscription) Resume() error { +// Stop should stop subscription +func (s *Subscription) Stop(ctx context.Context, st stopper) error { + + if s.Status == StatusStop { + return fmt.Errorf("subscriptions has been stopped: %w", payment.ErrCantProceed) + } + + if err := st.Stop(ctx, s); err != nil { + return err + } + + s.Schedule.NextExecutionAt = nil + s.Status = StatusStop return nil } -// Record ... -func (s *Subscription) Record(inv *invoice.Invoice) error { +// Save stores invoice created for subscription and renew subscription +// schedule +func (s *Subscription) Save(inv *invoice.Invoice) error { - if s.recurrenceProgress() >= s.TotalReccurence { + if s.TotalReccurence != 0 && s.recurrenceProgress() >= s.TotalReccurence { return fmt.Errorf("should not accept more invoice since all invoices has been recorded %w", payment.ErrCantProceed) } inv.SubscriptionID = &s.ID s.Invoices = append(s.Invoices, *inv) - s.Schedule.ScheduleNext() + + if s.Schedule.NextExecutionAt != nil { + next := s.Schedule.NextSince(*s.Schedule.NextExecutionAt) + s.Schedule.PreviousExecutionAt = s.Schedule.NextExecutionAt + s.Schedule.NextExecutionAt = &next + } return nil } -// Charge should create new invoice belong to the subscription -func (s *Subscription) Charge() (*invoice.Invoice, error) { - return nil, nil +// NewSchedule create new payment schedule +func NewSchedule(interval int, unit IntervalUnit, start *time.Time) *Schedule { + s := &Schedule{ + Interval: interval, + IntervalUnit: unit, + StartAt: start, + } + return s } // Schedule tells when subscription starts and charges @@ -125,16 +176,35 @@ type Schedule struct { NextExecutionAt *time.Time `json:"next_execution_at"` } -// ScheduleNext calculates the next time invoice should be generated -// and update the previous execution time -func (s *Schedule) ScheduleNext() { - var cur *time.Time - if s.PreviousExecutionAt == nil { - cur = s.StartAt - } else { - cur = s.NextExecutionAt +// NextSince ... +func (s *Schedule) NextSince(t time.Time) time.Time { + return t.Add(time.Duration(s.Interval) * s.IntervalUnit.Duration()) +} + +// NextAfterPause calculate when the next payment should be executed after it is paused +func (s *Schedule) NextAfterPause() *time.Time { + + // if this schedule is only one time, thus no next charge + if s.NextExecutionAt == nil { + return nil + } + + now := time.Now() + if s.NextExecutionAt.After(now) { + return s.NextExecutionAt } - next := cur.Add(time.Duration(s.Interval) * s.IntervalUnit.Duration()) - s.NextExecutionAt = &next - s.PreviousExecutionAt = cur + + if s.NextExecutionAt.Before(now) { + var next time.Time + prev := s.NextExecutionAt + for { + next = s.NextSince(*prev) + if next.After(now) { + break + } + prev = &next + } + return &next + } + return nil } diff --git a/subscription/subscription_test.go b/subscription/subscription_test.go index b40e2de..01f7109 100644 --- a/subscription/subscription_test.go +++ b/subscription/subscription_test.go @@ -1,67 +1,273 @@ package subscription_test import ( + "context" + "errors" "testing" "time" + "github.com/imrenagi/go-payment/invoice" + + "github.com/imrenagi/go-payment" + . "github.com/imrenagi/go-payment/subscription" + sm "github.com/imrenagi/go-payment/subscription/mocks" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" ) -func TestSchedule_ScheduleNext(t *testing.T) { +func TestSubscription_Start(t *testing.T) { + + c := &sm.Controller{} + + c.On("Create", mock.Anything, mock.Anything). + Return(&CreateResponse{ + ID: "12345", + Status: StatusActive, + LastCreatedInvoiceURL: "http://example.com", + }, nil) + c.On("Gateway").Return(payment.GatewayXendit) + + t.Run("crete subscription with multiple recurrence", func(t *testing.T) { + + s := New() + startAt := time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local) + s.Schedule = *NewSchedule(1, IntervalUnitDay, &startAt) + + err := s.Start(context.TODO(), c) + assert.Nil(t, err) + assert.Equal(t, payment.GatewayXendit.String(), s.Gateway) + assert.Equal(t, "12345", s.GatewayRecurringID) + assert.Equal(t, StatusActive, s.Status) + assert.Equal(t, "http://example.com", s.LastCreatedInvoice) + + assert.Equal(t, startAt, *s.Schedule.PreviousExecutionAt) + assert.Equal(t, startAt.AddDate(0, 0, 1), *s.Schedule.NextExecutionAt) + + }) + + t.Run("crete subscription with one recurrence", func(t *testing.T) { + + s := New() + s.TotalReccurence = 1 + startAt := time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local) + s.Schedule = *NewSchedule(1, IntervalUnitDay, &startAt) + + err := s.Start(context.TODO(), c) + assert.Nil(t, err) + assert.Equal(t, payment.GatewayXendit.String(), s.Gateway) + assert.Equal(t, "12345", s.GatewayRecurringID) + assert.Equal(t, StatusActive, s.Status) + assert.Equal(t, "http://example.com", s.LastCreatedInvoice) + + assert.Equal(t, startAt, *s.Schedule.PreviousExecutionAt) + assert.Nil(t, s.Schedule.NextExecutionAt) + + }) + +} + +func TestSubscription_Pause(t *testing.T) { + + t.Run("successfully pause", func(t *testing.T) { + + c := &sm.Controller{} + c.On("Pause", mock.Anything, mock.Anything).Return(nil) + + s := New() + startAt := time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local) + s.Schedule = *NewSchedule(1, IntervalUnitDay, &startAt) + s.Status = StatusActive + + err := s.Pause(context.TODO(), c) + assert.Nil(t, err) + + assert.Equal(t, StatusPaused, s.Status) + }) + + t.Run("cant pause if it is nnot in active", func(t *testing.T) { + c := &sm.Controller{} + s := New() + startAt := time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local) + s.Schedule = *NewSchedule(1, IntervalUnitDay, &startAt) + s.Status = StatusPaused + + err := s.Pause(context.TODO(), c) + assert.NotNil(t, err) + assert.True(t, errors.Is(err, payment.ErrCantProceed)) + assert.Equal(t, StatusPaused, s.Status) + }) + +} + +func TestSubscription_Resume(t *testing.T) { + + t.Run("can't resume if it is not paused", func(t *testing.T) { + + c := &sm.Controller{} + c.On("Resume", mock.Anything, mock.Anything).Return(nil) + + s := New() + s.Status = StatusStop + startAt := time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local) + s.Schedule = *NewSchedule(1, IntervalUnitDay, &startAt) + + err := s.Resume(context.TODO(), c) + assert.NotNil(t, err) + assert.True(t, errors.Is(err, payment.ErrCantProceed)) + assert.Equal(t, StatusStop, s.Status) + }) + + t.Run("successfully resume", func(t *testing.T) { + + c := &sm.Controller{} + c.On("Resume", mock.Anything, mock.Anything).Return(nil) + + s := New() + s.Status = StatusPaused + startAt := time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local) + s.Schedule = *NewSchedule(1, IntervalUnitDay, &startAt) + + next := time.Now().Add(-1 * time.Hour) + s.Schedule.NextExecutionAt = &next + + err := s.Resume(context.TODO(), c) + assert.Nil(t, err) + + expected := next.AddDate(0, 0, 1) + assert.Equal(t, StatusActive, s.Status) + assert.Equal(t, expected.Second(), s.Schedule.NextExecutionAt.Second()) + }) + +} + +func TestSubscription_Stop(t *testing.T) { + t.Run("successfully stop", func(t *testing.T) { + + c := &sm.Controller{} + c.On("Stop", mock.Anything, mock.Anything).Return(nil) + + s := New() + s.Status = StatusActive + startAt := time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local) + s.Schedule = *NewSchedule(1, IntervalUnitDay, &startAt) + + next := time.Now().Add(-1 * time.Hour) + s.Schedule.NextExecutionAt = &next + + assert.Equal(t, StatusActive, s.Status) + assert.NotNil(t, s.Schedule.NextExecutionAt) + + err := s.Stop(context.TODO(), c) + assert.Nil(t, err) + + assert.Equal(t, StatusStop, s.Status) + assert.Nil(t, s.Schedule.NextExecutionAt) + }) +} + +func TestSubscription_Save(t *testing.T) { + + t.Run("one time subscription, should save invoice if no invoice is paid/expired", func(t *testing.T) { + s := New() + s.TotalReccurence = 1 + startAt := time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local) + s.Schedule = *NewSchedule(1, IntervalUnitDay, &startAt) + s.Schedule.PreviousExecutionAt = &startAt + + next := time.Now().Add(-1 * time.Hour) + s.Schedule.NextExecutionAt = &next + + assert.Len(t, s.Invoices, 0) + + now := time.Now() + inv := invoice.New(now, now.Add(1*time.Hour)) + + err := s.Save(inv) + assert.Nil(t, err) + assert.Len(t, s.Invoices, 1) + + assert.Equal(t, next.AddDate(0, 0, 1).Second(), s.Schedule.NextExecutionAt.Second()) + }) + + t.Run("one time subscription, should not save invoice if paid before", func(t *testing.T) { + s := New() + s.TotalReccurence = 1 + assert.Len(t, s.Invoices, 0) + + now := time.Now() + inv := invoice.New(now, now.Add(1*time.Hour)) + err := s.Save(inv) + assert.Nil(t, err) + + err = s.Save(inv) + assert.NotNil(t, err) + assert.True(t, errors.Is(err, payment.ErrCantProceed)) + + assert.Len(t, s.Invoices, 1) + }) + + t.Run("save multiple invoice for recurring payment", func(t *testing.T) { + + s := New() + assert.Len(t, s.Invoices, 0) + + now := time.Now() + inv := invoice.New(now, now.Add(1*time.Hour)) + + err := s.Save(inv) + assert.Nil(t, err) + assert.Len(t, s.Invoices, 1) + + err = s.Save(inv) + assert.Nil(t, err) + assert.Len(t, s.Invoices, 2) + + }) + +} + +func TestSchedule_NextAfterPause(t *testing.T) { startAt := time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local) - nextAt := startAt.AddDate(0, 0, 1) - nextAfterAt := nextAt.AddDate(0, 0, 1) - - cases := []struct { - Name string - Schedule Schedule - ExpectedPrevious time.Time - ExpectedNext time.Time - }{ - { - Name: "no prev and next", - Schedule: Schedule{ - Interval: 1, - IntervalUnit: IntervalUnitDay, - StartAt: &startAt, - }, - ExpectedPrevious: startAt, - ExpectedNext: startAt.AddDate(0, 0, 1), - }, - { - Name: "has prev and next", - Schedule: Schedule{ - Interval: 1, - IntervalUnit: IntervalUnitDay, - StartAt: &startAt, - PreviousExecutionAt: &startAt, - NextExecutionAt: &nextAt, - }, - ExpectedPrevious: nextAt, - ExpectedNext: nextAt.AddDate(0, 0, 1), - }, - { - Name: "has prev and next, but prev is different than the start", - Schedule: Schedule{ - Interval: 1, - IntervalUnit: IntervalUnitDay, - StartAt: &startAt, - PreviousExecutionAt: &nextAt, - NextExecutionAt: &nextAfterAt, - }, - ExpectedPrevious: nextAfterAt, - ExpectedNext: nextAfterAt.AddDate(0, 0, 1), - }, - } - - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - c.Schedule.ScheduleNext() - assert.Equal(t, c.ExpectedPrevious, *c.Schedule.PreviousExecutionAt) - assert.Equal(t, c.ExpectedNext, *c.Schedule.NextExecutionAt) - }) - } + + t.Run("one time schedule should has no next execution date", func(t *testing.T) { + s := NewSchedule(1, IntervalUnitDay, &startAt) + assert.Nil(t, s.NextAfterPause()) + }) + + t.Run("next execution date is after now, return next execution date", func(t *testing.T) { + nextAt := time.Now().AddDate(0, 0, 1) + s := NewSchedule(1, IntervalUnitDay, &startAt) + s.NextExecutionAt = &nextAt + assert.Equal(t, nextAt, *s.NextAfterPause()) + }) + + t.Run("next execution date is passed once, find the next execution date after now", func(t *testing.T) { + now := time.Now() + s := NewSchedule(1, IntervalUnitMonth, &now) + start := now.AddDate(0, -2, 0) + next := now.AddDate(0, -1, 0).Add(-5 * time.Hour) + + s.StartAt = &start + s.NextExecutionAt = &next + + expected := now.Add(-5 * time.Hour) + + assert.Equal(t, expected.Second(), s.NextAfterPause().Second()) + }) + + t.Run("next execution date is passed multiple times, find the next execution date after now", func(t *testing.T) { + now := time.Now() + s := NewSchedule(1, IntervalUnitMonth, &now) + start := now.AddDate(0, -5, 0) + next := now.AddDate(0, -4, 0) + + s.StartAt = &start + s.NextExecutionAt = &next + + result := s.NextAfterPause() + assert.True(t, result.After(now)) + }) }