diff --git a/pkg/server/api/handlers/routes.go b/pkg/server/api/handlers/routes.go index 88a6de69..44dabfe6 100644 --- a/pkg/server/api/handlers/routes.go +++ b/pkg/server/api/handlers/routes.go @@ -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) diff --git a/pkg/server/api/handlers/subscription.go b/pkg/server/api/handlers/subscription.go index 948e1232..3d36ea8e 100644 --- a/pkg/server/api/handlers/subscription.go +++ b/pkg/server/api/handlers/subscription.go @@ -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 { diff --git a/web/src/components/Common/PaymentInput/Card.js b/web/src/components/Common/PaymentInput/Card.js new file mode 100644 index 00000000..0a6bf5ae --- /dev/null +++ b/web/src/components/Common/PaymentInput/Card.js @@ -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 . + */ + +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 ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + +
+ ); +} + +export default Card; diff --git a/web/src/components/Common/PaymentInput/Country.js b/web/src/components/Common/PaymentInput/Country.js new file mode 100644 index 00000000..848b1d19 --- /dev/null +++ b/web/src/components/Common/PaymentInput/Country.js @@ -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 . + */ + +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 ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + +
+ ); +} + +export default NameOnCard; diff --git a/web/src/components/Subscription/Checkout/CountrySelect.js b/web/src/components/Common/PaymentInput/CountrySelect.js similarity index 100% rename from web/src/components/Subscription/Checkout/CountrySelect.js rename to web/src/components/Common/PaymentInput/CountrySelect.js diff --git a/web/src/components/Subscription/Checkout/CountrySelect.module.scss b/web/src/components/Common/PaymentInput/CountrySelect.module.scss similarity index 100% rename from web/src/components/Subscription/Checkout/CountrySelect.module.scss rename to web/src/components/Common/PaymentInput/CountrySelect.module.scss diff --git a/web/src/components/Common/PaymentInput/NameOnCard.js b/web/src/components/Common/PaymentInput/NameOnCard.js new file mode 100644 index 00000000..3e9f985f --- /dev/null +++ b/web/src/components/Common/PaymentInput/NameOnCard.js @@ -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 . + */ + +import React from 'react'; + +import classnames from 'classnames'; + +import styles from './PaymentInput.module.scss'; + +function NameOnCard({ value, onUpdate, containerClassName, labelClassName }) { + return ( +
+ +
+ ); +} + +export default NameOnCard; diff --git a/web/src/components/Common/PaymentInput/PaymentInput.module.scss b/web/src/components/Common/PaymentInput/PaymentInput.module.scss new file mode 100644 index 00000000..289fbdab --- /dev/null +++ b/web/src/components/Common/PaymentInput/PaymentInput.module.scss @@ -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 . + */ + +@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; +} diff --git a/web/src/components/Settings/Billing/PaymentMethodModal/Form.js b/web/src/components/Settings/Billing/PaymentMethodModal/Form.js new file mode 100644 index 00000000..a0c442ce --- /dev/null +++ b/web/src/components/Settings/Billing/PaymentMethodModal/Form.js @@ -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 . + */ + +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 ( +
+
+ + + + + +
+ +
+ + + +
+
+ ); +} + +export default injectStripe(Form); diff --git a/web/src/components/Settings/Billing/PaymentMethodModal/Form.module.scss b/web/src/components/Settings/Billing/PaymentMethodModal/Form.module.scss new file mode 100644 index 00000000..b076ad4d --- /dev/null +++ b/web/src/components/Settings/Billing/PaymentMethodModal/Form.module.scss @@ -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 . + */ + +@import '../../../App/rem'; +@import '../../../App/font'; +@import '../../../App/theme'; + +.input-row { + &:not(:first-child) { + margin-top: rem(10px); + } +} diff --git a/web/src/components/Settings/Billing/PaymentMethodModal/index.js b/web/src/components/Settings/Billing/PaymentMethodModal/index.js new file mode 100644 index 00000000..bb977656 --- /dev/null +++ b/web/src/components/Settings/Billing/PaymentMethodModal/index.js @@ -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 . + */ + +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 ( + +
+ + {errMessage && ( + { + setErrMessage(''); + }} + > + {errMessage} + + )} + + + + +
+ + + + + ); +} + +const mapDispatchToProps = {}; + +export default connect( + null, + mapDispatchToProps +)(PaymentMethodModal); diff --git a/web/src/components/Settings/Billing/index.js b/web/src/components/Settings/Billing/index.js index 93f22e41..ae3e33b4 100644 --- a/web/src/components/Settings/Billing/index.js +++ b/web/src/components/Settings/Billing/index.js @@ -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 ; + return ( + { + setIsPaymentMethodModalOpen(true); + }} + disabled={!stripeLoaded} + > + Update + + } + /> + ); } function Content({ subscription, source, setIsPlanModalOpen, + setIsPaymentMethodModalOpen, successMsg, failureMsg, setSuccessMsg, setFailureMsg, - doGetSubscription + doGetSubscription, + stripeLoaded }) { return (
@@ -177,7 +204,11 @@ function Content({

Payment

- +
@@ -185,6 +216,21 @@ function Content({ ); } +function ErrorMessage({ desc, message }) { + return ( +
+
+
+ +
{desc}
+ {message} +
+
+
+
+ ); +} + 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 (
@@ -220,28 +278,22 @@ function Billing({ {subscriptionData.errorMessage && ( -
-
-
- -
Failed to fetch the billing information
- {subscriptionData.errorMessage} -
-
-
-
+ )} {sourceData.errorMessage && ( -
-
-
- -
Failed to fetch the payment source
- {sourceData.errorMessage} -
-
-
-
+ + )} + {stripeLoadError && ( + )} {!subscriptionData.isFetched || !sourceData.isFetched ? ( @@ -256,6 +308,8 @@ function Billing({ setSuccessMsg={setSuccessMsg} setFailureMsg={setFailureMsg} doGetSubscription={doGetSubscription} + setIsPaymentMethodModalOpen={setIsPaymentMethodModalOpen} + stripeLoaded={stripeLoaded} /> )} @@ -270,6 +324,16 @@ function Billing({ setFailureMsg={setFailureMsg} doGetSubscription={doGetSubscription} /> + + { + setIsPaymentMethodModalOpen(false); + }} + setSuccessMsg={setSuccessMsg} + doGetSource={doGetSource} + stripe={stripe} + />
); } diff --git a/web/src/components/Settings/SettingRow.js b/web/src/components/Settings/SettingRow.js index 31ee62f7..e8fefb2a 100644 --- a/web/src/components/Settings/SettingRow.js +++ b/web/src/components/Settings/SettingRow.js @@ -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 ( -
+

{name}

{desc}

diff --git a/web/src/components/Subscription/Checkout/Form.js b/web/src/components/Subscription/Checkout/Form.js index 825cbc4d..055bffdd 100644 --- a/web/src/components/Subscription/Checkout/Form.js +++ b/web/src/components/Subscription/Checkout/Form.js @@ -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({

You are almost there.

-
- -
+ -
- {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} - -
- -
- {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} - -
+
diff --git a/web/src/components/Subscription/Checkout/Form.module.scss b/web/src/components/Subscription/Checkout/Form.module.scss index 85d5d13f..109f5a3f 100644 --- a/web/src/components/Subscription/Checkout/Form.module.scss +++ b/web/src/components/Subscription/Checkout/Form.module.scss @@ -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'); } diff --git a/web/src/services/payment.js b/web/src/services/payment.js index d4478fb7..354d089d 100644 --- a/web/src/services/payment.js +++ b/web/src/services/payment.js @@ -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); +}