Implement payment method update (#245)

* Implement payment method update

* Add license
This commit is contained in:
Sung Won Cho 2019-08-03 19:22:48 +10:00 committed by GitHub
commit 7e81c2cde6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 760 additions and 208 deletions

View file

@ -346,73 +346,74 @@ func NewRouter(app *App) *mux.Router {
var routes = []Route{
// internal
Route{"GET", "/health", app.checkHealth, false},
Route{"GET", "/me", auth(app.getMe, nil), true},
Route{"POST", "/verification-token", auth(app.createVerificationToken, nil), true},
Route{"PATCH", "/verify-email", app.verifyEmail, true},
Route{"GET", "/auth/{provider}", gothic.BeginAuthHandler, true},
Route{"GET", "/auth/{provider}/callback", app.oauthCallbackHandler, true},
Route{"PATCH", "/account/profile", auth(app.updateProfile, nil), true},
Route{"PATCH", "/account/email", auth(app.updateEmail, nil), true},
Route{"PATCH", "/account/password", auth(app.updatePassword, nil), true},
Route{"GET", "/account/email-preference", tokenAuth(app.getEmailPreference, database.TokenTypeEmailPreference), true},
Route{"PATCH", "/account/email-preference", tokenAuth(app.updateEmailPreference, database.TokenTypeEmailPreference), true},
Route{"POST", "/subscriptions", auth(app.createSub, nil), true},
Route{"PATCH", "/subscriptions", auth(app.updateSub, nil), true},
Route{"POST", "/webhooks/stripe", app.stripeWebhook, true},
Route{"GET", "/subscriptions", auth(app.getSub, nil), true},
Route{"GET", "/stripe_source", auth(app.getStripeSource, nil), true},
Route{"GET", "/notes", auth(app.getNotes, &proOnly), false},
Route{"GET", "/demo/notes", app.getDemoNotes, true},
Route{"GET", "/notes/{noteUUID}", auth(app.getNote, &proOnly), true},
Route{"GET", "/demo/notes/{noteUUID}", app.getDemoNote, true},
Route{"GET", "/calendar", auth(app.getCalendar, &proOnly), true},
Route{"GET", "/demo/calendar", app.getDemoCalendar, true},
Route{"GET", "/digests/{digestUUID}", auth(app.getDigest, &proOnly), true},
Route{"GET", "/demo/digests/{digestUUID}", app.getDemoDigest, true},
Route{"GET", "/digests", auth(app.getDigests, &proOnly), true},
Route{"GET", "/demo/digests", app.getDemoDigests, true},
{"GET", "/health", app.checkHealth, false},
{"GET", "/me", auth(app.getMe, nil), true},
{"POST", "/verification-token", auth(app.createVerificationToken, nil), true},
{"PATCH", "/verify-email", app.verifyEmail, true},
{"GET", "/auth/{provider}", gothic.BeginAuthHandler, true},
{"GET", "/auth/{provider}/callback", app.oauthCallbackHandler, true},
{"PATCH", "/account/profile", auth(app.updateProfile, nil), true},
{"PATCH", "/account/email", auth(app.updateEmail, nil), true},
{"PATCH", "/account/password", auth(app.updatePassword, nil), true},
{"GET", "/account/email-preference", tokenAuth(app.getEmailPreference, database.TokenTypeEmailPreference), true},
{"PATCH", "/account/email-preference", tokenAuth(app.updateEmailPreference, database.TokenTypeEmailPreference), true},
{"POST", "/subscriptions", auth(app.createSub, nil), true},
{"PATCH", "/subscriptions", auth(app.updateSub, nil), true},
{"POST", "/webhooks/stripe", app.stripeWebhook, true},
{"GET", "/subscriptions", auth(app.getSub, nil), true},
{"GET", "/stripe_source", auth(app.getStripeSource, nil), true},
{"PATCH", "/stripe_source", auth(app.updateStripeSource, nil), true},
{"GET", "/notes", auth(app.getNotes, &proOnly), false},
{"GET", "/demo/notes", app.getDemoNotes, true},
{"GET", "/notes/{noteUUID}", auth(app.getNote, &proOnly), true},
{"GET", "/demo/notes/{noteUUID}", app.getDemoNote, true},
{"GET", "/calendar", auth(app.getCalendar, &proOnly), true},
{"GET", "/demo/calendar", app.getDemoCalendar, true},
{"GET", "/digests/{digestUUID}", auth(app.getDigest, &proOnly), true},
{"GET", "/demo/digests/{digestUUID}", app.getDemoDigest, true},
{"GET", "/digests", auth(app.getDigests, &proOnly), true},
{"GET", "/demo/digests", app.getDemoDigests, true},
//Route{"GET", "/books/{bookUUID}", cors(auth(app.getBook)), true},
// routes for user migration to use encryption
Route{"POST", "/legacy/signin", app.legacyPasswordLogin, true},
Route{"POST", "/legacy/register", legacyAuth(app.legacyRegister), true},
Route{"GET", "/legacy/me", legacyAuth(app.getMe), true},
Route{"GET", "/legacy/notes", auth(app.legacyGetNotes, &proOnly), false},
Route{"PATCH", "/legacy/migrate", auth(app.legacyMigrate, &proOnly), false},
Route{"GET", "/auth/{provider}", gothic.BeginAuthHandler, true},
Route{"GET", "/auth/{provider}/callback", app.oauthCallbackHandler, true},
{"POST", "/legacy/signin", app.legacyPasswordLogin, true},
{"POST", "/legacy/register", legacyAuth(app.legacyRegister), true},
{"GET", "/legacy/me", legacyAuth(app.getMe), true},
{"GET", "/legacy/notes", auth(app.legacyGetNotes, &proOnly), false},
{"PATCH", "/legacy/migrate", auth(app.legacyMigrate, &proOnly), false},
{"GET", "/auth/{provider}", gothic.BeginAuthHandler, true},
{"GET", "/auth/{provider}/callback", app.oauthCallbackHandler, true},
// v1
Route{"POST", "/v1/sync", cors(app.Sync), true},
Route{"GET", "/v1/sync/fragment", cors(auth(app.GetSyncFragment, &proOnly)), true},
Route{"GET", "/v1/sync/state", cors(auth(app.GetSyncState, &proOnly)), true},
{"POST", "/v1/sync", cors(app.Sync), true},
{"GET", "/v1/sync/fragment", cors(auth(app.GetSyncFragment, &proOnly)), true},
{"GET", "/v1/sync/state", cors(auth(app.GetSyncState, &proOnly)), true},
Route{"OPTIONS", "/v1/books", cors(app.BooksOptions), false},
Route{"GET", "/v1/demo/books", app.GetDemoBooks, true},
Route{"GET", "/v1/books", cors(auth(app.GetBooks, &proOnly)), true},
Route{"GET", "/v1/books/{bookUUID}", cors(auth(app.GetBook, &proOnly)), true},
Route{"POST", "/v1/books", cors(app.CreateBook), false},
Route{"PATCH", "/v1/books/{bookUUID}", cors(auth(app.UpdateBook, &proOnly)), false},
Route{"DELETE", "/v1/books/{bookUUID}", cors(auth(app.DeleteBook, &proOnly)), false},
{"OPTIONS", "/v1/books", cors(app.BooksOptions), false},
{"GET", "/v1/demo/books", app.GetDemoBooks, true},
{"GET", "/v1/books", cors(auth(app.GetBooks, &proOnly)), true},
{"GET", "/v1/books/{bookUUID}", cors(auth(app.GetBook, &proOnly)), true},
{"POST", "/v1/books", cors(app.CreateBook), false},
{"PATCH", "/v1/books/{bookUUID}", cors(auth(app.UpdateBook, &proOnly)), false},
{"DELETE", "/v1/books/{bookUUID}", cors(auth(app.DeleteBook, &proOnly)), false},
Route{"OPTIONS", "/v1/notes", cors(app.NotesOptions), true},
Route{"POST", "/v1/notes", cors(app.CreateNote), false},
Route{"PATCH", "/v1/notes/{noteUUID}", auth(app.UpdateNote, &proOnly), false},
Route{"DELETE", "/v1/notes/{noteUUID}", auth(app.DeleteNote, &proOnly), false},
{"OPTIONS", "/v1/notes", cors(app.NotesOptions), true},
{"POST", "/v1/notes", cors(app.CreateNote), false},
{"PATCH", "/v1/notes/{noteUUID}", auth(app.UpdateNote, &proOnly), false},
{"DELETE", "/v1/notes/{noteUUID}", auth(app.DeleteNote, &proOnly), false},
Route{"POST", "/v1/register", app.register, true},
Route{"GET", "/v1/presignin", cors(app.presignin), true},
Route{"POST", "/v1/signin", cors(app.signin), true},
Route{"OPTIONS", "/v1/signout", cors(app.signoutOptions), true},
Route{"POST", "/v1/signout", cors(app.signout), true},
{"POST", "/v1/register", app.register, true},
{"GET", "/v1/presignin", cors(app.presignin), true},
{"POST", "/v1/signin", cors(app.signin), true},
{"OPTIONS", "/v1/signout", cors(app.signoutOptions), true},
{"POST", "/v1/signout", cors(app.signout), true},
// v2
Route{"OPTIONS", "/v2/notes", cors(app.NotesOptionsV2), true},
Route{"POST", "/v2/notes", cors(auth(app.CreateNoteV2, &proOnly)), true},
{"OPTIONS", "/v2/notes", cors(app.NotesOptionsV2), true},
{"POST", "/v2/notes", cors(auth(app.CreateNoteV2, &proOnly)), true},
Route{"OPTIONS", "/v2/books", cors(app.BooksOptionsV2), true},
Route{"POST", "/v2/books", cors(auth(app.CreateBookV2, &proOnly)), true},
{"OPTIONS", "/v2/books", cors(app.BooksOptionsV2), true},
{"POST", "/v2/books", cors(auth(app.CreateBookV2, &proOnly)), true},
}
router := mux.NewRouter().StrictSlash(true)

View file

@ -89,6 +89,18 @@ func addCustomerSource(customerID, sourceID string) (*stripe.PaymentSource, erro
return src, nil
}
func removeCustomerSource(customerID, sourceID string) (*stripe.Source, error) {
params := &stripe.SourceObjectDetachParams{
Customer: stripe.String(customerID),
}
s, err := source.Detach(sourceID, params)
if err != nil {
return nil, err
}
return s, nil
}
func createCustomerSubscription(customerID, planID string) (*stripe.Subscription, error) {
subParams := &stripe.SubscriptionParams{
Customer: stripe.String(customerID),
@ -386,6 +398,79 @@ func getStripeCard(stripeCustomerID, sourceID string) (*stripe.Card, error) {
return nil, errors.Errorf("malformed sourceID %s", sourceID)
}
type updateStripeSourcePayload struct {
Source stripe.Source `json:"source"`
Country string `json:"country"`
}
func validateUpdateStripeSourcePayload(p updateStripeSourcePayload) error {
if p.Source.ID == "" {
return errors.New("empty source id")
}
if p.Country == "" {
return errors.New("empty country")
}
return nil
}
func (a *App) updateStripeSource(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {
handleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
var payload updateStripeSourcePayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
handleError(w, "decoding params", err, http.StatusBadRequest)
return
}
if err := validateUpdateStripeSourcePayload(payload); err != nil {
http.Error(w, errors.Wrap(err, "validating payload").Error(), http.StatusBadRequest)
return
}
db := database.DBConn
tx := db.Begin()
if err := tx.Model(&user).
Update(map[string]interface{}{
"billing_country": payload.Country,
}).Error; err != nil {
tx.Rollback()
handleError(w, "updating user", err, http.StatusInternalServerError)
return
}
c, err := customer.Get(user.StripeCustomerID, nil)
if err != nil {
tx.Rollback()
handleError(w, "retriving customer", err, http.StatusInternalServerError)
return
}
if _, err := removeCustomerSource(user.StripeCustomerID, c.DefaultSource.ID); err != nil {
tx.Rollback()
handleError(w, "removing source", err, http.StatusInternalServerError)
return
}
if _, err := addCustomerSource(user.StripeCustomerID, payload.Source.ID); err != nil {
tx.Rollback()
handleError(w, "attaching source", err, http.StatusInternalServerError)
return
}
if err := tx.Commit().Error; err != nil {
tx.Rollback()
handleError(w, "committing transaction", err, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (a *App) getStripeSource(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
if !ok {

View file

@ -0,0 +1,88 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
import React, { useState } from 'react';
import { CardElement } from 'react-stripe-elements';
import classnames from 'classnames';
import styles from './PaymentInput.module.scss';
const elementStyles = {
base: {
color: '#32325D',
fontFamily: 'Source Code Pro, Consolas, Menlo, monospace',
fontSize: '16px',
fontSmoothing: 'antialiased',
'::placeholder': {
color: '#CFD7DF'
},
':-webkit-autofill': {
color: '#e39f48'
}
},
invalid: {
color: '#E25950',
'::placeholder': {
color: '#FFCCA5'
}
}
};
function Card({
cardElementRef,
setCardElementLoaded,
containerClassName,
labelClassName
}) {
const [cardElementFocused, setCardElementFocused] = useState(false);
return (
<div className={classnames(styles['card-row'], containerClassName)}>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label htmlFor="card-number" className={styles.number}>
<span className={classnames(labelClassName)}>Card Number</span>
<CardElement
id="card"
className={classnames(styles['card-number'], styles.input, {
[styles['card-number-active']]: cardElementFocused
})}
onFocus={() => {
setCardElementFocused(true);
}}
onBlur={() => {
setCardElementFocused(false);
}}
onReady={el => {
if (cardElementRef) {
// eslint-disable-next-line no-param-reassign
cardElementRef.current = el;
}
setCardElementLoaded(true);
}}
style={elementStyles}
/>
</label>
</div>
);
}
export default Card;

View file

@ -0,0 +1,46 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
import React from 'react';
import classnames from 'classnames';
import CountrySelect from './CountrySelect';
import styles from './PaymentInput.module.scss';
function NameOnCard({ value, onUpdate, containerClassName, labelClassName }) {
return (
<div className={classnames(containerClassName)}>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label htmlFor="billing-country" className="label-full">
<span className={classnames(labelClassName)}>Country</span>
<CountrySelect
id="billing-country"
className={classnames(styles['countries-select'], styles.input)}
value={value}
onChange={e => {
const val = e.target.value;
onUpdate(val);
}}
/>
</label>
</div>
);
}
export default NameOnCard;

View file

@ -0,0 +1,49 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
import React from 'react';
import classnames from 'classnames';
import styles from './PaymentInput.module.scss';
function NameOnCard({ value, onUpdate, containerClassName, labelClassName }) {
return (
<div className={classnames(containerClassName)}>
<label htmlFor="name-on-card" className="label-full">
<span className={classnames(labelClassName)}>Name on Card</span>
<input
autoFocus
id="name-on-card"
className={classnames(
'text-input text-input-stretch text-input-medium',
styles.input
)}
type="text"
value={value}
onChange={e => {
const val = e.target.value;
onUpdate(val);
}}
/>
</label>
</div>
);
}
export default NameOnCard;

View file

@ -0,0 +1,49 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
@import '../../App/responsive';
@import '../../App/theme';
@import '../../App/font';
@import '../../App/rem';
.input {
margin-top: rem(8px);
}
.card-row {
display: flex;
}
.number {
flex-grow: 1;
}
.card-number {
// match the height of the stripe element with other inputs
padding: rem(10.4px) rem(12px);
border: 2px solid $border-color;
&.card-number-active {
border: 2px solid $third;
}
}
.countries-select {
width: 100%;
display: block;
}

View file

@ -0,0 +1,135 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
import React, { useState, useRef } from 'react';
import { injectStripe } from 'react-stripe-elements';
import Button from '../../../Common/Button';
import NameOnCardInput from '../../../Common/PaymentInput/NameOnCard';
import CardInput from '../../../Common/PaymentInput/Card';
import CountryInput from '../../../Common/PaymentInput/Country';
import settingsStyles from '../../Settings.module.scss';
import * as paymentService from '../../../../services/payment';
import styles from './Form.module.scss';
function Form({
stripe,
nameOnCard,
setNameOnCard,
billingCountry,
setBillingCountry,
inProgress,
onDismiss,
setSuccessMsg,
setInProgress,
doGetSource,
setErrMessage
}) {
const [cardElementLoaded, setCardElementLoaded] = useState(false);
const cardElementRef = useRef(null);
async function handleSubmit(e) {
e.preventDefault();
if (!cardElementLoaded) {
return;
}
if (!nameOnCard) {
setErrMessage('Please enter the name on card');
return;
}
if (!billingCountry) {
setErrMessage('Please enter the country');
return;
}
setSuccessMsg('');
setErrMessage('');
setInProgress(true);
try {
const { source, error } = await stripe.createSource({
type: 'card',
currency: 'usd',
owner: {
name: nameOnCard
}
});
if (error) {
throw error;
}
await paymentService.updateSource({ source, country: billingCountry });
await doGetSource();
setSuccessMsg('Your payment method was successfully updated.');
setInProgress(false);
onDismiss();
} catch (err) {
setErrMessage(`An error occurred: ${err.message}`);
setInProgress(false);
}
}
return (
<form onSubmit={handleSubmit} id="T-payment-method-form">
<div>
<NameOnCardInput
value={nameOnCard}
onUpdate={setNameOnCard}
containerClassName={styles['input-row']}
/>
<CardInput
cardElementRef={cardElementRef}
setCardElementLoaded={setCardElementLoaded}
containerClassName={styles['input-row']}
/>
<CountryInput
value={billingCountry}
onUpdate={setBillingCountry}
containerClassName={styles['input-row']}
/>
</div>
<div className={settingsStyles.actions}>
<Button
type="submit"
kind="first"
isBusy={!cardElementLoaded || inProgress}
>
Update
</Button>
<Button
type="button"
kind="second"
disabled={inProgress}
onClick={onDismiss}
>
Cancel
</Button>
</div>
</form>
);
}
export default injectStripe(Form);

View file

@ -0,0 +1,27 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
@import '../../../App/rem';
@import '../../../App/font';
@import '../../../App/theme';
.input-row {
&:not(:first-child) {
margin-top: rem(10px);
}
}

View file

@ -0,0 +1,93 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
import React, { useState } from 'react';
import { connect } from 'react-redux';
import { StripeProvider, Elements } from 'react-stripe-elements';
import Modal, { Header, Body } from '../../../Common/Modal';
import Flash from '../../../Common/Flash';
import Form from './Form';
function PaymentMethodModal({
isOpen,
onDismiss,
setSuccessMsg,
doGetSource,
stripe
}) {
const [nameOnCard, setNameOnCard] = useState('');
const [billingCountry, setBillingCountry] = useState('');
const [inProgress, setInProgress] = useState(false);
const [errMessage, setErrMessage] = useState('');
const labelId = 'payment-method-modal';
function handleDismiss() {
setNameOnCard('');
setBillingCountry('');
onDismiss();
}
return (
<Modal isOpen={isOpen} onDismiss={handleDismiss} ariaLabelledBy={labelId}>
<Header
labelId={labelId}
heading="Update payment method"
onDismiss={onDismiss}
/>
{errMessage && (
<Flash
type="danger"
onDismiss={() => {
setErrMessage('');
}}
>
{errMessage}
</Flash>
)}
<Body>
<StripeProvider stripe={stripe}>
<Elements>
<Form
nameOnCard={nameOnCard}
setNameOnCard={setNameOnCard}
billingCountry={billingCountry}
setBillingCountry={setBillingCountry}
inProgress={inProgress}
onDismiss={handleDismiss}
setSuccessMsg={setSuccessMsg}
setInProgress={setInProgress}
doGetSource={doGetSource}
setErrMessage={setErrMessage}
/>
</Elements>
</StripeProvider>
</Body>
</Modal>
);
}
const mapDispatchToProps = {};
export default connect(
null,
mapDispatchToProps
)(PaymentMethodModal);

View file

@ -25,6 +25,7 @@ import Header from '../../Common/Page/Header';
import Body from '../../Common/Page/Body';
import Flash from '../../Common/Flash';
import CancelPlanModal from './CancelPlanModal';
import PaymentMethodModal from './PaymentMethodModal';
import {
getSubscription,
clearSubscription,
@ -35,6 +36,7 @@ import SettingRow from '../SettingRow';
import PlanRow from './PlanRow';
import Placeholder from './Placeholder';
import * as paymentService from '../../../services/payment';
import { useScript } from '../../../libs/hooks';
import settingsStyles from '../Settings.module.scss';
@ -104,7 +106,11 @@ function CancelRow({ setIsPlanModalOpen }) {
);
}
function PaymentMethodRow({ source }) {
function PaymentMethodRow({
stripeLoaded,
source,
setIsPaymentMethodModalOpen
}) {
let value;
if (source.brand) {
value = `${source.brand} ending in ${source.last4}. expiry ${
@ -114,18 +120,39 @@ function PaymentMethodRow({ source }) {
value = 'No payment method';
}
return <SettingRow name="Payment method" value={value} />;
return (
<SettingRow
id="T-payment-method-row"
name="Payment method"
value={value}
actionContent={
<button
id="T-update-payment-method-button"
className={classnames('button-no-ui', settingsStyles.edit)}
type="button"
onClick={() => {
setIsPaymentMethodModalOpen(true);
}}
disabled={!stripeLoaded}
>
Update
</button>
}
/>
);
}
function Content({
subscription,
source,
setIsPlanModalOpen,
setIsPaymentMethodModalOpen,
successMsg,
failureMsg,
setSuccessMsg,
setFailureMsg,
doGetSubscription
doGetSubscription,
stripeLoaded
}) {
return (
<div className="container-wide">
@ -177,7 +204,11 @@ function Content({
<section className={settingsStyles.section}>
<h2 className={settingsStyles['section-heading']}>Payment</h2>
<PaymentMethodRow source={source} />
<PaymentMethodRow
source={source}
setIsPaymentMethodModalOpen={setIsPaymentMethodModalOpen}
stripeLoaded={stripeLoaded}
/>
</section>
</div>
</div>
@ -185,6 +216,21 @@ function Content({
);
}
function ErrorMessage({ desc, message }) {
return (
<div className="container-wide">
<div className="row">
<div className="col-12 col-md-12 col-lg-10">
<Flash type="danger" wrapperClassName={settingsStyles.flash}>
<div>{desc}</div>
{message}
</Flash>
</div>
</div>
</div>
);
}
function Billing({
doGetSubscription,
doClearSubscription,
@ -194,9 +240,14 @@ function Billing({
doClearSource
}) {
const [isPlanModalOpen, setIsPlanModalOpen] = useState(false);
const [isPaymentMethodModalOpen, setIsPaymentMethodModalOpen] = useState(
false
);
const [successMsg, setSuccessMsg] = useState('');
const [failureMsg, setFailureMsg] = useState('');
const [stripeLoaded, stripeLoadError] = useScript('https://js.stripe.com/v3');
useEffect(() => {
doGetSubscription();
doGetSource();
@ -210,6 +261,13 @@ function Billing({
const subscription = subscriptionData.data;
const source = sourceData.data;
const key = `${__STRIPE_PUBLIC_KEY__}`;
let stripe = null;
if (stripeLoaded) {
stripe = window.Stripe(key);
}
return (
<div>
<Helmet>
@ -220,28 +278,22 @@ function Billing({
<Body>
{subscriptionData.errorMessage && (
<div className="container-wide">
<div className="row">
<div className="col-12 col-md-12 col-lg-10">
<Flash type="danger" wrapperClassName={settingsStyles.flash}>
<div>Failed to fetch the billing information</div>
{subscriptionData.errorMessage}
</Flash>
</div>
</div>
</div>
<ErrorMessage
desc="Failed to fetch the billing information"
message={subscriptionData.errorMessage}
/>
)}
{sourceData.errorMessage && (
<div className="container-wide">
<div className="row">
<div className="col-12 col-md-12 col-lg-10">
<Flash type="danger" wrapperClassName={settingsStyles.flash}>
<div>Failed to fetch the payment source</div>
{sourceData.errorMessage}
</Flash>
</div>
</div>
</div>
<ErrorMessage
desc="Failed to fetch the payment source"
message={sourceData.errorMessage}
/>
)}
{stripeLoadError && (
<ErrorMessage
desc="Failed to load Stripe"
message={stripeLoadError}
/>
)}
{!subscriptionData.isFetched || !sourceData.isFetched ? (
@ -256,6 +308,8 @@ function Billing({
setSuccessMsg={setSuccessMsg}
setFailureMsg={setFailureMsg}
doGetSubscription={doGetSubscription}
setIsPaymentMethodModalOpen={setIsPaymentMethodModalOpen}
stripeLoaded={stripeLoaded}
/>
)}
</Body>
@ -270,6 +324,16 @@ function Billing({
setFailureMsg={setFailureMsg}
doGetSubscription={doGetSubscription}
/>
<PaymentMethodModal
isOpen={isPaymentMethodModalOpen}
onDismiss={() => {
setIsPaymentMethodModalOpen(false);
}}
setSuccessMsg={setSuccessMsg}
doGetSource={doGetSource}
stripe={stripe}
/>
</div>
);
}

View file

@ -21,9 +21,9 @@ import classnames from 'classnames';
import styles from './SettingRow.module.scss';
function SettingRow({ name, desc, value, actionContent }) {
function SettingRow({ name, desc, value, actionContent, id }) {
return (
<div className={classnames(styles.wrapper, styles.row)}>
<div className={classnames(styles.wrapper, styles.row)} id={id}>
<div>
<h3 className={styles.name}>{name}</h3>
<p className={styles.desc}>{desc}</p>

View file

@ -21,41 +21,20 @@ import { withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import classnames from 'classnames';
import Helmet from 'react-helmet';
import { injectStripe, CardElement } from 'react-stripe-elements';
import { injectStripe } from 'react-stripe-elements';
import Sidebar from './Sidebar';
import CountrySelect from './CountrySelect';
import Flash from '../../Common/Flash';
import * as paymentService from '../../../services/payment';
import { getCurrentUser } from '../../../actions/auth';
import { updateMessage } from '../../../actions/ui';
import { getHomePath } from '../../../libs/paths';
import NameOnCardInput from '../../Common/PaymentInput/NameOnCard';
import CardInput from '../../Common/PaymentInput/Card';
import CountryInput from '../../Common/PaymentInput/Country';
import styles from './Form.module.scss';
const elementStyles = {
base: {
color: '#32325D',
fontFamily: 'Source Code Pro, Consolas, Menlo, monospace',
fontSize: '16px',
fontSmoothing: 'antialiased',
'::placeholder': {
color: '#CFD7DF'
},
':-webkit-autofill': {
color: '#e39f48'
}
},
invalid: {
color: '#E25950',
'::placeholder': {
color: '#FFCCA5'
}
}
};
function Form({
stripe,
stripeLoadError,
@ -65,7 +44,6 @@ function Form({
}) {
const [nameOnCard, setNameOnCard] = useState('');
const cardElementRef = useRef(null);
const [cardElementFocused, setCardElementFocused] = useState(false);
const [cardElementLoaded, setCardElementLoaded] = useState(false);
const [billingCountry, setBillingCountry] = useState('');
const [transacting, setTransacting] = useState(false);
@ -154,71 +132,26 @@ function Form({
<h1 className={styles.heading}>You are almost there.</h1>
<div className={styles.content}>
<div className={styles['input-row']}>
<label htmlFor="name-on-card" className="label-full">
<span className={styles.label}>Name on Card</span>
<input
autoFocus
id="name-on-card"
className={classnames(
'text-input text-input-stretch text-input-medium',
styles.input
)}
type="text"
value={nameOnCard}
onChange={e => {
const val = e.target.value;
setNameOnCard(val);
}}
/>
</label>
</div>
<NameOnCardInput
value={nameOnCard}
onUpdate={setNameOnCard}
containerClassName={styles['input-row']}
labelClassName={styles.label}
/>
<div
className={classnames(styles['input-row'], styles['card-row'])}
>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label htmlFor="card-number" className={styles.number}>
<span className={styles.label}>Card Number</span>
<CardInput
cardElementRef={cardElementRef}
setCardElementLoaded={setCardElementLoaded}
containerClassName={styles['input-row']}
labelClassName={styles.label}
/>
<CardElement
id="card"
className={classnames(styles['card-number'], styles.input, {
[styles['card-number-active']]: cardElementFocused
})}
onFocus={() => {
setCardElementFocused(true);
}}
onBlur={() => {
setCardElementFocused(false);
}}
onReady={el => {
cardElementRef.current = el;
setCardElementLoaded(true);
}}
style={elementStyles}
/>
</label>
</div>
<div className={styles['input-row']}>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label htmlFor="billing-country" className="label-full">
<span className={styles.label}>Country</span>
<CountrySelect
id="billing-country"
className={classnames(
styles['countries-select'],
styles.input
)}
value={billingCountry}
onChange={e => {
const val = e.target.value;
setBillingCountry(val);
}}
/>
</label>
</div>
<CountryInput
value={billingCountry}
onUpdate={setBillingCountry}
containerClassName={styles['input-row']}
labelClassName={styles.label}
/>
</div>
</div>
</div>

View file

@ -25,10 +25,6 @@
margin-top: rem(52px);
}
.number {
flex-grow: 1;
}
.cvc,
.expiry {
width: rem(100px);
@ -38,24 +34,6 @@
margin-bottom: rem(24px);
}
.label {
@include font-size('medium');
}
.input {
margin-top: rem(8px);
}
.input-row {
&:not(:first-child) {
margin-top: rem(20px);
}
}
.card-row {
display: flex;
}
.heading {
@include font-size('3x-large');
font-weight: 400;
@ -71,17 +49,12 @@
margin-top: rem(32px);
}
.card-number {
// match the height of the stripe element with other inputs
padding: rem(10.4px) rem(12px);
border: 2px solid $border-color;
&.card-number-active {
border: 2px solid $third;
.input-row {
&:not(:first-child) {
margin-top: rem(20px);
}
}
.countries-select {
width: 100%;
display: block;
.label {
@include font-size('medium');
}

View file

@ -52,3 +52,12 @@ export function reactivateSubscription({ subscriptionId }) {
export function getSource() {
return apiClient.get('/stripe_source');
}
export function updateSource({ source, country }) {
const payload = {
source,
country
};
return apiClient.patch('/stripe_source', payload);
}