diff --git a/satellite/console/consoleweb/consoleapi/payments.go b/satellite/console/consoleweb/consoleapi/payments.go index e9a3a2b14..0a691f34f 100644 --- a/satellite/console/consoleweb/consoleapi/payments.go +++ b/satellite/console/consoleweb/consoleapi/payments.go @@ -330,6 +330,32 @@ func (p *Payments) ApplyCouponCode(w http.ResponseWriter, r *http.Request) { } } +// HasCouponApplied checks if user has coupon applied to account. +func (p *Payments) HasCouponApplied(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var err error + defer mon.Task()(&ctx)(&err) + + w.Header().Set("Content-Type", "application/json") + + hasCoupon, err := p.service.Payments().HasCouponApplied(ctx) + if err != nil { + if console.ErrUnauthorized.Has(err) { + p.serveJSONError(w, http.StatusUnauthorized, err) + return + } + + p.serveJSONError(w, http.StatusInternalServerError, err) + return + } + + err = json.NewEncoder(w).Encode(&hasCoupon) + + if err != nil { + p.log.Error("failed to return coupon check", zap.Error(ErrPaymentsAPI.Wrap(err))) + } +} + // serveJSONError writes JSON error to response output stream. func (p *Payments) serveJSONError(w http.ResponseWriter, status int, err error) { serveJSONError(p.log, w, status, err) diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index f2afc34f2..0c2dc64f0 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -245,6 +245,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, mail paymentsRouter.HandleFunc("/billing-history", paymentController.BillingHistory).Methods(http.MethodGet) paymentsRouter.HandleFunc("/tokens/deposit", paymentController.TokenDeposit).Methods(http.MethodPost) paymentsRouter.HandleFunc("/couponcodes/apply", paymentController.ApplyCouponCode).Methods(http.MethodPatch) + paymentsRouter.HandleFunc("/couponcodes/coupon", paymentController.HasCouponApplied).Methods(http.MethodGet) bucketsController := consoleapi.NewBuckets(logger, service) bucketsRouter := router.PathPrefix("/api/v0/buckets").Subrouter() diff --git a/satellite/console/service.go b/satellite/console/service.go index c20a1c352..74c962d16 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -540,6 +540,18 @@ func (paymentService PaymentsService) ApplyCouponCode(ctx context.Context, coupo return paymentService.service.accounts.Coupons().ApplyCouponCode(ctx, auth.User.ID, couponCode) } +// HasCouponApplied checks if a user as a coupon applied to their Stripe account. +func (paymentService PaymentsService) HasCouponApplied(ctx context.Context) (_ bool, err error) { + defer mon.Task()(&ctx)(&err) + + auth, err := paymentService.service.getAuthAndAuditLog(ctx, "list coupon codes") + if err != nil { + return false, Error.Wrap(err) + } + + return paymentService.service.accounts.Coupons().HasCouponApplied(ctx, auth.User.ID) +} + // AddPromotionalCoupon creates new coupon for specified user. func (paymentService PaymentsService) AddPromotionalCoupon(ctx context.Context, userID uuid.UUID) (err error) { defer mon.Task()(&ctx, userID)(&err) diff --git a/satellite/console/service_test.go b/satellite/console/service_test.go index 2974473a4..dae7df272 100644 --- a/satellite/console/service_test.go +++ b/satellite/console/service_test.go @@ -430,3 +430,34 @@ func TestMFA(t *testing.T) { }) }) } + +func TestHasCouponApplied(t *testing.T) { + testplanet.Run(t, testplanet.Config{ + SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0, + }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + sat := planet.Satellites[0] + service := sat.API.Console.Service + paymentService := service.Payments() + + user, err := sat.AddUser(ctx, console.CreateUser{ + FullName: "MFA Test User", + Email: "mfauser@mail.test", + }, 1) + require.NoError(t, err) + + authCtx, err := sat.AuthenticatedContext(ctx, user.ID) + require.NoError(t, err) + + hasCoupon, err := paymentService.HasCouponApplied(authCtx) + require.NoError(t, err) + require.False(t, hasCoupon) + + // "testpromocode" defined in satellite/payments/stripecoinpayments/stripemock.go + err = paymentService.ApplyCouponCode(authCtx, "testpromocode") + require.NoError(t, err) + + hasCoupon, err = paymentService.HasCouponApplied(authCtx) + require.NoError(t, err) + require.True(t, hasCoupon) + }) +} diff --git a/satellite/payments/coupons.go b/satellite/payments/coupons.go index 3379dfa11..4da0bd4be 100644 --- a/satellite/payments/coupons.go +++ b/satellite/payments/coupons.go @@ -18,6 +18,9 @@ type Coupons interface { // ListByUserID return list of all coupons of specified payment account. ListByUserID(ctx context.Context, userID uuid.UUID) ([]Coupon, error) + // HasCouponApplied checks if a user as a coupon applied to their Stripe account. + HasCouponApplied(ctx context.Context, userID uuid.UUID) (bool, error) + // TotalUsage returns sum of all usage records for specified coupon. TotalUsage(ctx context.Context, couponID uuid.UUID) (int64, error) diff --git a/satellite/payments/stripecoinpayments/coupons.go b/satellite/payments/stripecoinpayments/coupons.go index 78f05c52a..ae97189be 100644 --- a/satellite/payments/stripecoinpayments/coupons.go +++ b/satellite/payments/stripecoinpayments/coupons.go @@ -239,3 +239,26 @@ func (coupons *coupons) ApplyCouponCode(ctx context.Context, userID uuid.UUID, c return nil } + +// HasCouponApplied checks if a user has a coupon applied to their account. +func (coupons *coupons) HasCouponApplied(ctx context.Context, userID uuid.UUID) (_ bool, err error) { + defer mon.Task()(&ctx, userID)(&err) + + customerID, err := coupons.service.db.Customers().GetCustomerID(ctx, userID) + + if err != nil { + return false, Error.Wrap(err) + } + + customer, err := coupons.service.stripeClient.Customers().Get(customerID, nil) + + if err != nil { + return false, err + } + + if customer.Discount == nil || customer.Discount.Coupon == nil { + return false, nil + } + + return true, nil +} diff --git a/satellite/payments/stripecoinpayments/stripemock.go b/satellite/payments/stripecoinpayments/stripemock.go index 7b61c5465..0fc3cadf1 100644 --- a/satellite/payments/stripecoinpayments/stripemock.go +++ b/satellite/payments/stripecoinpayments/stripemock.go @@ -41,6 +41,8 @@ var mocks = struct { m: make(map[storj.NodeID]*mockStripeState), } +const testPromoCode string = "testpromocode" + // mockStripeState Stripe client mock. type mockStripeState struct { customers *mockCustomersState @@ -78,6 +80,9 @@ func NewStripeMock(id storj.NodeID, customersDB CustomersDB, usersDB console.Use state, ok := mocks.m[id] if !ok { + promoCodes := make(map[string][]*stripe.PromotionCode) + promoCodes[testPromoCode] = []*stripe.PromotionCode{{}} + state = &mockStripeState{ customers: &mockCustomersState{}, paymentMethods: newMockPaymentMethods(), @@ -85,7 +90,9 @@ func NewStripeMock(id storj.NodeID, customersDB CustomersDB, usersDB console.Use invoiceItems: &mockInvoiceItems{}, customerBalanceTransactions: newMockCustomerBalanceTransactions(), charges: &mockCharges{}, - promoCodes: &mockPromoCodes{}, + promoCodes: &mockPromoCodes{ + promoCodes: promoCodes, + }, } mocks.m[id] = state } @@ -219,6 +226,7 @@ func (m *mockCustomers) New(params *stripe.CustomerParams) (*stripe.Customer, er func (m *mockCustomers) Get(id string, params *stripe.CustomerParams) (*stripe.Customer, error) { if err := m.repopulate(); err != nil { + return nil, err } @@ -254,6 +262,9 @@ func (m *mockCustomers) Update(id string, params *stripe.CustomerParams) (*strip if params.Metadata != nil { customer.Metadata = params.Metadata } + if params.PromotionCode != nil { + customer.Discount = &stripe.Discount{Coupon: &stripe.Coupon{}} + } // TODO update customer with more params as necessary diff --git a/satellite/satellitedb/coupons.go b/satellite/satellitedb/coupons.go index 51f96bdb7..0023b59a2 100644 --- a/satellite/satellitedb/coupons.go +++ b/satellite/satellitedb/coupons.go @@ -280,9 +280,9 @@ func (coupons *coupons) GetLatest(ctx context.Context, couponID uuid.UUID) (_ ti defer mon.Task()(&ctx, couponID)(&err) query := coupons.db.Rebind( - `SELECT period - FROM coupon_usages - WHERE coupon_id = ? + `SELECT period + FROM coupon_usages + WHERE coupon_id = ? ORDER BY period DESC LIMIT 1;`, ) diff --git a/web/satellite/src/api/payments.ts b/web/satellite/src/api/payments.ts index bfbcad43b..bd26f07a7 100644 --- a/web/satellite/src/api/payments.ts +++ b/web/satellite/src/api/payments.ts @@ -273,4 +273,23 @@ export class PaymentsHttpApi implements PaymentsApi { } throw new Error(`Can not apply coupon code "${couponCode}"`); } + + /** + * hasCouponApplied checks if a user has a coupon applied in Stripe + * + * @throws Error + */ + public async hasCouponApplied(): Promise { + const path = `${this.ROOT_PATH}/couponcodes/coupon`; + const response = await this.client.get(path); + if (!response.ok) { + if (response.status === 401) { + throw new ErrorUnauthorized(); + } + + throw new Error('cannot list coupons'); + } + + return await response.json(); + } } diff --git a/web/satellite/src/components/account/billing/freeCredits/AddCouponCode.vue b/web/satellite/src/components/account/billing/freeCredits/AddCouponCode.vue index aec2af38e..28aaec900 100644 --- a/web/satellite/src/components/account/billing/freeCredits/AddCouponCode.vue +++ b/web/satellite/src/components/account/billing/freeCredits/AddCouponCode.vue @@ -50,7 +50,6 @@ export default class AddCouponCode extends Vue { public onCloseClick(): void { this.$router.push(RouteConfig.Account.with(RouteConfig.Billing).path); } - } diff --git a/web/satellite/src/components/common/AddCouponCodeInput.vue b/web/satellite/src/components/common/AddCouponCodeInput.vue index 372164893..e17b2dbaf 100644 --- a/web/satellite/src/components/common/AddCouponCodeInput.vue +++ b/web/satellite/src/components/common/AddCouponCodeInput.vue @@ -2,42 +2,67 @@ // See LICENSE for copying information. @@ -51,12 +76,8 @@ import VButton from '@/components/common/VButton.vue'; import CloseIcon from '@/../static/images/common/closeCross.svg'; import CheckIcon from '@/../static/images/common/validCheck.svg'; +import { PaymentsHttpApi } from '@/api/payments'; import { RouteConfig } from '@/router'; -import { PAYMENTS_ACTIONS } from '@/store/modules/payments'; - -const { - APPLY_COUPON_CODE, -} = PAYMENTS_ACTIONS; @Component({ components: { @@ -68,6 +89,7 @@ const { }, }) export default class AddCouponCodeInput extends Vue { + @Prop({default: false}) private showValidationMessage = false; private errorMessage = ''; @@ -75,6 +97,10 @@ export default class AddCouponCodeInput extends Vue { private couponCode = ''; + private showConfirmMessage = false; + + private readonly payments: PaymentsHttpApi = new PaymentsHttpApi(); + /** * Signup view requires some unque styling and element text. */ @@ -94,12 +120,48 @@ export default class AddCouponCodeInput extends Vue { this.couponCode = value; } + /** + * Toggles showing of coupon code replace confirmation message + */ + public toggleConfirmMessage(): void { + this.showConfirmMessage = !this.showConfirmMessage; + } + /** * Check if coupon code is valid */ - public async onApplyClick(): Promise { + public async applyCouponCode(): Promise { try { - await this.$store.dispatch(APPLY_COUPON_CODE, this.couponCode); + await this.payments.applyCouponCode(this.couponCode); + } + catch (error) { + if (this.showConfirmMessage) { + this.toggleConfirmMessage(); + } + this.errorMessage = error.message; + this.isCodeValid = false; + this.showValidationMessage = true; + + return; + } + if (this.showConfirmMessage) { + this.toggleConfirmMessage(); + } + this.isCodeValid = true; + this.showValidationMessage = true; + } + + /** + * Check if user has a coupon code applied to their account before applying + */ + public async couponCheck(): Promise { + try { + const userCouponCodes = await this.payments.hasCouponApplied(); + if (userCouponCodes) { + this.toggleConfirmMessage(); + } else { + this.applyCouponCode(); + } } catch (error) { this.errorMessage = error.message; @@ -108,8 +170,6 @@ export default class AddCouponCodeInput extends Vue { return; } - this.isCodeValid = true; - this.showValidationMessage = true; } } @@ -118,15 +178,6 @@ export default class AddCouponCodeInput extends Vue { .add-coupon { - &__wrapper { - background: #1b2533c7 75%; - position: fixed; - width: 100%; - height: 100%; - top: 0; - left: 0; - } - &__input-wrapper { position: relative; } @@ -167,6 +218,18 @@ export default class AddCouponCodeInput extends Vue { &__input-icon.signup-view { top: 63px; } + + &__confirm-message { + font-family: 'font_regular', sans-serif; + font-size: 18px; + margin-top: 35px; + } + + &__button-wrapper { + display: flex; + justify-content: space-evenly; + margin-top: 30px; + } }