satellite/{payments,console},web/satellite: Adds confirmation step if user already has coupon code applied and wants to replace it
Change-Id: I04d40d3b25bd67e29c043d651541ff300b5379ac
This commit is contained in:
parent
c5d5229716
commit
92c53afb84
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
@ -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;`,
|
||||
)
|
||||
|
@ -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<PaymentsHistoryItem[]> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -50,7 +50,6 @@ export default class AddCouponCode extends Vue {
|
||||
public onCloseClick(): void {
|
||||
this.$router.push(RouteConfig.Account.with(RouteConfig.Billing).path);
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -2,42 +2,67 @@
|
||||
// See LICENSE for copying information.
|
||||
|
||||
<template>
|
||||
<div class="add-coupon__warpperr">
|
||||
<div class="add-coupon__input-wrapper">
|
||||
<img
|
||||
class="add-coupon__input-icon"
|
||||
:class="{'signup-view': isSignupView}"
|
||||
src="@/../static/images/account/billing/coupon.png"
|
||||
alt="Coupon"
|
||||
>
|
||||
<HeaderlessInput
|
||||
:label="inputLabel"
|
||||
placeholder="Enter Coupon Code"
|
||||
class="add-coupon__input"
|
||||
height="52px"
|
||||
:with-icon="true"
|
||||
@setData="setCouponCode"
|
||||
<div class="add-coupon__input-container">
|
||||
<div v-if="!showConfirmMessage">
|
||||
<div class="add-coupon__input-wrapper">
|
||||
<img
|
||||
class="add-coupon__input-icon"
|
||||
:class="{'signup-view': isSignupView}"
|
||||
src="@/../static/images/account/billing/coupon.png"
|
||||
alt="Coupon"
|
||||
>
|
||||
<HeaderlessInput
|
||||
:label="inputLabel"
|
||||
placeholder="Enter Coupon Code"
|
||||
class="add-coupon__input"
|
||||
height="52px"
|
||||
:with-icon="true"
|
||||
@setData="setCouponCode"
|
||||
/>
|
||||
<CheckIcon
|
||||
v-if="isCodeValid"
|
||||
class="add-coupon__check"
|
||||
/>
|
||||
</div>
|
||||
<ValidationMessage
|
||||
class="add-coupon__valid-message"
|
||||
success-message="Successfully applied coupon code."
|
||||
:error-message="errorMessage"
|
||||
:is-valid="isCodeValid"
|
||||
:show-message="showValidationMessage"
|
||||
/>
|
||||
<CheckIcon
|
||||
v-if="isCodeValid"
|
||||
class="add-coupon__check"
|
||||
<VButton
|
||||
v-if="!isSignupView"
|
||||
class="add-coupon__apply-button"
|
||||
label="Apply Coupon Code"
|
||||
width="85%"
|
||||
height="44px"
|
||||
:on-press="couponCheck"
|
||||
/>
|
||||
</div>
|
||||
<ValidationMessage
|
||||
class="add-coupon__valid-message"
|
||||
success-message="Successfully applied coupon code."
|
||||
:error-message="errorMessage"
|
||||
:is-valid="isCodeValid"
|
||||
:show-message="showValidationMessage"
|
||||
/>
|
||||
<VButton
|
||||
v-if="!isSignupView"
|
||||
class="add-coupon__apply-button"
|
||||
label="Apply Coupon Code"
|
||||
width="85%"
|
||||
height="44px"
|
||||
:on-press="onApplyClick"
|
||||
/>
|
||||
<div v-if="showConfirmMessage">
|
||||
<p class="add-coupon__confirm-message">
|
||||
By applying this coupon you will override your existing coupon.
|
||||
Are you sure you want to remove your current coupon and replace it with this new coupon?
|
||||
</p>
|
||||
<div class="add-coupon__button-wrapper">
|
||||
<VButton
|
||||
class="add-coupon__confirm-button"
|
||||
label="Yes"
|
||||
width="250px"
|
||||
height="44px"
|
||||
:on-press="applyCouponCode"
|
||||
/>
|
||||
<VButton
|
||||
class="add-coupon__back-button"
|
||||
label="Back"
|
||||
width="250px"
|
||||
height="44px"
|
||||
is-blue-white="true"
|
||||
:on-press="toggleConfirmMessage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -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<void> {
|
||||
public async applyCouponCode(): Promise<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
Loading…
Reference in New Issue
Block a user