diff --git a/CHANGELOG.md b/CHANGELOG.md index 70070de229..b5572e442a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,20 @@ way to update this template, but currently, we follow a pattern: ## Upcoming version 2019-XX-XX +## [v3.0.0] 2019-07-02 + +- [add] Strong Customer Authentication (SCA) with Stripe's new PaymentIntents flow. This is a big + change for checkout flow and includes a madatory transaction process change. + [#1089](https://github.com/sharetribe/flex-template-web/pull/1089) + + - You should check [the pull request](https://github.com/sharetribe/flex-template-web/pull/1089) + - and read 3 Flex Docs articles: + [SCA](https://www.sharetribe.com/docs/background/strong-customer-authentication/), + [PaymentIntents](https://www.sharetribe.com/docs/background/payment-intents/), and + [How to take PaymentIntents into use](https://www.sharetribe.com/docs/guide/how-to-take-payment-intents-into-use/) + + [v3.0.0]: https://github.com/sharetribe/flex-template-web/compare/v2.17.1...v3.0.0 + ## [v2.17.1] 2019-06-11 - [fix] `stripeCardToken` didn't update when the user tried to book the same listing for a second diff --git a/src/app.js b/src/app.js index 71497cf476..8ac49fa290 100644 --- a/src/app.js +++ b/src/app.js @@ -27,6 +27,10 @@ import defaultMessages from './translations/en.json'; // 3) Import correct locale rules for Moment library // 4) Use the `messagesInLocale` import to add the correct translation file. +// Note that there is also translations in './translations/countryCodes.js' file +// This file contains ISO 3166-1 alpha-2 country codes, country names and their translations in our default languages +// This used to collect billing address in StripePaymentAddress on CheckoutPage + // Step 2: // Import locale rules for React Intl library import localeData from 'react-intl/locale-data/en'; diff --git a/src/components/ActivityFeed/ActivityFeed.example.js b/src/components/ActivityFeed/ActivityFeed.example.js index af9dbe80eb..793ef69a4f 100644 --- a/src/components/ActivityFeed/ActivityFeed.example.js +++ b/src/components/ActivityFeed/ActivityFeed.example.js @@ -13,7 +13,8 @@ import { TRANSITION_COMPLETE, TRANSITION_DECLINE, TRANSITION_EXPIRE_REVIEW_PERIOD, - TRANSITION_REQUEST, + TRANSITION_REQUEST_PAYMENT, + TRANSITION_CONFIRM_PAYMENT, TRANSITION_REVIEW_1_BY_CUSTOMER, TRANSITION_REVIEW_1_BY_PROVIDER, TRANSITION_REVIEW_2_BY_CUSTOMER, @@ -80,10 +81,16 @@ export const WithTransitions = { provider: createUser('user2'), listing: createListing('Listing'), transitions: [ + // this should not be visible in the feed createTxTransition({ createdAt: new Date(Date.UTC(2017, 10, 9, 8, 10)), by: TX_TRANSITION_ACTOR_CUSTOMER, - transition: TRANSITION_REQUEST, + transition: TRANSITION_REQUEST_PAYMENT, + }), + createTxTransition({ + createdAt: new Date(Date.UTC(2017, 10, 9, 8, 10)), + by: TX_TRANSITION_ACTOR_CUSTOMER, + transition: TRANSITION_CONFIRM_PAYMENT, }), createTxTransition({ createdAt: new Date(Date.UTC(2017, 10, 9, 8, 12)), @@ -120,7 +127,12 @@ export const WithMessagesTransitionsAndReviews = { createTxTransition({ createdAt: new Date(Date.UTC(2017, 10, 9, 8, 10)), by: TX_TRANSITION_ACTOR_CUSTOMER, - transition: TRANSITION_REQUEST, + transition: TRANSITION_REQUEST_PAYMENT, + }), + createTxTransition({ + createdAt: new Date(Date.UTC(2017, 10, 9, 8, 10)), + by: TX_TRANSITION_ACTOR_CUSTOMER, + transition: TRANSITION_CONFIRM_PAYMENT, }), createTxTransition({ createdAt: new Date(Date.UTC(2017, 10, 9, 8, 12)), @@ -257,9 +269,14 @@ class PagedFeed extends Component { const trans1 = createTxTransition({ createdAt: dates[0], by: TX_TRANSITION_ACTOR_CUSTOMER, - transition: TRANSITION_REQUEST, + transition: TRANSITION_REQUEST_PAYMENT, }); const trans2 = createTxTransition({ + createdAt: dates[0], + by: TX_TRANSITION_ACTOR_CUSTOMER, + transition: TRANSITION_CONFIRM_PAYMENT, + }); + const trans3 = createTxTransition({ createdAt: dates[2], by: TX_TRANSITION_ACTOR_PROVIDER, transition: TRANSITION_ACCEPT, @@ -267,7 +284,7 @@ class PagedFeed extends Component { // Last transition timestamp is interleaved between the last two // messages. - const trans3 = createTxTransition({ + const trans4 = createTxTransition({ createdAt: dates[5], by: TX_TRANSITION_ACTOR_CUSTOMER, transition: TRANSITION_COMPLETE, @@ -285,7 +302,7 @@ class PagedFeed extends Component { id: 'tx1', lastTransition: TRANSITION_COMPLETE, lastTransitionedAt: dates[5], - transitions: [trans1, trans2, trans3], + transitions: [trans1, trans2, trans3, trans4], listing: createListing('listing'), customer, provider, diff --git a/src/components/ActivityFeed/ActivityFeed.js b/src/components/ActivityFeed/ActivityFeed.js index 7e9bb04df4..7795d2ea70 100644 --- a/src/components/ActivityFeed/ActivityFeed.js +++ b/src/components/ActivityFeed/ActivityFeed.js @@ -12,8 +12,7 @@ import { TRANSITION_COMPLETE, TRANSITION_DECLINE, TRANSITION_EXPIRE, - TRANSITION_REQUEST, - TRANSITION_REQUEST_AFTER_ENQUIRY, + TRANSITION_CONFIRM_PAYMENT, TRANSITION_REVIEW_1_BY_CUSTOMER, TRANSITION_REVIEW_1_BY_PROVIDER, TRANSITION_REVIEW_2_BY_CUSTOMER, @@ -115,8 +114,7 @@ const resolveTransitionMessage = ( const displayName = otherUsersName; switch (currentTransition) { - case TRANSITION_REQUEST: - case TRANSITION_REQUEST_AFTER_ENQUIRY: + case TRANSITION_CONFIRM_PAYMENT: return isOwnTransition ? ( ) : ( diff --git a/src/components/ActivityFeed/__snapshots__/ActivityFeed.test.js.snap b/src/components/ActivityFeed/__snapshots__/ActivityFeed.test.js.snap index 91327ee129..0e0fbac97d 100644 --- a/src/components/ActivityFeed/__snapshots__/ActivityFeed.test.js.snap +++ b/src/components/ActivityFeed/__snapshots__/ActivityFeed.test.js.snap @@ -5,7 +5,7 @@ exports[`ActivityFeed matches snapshot 1`] = ` className="" >
  • diff --git a/src/components/BookingBreakdown/BookingBreakdown.example.js b/src/components/BookingBreakdown/BookingBreakdown.example.js index 5c8855c3fb..271536f584 100644 --- a/src/components/BookingBreakdown/BookingBreakdown.example.js +++ b/src/components/BookingBreakdown/BookingBreakdown.example.js @@ -6,7 +6,8 @@ import { TRANSITION_COMPLETE, TRANSITION_DECLINE, TRANSITION_EXPIRE, - TRANSITION_REQUEST, + TRANSITION_REQUEST_PAYMENT, + TRANSITION_CONFIRM_PAYMENT, TX_TRANSITION_ACTOR_CUSTOMER, } from '../../util/transaction'; import { LINE_ITEM_DAY, LINE_ITEM_NIGHT, LINE_ITEM_UNITS } from '../../util/types'; @@ -27,18 +28,24 @@ const exampleBooking = attributes => { const exampleTransaction = params => { const created = new Date(Date.UTC(2017, 1, 1)); + const confirmed = new Date(Date.UTC(2017, 1, 1, 0, 1)); return { id: new UUID('example-transaction'), type: 'transaction', attributes: { createdAt: created, lastTransitionedAt: created, - lastTransition: TRANSITION_REQUEST, + lastTransition: TRANSITION_CONFIRM_PAYMENT, transitions: [ { createdAt: created, by: TX_TRANSITION_ACTOR_CUSTOMER, - transition: TRANSITION_REQUEST, + transition: TRANSITION_REQUEST_PAYMENT, + }, + { + createdAt: confirmed, + by: TX_TRANSITION_ACTOR_CUSTOMER, + transition: TRANSITION_CONFIRM_PAYMENT, }, ], @@ -205,7 +212,7 @@ export const ProviderSalePreauthorized = { userRole: 'provider', unitType: LINE_ITEM_NIGHT, transaction: exampleTransaction({ - lastTransition: TRANSITION_REQUEST, + lastTransition: TRANSITION_CONFIRM_PAYMENT, payinTotal: new Money(4500, CURRENCY), payoutTotal: new Money(2500, CURRENCY), lineItems: [ diff --git a/src/components/BookingBreakdown/BookingBreakdown.test.js b/src/components/BookingBreakdown/BookingBreakdown.test.js index 90a953729e..8d797c2c9d 100644 --- a/src/components/BookingBreakdown/BookingBreakdown.test.js +++ b/src/components/BookingBreakdown/BookingBreakdown.test.js @@ -5,7 +5,7 @@ import { renderDeep } from '../../util/test-helpers'; import { types as sdkTypes } from '../../util/sdkLoader'; import { TRANSITION_CANCEL, - TRANSITION_REQUEST, + TRANSITION_REQUEST_PAYMENT, TX_TRANSITION_ACTOR_CUSTOMER, } from '../../util/transaction'; import { LINE_ITEM_NIGHT } from '../../util/types'; @@ -21,12 +21,12 @@ const exampleTransaction = params => { attributes: { createdAt: created, lastTransitionedAt: created, - lastTransition: TRANSITION_REQUEST, + lastTransition: TRANSITION_REQUEST_PAYMENT, transitions: [ { createdAt: created, by: TX_TRANSITION_ACTOR_CUSTOMER, - transition: TRANSITION_REQUEST, + transition: TRANSITION_REQUEST_PAYMENT, }, ], diff --git a/src/components/TransactionPanel/PanelHeading.js b/src/components/TransactionPanel/PanelHeading.js index b41275f9af..86a45dd054 100644 --- a/src/components/TransactionPanel/PanelHeading.js +++ b/src/components/TransactionPanel/PanelHeading.js @@ -7,6 +7,8 @@ import { NamedLink } from '../../components'; import css from './TransactionPanel.css'; export const HEADING_ENQUIRED = 'enquired'; +export const HEADING_PAYMENT_PENDING = 'pending-payment'; +export const HEADING_PAYMENT_EXPIRED = 'payment-expired'; export const HEADING_REQUESTED = 'requested'; export const HEADING_ACCEPTED = 'accepted'; export const HEADING_DECLINED = 'declined'; @@ -74,7 +76,7 @@ const CustomerBannedInfoMaybe = props => { }; const HeadingProvider = props => { - const { className, id, values, isCustomerBanned } = props; + const { className, id, values, isCustomerBanned, children } = props; return (

    @@ -82,6 +84,7 @@ const HeadingProvider = props => {

    + {children}
    ); @@ -125,6 +128,45 @@ const PanelHeading = props => { isCustomerBanned={isCustomerBanned} /> ); + case HEADING_PAYMENT_PENDING: + return isCustomer ? ( + + ) : ( + +

    + +

    +
    + ); + case HEADING_PAYMENT_EXPIRED: + return isCustomer ? ( + + ) : ( + + ); case HEADING_REQUESTED: return isCustomer ? ( { const txPreauthorized = createTransaction({ id: 'sale-preauthorized', - lastTransition: TRANSITION_REQUEST, + lastTransition: TRANSITION_REQUEST_PAYMENT, ...baseTxAttrs, }); @@ -92,7 +93,7 @@ describe('TransactionPanel - Sale', () => { createTxTransition({ createdAt: new Date(Date.UTC(2017, 4, 1)), by: 'customer', - transition: TRANSITION_REQUEST, + transition: TRANSITION_REQUEST_PAYMENT, }), createTxTransition({ createdAt: new Date(Date.UTC(2017, 5, 1)), @@ -197,7 +198,7 @@ describe('TransactionPanel - Sale', () => { const transaction = createTransaction({ id: 'sale-tx', - lastTransition: TRANSITION_REQUEST, + lastTransition: TRANSITION_REQUEST_PAYMENT, total: new Money(16500, 'USD'), commission: new Money(1000, 'USD'), booking: createBooking('booking1', { @@ -252,7 +253,7 @@ describe('TransactionPanel - Order', () => { const txPreauthorized = createTransaction({ id: 'order-preauthorized', - lastTransition: TRANSITION_REQUEST, + lastTransition: TRANSITION_CONFIRM_PAYMENT, ...baseTxAttrs, }); @@ -287,7 +288,12 @@ describe('TransactionPanel - Order', () => { createTxTransition({ createdAt: new Date(Date.UTC(2017, 4, 1)), by: 'customer', - transition: TRANSITION_REQUEST, + transition: TRANSITION_REQUEST_PAYMENT, + }), + createTxTransition({ + createdAt: new Date(Date.UTC(2017, 4, 1, 0, 0, 1)), + by: 'customer', + transition: TRANSITION_CONFIRM_PAYMENT, }), createTxTransition({ createdAt: new Date(Date.UTC(2017, 5, 1)), @@ -393,7 +399,7 @@ describe('TransactionPanel - Order', () => { const end = new Date(Date.UTC(2017, 5, 13)); const tx = createTransaction({ id: 'order-tx', - lastTransition: TRANSITION_REQUEST, + lastTransition: TRANSITION_REQUEST_PAYMENT, total: new Money(16500, 'USD'), booking: createBooking('booking1', { start, diff --git a/src/components/TransactionPanel/__snapshots__/TransactionPanel.test.js.snap b/src/components/TransactionPanel/__snapshots__/TransactionPanel.test.js.snap index bc55429202..24d528229d 100644 --- a/src/components/TransactionPanel/__snapshots__/TransactionPanel.test.js.snap +++ b/src/components/TransactionPanel/__snapshots__/TransactionPanel.test.js.snap @@ -180,7 +180,12 @@ exports[`TransactionPanel - Order accepted matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -325,7 +330,12 @@ exports[`TransactionPanel - Order accepted matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -605,7 +615,12 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -922,7 +937,12 @@ exports[`TransactionPanel - Order autodeclined matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -1067,7 +1087,12 @@ exports[`TransactionPanel - Order autodeclined matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -1346,7 +1371,12 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -1664,7 +1694,12 @@ exports[`TransactionPanel - Order canceled matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -1809,7 +1844,12 @@ exports[`TransactionPanel - Order canceled matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -2089,7 +2129,12 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -2406,7 +2451,12 @@ exports[`TransactionPanel - Order declined matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -2551,7 +2601,12 @@ exports[`TransactionPanel - Order declined matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -2830,7 +2885,12 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -3148,7 +3208,12 @@ exports[`TransactionPanel - Order delivered matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -3298,7 +3363,12 @@ exports[`TransactionPanel - Order delivered matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -3583,7 +3653,12 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -3905,7 +3980,12 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -4050,7 +4130,12 @@ exports[`TransactionPanel - Order enquired matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -4328,7 +4413,12 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -4591,7 +4681,7 @@ exports[`TransactionPanel - Order preauthorized matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/request", + "lastTransition": "transition/confirm-payment", "lastTransitionedAt": 2017-06-01T00:00:00.000Z, "lineItems": Array [ Object { @@ -4645,7 +4735,12 @@ exports[`TransactionPanel - Order preauthorized matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -4736,7 +4831,7 @@ exports[`TransactionPanel - Order preauthorized matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/request", + "lastTransition": "transition/confirm-payment", "lastTransitionedAt": 2017-06-01T00:00:00.000Z, "lineItems": Array [ Object { @@ -4790,7 +4885,12 @@ exports[`TransactionPanel - Order preauthorized matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -5015,7 +5115,7 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/request", + "lastTransition": "transition/confirm-payment", "lastTransitionedAt": 2017-06-01T00:00:00.000Z, "lineItems": Array [ Object { @@ -5069,7 +5169,12 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -5387,7 +5492,12 @@ exports[`TransactionPanel - Sale accepted matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -5532,7 +5642,12 @@ exports[`TransactionPanel - Sale accepted matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -5812,7 +5927,12 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -6129,7 +6249,12 @@ exports[`TransactionPanel - Sale autodeclined matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -6274,7 +6399,12 @@ exports[`TransactionPanel - Sale autodeclined matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -6553,7 +6683,12 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -6871,7 +7006,12 @@ exports[`TransactionPanel - Sale canceled matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -7016,7 +7156,12 @@ exports[`TransactionPanel - Sale canceled matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -7296,7 +7441,12 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -7613,7 +7763,12 @@ exports[`TransactionPanel - Sale declined matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -7758,7 +7913,12 @@ exports[`TransactionPanel - Sale declined matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -8037,7 +8197,12 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -8355,7 +8520,7 @@ exports[`TransactionPanel - Sale delivered matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", }, Object { "by": "provider", @@ -8505,7 +8670,7 @@ exports[`TransactionPanel - Sale delivered matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", }, Object { "by": "provider", @@ -8790,7 +8955,7 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", }, Object { "by": "provider", @@ -9112,7 +9277,12 @@ exports[`TransactionPanel - Sale enquired matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -9257,7 +9427,12 @@ exports[`TransactionPanel - Sale enquired matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -9535,7 +9710,12 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -9743,7 +9923,7 @@ exports[`TransactionPanel - Sale preauthorized matches snapshot 1`] = ` listingDeleted={false} listingId="listing1" listingTitle="listing1 title" - panelHeadingState="requested" + panelHeadingState="pending-payment" providerName={ ({ const initiateOrderRequest = () => ({ type: INITIATE_ORDER_REQUEST }); -const initiateOrderSuccess = orderId => ({ +const initiateOrderSuccess = order => ({ type: INITIATE_ORDER_SUCCESS, - payload: orderId, + payload: order, }); const initiateOrderError = e => ({ @@ -92,6 +110,19 @@ const initiateOrderError = e => ({ payload: e, }); +const confirmPaymentRequest = () => ({ type: CONFIRM_PAYMENT_REQUEST }); + +const confirmPaymentSuccess = orderId => ({ + type: CONFIRM_PAYMENT_SUCCESS, + payload: orderId, +}); + +const confirmPaymentError = e => ({ + type: CONFIRM_PAYMENT_ERROR, + error: true, + payload: e, +}); + export const speculateTransactionRequest = () => ({ type: SPECULATE_TRANSACTION_REQUEST }); export const speculateTransactionSuccess = transaction => ({ @@ -107,37 +138,39 @@ export const speculateTransactionError = e => ({ /* ================ Thunks ================ */ -export const initiateOrder = (orderParams, initialMessage) => (dispatch, getState, sdk) => { +export const initiateOrder = (orderParams, transactionId) => (dispatch, getState, sdk) => { dispatch(initiateOrderRequest()); - const bodyParams = { - transition: TRANSITION_REQUEST, - processAlias: config.bookingProcessAlias, - params: orderParams, + const bodyParams = transactionId + ? { + id: transactionId, + transition: TRANSITION_REQUEST_PAYMENT_AFTER_ENQUIRY, + params: orderParams, + } + : { + processAlias: config.bookingProcessAlias, + transition: TRANSITION_REQUEST_PAYMENT, + params: orderParams, + }; + const queryParams = { + include: ['booking', 'provider'], + expand: true, }; - return sdk.transactions - .initiate(bodyParams) + + const createOrder = transactionId ? sdk.transactions.transition : sdk.transactions.initiate; + + return createOrder(bodyParams, queryParams) .then(response => { - const orderId = response.data.data.id; - dispatch(initiateOrderSuccess(orderId)); + const entities = denormalisedResponseEntities(response); + const order = entities[0]; + dispatch(initiateOrderSuccess(order)); dispatch(fetchCurrentUserHasOrdersSuccess(true)); - - if (initialMessage) { - return sdk.messages - .send({ transactionId: orderId, content: initialMessage }) - .then(() => { - return { orderId, initialMessageSuccess: true }; - }) - .catch(e => { - log.error(e, 'initial-message-send-failed', { txId: orderId }); - return { orderId, initialMessageSuccess: false }; - }); - } else { - return Promise.resolve({ orderId, initialMessageSuccess: true }); - } + return order; }) .catch(e => { dispatch(initiateOrderError(storableError(e))); + const transactionIdMaybe = transactionId ? { transactionId: transactionId.uuid } : {}; log.error(e, 'initiate-order-failed', { + ...transactionIdMaybe, listingId: orderParams.listingId.uuid, bookingStart: orderParams.bookingStart, bookingEnd: orderParams.bookingEnd, @@ -146,43 +179,53 @@ export const initiateOrder = (orderParams, initialMessage) => (dispatch, getStat }); }; -/** - * Initiate an order after an enquiry. Transitions previously created transaction. - */ -export const initiateOrderAfterEnquiry = (transactionId, orderParams) => ( - dispatch, - getState, - sdk -) => { - dispatch(initiateOrderRequest()); +export const confirmPayment = orderParams => (dispatch, getState, sdk) => { + dispatch(confirmPaymentRequest()); const bodyParams = { - id: transactionId, - transition: TRANSITION_REQUEST_AFTER_ENQUIRY, - params: orderParams, + id: orderParams.transactionId, + transition: TRANSITION_CONFIRM_PAYMENT, + params: {}, }; return sdk.transactions .transition(bodyParams) .then(response => { - const orderId = response.data.data.id; - dispatch(initiateOrderSuccess(orderId)); - dispatch(fetchCurrentUserHasOrdersSuccess(true)); - // set initialMessageSuccess to true to unify promise handling with initiateOrder - return Promise.resolve({ orderId, initialMessageSuccess: true }); + const order = response.data.data; + dispatch(confirmPaymentSuccess(order.id)); + return order; }) .catch(e => { - dispatch(initiateOrderError(storableError(e))); + dispatch(confirmPaymentError(storableError(e))); + const transactionIdMaybe = orderParams.transactionId + ? { transactionId: orderParams.transactionId.uuid } + : {}; log.error(e, 'initiate-order-failed', { - transactionId: transactionId.uuid, - listingId: orderParams.listingId.uuid, - bookingStart: orderParams.bookingStart, - bookingEnd: orderParams.bookingEnd, + ...transactionIdMaybe, }); throw e; }); }; +export const sendMessage = params => (dispatch, getState, sdk) => { + const message = params.message; + const orderId = params.id; + + if (message) { + return sdk.messages + .send({ transactionId: orderId, content: message }) + .then(() => { + return { orderId, messageSuccess: true }; + }) + .catch(e => { + log.error(e, 'initial-message-send-failed', { txId: orderId }); + return { orderId, messageSuccess: false }; + }); + } else { + return Promise.resolve({ orderId, messageSuccess: true }); + } +}; + /** * Initiate the speculative transaction with the given booking details * @@ -198,7 +241,7 @@ export const initiateOrderAfterEnquiry = (transactionId, orderParams) => ( export const speculateTransaction = params => (dispatch, getState, sdk) => { dispatch(speculateTransactionRequest()); const bodyParams = { - transition: TRANSITION_REQUEST, + transition: TRANSITION_REQUEST_PAYMENT, processAlias: config.bookingProcessAlias, params: { ...params, diff --git a/src/containers/CheckoutPage/CheckoutPage.js b/src/containers/CheckoutPage/CheckoutPage.js index 680f9faf9e..14dd651e3b 100644 --- a/src/containers/CheckoutPage/CheckoutPage.js +++ b/src/containers/CheckoutPage/CheckoutPage.js @@ -1,15 +1,22 @@ import React, { Component } from 'react'; -import { bool, func, instanceOf, object, shape, string } from 'prop-types'; +import { bool, func, instanceOf, object, oneOfType, shape, string } from 'prop-types'; import { compose } from 'redux'; import { connect } from 'react-redux'; import { FormattedMessage, injectIntl, intlShape } from 'react-intl'; import { withRouter } from 'react-router-dom'; import classNames from 'classnames'; +import config from '../../config'; import routeConfiguration from '../../routeConfiguration'; import { pathByRouteName, findRouteByRouteName } from '../../util/routes'; import { propTypes, LINE_ITEM_NIGHT, LINE_ITEM_DAY } from '../../util/types'; -import { ensureListing, ensureUser, ensureTransaction, ensureBooking } from '../../util/data'; -import { dateFromLocalToAPI } from '../../util/dates'; +import { + ensureListing, + ensureCurrentUser, + ensureUser, + ensureTransaction, + ensureBooking, +} from '../../util/data'; +import { dateFromLocalToAPI, minutesBetween } from '../../util/dates'; import { createSlug } from '../../util/urlHelpers'; import { isTransactionInitiateAmountTooLowError, @@ -21,6 +28,7 @@ import { transactionInitiateOrderStripeErrors, } from '../../util/errors'; import { formatMoney } from '../../util/currency'; +import { TRANSITION_ENQUIRE, txIsPaymentPending, txIsPaymentExpired } from '../../util/transaction'; import { AvatarMedium, BookingBreakdown, @@ -32,20 +40,40 @@ import { } from '../../components'; import { StripePaymentForm } from '../../forms'; import { isScrollingDisabled } from '../../ducks/UI.duck'; +import { handleCardPayment, retrievePaymentIntent } from '../../ducks/stripe.duck.js'; + import { initiateOrder, - initiateOrderAfterEnquiry, setInitialValues, speculateTransaction, + confirmPayment, + sendMessage, } from './CheckoutPage.duck'; -import { createStripePaymentToken, clearStripePaymentToken } from '../../ducks/stripe.duck.js'; -import config from '../../config'; - import { storeData, storedData, clearData } from './CheckoutPageSessionHelpers'; import css from './CheckoutPage.css'; const STORAGE_KEY = 'CheckoutPage'; +// Stripe PaymentIntent statuses, where user actions are already completed +// https://stripe.com/docs/payments/payment-intents/status +const STRIPE_PI_USER_ACTIONS_DONE_STATUSES = ['processing', 'requires_capture', 'succeeded']; + +const initializeOrderPage = (initialValues, routes, dispatch) => { + const OrderPage = findRouteByRouteName('OrderDetailsPage', routes); + + // Transaction is already created, but if the initial message + // sending failed, we tell it to the OrderDetailsPage. + dispatch(OrderPage.setInitialValues(initialValues)); +}; + +const checkIsPaymentExpired = existingTransaction => { + return txIsPaymentExpired(existingTransaction) + ? true + : txIsPaymentPending(existingTransaction) + ? minutesBetween(existingTransaction.attributes.lastTransitionedAt, new Date()) >= 15 + : false; +}; + export class CheckoutPageComponent extends Component { constructor(props) { super(props); @@ -55,8 +83,11 @@ export class CheckoutPageComponent extends Component { dataLoaded: false, submitting: false, }; + this.stripe = null; + this.onStripeInitialized = this.onStripeInitialized.bind(this); this.loadInitialData = this.loadInitialData.bind(this); + this.handlePaymentIntent = this.handlePaymentIntent.bind(this); this.handleSubmit = this.handleSubmit.bind(this); } @@ -87,7 +118,7 @@ export class CheckoutPageComponent extends Component { bookingData, bookingDates, listing, - enquiredTransaction, + transaction, fetchSpeculatedTransaction, history, } = this.props; @@ -100,24 +131,29 @@ export class CheckoutPageComponent extends Component { const hasDataInProps = !!(bookingData && bookingDates && listing) && hasNavigatedThroughLink; if (hasDataInProps) { // Store data only if data is passed through props and user has navigated through a link. - storeData(bookingData, bookingDates, listing, enquiredTransaction, STORAGE_KEY); + storeData(bookingData, bookingDates, listing, transaction, STORAGE_KEY); } // NOTE: stored data can be empty if user has already successfully completed transaction. const pageData = hasDataInProps - ? { bookingData, bookingDates, listing, enquiredTransaction } + ? { bookingData, bookingDates, listing, transaction } : storedData(STORAGE_KEY); - const hasData = + // Check if a booking is already created according to stored data. + const tx = pageData ? pageData.transaction : null; + const isBookingCreated = tx && tx.booking && tx.booking.id; + + const shouldFetchSpeculatedTransaction = pageData && pageData.listing && pageData.listing.id && pageData.bookingData && pageData.bookingDates && pageData.bookingDates.bookingStart && - pageData.bookingDates.bookingEnd; + pageData.bookingDates.bookingEnd && + !isBookingCreated; - if (hasData) { + if (shouldFetchSpeculatedTransaction) { const listingId = pageData.listing.id; const { bookingStart, bookingEnd } = pageData.bookingDates; @@ -139,81 +175,209 @@ export class CheckoutPageComponent extends Component { this.setState({ pageData: pageData || {}, dataLoaded: true }); } - handleSubmit(values) { - if (this.state.submitting) { - return; - } - this.setState({ submitting: true }); + handlePaymentIntent(handlePaymentParams) { + const { onInitiateOrder, onHandleCardPayment, onConfirmPayment, onSendMessage } = this.props; + const { pageData, speculatedTransaction, message } = handlePaymentParams; + const storedTx = ensureTransaction(pageData.transaction); - const cardToken = values.token; - const initialMessage = values.message; - const { - history, - sendOrderRequest, - sendOrderRequestAfterEnquiry, - speculatedTransaction, - onClearStripePaymentToken, - dispatch, - } = this.props; + // Step 1: initiate order by requesting payment from Marketplace API + const fnRequestPayment = fnParams => { + // fnParams should be { listingId, bookingStart, bookingEnd } + const hasPaymentIntents = + storedTx.attributes.protectedData && storedTx.attributes.protectedData.stripePaymentIntents; + + // If paymentIntent exists, order has been initiated previously. + return hasPaymentIntents ? Promise.resolve(storedTx) : onInitiateOrder(fnParams, storedTx.id); + }; + + // Step 2: pay using Stripe SDK + const fnHandleCardPayment = fnParams => { + // fnParams should be returned transaction entity + + const order = ensureTransaction(fnParams); + if (order.id) { + // Store order. + const { bookingData, bookingDates, listing } = pageData; + storeData(bookingData, bookingDates, listing, order, STORAGE_KEY); + this.setState({ pageData: { ...pageData, transaction: order } }); + } + + const hasPaymentIntents = + order.attributes.protectedData && order.attributes.protectedData.stripePaymentIntents; + + if (!hasPaymentIntents) { + throw new Error( + `Missing StripePaymentIntents key in transaction's protectedData. Check that your transaction process is configured to use payment intents.` + ); + } + + const { stripePaymentIntentClientSecret } = hasPaymentIntents + ? order.attributes.protectedData.stripePaymentIntents.default + : null; + + const { stripe, card, billingDetails, paymentIntent } = handlePaymentParams; + + const params = { + stripePaymentIntentClientSecret, + orderId: order.id, + stripe, + card, + paymentParams: { + payment_method_data: { + billing_details: billingDetails, + }, + }, + }; + + // If paymentIntent status is not waiting user action, + // handleCardPayment has been called previously. + const hasPaymentIntentUserActionsDone = + paymentIntent && STRIPE_PI_USER_ACTIONS_DONE_STATUSES.includes(paymentIntent.status); + return hasPaymentIntentUserActionsDone + ? Promise.resolve({ transactionId: order.id, paymentIntent }) + : onHandleCardPayment(params); + }; + + // Step 3: complete order by confirming payment to Marketplace API + // Parameter should contain { paymentIntent, transactionId } returned in step 2 + const fnConfirmPayment = onConfirmPayment; + + // Step 4: send initial message + const fnSendMessage = fnParams => { + return onSendMessage({ ...fnParams, message }); + }; + + // Here we create promise calls in sequence + // This is pretty much the same as: + // fnRequestPayment({...initialParams}) + // .then(result => fnHandleCardPayment({...result})) + // .then(result => fnConfirmPayment({...result})) + const applyAsync = (acc, val) => acc.then(val); + const composeAsync = (...funcs) => x => funcs.reduce(applyAsync, Promise.resolve(x)); + const handlePaymentIntentCreation = composeAsync( + fnRequestPayment, + fnHandleCardPayment, + fnConfirmPayment, + fnSendMessage + ); // Create order aka transaction // NOTE: if unit type is line-item/units, quantity needs to be added. // The way to pass it to checkout page is through pageData.bookingData - const requestParams = { - listingId: this.state.pageData.listing.id, - cardToken, - bookingStart: speculatedTransaction.booking.attributes.start, - bookingEnd: speculatedTransaction.booking.attributes.end, + const tx = speculatedTransaction ? speculatedTransaction : storedTx; + const orderParams = { + listingId: pageData.listing.id, + bookingStart: tx.booking.attributes.start, + bookingEnd: tx.booking.attributes.end, }; - const enquiredTransaction = this.state.pageData.enquiredTransaction; + return handlePaymentIntentCreation(orderParams); + } - // if an enquired transaction is available, use that as basis - // otherwise initiate a new transaction - const initiateRequest = enquiredTransaction - ? sendOrderRequestAfterEnquiry(enquiredTransaction.id, requestParams) - : sendOrderRequest(requestParams, initialMessage); + handleSubmit(values) { + if (this.state.submitting) { + return; + } + this.setState({ submitting: true }); + + const { history, speculatedTransaction, currentUser, paymentIntent, dispatch } = this.props; + const { card, message, formValues } = values; + const { name, addressLine1, addressLine2, postal, city, state, country } = formValues; + + // Billing address is recommended. + // However, let's not assume that data is among formValues. + // Read more about this from Stripe's docs + // https://stripe.com/docs/stripe-js/reference#stripe-handle-card-payment-no-element + const addressMaybe = + addressLine1 && postal + ? { + address: { + city: city, + country: country, + line1: addressLine1, + line2: addressLine2, + postal_code: postal, + state: state, + }, + } + : {}; + const billingDetails = { + name, + email: ensureCurrentUser(currentUser).attributes.email, + ...addressMaybe, + }; - initiateRequest - .then(values => { - const { orderId, initialMessageSuccess } = values; + const requestPaymentParams = { + pageData: this.state.pageData, + speculatedTransaction, + stripe: this.stripe, + card, + billingDetails, + message, + paymentIntent, + }; + + this.handlePaymentIntent(requestPaymentParams) + .then(res => { + const { orderId, messageSuccess } = res; this.setState({ submitting: false }); + const routes = routeConfiguration(); - const OrderPage = findRouteByRouteName('OrderDetailsPage', routes); - - // Transaction is already created, but if the initial message - // sending failed, we tell it to the OrderDetailsPage. - dispatch( - OrderPage.setInitialValues({ - initialMessageFailedToTransaction: initialMessageSuccess ? null : orderId, - }) - ); - const orderDetailsPath = pathByRouteName('OrderDetailsPage', routes, { - id: orderId.uuid, - }); - onClearStripePaymentToken(); + const initialMessageFailedToTransaction = messageSuccess ? null : orderId; + const orderDetailsPath = pathByRouteName('OrderDetailsPage', routes, { id: orderId.uuid }); + + initializeOrderPage({ initialMessageFailedToTransaction }, routes, dispatch); clearData(STORAGE_KEY); history.push(orderDetailsPath); }) - .catch(() => { + .catch(err => { + console.error(err); this.setState({ submitting: false }); }); } + onStripeInitialized(stripe) { + this.stripe = stripe; + + const { paymentIntent, onRetrievePaymentIntent } = this.props; + const tx = this.state.pageData ? this.state.pageData.transaction : null; + + // We need to get up to date PI, if booking is created but payment is not expired. + const shouldFetchPaymentIntent = + this.stripe && + !paymentIntent && + tx && + tx.id && + tx.booking && + tx.booking.id && + txIsPaymentPending(tx) && + !checkIsPaymentExpired(tx); + + if (shouldFetchPaymentIntent) { + const { stripePaymentIntentClientSecret } = + tx.attributes.protectedData && tx.attributes.protectedData.stripePaymentIntents + ? tx.attributes.protectedData.stripePaymentIntents.default + : {}; + + // Fetch up to date PaymentIntent from Stripe + onRetrievePaymentIntent({ stripe, stripePaymentIntentClientSecret }); + } + } + render() { const { scrollingDisabled, speculateTransactionInProgress, speculateTransactionError, - speculatedTransaction, + speculatedTransaction: speculatedTransactionMaybe, initiateOrderError, + confirmPaymentError, intl, params, currentUser, - onCreateStripePaymentToken, - stripePaymentTokenInProgress, - stripePaymentTokenError, - stripePaymentToken, + handleCardPaymentError, + paymentIntent, + retrievePaymentIntentError, } = this.props; // Since the listing data is already given from the ListingPage @@ -228,9 +392,9 @@ export class CheckoutPageComponent extends Component { const isLoading = !this.state.dataLoaded || speculateTransactionInProgress; - const { listing, bookingDates, enquiredTransaction } = this.state.pageData; - const currentTransaction = ensureTransaction(speculatedTransaction, {}, null); - const currentBooking = ensureBooking(currentTransaction.booking); + const { listing, bookingDates, transaction } = this.state.pageData; + const existingTransaction = ensureTransaction(transaction); + const speculatedTransaction = ensureTransaction(speculatedTransactionMaybe, {}, null); const currentListing = ensureListing(listing); const currentAuthor = ensureUser(currentListing.author); @@ -256,26 +420,30 @@ export class CheckoutPageComponent extends Component { if (shouldRedirect) { // eslint-disable-next-line no-console console.error('Missing or invalid data for checkout, redirecting back to listing page.', { - transaction: currentTransaction, + transaction: speculatedTransaction, bookingDates, listing, }); return ; } - // Show breakdown only when transaction and booking are loaded + // Show breakdown only when speculated transaction and booking are loaded // (i.e. have an id) + const tx = existingTransaction.booking ? existingTransaction : speculatedTransaction; + const txBooking = ensureBooking(tx.booking); const breakdown = - currentTransaction.id && currentBooking.id ? ( + tx.id && txBooking.id ? ( ) : null; + const isPaymentExpired = checkIsPaymentExpired(existingTransaction); + // Allow showing page when currentUser is still being downloaded, // but show payment form only when user info is loaded. const showPaymentForm = !!( @@ -283,7 +451,9 @@ export class CheckoutPageComponent extends Component { hasRequiredData && !listingNotFound && !initiateOrderError && - !speculateTransactionError + !speculateTransactionError && + !retrievePaymentIntentError && + !isPaymentExpired ); const listingTitle = currentListing.attributes.title; @@ -292,11 +462,6 @@ export class CheckoutPageComponent extends Component { const firstImage = currentListing.images && currentListing.images.length > 0 ? currentListing.images[0] : null; - const listingNotFoundErrorMessage = listingNotFound ? ( -

    - -

    - ) : null; const listingLink = ( + +

    + ); + } else if (isAmountTooLowError) { initiateOrderErrorMessage = (

    ); - } else if (!listingNotFound && isBookingTimeNotAvailableError) { + } else if (isBookingTimeNotAvailableError) { initiateOrderErrorMessage = (

    ); - } else if (!listingNotFound && isChargeDisabledError) { + } else if (isChargeDisabledError) { initiateOrderErrorMessage = (

    ); - } else if (!listingNotFound && stripeErrors && stripeErrors.length > 0) { + } else if (stripeErrors && stripeErrors.length > 0) { // NOTE: Error messages from Stripes are not part of translations. // By default they are in English. const stripeErrorsAsString = stripeErrors.join(', '); @@ -345,7 +517,8 @@ export class CheckoutPageComponent extends Component { />

    ); - } else if (!listingNotFound && initiateOrderError) { + } else if (initiateOrderError) { + // Generic initiate order error initiateOrderErrorMessage = (

    @@ -417,7 +590,9 @@ export class CheckoutPageComponent extends Component { const formattedPrice = formatMoney(intl, price); const detailsSubTitle = `${formattedPrice} ${intl.formatMessage({ id: unitTranslationKey })}`; - const showInitialMessageInput = !enquiredTransaction; + const showInitialMessageInput = !( + existingTransaction && existingTransaction.attributes.lastTransition === TRANSITION_ENQUIRE + ); const pageProps = { title, scrollingDisabled }; @@ -432,6 +607,22 @@ export class CheckoutPageComponent extends Component { ); } + // Get first and last name of the current user and use it in the StripePaymentForm to autofill the name field + const userName = + currentUser && currentUser.attributes + ? `${currentUser.attributes.profile.firstName} ${currentUser.attributes.profile.lastName}` + : null; + + // If paymentIntent status is not waiting user action, + // handleCardPayment has been called previously. + const hasPaymentIntentUserActionsDone = + paymentIntent && STRIPE_PI_USER_ACTIONS_DONE_STATUSES.includes(paymentIntent.status); + + // If your marketplace works mostly in one country you can use initial values to select country automatically + // e.g. {country: 'FI'} + + const initalValuesForStripePayment = { name: userName }; + return ( {topbar} @@ -470,6 +661,14 @@ export class CheckoutPageComponent extends Component { {initiateOrderErrorMessage} {listingNotFoundErrorMessage} {speculateErrorMessage} + {retrievePaymentIntentError ? ( +

    + +

    + ) : null} {showPaymentForm ? ( ) : null} + {isPaymentExpired ? ( +

    + +

    + ) : null}
    @@ -518,16 +728,15 @@ export class CheckoutPageComponent extends Component { CheckoutPageComponent.defaultProps = { initiateOrderError: null, + confirmPaymentError: null, listing: null, bookingData: {}, bookingDates: null, speculateTransactionError: null, speculatedTransaction: null, - enquiredTransaction: null, + transaction: null, currentUser: null, - stripePaymentToken: null, - stripePaymentTokenInProgress: false, - stripePaymentTokenError: null, + paymentIntent: null, }; CheckoutPageComponent.propTypes = { @@ -542,18 +751,20 @@ CheckoutPageComponent.propTypes = { speculateTransactionInProgress: bool.isRequired, speculateTransactionError: propTypes.error, speculatedTransaction: propTypes.transaction, - enquiredTransaction: propTypes.transaction, - initiateOrderError: propTypes.error, + transaction: propTypes.transaction, currentUser: propTypes.currentUser, params: shape({ id: string, slug: string, }).isRequired, - sendOrderRequest: func.isRequired, - onCreateStripePaymentToken: func.isRequired, - stripePaymentTokenInProgress: bool, - stripePaymentTokenError: propTypes.error, - stripePaymentToken: object, + onInitiateOrder: func.isRequired, + onHandleCardPayment: func.isRequired, + onRetrievePaymentIntent: func.isRequired, + initiateOrderError: propTypes.error, + confirmPaymentError: propTypes.error, + // handleCardPaymentError comes from Stripe so that's why we can't expect it to be in a specific form + handleCardPaymentError: oneOfType([propTypes.error, object]), + paymentIntent: object, // from connect dispatch: func.isRequired, @@ -575,15 +786,12 @@ const mapStateToProps = state => { speculateTransactionInProgress, speculateTransactionError, speculatedTransaction, - enquiredTransaction, + transaction, initiateOrderError, + confirmPaymentError, } = state.CheckoutPage; const { currentUser } = state.user; - const { - stripePaymentTokenInProgress, - stripePaymentTokenError, - stripePaymentToken, - } = state.stripe; + const { handleCardPaymentError, paymentIntent, retrievePaymentIntentError } = state.stripe; return { scrollingDisabled: isScrollingDisabled(state), currentUser, @@ -592,23 +800,24 @@ const mapStateToProps = state => { speculateTransactionInProgress, speculateTransactionError, speculatedTransaction, - enquiredTransaction, + transaction, listing, initiateOrderError, - stripePaymentTokenInProgress, - stripePaymentTokenError, - stripePaymentToken, + handleCardPaymentError, + confirmPaymentError, + paymentIntent, + retrievePaymentIntentError, }; }; const mapDispatchToProps = dispatch => ({ dispatch, - sendOrderRequest: (params, initialMessage) => dispatch(initiateOrder(params, initialMessage)), - sendOrderRequestAfterEnquiry: (transactionId, params) => - dispatch(initiateOrderAfterEnquiry(transactionId, params)), + onInitiateOrder: (params, transactionId) => dispatch(initiateOrder(params, transactionId)), fetchSpeculatedTransaction: params => dispatch(speculateTransaction(params)), - onCreateStripePaymentToken: params => dispatch(createStripePaymentToken(params)), - onClearStripePaymentToken: () => dispatch(clearStripePaymentToken()), + onRetrievePaymentIntent: params => dispatch(retrievePaymentIntent(params)), + onHandleCardPayment: params => dispatch(handleCardPayment(params)), + onConfirmPayment: params => dispatch(confirmPayment(params)), + onSendMessage: params => dispatch(sendMessage(params)), }); const CheckoutPage = compose( diff --git a/src/containers/CheckoutPage/CheckoutPage.test.js b/src/containers/CheckoutPage/CheckoutPage.test.js index 4171266038..caddeb3c10 100644 --- a/src/containers/CheckoutPage/CheckoutPage.test.js +++ b/src/containers/CheckoutPage/CheckoutPage.test.js @@ -24,9 +24,10 @@ describe('CheckoutPage', () => { fetchSpeculatedTransaction: noop, speculateTransactionInProgress: false, scrollingDisabled: false, - onCreateStripePaymentToken: noop, - stripePaymentTokenInProgress: false, - stripePaymentTokenError: null, + onHandleCardPayment: noop, + onInitiateOrder: noop, + onRetrievePaymentIntent: noop, + handleCardPaymentInProgress: false, }; const tree = renderShallow(); expect(tree).toMatchSnapshot(); @@ -62,7 +63,8 @@ describe('CheckoutPage', () => { speculateTransactionError: null, speculateTransactionInProgress: false, speculatedTransaction: null, - enquiredTransaction: null, + transaction: null, + confirmPaymentError: null, }; it('should return the initial state', () => { diff --git a/src/containers/CheckoutPage/CheckoutPageSessionHelpers.js b/src/containers/CheckoutPage/CheckoutPageSessionHelpers.js index 1dd9a3fe7c..37222aec5c 100644 --- a/src/containers/CheckoutPage/CheckoutPageSessionHelpers.js +++ b/src/containers/CheckoutPage/CheckoutPageSessionHelpers.js @@ -6,8 +6,9 @@ */ import moment from 'moment'; import reduce from 'lodash/reduce'; +import Decimal from 'decimal.js'; import { types as sdkTypes } from '../../util/sdkLoader'; -import { TRANSITION_ENQUIRE } from '../../util/transaction'; +import { TRANSITIONS } from '../../util/transaction'; const { UUID, Money } = sdkTypes; @@ -47,37 +48,41 @@ export const isValidListing = listing => { return validateProperties(listing, props); }; -// Validate content of an enquired transaction received from SessionStore. -// An id is required and the last transition needs to be the enquire transition. -export const isValidEnquiredTransaction = transaction => { +// Validate content of an transaction received from SessionStore. +// An id is required and the last transition needs to be one of the known transitions +export const isValidTransaction = transaction => { const props = { id: id => id instanceof UUID, + type: type => type === 'transaction', attributes: v => { - return typeof v === 'object' && v.lastTransition === TRANSITION_ENQUIRE; + return typeof v === 'object' && TRANSITIONS.includes(v.lastTransition); }, }; return validateProperties(transaction, props); }; // Stores given bookingDates and listing to sessionStorage -export const storeData = (bookingData, bookingDates, listing, enquiredTransaction, storageKey) => { +export const storeData = (bookingData, bookingDates, listing, transaction, storageKey) => { if (window && window.sessionStorage && listing && bookingDates && bookingData) { - // TODO: How should we deal with Dates when data is serialized? - // Hard coded serializable date objects atm. - /* eslint-disable no-underscore-dangle */ const data = { bookingData, - bookingDates: { - bookingStart: { date: bookingDates.bookingStart, _serializedType: 'SerializableDate' }, - bookingEnd: { date: bookingDates.bookingEnd, _serializedType: 'SerializableDate' }, - }, + bookingDates, listing, - enquiredTransaction, - storedAt: { date: new Date(), _serializedType: 'SerializableDate' }, + transaction, + storedAt: new Date(), }; - /* eslint-enable no-underscore-dangle */ - const storableData = JSON.stringify(data, sdkTypes.replacer); + const replacer = function(k, v) { + if (this[k] instanceof Date) { + return { date: v, _serializedType: 'SerializableDate' }; + } + if (this[k] instanceof Decimal) { + return { decimal: v, _serializedType: 'SerializableDecimal' }; + } + return sdkTypes.replacer(k, v); + }; + + const storableData = JSON.stringify(data, replacer); window.sessionStorage.setItem(storageKey, storableData); } }; @@ -87,17 +92,20 @@ export const storedData = storageKey => { if (window && window.sessionStorage) { const checkoutPageData = window.sessionStorage.getItem(storageKey); - // TODO How should we deal with Dates when data is serialized? - // Dates are expected to be in format: { date: new Date(), _serializedType: 'SerializableDate' } const reviver = (k, v) => { - // eslint-disable-next-line no-underscore-dangle if (v && typeof v === 'object' && v._serializedType === 'SerializableDate') { + // Dates are expected to be stored as: + // { date: new Date(), _serializedType: 'SerializableDate' } return new Date(v.date); + } else if (v && typeof v === 'object' && v._serializedType === 'SerializableDecimal') { + // Decimals are expected to be stored as: + // { decimal: v, _serializedType: 'SerializableDecimal' } + return new Decimal(v.decimal); } return sdkTypes.reviver(k, v); }; - const { bookingData, bookingDates, listing, enquiredTransaction, storedAt } = checkoutPageData + const { bookingData, bookingDates, listing, transaction, storedAt } = checkoutPageData ? JSON.parse(checkoutPageData, reviver) : {}; @@ -106,19 +114,17 @@ export const storedData = storageKey => { ? moment(storedAt).isAfter(moment().subtract(1, 'days')) : false; - // resolve enquired transaction as valid if it is missing - const isEnquiredTransactionValid = !!enquiredTransaction - ? isValidEnquiredTransaction(enquiredTransaction) - : true; + // resolve transaction as valid if it is missing + const isTransactionValid = !!transaction ? isValidTransaction(transaction) : true; const isStoredDataValid = isFreshlySaved && isValidBookingDates(bookingDates) && isValidListing(listing) && - isEnquiredTransactionValid; + isTransactionValid; if (isStoredDataValid) { - return { bookingData, bookingDates, listing, enquiredTransaction }; + return { bookingData, bookingDates, listing, transaction }; } } return {}; diff --git a/src/containers/CheckoutPage/__snapshots__/CheckoutPage.test.js.snap b/src/containers/CheckoutPage/__snapshots__/CheckoutPage.test.js.snap index 56452338d8..ff50630ef3 100644 --- a/src/containers/CheckoutPage/__snapshots__/CheckoutPage.test.js.snap +++ b/src/containers/CheckoutPage/__snapshots__/CheckoutPage.test.js.snap @@ -88,15 +88,21 @@ exports[`CheckoutPage matches snapshot 1`] = `
    diff --git a/src/containers/InboxPage/InboxPage.js b/src/containers/InboxPage/InboxPage.js index d2aaac3d04..3034d8db5c 100644 --- a/src/containers/InboxPage/InboxPage.js +++ b/src/containers/InboxPage/InboxPage.js @@ -12,6 +12,8 @@ import { txIsEnquired, txIsRequested, txHasBeenDelivered, + txIsPaymentExpired, + txIsPaymentPending, } from '../../util/transaction'; import { LINE_ITEM_DAY, LINE_ITEM_UNITS, propTypes } from '../../util/types'; import { formatMoney } from '../../util/currency'; @@ -89,6 +91,26 @@ export const txState = (intl, tx, type) => { }; return requested; + } else if (txIsPaymentPending(tx)) { + return { + nameClassName: isOrder ? css.nameNotEmphasized : css.nameEmphasized, + bookingClassName: css.bookingNoActionNeeded, + lastTransitionedAtClassName: css.lastTransitionedAtNotEmphasized, + stateClassName: isOrder ? css.stateActionNeeded : css.stateNoActionNeeded, + state: intl.formatMessage({ + id: 'InboxPage.statePendingPayment', + }), + }; + } else if (txIsPaymentExpired(tx)) { + return { + nameClassName: css.nameNotEmphasized, + bookingClassName: css.bookingNoActionNeeded, + lastTransitionedAtClassName: css.lastTransitionedAtNotEmphasized, + stateClassName: css.stateNoActionNeeded, + state: intl.formatMessage({ + id: 'InboxPage.stateExpired', + }), + }; } else if (txIsDeclined(tx)) { return { nameClassName: css.nameNotEmphasized, diff --git a/src/containers/InboxPage/InboxPage.test.js b/src/containers/InboxPage/InboxPage.test.js index 8207469849..478dd6828b 100644 --- a/src/containers/InboxPage/InboxPage.test.js +++ b/src/containers/InboxPage/InboxPage.test.js @@ -10,7 +10,7 @@ import { } from '../../util/test-data'; import { InboxPageComponent, InboxItem, txState } from './InboxPage'; import routeConfiguration from '../../routeConfiguration'; -import { TRANSITION_REQUEST } from '../../util/transaction'; +import { TRANSITION_CONFIRM_PAYMENT } from '../../util/transaction'; import { LINE_ITEM_NIGHT } from '../../util/types'; const noop = () => null; @@ -61,7 +61,7 @@ describe('InboxPage', () => { transactions: [ createTransaction({ id: 'order-1', - lastTransition: TRANSITION_REQUEST, + lastTransition: TRANSITION_CONFIRM_PAYMENT, customer, provider, lastTransitionedAt: new Date(Date.UTC(2017, 0, 15)), @@ -69,7 +69,7 @@ describe('InboxPage', () => { }), createTransaction({ id: 'order-2', - lastTransition: TRANSITION_REQUEST, + lastTransition: TRANSITION_CONFIRM_PAYMENT, customer, provider, lastTransitionedAt: new Date(Date.UTC(2016, 0, 15)), @@ -118,7 +118,7 @@ describe('InboxPage', () => { transactions: [ createTransaction({ id: 'sale-1', - lastTransition: TRANSITION_REQUEST, + lastTransition: TRANSITION_CONFIRM_PAYMENT, customer, provider, lastTransitionedAt: new Date(Date.UTC(2017, 0, 15)), @@ -126,7 +126,7 @@ describe('InboxPage', () => { }), createTransaction({ id: 'sale-2', - lastTransition: TRANSITION_REQUEST, + lastTransition: TRANSITION_CONFIRM_PAYMENT, customer, provider, lastTransitionedAt: new Date(Date.UTC(2016, 0, 15)), diff --git a/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap b/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap index c1f85580fe..4502ad6eb4 100644 --- a/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap +++ b/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap @@ -104,7 +104,7 @@ exports[`InboxPage matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/request", + "lastTransition": "transition/confirm-payment", "lastTransitionedAt": 2017-01-15T00:00:00.000Z, "lineItems": Array [ Object { @@ -158,7 +158,12 @@ exports[`InboxPage matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -252,7 +257,7 @@ exports[`InboxPage matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/request", + "lastTransition": "transition/confirm-payment", "lastTransitionedAt": 2016-01-15T00:00:00.000Z, "lineItems": Array [ Object { @@ -306,7 +311,12 @@ exports[`InboxPage matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -546,7 +556,7 @@ exports[`InboxPage matches snapshot 3`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/request", + "lastTransition": "transition/confirm-payment", "lastTransitionedAt": 2017-01-15T00:00:00.000Z, "lineItems": Array [ Object { @@ -600,7 +610,12 @@ exports[`InboxPage matches snapshot 3`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -694,7 +709,7 @@ exports[`InboxPage matches snapshot 3`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/request", + "lastTransition": "transition/confirm-payment", "lastTransitionedAt": 2016-01-15T00:00:00.000Z, "lineItems": Array [ Object { @@ -748,7 +763,12 @@ exports[`InboxPage matches snapshot 3`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", diff --git a/src/containers/ListingPage/ListingPage.js b/src/containers/ListingPage/ListingPage.js index 065156be7d..b956fe284a 100644 --- a/src/containers/ListingPage/ListingPage.js +++ b/src/containers/ListingPage/ListingPage.js @@ -27,6 +27,7 @@ import { import { richText } from '../../util/richText'; import { getMarketplaceEntities } from '../../ducks/marketplaceData.duck'; import { manageDisableScrolling, isScrollingDisabled } from '../../ducks/UI.duck'; +import { initializeCardPaymentData } from '../../ducks/stripe.duck.js'; import { Page, NamedLink, @@ -90,7 +91,13 @@ export class ListingPageComponent extends Component { } handleSubmit(values) { - const { history, getListing, params, callSetInitialValues } = this.props; + const { + history, + getListing, + params, + callSetInitialValues, + onInitializeCardPaymentData, + } = this.props; const listingId = new UUID(params.id); const listing = getListing(listingId); @@ -103,6 +110,7 @@ export class ListingPageComponent extends Component { bookingStart: bookingDates.startDate, bookingEnd: bookingDates.endDate, }, + confirmPaymentError: null, }; const routes = routeConfiguration(); @@ -110,6 +118,9 @@ export class ListingPageComponent extends Component { const { setInitialValues } = findRouteByRouteName('CheckoutPage', routes); callSetInitialValues(setInitialValues, initialValues); + // Clear previous Stripe errors from store if there is any + onInitializeCardPaymentData(); + // Redirect to CheckoutPage history.push( createResourceLocatorString( @@ -512,6 +523,7 @@ ListingPageComponent.propTypes = { sendEnquiryInProgress: bool.isRequired, sendEnquiryError: propTypes.error, onSendEnquiry: func.isRequired, + onInitializeCardPaymentData: func.isRequired, categoriesConfig: array, amenitiesConfig: array, @@ -565,6 +577,7 @@ const mapDispatchToProps = dispatch => ({ dispatch(manageDisableScrolling(componentId, disableScrolling)), callSetInitialValues: (setInitialValues, values) => dispatch(setInitialValues(values)), onSendEnquiry: (listingId, message) => dispatch(sendEnquiry(listingId, message)), + onInitializeCardPaymentData: () => dispatch(initializeCardPaymentData()), }); // Note: it is important that the withRouter HOC is **outside** the diff --git a/src/containers/ListingPage/ListingPage.test.js b/src/containers/ListingPage/ListingPage.test.js index e1fe74ee95..b4c3d1fe65 100644 --- a/src/containers/ListingPage/ListingPage.test.js +++ b/src/containers/ListingPage/ListingPage.test.js @@ -73,6 +73,7 @@ describe('ListingPage', () => { callSetInitialValues: noop, sendVerificationEmailInProgress: false, onResendVerificationEmail: noop, + onInitializeCardPaymentData: noop, sendEnquiryInProgress: false, onSendEnquiry: noop, categoriesConfig, diff --git a/src/containers/TransactionPage/TransactionPage.js b/src/containers/TransactionPage/TransactionPage.js index da66cce877..15df684c16 100644 --- a/src/containers/TransactionPage/TransactionPage.js +++ b/src/containers/TransactionPage/TransactionPage.js @@ -9,9 +9,12 @@ import { createResourceLocatorString, findRouteByRouteName } from '../../util/ro import routeConfiguration from '../../routeConfiguration'; import { propTypes } from '../../util/types'; import { ensureListing, ensureTransaction } from '../../util/data'; +import { dateFromAPIToLocalNoon } from '../../util/dates'; import { createSlug } from '../../util/urlHelpers'; +import { txIsPaymentPending } from '../../util/transaction'; import { getMarketplaceEntities } from '../../ducks/marketplaceData.duck'; import { isScrollingDisabled, manageDisableScrolling } from '../../ducks/UI.duck'; +import { initializeCardPaymentData } from '../../ducks/stripe.duck.js'; import { NamedRedirect, TransactionPanel, @@ -72,29 +75,23 @@ export const TransactionPageComponent = props => { timeSlots, fetchTimeSlotsError, callSetInitialValues, + onInitializeCardPaymentData, } = props; const currentTransaction = ensureTransaction(transaction); const currentListing = ensureListing(currentTransaction.listing); + const isProviderRole = transactionRole === PROVIDER; + const isCustomerRole = transactionRole === CUSTOMER; - const handleSubmitBookingRequest = values => { - const { bookingDates, ...bookingData } = values; - - const initialValues = { - listing: currentListing, - enquiredTransaction: currentTransaction, - bookingData, - bookingDates: { - bookingStart: bookingDates.startDate, - bookingEnd: bookingDates.endDate, - }, - }; - + const redirectToCheckoutPageWithInitialValues = (initialValues, listing) => { const routes = routeConfiguration(); // Customize checkout page state with current listing and selected bookingDates const { setInitialValues } = findRouteByRouteName('CheckoutPage', routes); callSetInitialValues(setInitialValues, initialValues); + // Clear previous Stripe errors from store if there is any + onInitializeCardPaymentData(); + // Redirect to CheckoutPage history.push( createResourceLocatorString( @@ -106,6 +103,46 @@ export const TransactionPageComponent = props => { ); }; + // If payment is pending, redirect to CheckoutPage + if (txIsPaymentPending(currentTransaction) && isCustomerRole) { + const currentBooking = ensureListing(currentTransaction.booking); + + const initialValues = { + listing: currentListing, + // Transaction with payment pending should be passed to CheckoutPage + transaction: currentTransaction, + // Original bookingData content is not available, + // but it is already used since booking is created. + // (E.g. quantity is used when booking is created.) + bookingData: {}, + bookingDates: { + bookingStart: dateFromAPIToLocalNoon(currentBooking.attributes.start), + bookingEnd: dateFromAPIToLocalNoon(currentBooking.attributes.end), + }, + }; + + redirectToCheckoutPageWithInitialValues(initialValues, currentListing); + } + + // Customer can create a booking, if the tx is in "enquiry" state. + const handleSubmitBookingRequest = values => { + const { bookingDates, ...bookingData } = values; + + const initialValues = { + listing: currentListing, + // enquired transaction should be passed to CheckoutPage + transaction: currentTransaction, + bookingData, + bookingDates: { + bookingStart: bookingDates.startDate, + bookingEnd: bookingDates.endDate, + }, + confirmPaymentError: null, + }; + + redirectToCheckoutPageWithInitialValues(initialValues, currentListing); + }; + const deletedListingTitle = intl.formatMessage({ id: 'TransactionPage.deletedListing', }); @@ -123,8 +160,6 @@ export const TransactionPageComponent = props => { currentTransaction.provider && !fetchTransactionError; - const isProviderRole = transactionRole === PROVIDER; - const isCustomerRole = transactionRole === CUSTOMER; const isOwnSale = isDataAvailable && isProviderRole && @@ -265,6 +300,7 @@ TransactionPageComponent.propTypes = { timeSlots: arrayOf(propTypes.timeSlot), fetchTimeSlotsError: propTypes.error, callSetInitialValues: func.isRequired, + onInitializeCardPaymentData: func.isRequired, // from withRouter history: shape({ @@ -339,6 +375,7 @@ const mapDispatchToProps = dispatch => { onSendReview: (role, tx, reviewRating, reviewContent) => dispatch(sendReview(role, tx, reviewRating, reviewContent)), callSetInitialValues: (setInitialValues, values) => dispatch(setInitialValues(values)), + onInitializeCardPaymentData: () => dispatch(initializeCardPaymentData()), }; }; diff --git a/src/containers/TransactionPage/TransactionPage.test.js b/src/containers/TransactionPage/TransactionPage.test.js index 550f5ab63a..358e48d96f 100644 --- a/src/containers/TransactionPage/TransactionPage.test.js +++ b/src/containers/TransactionPage/TransactionPage.test.js @@ -8,7 +8,7 @@ import { fakeIntl, } from '../../util/test-data'; import { renderShallow } from '../../util/test-helpers'; -import { TRANSITION_REQUEST } from '../../util/transaction'; +import { TRANSITION_CONFIRM_PAYMENT } from '../../util/transaction'; import { TransactionPageComponent } from './TransactionPage'; const noop = () => null; @@ -20,7 +20,7 @@ describe('TransactionPage - Sale', () => { const end = new Date(Date.UTC(2017, 5, 13)); const transaction = createTransaction({ id: txId, - lastTransition: TRANSITION_REQUEST, + lastTransition: TRANSITION_CONFIRM_PAYMENT, booking: createBooking('booking1', { start, end, @@ -49,6 +49,7 @@ describe('TransactionPage - Sale', () => { oldestMessagePageFetched: 0, messages: [], sendMessageInProgress: false, + onInitializeCardPaymentData: noop, onShowMoreMessages: noop, onSendMessage: noop, onResetForm: noop, @@ -77,7 +78,7 @@ describe('TransactionPage - Order', () => { const transaction = createTransaction({ id: txId, - lastTransition: TRANSITION_REQUEST, + lastTransition: TRANSITION_CONFIRM_PAYMENT, booking: createBooking('booking1', { start, end, @@ -103,6 +104,7 @@ describe('TransactionPage - Order', () => { scrollingDisabled: false, callSetInitialValues: noop, transaction, + onInitializeCardPaymentData: noop, onShowMoreMessages: noop, onSendMessage: noop, onResetForm: noop, diff --git a/src/containers/TransactionPage/__snapshots__/TransactionPage.test.js.snap b/src/containers/TransactionPage/__snapshots__/TransactionPage.test.js.snap index 318752a274..4183e204f4 100644 --- a/src/containers/TransactionPage/__snapshots__/TransactionPage.test.js.snap +++ b/src/containers/TransactionPage/__snapshots__/TransactionPage.test.js.snap @@ -66,7 +66,7 @@ exports[`TransactionPage - Order matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/request", + "lastTransition": "transition/confirm-payment", "lastTransitionedAt": 2017-06-01T00:00:00.000Z, "lineItems": Array [ Object { @@ -120,7 +120,12 @@ exports[`TransactionPage - Order matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", @@ -283,7 +288,7 @@ exports[`TransactionPage - Sale matches snapshot 1`] = ` Object { "attributes": Object { "createdAt": 2017-05-01T00:00:00.000Z, - "lastTransition": "transition/request", + "lastTransition": "transition/confirm-payment", "lastTransitionedAt": 2017-06-01T00:00:00.000Z, "lineItems": Array [ Object { @@ -337,7 +342,12 @@ exports[`TransactionPage - Sale matches snapshot 1`] = ` Object { "by": "customer", "createdAt": 2017-05-01T00:00:00.000Z, - "transition": "transition/request", + "transition": "transition/request-payment", + }, + Object { + "by": "customer", + "createdAt": 2017-05-01T00:00:01.000Z, + "transition": "transition/confirm-payment", }, Object { "by": "provider", diff --git a/src/ducks/stripe.duck.js b/src/ducks/stripe.duck.js index b158807c71..ba6833ed31 100644 --- a/src/ducks/stripe.duck.js +++ b/src/ducks/stripe.duck.js @@ -18,12 +18,18 @@ export const PERSON_CREATE_REQUEST = 'app/stripe/PERSON_CREATE_REQUEST'; export const PERSON_CREATE_SUCCESS = 'app/stripe/PERSON_CREATE_SUCCESS'; export const PERSON_CREATE_ERROR = 'app/stripe/PERSON_CREATE_ERROR'; -export const CREATE_PAYMENT_TOKEN_REQUEST = 'app/stripe/CREATE_PAYMENT_TOKEN_REQUEST'; -export const CREATE_PAYMENT_TOKEN_SUCCESS = 'app/stripe/CREATE_PAYMENT_TOKEN_SUCCESS'; -export const CREATE_PAYMENT_TOKEN_ERROR = 'app/stripe/CREATE_PAYMENT_TOKEN_ERROR'; - export const CLEAR_PAYMENT_TOKEN = 'app/stripe/CLEAR_PAYMENT_TOKEN'; +export const HANDLE_CARD_PAYMENT_REQUEST = 'app/stripe/HANDLE_CARD_PAYMENT_REQUEST'; +export const HANDLE_CARD_PAYMENT_SUCCESS = 'app/stripe/HANDLE_CARD_PAYMENT_SUCCESS'; +export const HANDLE_CARD_PAYMENT_ERROR = 'app/stripe/HANDLE_CARD_PAYMENT_ERROR'; + +export const CLEAR_HANDLE_CARD_PAYMENT = 'app/stripe/CLEAR_HANDLE_CARD_PAYMENT'; + +export const RETRIEVE_PAYMENT_INTENT_REQUEST = 'app/stripe/RETRIEVE_PAYMENT_INTENT_REQUEST'; +export const RETRIEVE_PAYMENT_INTENT_SUCCESS = 'app/stripe/RETRIEVE_PAYMENT_INTENT_SUCCESS'; +export const RETRIEVE_PAYMENT_INTENT_ERROR = 'app/stripe/RETRIEVE_PAYMENT_INTENT_ERROR'; + // ================ Reducer ================ // const initialState = { @@ -35,9 +41,11 @@ const initialState = { persons: [], stripeAccount: null, stripeAccountFetched: false, - stripePaymentTokenInProgress: false, - stripePaymentTokenError: null, - stripePaymentToken: null, + handleCardPaymentInProgress: false, + handleCardPaymentError: null, + paymentIntent: null, + retrievePaymentIntentInProgress: false, + retrievePaymentIntentError: null, }; export default function reducer(state = initialState, action = {}) { @@ -103,19 +111,41 @@ export default function reducer(state = initialState, action = {}) { }), }; - case CREATE_PAYMENT_TOKEN_REQUEST: + case HANDLE_CARD_PAYMENT_REQUEST: return { ...state, - stripePaymentTokenError: null, - stripePaymentTokenInProgress: true, + handleCardPaymentError: null, + handleCardPaymentInProgress: true, }; - case CREATE_PAYMENT_TOKEN_SUCCESS: - return { ...state, stripePaymentTokenInProgress: false, stripePaymentToken: payload }; - case CREATE_PAYMENT_TOKEN_ERROR: + case HANDLE_CARD_PAYMENT_SUCCESS: + return { ...state, paymentIntent: payload, handleCardPaymentInProgress: false }; + case HANDLE_CARD_PAYMENT_ERROR: console.error(payload); - return { ...state, stripePaymentTokenError: payload, stripePaymentTokenInProgress: false }; - case CLEAR_PAYMENT_TOKEN: - return { ...state, stripePaymentToken: null }; + return { ...state, handleCardPaymentError: payload, handleCardPaymentInProgress: false }; + + case CLEAR_HANDLE_CARD_PAYMENT: + return { + ...state, + handleCardPaymentInProgress: false, + handleCardPaymentError: null, + paymentIntent: null, + }; + + case RETRIEVE_PAYMENT_INTENT_REQUEST: + return { + ...state, + retrievePaymentIntentError: null, + retrievePaymentIntentInProgress: true, + }; + case RETRIEVE_PAYMENT_INTENT_SUCCESS: + return { ...state, paymentIntent: payload, retrievePaymentIntentInProgress: false }; + case RETRIEVE_PAYMENT_INTENT_ERROR: + console.error(payload); + return { + ...state, + retrievePaymentIntentError: payload, + retrievePaymentIntentInProgress: false, + }; default: return state; @@ -173,23 +203,38 @@ export const personCreateError = payload => ({ error: true, }); -export const createPaymentTokenRequest = () => ({ - type: CREATE_PAYMENT_TOKEN_REQUEST, +export const handleCardPaymentRequest = () => ({ + type: HANDLE_CARD_PAYMENT_REQUEST, }); -export const createPaymentTokenSuccess = payload => ({ - type: CREATE_PAYMENT_TOKEN_SUCCESS, +export const handleCardPaymentSuccess = payload => ({ + type: HANDLE_CARD_PAYMENT_SUCCESS, payload, }); -export const createPaymentTokenError = payload => ({ - type: CREATE_PAYMENT_TOKEN_ERROR, +export const handleCardPaymentError = payload => ({ + type: HANDLE_CARD_PAYMENT_ERROR, payload, error: true, }); -export const clearPaymentToken = () => ({ - type: CLEAR_PAYMENT_TOKEN, +export const initializeCardPaymentData = () => ({ + type: CLEAR_HANDLE_CARD_PAYMENT, +}); + +export const retrievePaymentIntentRequest = () => ({ + type: RETRIEVE_PAYMENT_INTENT_REQUEST, +}); + +export const retrievePaymentIntentSuccess = payload => ({ + type: RETRIEVE_PAYMENT_INTENT_SUCCESS, + payload, +}); + +export const retrievePaymentIntentError = payload => ({ + type: RETRIEVE_PAYMENT_INTENT_ERROR, + payload, + error: true, }); // ================ Thunks ================ // @@ -502,28 +547,81 @@ export const createStripeAccount = payoutDetails => (dispatch, getState, sdk) => } }; -export const createStripePaymentToken = params => dispatch => { +export const retrievePaymentIntent = params => dispatch => { + const { stripe, stripePaymentIntentClientSecret } = params; + dispatch(retrievePaymentIntentRequest()); + + return stripe + .retrievePaymentIntent(stripePaymentIntentClientSecret) + .then(response => { + if (response.error) { + return Promise.reject(response); + } else { + dispatch(retrievePaymentIntentSuccess(response.paymentIntent)); + return response; + } + }) + .catch(err => { + // Unwrap Stripe error. + const e = err.error || storableError(err); + dispatch(retrievePaymentIntentError(e)); + + // Log error + const { code, doc_url, message, payment_intent } = err.error || {}; + const loggableError = err.error + ? { + code, + message, + doc_url, + paymentIntentStatus: payment_intent + ? payment_intent.status + : 'no payment_intent included', + } + : e; + log.error(loggableError, 'stripe-retrieve-payment-intent-failed', { + stripeMessage: loggableError.message, + }); + throw err; + }); +}; + +export const handleCardPayment = params => dispatch => { // It's required to use the same instance of Stripe as where the card has been created // so that's why Stripe needs to be passed here and we can't create a new instance. - const { stripe, card } = params; + const { stripe, card, paymentParams, stripePaymentIntentClientSecret } = params; + const transactionId = params.orderId; - dispatch(createPaymentTokenRequest()); + dispatch(handleCardPaymentRequest()); return stripe - .createToken(card) + .handleCardPayment(stripePaymentIntentClientSecret, card, paymentParams) .then(response => { - dispatch(createPaymentTokenSuccess(response.token)); - return response; + if (response.error) { + return Promise.reject(response); + } else { + dispatch(handleCardPaymentSuccess(response)); + return { ...response, transactionId }; + } }) .catch(err => { - const e = storableError(err); - dispatch(createPaymentTokenError(e)); - const stripeMessage = e.message; - log.error(err, 'create-stripe-payment-token-failed', { stripeMessage }); + // Unwrap Stripe error. + const e = err.error || storableError(err); + dispatch(handleCardPaymentError(e)); + + // Log error + const containsPaymentIntent = err.error && err.error.payment_intent; + const { code, doc_url, message, payment_intent } = containsPaymentIntent ? err.error : {}; + const loggableError = containsPaymentIntent + ? { + code, + message, + doc_url, + paymentIntentStatus: payment_intent.status, + } + : e; + log.error(loggableError, 'stripe-handle-card-payment-failed', { + stripeMessage: loggableError.message, + }); throw e; }); }; - -export const clearStripePaymentToken = () => dispatch => { - dispatch(clearPaymentToken()); -}; diff --git a/src/forms/BookingDatesForm/EstimatedBreakdownMaybe.js b/src/forms/BookingDatesForm/EstimatedBreakdownMaybe.js index 8289d4235d..6fa01417de 100644 --- a/src/forms/BookingDatesForm/EstimatedBreakdownMaybe.js +++ b/src/forms/BookingDatesForm/EstimatedBreakdownMaybe.js @@ -30,7 +30,7 @@ import moment from 'moment'; import Decimal from 'decimal.js'; import { types as sdkTypes } from '../../util/sdkLoader'; import { dateFromLocalToAPI, nightsBetween, daysBetween } from '../../util/dates'; -import { TRANSITION_REQUEST, TX_TRANSITION_ACTOR_CUSTOMER } from '../../util/transaction'; +import { TRANSITION_REQUEST_PAYMENT, TX_TRANSITION_ACTOR_CUSTOMER } from '../../util/transaction'; import { LINE_ITEM_DAY, LINE_ITEM_NIGHT, LINE_ITEM_UNITS } from '../../util/types'; import { unitDivisor, convertMoneyToNumber, convertUnitToSubUnit } from '../../util/currency'; import { BookingBreakdown } from '../../components'; @@ -86,7 +86,7 @@ const estimatedTransaction = (unitType, bookingStart, bookingEnd, unitPrice, qua attributes: { createdAt: now, lastTransitionedAt: now, - lastTransition: TRANSITION_REQUEST, + lastTransition: TRANSITION_REQUEST_PAYMENT, payinTotal: totalPrice, payoutTotal: totalPrice, lineItems: [ @@ -103,7 +103,7 @@ const estimatedTransaction = (unitType, bookingStart, bookingEnd, unitPrice, qua { createdAt: now, by: TX_TRANSITION_ACTOR_CUSTOMER, - transition: TRANSITION_REQUEST, + transition: TRANSITION_REQUEST_PAYMENT, }, ], }, diff --git a/src/forms/StripePaymentForm/StripePaymentAddress.js b/src/forms/StripePaymentForm/StripePaymentAddress.js new file mode 100644 index 0000000000..14dbe1f207 --- /dev/null +++ b/src/forms/StripePaymentForm/StripePaymentAddress.js @@ -0,0 +1,186 @@ +import React from 'react'; +import { intlShape } from 'react-intl'; +import { bool, object, string } from 'prop-types'; +import config from '../../config'; +import * as validators from '../../util/validators'; +import getCountryCodes from '../../translations/countryCodes'; +import { FieldTextInput, FieldSelect } from '../../components'; + +import css from './StripePaymentForm.css'; + +const StripePaymentAddress = props => { + const { className, intl, disabled, form, fieldId, card } = props; + + const optionalText = intl.formatMessage({ + id: 'StripePaymentAddress.optionalText', + }); + + const addressLine1Label = intl.formatMessage({ + id: 'StripePaymentAddress.addressLine1Label', + }); + const addressLine1Placeholder = intl.formatMessage({ + id: 'StripePaymentAddress.addressLine1Placeholder', + }); + const addressLine1Required = validators.required( + intl.formatMessage({ + id: 'StripePaymentAddress.addressLine1Required', + }) + ); + + const addressLine2Label = intl.formatMessage( + { id: 'StripePaymentAddress.addressLine2Label' }, + { optionalText: optionalText } + ); + + const addressLine2Placeholder = intl.formatMessage({ + id: 'StripePaymentAddress.addressLine2Placeholder', + }); + + const postalCodeLabel = intl.formatMessage({ id: 'StripePaymentAddress.postalCodeLabel' }); + const postalCodePlaceholder = intl.formatMessage({ + id: 'StripePaymentAddress.postalCodePlaceholder', + }); + const postalCodeRequired = validators.required( + intl.formatMessage({ + id: 'StripePaymentAddress.postalCodeRequired', + }) + ); + + const cityLabel = intl.formatMessage({ id: 'StripePaymentAddress.cityLabel' }); + const cityPlaceholder = intl.formatMessage({ id: 'StripePaymentAddress.cityPlaceholder' }); + const cityRequired = validators.required( + intl.formatMessage({ + id: 'StripePaymentAddress.cityRequired', + }) + ); + + const stateLabel = intl.formatMessage( + { id: 'StripePaymentAddress.stateLabel' }, + { optionalText: optionalText } + ); + const statePlaceholder = intl.formatMessage({ id: 'StripePaymentAddress.statePlaceholder' }); + + const countryLabel = intl.formatMessage({ id: 'StripePaymentAddress.countryLabel' }); + const countryPlaceholder = intl.formatMessage({ id: 'StripePaymentAddress.countryPlaceholder' }); + const countryRequired = validators.required( + intl.formatMessage({ + id: 'StripePaymentAddress.countryRequired', + }) + ); + + const handleOnChange = event => { + const value = event.target.value; + form.change('postal', value); + card.update({ value: { postalCode: value } }); + }; + + // Use tha language set in config.locale to get the correct translations of the country names + const countryCodes = getCountryCodes(config.locale); + + return ( +
    + form.change('addressLine1', undefined)} + /> + + form.change('addressLine2', undefined)} + /> + +
    + form.change('postal', undefined)} + onChange={event => handleOnChange(event)} + /> + + form.change('city', undefined)} + /> +
    + + form.change('state', undefined)} + /> + + + + {countryCodes.map(country => { + return ( + + ); + })} + +
    + ); +}; +StripePaymentAddress.defaultProps = { + country: null, + disabled: false, + fieldId: null, +}; + +StripePaymentAddress.propTypes = { + country: string, + disabled: bool, + form: object.isRequired, + fieldId: string, + + // from injectIntl + intl: intlShape.isRequired, +}; + +export default StripePaymentAddress; diff --git a/src/forms/StripePaymentForm/StripePaymentForm.css b/src/forms/StripePaymentForm/StripePaymentForm.css index c24e4c637d..786ef362e8 100644 --- a/src/forms/StripePaymentForm/StripePaymentForm.css +++ b/src/forms/StripePaymentForm/StripePaymentForm.css @@ -19,7 +19,7 @@ height: 35px; } @media (--viewportLarge) { - height: 42px; + height: 38px; padding: 6px 0 14px 0; } } @@ -32,10 +32,34 @@ border-bottom-color: var(--failColor); } +.error { + color: var(--failColor); +} + +.errorMessage { + margin-top: 24px; + color: var(--failColor); +} + .paymentHeading { margin: 0 0 14px 0; color: var(--matterColorAnti); + padding-top: 8px; + padding-bottom: 0px; + + @media (--viewportMedium) { + margin: 0 0 26px 0; + } +} + +.billingHeading { + margin: 0 0 14px 0; + color: var(--matterColorAnti); + + padding-top: 7px; + padding-bottom: 1px; + @media (--viewportMedium) { margin: 0 0 26px 0; } @@ -56,6 +80,9 @@ color: var(--matterColorAnti); margin: 40px 0 14px 0; + padding-top: 4px; + padding-bottom: 4px; + @media (--viewportMedium) { margin: 41px 0 26px 0; } @@ -116,3 +143,29 @@ .missingStripeKey { color: var(--failColor); } + +.paymentAddressField { + padding-top: 38px; +} + +.formRow { + display: flex; + justify-content: space-between; + flex-shrink: 0; + width: 100%; + margin-bottom: 24px; +} + +.postalCode { + margin-top: 24px; + width: calc(40% - 9px); +} + +.city { + margin-top: 24px; + width: calc(60% - 9px); +} + +.field { + margin-top: 24px; +} diff --git a/src/forms/StripePaymentForm/StripePaymentForm.example.js b/src/forms/StripePaymentForm/StripePaymentForm.example.js index 739a31e677..1ec25073f7 100644 --- a/src/forms/StripePaymentForm/StripePaymentForm.example.js +++ b/src/forms/StripePaymentForm/StripePaymentForm.example.js @@ -17,6 +17,7 @@ export const Empty = { }, intl: fakeIntl, onCreateStripePaymentToken: noop, + onStripeInitialized: noop, stripePaymentTokenInProgress: false, stripePaymentTokenError: null, }, diff --git a/src/forms/StripePaymentForm/StripePaymentForm.js b/src/forms/StripePaymentForm/StripePaymentForm.js index 3ad1f62161..b9025536fd 100644 --- a/src/forms/StripePaymentForm/StripePaymentForm.js +++ b/src/forms/StripePaymentForm/StripePaymentForm.js @@ -4,14 +4,13 @@ * It's also handled separately in handleSubmit function. */ import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import { bool, func, object, string } from 'prop-types'; import { FormattedMessage, injectIntl, intlShape } from 'react-intl'; import { Form as FinalForm } from 'react-final-form'; import classNames from 'classnames'; import config from '../../config'; -import { propTypes } from '../../util/types'; import { Form, PrimaryButton, FieldTextInput } from '../../components'; - +import StripePaymentAddress from './StripePaymentAddress'; import css from './StripePaymentForm.css'; /** @@ -78,19 +77,18 @@ const cardStyles = { const initialState = { error: null, - submitting: false, cardValueValid: false, - token: null, }; /** * Payment form that asks for credit card info using Stripe Elements. * * When the card is valid and the user submits the form, a request is - * sent to the Stripe API to fetch a token that is passed to the - * onSubmit prop of this form. + * sent to the Stripe API to handle payment. `stripe.handleCardPayment` + * may ask more details from cardholder if 3D security steps are needed. * - * See: https://stripe.com/docs/elements + * See: https://stripe.com/docs/payments/payment-intents + * https://stripe.com/docs/elements */ class StripePaymentForm extends Component { constructor(props) { @@ -99,6 +97,7 @@ class StripePaymentForm extends Component { this.handleCardValueChange = this.handleCardValueChange.bind(this); this.handleSubmit = this.handleSubmit.bind(this); this.paymentForm = this.paymentForm.bind(this); + this.finalFormAPI = null; } componentDidMount() { if (!window.Stripe) { @@ -107,18 +106,22 @@ class StripePaymentForm extends Component { if (config.stripe.publishableKey) { this.stripe = window.Stripe(config.stripe.publishableKey); - const elements = this.stripe.elements(stripeElementsOptions); - this.card = elements.create('card', { style: cardStyles }); - this.card.mount(this.cardContainer); - this.card.addEventListener('change', this.handleCardValueChange); - // EventListener is the only way to simulate breakpoints with Stripe. - window.addEventListener('resize', () => { - if (window.innerWidth < 1024) { - this.card.update({ style: { base: { fontSize: '18px', lineHeight: '24px' } } }); - } else { - this.card.update({ style: { base: { fontSize: '20px', lineHeight: '32px' } } }); - } - }); + this.props.onStripeInitialized(this.stripe); + + if (!this.props.hasHandledCardPayment) { + const elements = this.stripe.elements(stripeElementsOptions); + this.card = elements.create('card', { style: cardStyles }); + this.card.mount(this.cardContainer); + this.card.addEventListener('change', this.handleCardValueChange); + // EventListener is the only way to simulate breakpoints with Stripe. + window.addEventListener('resize', () => { + if (window.innerWidth < 1024) { + this.card.update({ style: { base: { fontSize: '18px', lineHeight: '24px' } } }); + } else { + this.card.update({ style: { base: { fontSize: '20px', lineHeight: '32px' } } }); + } + }); + } } } componentWillUnmount() { @@ -128,71 +131,90 @@ class StripePaymentForm extends Component { } } handleCardValueChange(event) { - const { intl, onChange } = this.props; + const { intl } = this.props; const { error, complete } = event; - // A change in the card should clear the token and trigger a call - // to the onChange prop with the cleared token and the current - // message. + const postalCode = event.value.postalCode; + if (this.finalFormAPI) { + this.finalFormAPI.change('postal', postalCode); + } this.setState(prevState => { - const { message } = prevState; - const token = null; - onChange({ token, message }); return { error: error ? stripeErrorTranslation(intl, error) : null, cardValueValid: complete, - token, }; }); } handleSubmit(values) { - const { onSubmit, stripePaymentTokenInProgress, stripePaymentToken } = this.props; - const initialMessage = values.initialMessage ? values.initialMessage.trim() : null; + const { onSubmit, inProgress, formId, hasHandledCardPayment } = this.props; + const { initialMessage } = values; + const cardInputNeedsAttention = !(hasHandledCardPayment || this.state.cardValueValid); - if (stripePaymentTokenInProgress || !this.state.cardValueValid) { + if (inProgress || cardInputNeedsAttention) { // Already submitting or card value incomplete/invalid return; } - if (stripePaymentToken) { - // Token already fetched for the current card value - onSubmit({ token: stripePaymentToken.id, message: initialMessage }); - return; - } - const params = { - stripe: this.stripe, + message: initialMessage ? initialMessage.trim() : null, card: this.card, + formId, + formValues: values, }; - - this.props.onCreateStripePaymentToken(params).then(() => { - onSubmit({ token: this.props.stripePaymentToken.id, message: initialMessage }); - }); + onSubmit(params); } paymentForm(formRenderProps) { const { className, rootClassName, - inProgress, + inProgress: submitInProgress, formId, paymentInfo, authorDisplayName, showInitialMessageInput, intl, - stripePaymentTokenInProgress, - stripePaymentTokenError, + initiateOrderError, + handleCardPaymentError, + confirmPaymentError, invalid, handleSubmit, + form, + hasHandledCardPayment, } = formRenderProps; - const submitInProgress = stripePaymentTokenInProgress || inProgress; - const submitDisabled = invalid || !this.state.cardValueValid || submitInProgress; + this.finalFormAPI = form; + const billingDetailsNeeded = !(confirmPaymentError || hasHandledCardPayment); + const cardInputNeedsAttention = !(hasHandledCardPayment || this.state.cardValueValid); + const submitDisabled = invalid || cardInputNeedsAttention || submitInProgress; + const hasCardError = this.state.error && !submitInProgress; + const hasPaymentErrors = handleCardPaymentError || confirmPaymentError; const classes = classNames(rootClassName || css.root, className); const cardClasses = classNames(css.card, { [css.cardSuccess]: this.state.cardValueValid, - [css.cardError]: stripePaymentTokenError && !submitInProgress, + [css.cardError]: hasCardError, + }); + + // TODO: handleCardPayment can create all kinds of errors. + // Currently, we provide translation support for one: + // https://stripe.com/docs/error-codes + const piAuthenticationFailure = 'payment_intent_authentication_failure'; + const paymentErrorMessage = + handleCardPaymentError && handleCardPaymentError.code === piAuthenticationFailure + ? intl.formatMessage({ id: 'StripePaymentForm.handleCardPaymentError' }) + : handleCardPaymentError + ? handleCardPaymentError.message + : confirmPaymentError + ? intl.formatMessage({ id: 'StripePaymentForm.confirmPaymentError' }) + : intl.formatMessage({ id: 'StripePaymentForm.genericError' }); + + const billingDetailsNameLabel = intl.formatMessage({ + id: 'StripePaymentForm.billingDetailsNameLabel', + }); + + const billingDetailsNamePlaceholder = intl.formatMessage({ + id: 'StripePaymentForm.billingDetailsNamePlaceholder', }); const messagePlaceholder = intl.formatMessage( @@ -209,43 +231,76 @@ class StripePaymentForm extends Component { { messageOptionalText: messageOptionalText } ); - const initialMessage = showInitialMessageInput ? ( -
    -

    - -

    - - -
    - ) : null; + // Asking billing address is recommended in PaymentIntent flow. + // In CheckoutPage, we send name and email as billing details, but address only if it exists. + const billingAddress = ( + + ); - return config.stripe.publishableKey ? ( + const hasStripeKey = config.stripe.publishableKey; + + return hasStripeKey ? (
    -

    - -

    - -
    { - this.cardContainer = el; - }} - /> - {this.state.error && !submitInProgress ? ( - {this.state.error} + {billingDetailsNeeded ? ( + +

    + +

    + + +
    { + this.cardContainer = el; + }} + /> + {hasCardError ? {this.state.error} : null} +
    +

    + +

    + + + + {billingAddress} +
    + + ) : null} + + {initiateOrderError ? ( + {initiateOrderError.message} + ) : null} + {showInitialMessageInput ? ( +
    +

    + +

    + + +
    ) : null} - {initialMessage}
    + {hasPaymentErrors ? ( + {paymentErrorMessage} + ) : null}

    {paymentInfo}

    - + {billingDetailsNeeded ? ( + + ) : ( + + )}
    @@ -274,30 +333,27 @@ StripePaymentForm.defaultProps = { className: null, rootClassName: null, inProgress: false, - onChange: () => null, showInitialMessageInput: true, - stripePaymentToken: null, - stripePaymentTokenInProgress: false, - stripePaymentTokenError: null, + hasHandledCardPayment: false, + initiateOrderError: null, + handleCardPaymentError: null, + confirmPaymentError: null, }; -const { bool, func, string, object } = PropTypes; - StripePaymentForm.propTypes = { className: string, rootClassName: string, inProgress: bool, + initiateOrderError: object, + handleCardPaymentError: object, + confirmPaymentError: object, formId: string.isRequired, intl: intlShape.isRequired, onSubmit: func.isRequired, - onChange: func, paymentInfo: string.isRequired, authorDisplayName: string.isRequired, showInitialMessageInput: bool, - onCreateStripePaymentToken: func.isRequired, - stripePaymentTokenInProgress: bool, - stripePaymentTokenError: propTypes.error, - stripePaymentToken: object, + hasHandledCardPayment: bool, }; export default injectIntl(StripePaymentForm); diff --git a/src/translations/countryCodes.js b/src/translations/countryCodes.js new file mode 100644 index 0000000000..fd6f237d76 --- /dev/null +++ b/src/translations/countryCodes.js @@ -0,0 +1,267 @@ +// Add here the translations of the country names using key ": 'transalation'" e.g. fi: 'Afganistan' +// prettier-ignore +const countryCodes = [ + { code: 'AF', en: 'Afghanistan', fr: 'Afghanistan', es: 'Afganistán', de: 'Afghanistan' }, + { code: 'AX', en: 'Åland Islands', fr: 'Îles Åland', es: 'Islas Áland', de: 'Åland' }, + { code: 'AL', en: 'Albania', fr: 'Albanie', es: 'Albania', de: 'Albanien' }, + { code: 'DZ', en: 'Algeria', fr: 'Algérie', es: 'Argel', de: 'Algerien' }, + { code: 'AS', en: 'American Samoa', fr: 'Samoa américaines', es: 'Samoa Americana', de: 'Amerikanisch-Samoa' }, + { code: 'AD', en: 'Andorra', fr: 'Andorre', es: 'Andorra', de: 'Andorra' }, + { code: 'AO', en: 'Angola', fr: 'Angola', es: 'Angola', de: 'Angola' }, + { code: 'AI', en: 'Anguilla', fr: 'Anguilla', es: 'Anguila', de: 'Anguilla' }, + { code: 'AQ', en: 'Antarctica', fr: 'Antarctique', es: 'Antártida', de: 'Antarktika' }, + { code: 'AG', en: 'Antigua and Barbuda', fr: 'Antigua-et-Barbuda', es: 'Antigua y Barbuda', de: 'Antigua und Barbuda' }, + { code: 'AR', en: 'Argentina', fr: 'Argentine', es: 'Argentina', de: 'Argentinien' }, + { code: 'AM', en: 'Armenia', fr: 'Arménie', es: 'Armenia', de: 'Armenien' }, + { code: 'AW', en: 'Aruba', fr: 'Aruba', es: 'Aruba', de: 'Aruba' }, + { code: 'AU', en: 'Australia', fr: 'Australie', es: 'Australia', de: 'Australien' }, + { code: 'AT', en: 'Austria', fr: 'Autriche', es: 'Austria', de: 'Österreich' }, + { code: 'AZ', en: 'Azerbaijan', fr: 'Azerbaïdjan', es: 'Azerbaiyán', de: 'Aserbaidschan' }, + { code: 'BS', en: 'Bahamas', fr: 'Bahamas', es: 'Bahamas', de: 'Bahamas' }, + { code: 'BH', en: 'Bahrain', fr: 'Bahreïn', es: 'Bahréin', de: 'Bahrain' }, + { code: 'BD', en: 'Bangladesh', fr: 'Bangladesh', es: 'Bangladesh', de: 'Bangladesch' }, + { code: 'BB', en: 'Barbados', fr: 'Barbade', es: 'Barbados', de: 'Barbados' }, + { code: 'BY', en: 'Belarus', fr: 'Biélorussie', es: 'Belarús', de: 'Belarus' }, + { code: 'BE', en: 'Belgium', fr: 'Belgique', es: 'Bélgica', de: 'Belgien' }, + { code: 'BZ', en: 'Belize', fr: 'Belize', es: 'Belice', de: 'Belize' }, + { code: 'BJ', en: 'Benin', fr: 'Bénin', es: 'Benin', de: 'Benin' }, + { code: 'BM', en: 'Bermuda', fr: 'Bermudes', es: 'Bermudas', de: 'Bermuda' }, + { code: 'BT', en: 'Bhutan', fr: 'Bhoutan', es: 'Bhután', de: 'Bhutan' }, + { code: 'BO', en: 'Bolivia', fr: 'Bolivie', es: 'Bolivia', de: 'Bolivien' }, + { code: 'BQ', en: 'Bonaire, Sint Eustatius and Saba', fr: 'Pays-Bas caribéens', es: 'Caribe Neerlandés', de: 'Bonaire, Sint Eustatius und Saba' }, + { code: 'BA', en: 'Bosnia and Herzegovina', fr: 'Bosnie-Herzégovine', es: 'Bosnia y Herzegovina', de: 'Bosnien und Herzegowina' }, + { code: 'BW', en: 'Botswana', fr: 'Botswana', es: 'Botsuana', de: 'Botswana' }, + { code: 'BV', en: 'Bouvet Island', fr: 'Île Bouvet', es: 'Isla Bouvet', de: 'Bouvetinsel' }, + { code: 'BR', en: 'Brazil', fr: 'Brésil', es: 'Brasil', de: 'Brasilien' }, + { code: 'IO', en: 'British Indian Ocean Territory', fr: 'Territoire britannique de l’Océan Indien', es: 'Territorio Británico del Océano Índico', de: 'Britisches Territorium im Indischen Ozean' }, + { code: 'BN', en: 'Brunei Darussalam', fr: 'Brunei Darussalam', es: 'Brunéi', de: 'Brunei Darussalam' }, + { code: 'BG', en: 'Bulgaria', fr: 'Bulgarie', es: 'Bulgaria', de: 'Bulgarien' }, + { code: 'BF', en: 'Burkina Faso', fr: 'Burkina Faso', es: 'Burkina Faso', de: 'Burkina Faso' }, + { code: 'BI', en: 'Burundi', fr: 'Burundi', es: 'Burundi', de: 'Burundi' }, + { code: 'KH', en: 'Cambodia', fr: 'Cambodge', es: 'Camboya', de: 'Kambodscha' }, + { code: 'CM', en: 'Cameroon', fr: 'Cameroun', es: 'Camerún', de: 'Kamerun' }, + { code: 'CA', en: 'Canada', fr: 'Canada', es: 'Canadá', de: 'Kanada' }, + { code: 'CV', en: 'Cape Verde', fr: 'Cap-Vert', es: 'Cabo Verde', de: 'Kap Verde' }, + { code: 'KY', en: 'Cayman Islands', fr: 'Iles Cayman', es: 'Islas Caimán', de: 'Kaimaninseln' }, + { code: 'CF', en: 'Central African Republic', fr: 'République centrafricaine', es: 'República Centro-Africana', de: 'Zentralafrikanische Republik' }, + { code: 'TD', en: 'Chad', fr: 'Tchad', es: 'Chad', de: 'Tschad' }, + { code: 'CL', en: 'Chile', fr: 'Chili', es: 'Chile', de: 'Chile' }, + { code: 'CN', en: 'China', fr: 'Chine', es: 'China', de: 'China, Volksrepublik' }, + { code: 'CX', en: 'Christmas Island', fr: 'Île Christmas', es: 'Islas Christmas', de: 'Weihnachtsinsel' }, + { code: 'CC', en: 'Cocos (Keeling) Islands', fr: 'Îles Cocos', es: 'Islas Cocos', de: 'Kokosinseln' }, + { code: 'CO', en: 'Colombia', fr: 'Colombie', es: 'Colombia', de: 'Kolumbien' }, + { code: 'KM', en: 'Comoros', fr: 'Comores', es: 'Comoros', de: 'Komoren' }, + { code: 'CG', en: 'Congo', fr: 'République du Congo', es: 'Congo', de: 'Kongo, Republik' }, + { code: 'CD', en: 'Congo, the Democratic Republic of the', fr: 'République démocratique du Congo', es: 'República democrática del Congo', de: 'Kongo, Demokratische Republik' }, + { code: 'CK', en: 'Cook Islands', fr: 'Îles Cook', es: 'Islas Cook', de: 'Cookinseln' }, + { code: 'CR', en: 'Costa Rica', fr: 'Costa Rica', es: 'Costa Rica', de: 'Costa Rica' }, + { code: 'CI', en: 'Côte d\'Ivoire', fr: 'Côte d’Ivoire', es: 'Costa de Marfil', de: 'Côte d’Ivoire' }, + { code: 'HR', en: 'Croatia', fr: 'Croatie', es: 'Croacia', de: 'Kroatien' }, + { code: 'CU', en: 'Cuba', fr: 'Cuba', es: 'Cuba', de: 'Kuba' }, + { code: 'CW', en: 'Curaçao', fr: 'Curaçao', es: 'Curazao', de: 'Curaçao' }, + { code: 'CY', en: 'Cyprus', fr: 'Chypre', es: 'Chipre', de: 'Zypern' }, + { code: 'CZ', en: 'Czech Republic', fr: 'République tchèque', es: 'República Checa', de: 'Tschechien' }, + { code: 'DK', en: 'Denmark', fr: 'Danemark', es: 'Dinamarca', de: 'Dänemark' }, + { code: 'DJ', en: 'Djibouti', fr: 'Djibouti', es: 'Yibuti', de: 'Dschibuti' }, + { code: 'DM', en: 'Dominica', fr: 'Dominique', es: 'Domínica', de: 'Dominica' }, + { code: 'DO', en: 'Dominican Republic', fr: 'République dominicaine', es: 'República Dominicana', de: 'Dominikanische Republik' }, + { code: 'EC', en: 'Ecuador', fr: 'Équateur', es: 'Ecuador', de: 'Ecuador' }, + { code: 'EG', en: 'Egypt', fr: 'Égypte', es: 'Egipto', de: 'Ägypten' }, + { code: 'SV', en: 'El Salvador', fr: 'Salvador', es: 'El Salvador', de: 'El Salvador' }, + { code: 'GQ', en: 'Equatorial Guinea', fr: 'Guinée équatoriale', es: 'Guinea Ecuatorial', de: 'Äquatorialguinea' }, + { code: 'ER', en: 'Eritrea', fr: 'Érythrée', es: 'Eritrea', de: 'Eritrea' }, + { code: 'EE', en: 'Estonia', fr: 'Estonie', es: 'Estonia', de: 'Estland' }, + { code: 'ET', en: 'Ethiopia', fr: 'Éthiopie', es: 'Etiopía', de: 'Äthiopien' }, + { code: 'FK', en: 'Falkland Islands (Malvinas)', fr: 'Îles Falkland', es: 'Islas Malvinas', de: 'Falklandinseln' }, + { code: 'FO', en: 'Faroe Islands', fr: 'Îles Féroé', es: 'Islas Faroe', de: 'Färöer' }, + { code: 'FJ', en: 'Fiji', fr: 'Fidji', es: 'Fiji', de: 'Fidschi' }, + { code: 'FI', en: 'Finland', fr: 'Finlande', es: 'Finlandia', de: 'Finnland' }, + { code: 'FR', en: 'France', fr: 'France', es: 'Francia', de: 'Frankreich' }, + { code: 'GF', en: 'French Guiana', fr: 'Guyane française', es: 'Guayana Francesa', de: 'Französisch-Guayana' }, + { code: 'PF', en: 'French Polynesia', fr: 'Polynésie française', es: 'Polinesia Francesa', de: 'Französisch-Polynesien' }, + { code: 'TF', en: 'French Southern Territories', fr: 'Terres australes et antarctiques françaises', es: 'Territorios Australes Franceses', de: 'Französische Süd- und Antarktisgebiete' }, + { code: 'GA', en: 'Gabon', fr: 'Gabon', es: 'Gabón', de: 'Gabun' }, + { code: 'GM', en: 'Gambia', fr: 'Gambie', es: 'Gambia', de: 'Gambia' }, + { code: 'GE', en: 'Georgia', fr: 'Géorgie', es: 'Georgia', de: 'Georgien' }, + { code: 'DE', en: 'Germany', fr: 'Allemagne', es: 'Alemania', de: 'Deutschland' }, + { code: 'GH', en: 'Ghana', fr: 'Ghana', es: 'Ghana', de: 'Ghana' }, + { code: 'GI', en: 'Gibraltar', fr: 'Gibraltar', es: 'Gibraltar', de: 'Gibraltar' }, + { code: 'GR', en: 'Greece', fr: 'Grèce', es: 'Grecia', de: 'Griechenland' }, + { code: 'GL', en: 'Greenland', fr: 'Groenland', es: 'Groenlandia', de: 'Grönland' }, + { code: 'GD', en: 'Grenada', fr: 'Grenade', es: 'Granada', de: 'Grenada' }, + { code: 'GP', en: 'Guadeloupe', fr: 'Guadeloupe', es: 'Guadalupe', de: 'Guadeloupe' }, + { code: 'GU', en: 'Guam', fr: 'Guam', es: 'Guam', de: 'Guam' }, + { code: 'GT', en: 'Guatemala', fr: 'Guatemala', es: 'Guatemala', de: 'Guatemala' }, + { code: 'GG', en: 'Guernsey', fr: 'Guernesey', es: 'Guernsey', de: 'Guernsey' }, + { code: 'GN', en: 'Guinea', fr: 'Guinée', es: 'Guinea', de: 'Guinea' }, + { code: 'GW', en: 'Guinea-Bissau', fr: 'Guinée-Bissau', es: 'Guinea-Bissau', de: 'Guinea-Bissau' }, + { code: 'GY', en: 'Guyana', fr: 'Guyane', es: 'Guayana', de: 'Guyana' }, + { code: 'HT', en: 'Haiti', fr: 'Haïti', es: 'Haití', de: 'Haiti' }, + { code: 'HM', en: 'Heard Island and McDonald Islands', fr: 'Îles Heard-et-MacDonald', es: 'Islas Heard y McDonald', de: 'Heard und McDonaldinseln' }, + { code: 'VA', en: 'Holy See (Vatican City State)', fr: 'Saint-Siège (Vatican)', es: 'Ciudad del Vaticano', de: 'Vatikanstadt' }, + { code: 'HN', en: 'Honduras', fr: 'Honduras', es: 'Honduras', de: 'Honduras' }, + { code: 'HK', en: 'Hong Kong', fr: 'Hong Kong', es: 'Hong Kong', de: 'Hongkong' }, + { code: 'HU', en: 'Hungary', fr: 'Hongrie', es: 'Hungría', de: 'Ungarn' }, + { code: 'IS', en: 'Iceland', fr: 'Islande', es: 'Islandia', de: 'Island' }, + { code: 'IN', en: 'India', fr: 'Inde', es: 'India', de: 'Indien' }, + { code: 'ID', en: 'Indonesia', fr: 'Indonésie', es: 'Indonesia', de: 'Indonesien' }, + { code: 'IR', en: 'Iran, Islamic Republic of', fr: 'Iran', es: 'Irán', de: 'Iran, Islamische Republik' }, + { code: 'IQ', en: 'Iraq', fr: 'Irak', es: 'Irak', de: 'Irak' }, + { code: 'IE', en: 'Ireland', fr: 'Irlande', es: 'Irlanda', de: 'Irland' }, + { code: 'IM', en: 'Isle of Man', fr: 'Ile de Man', es: 'Isla de Man', de: 'Insel Man' }, + { code: 'IL', en: 'Israel', fr: 'Israël', es: 'Israel', de: 'Israel' }, + { code: 'IT', en: 'Italy', fr: 'Italie', es: 'Italia', de: 'Italien' }, + { code: 'JM', en: 'Jamaica', fr: 'Jamaïque', es: 'Jamaica', de: 'Jamaika' }, + { code: 'JP', en: 'Japan', fr: 'Japon', es: 'Japón', de: 'Japan' }, + { code: 'JE', en: 'Jersey', fr: 'Jersey', es: 'Jersey', de: 'Jersey (Kanalinsel)' }, + { code: 'JO', en: 'Jordan', fr: 'Jordanie', es: 'Jordania', de: 'Jordanien' }, + { code: 'KZ', en: 'Kazakhstan', fr: 'Kazakhstan', es: 'Kazajstán', de: 'Kasachstan' }, + { code: 'KE', en: 'Kenya', fr: 'Kenya', es: 'Kenia', de: 'Kenia' }, + { code: 'KI', en: 'Kiribati', fr: 'Kiribati', es: 'Kiribati', de: 'Kiribati' }, + { code: 'KP', en: 'Korea, Democratic People\'s Republic of', fr: 'Corée du Nord', es: 'Corea del Norte', de: 'Korea, Demokratische Volksrepublik (Nordkorea)' }, + { code: 'KR', en: 'Korea, Republic of', fr: 'Corée du Sud', es: 'Corea del Sur', de: 'Korea, Republik (Südkorea)' }, + { code: 'KW', en: 'Kuwait', fr: 'Koweït', es: 'Kuwait', de: 'Kuwait' }, + { code: 'KG', en: 'Kyrgyzstan', fr: 'Kirghizistan', es: 'Kirguistán', de: 'Kirgisistan' }, + { code: 'LA', en: 'Laos', fr: 'Laos', es: 'Laos', de: 'Laos' }, + { code: 'LV', en: 'Latvia', fr: 'Lettonie', es: 'Letonia', de: 'Lettland' }, + { code: 'LB', en: 'Lebanon', fr: 'Liban', es: 'Líbano', de: 'Libanon' }, + { code: 'LS', en: 'Lesotho', fr: 'Lesotho', es: 'Lesotho', de: 'Lesotho' }, + { code: 'LR', en: 'Liberia', fr: 'Libéria', es: 'Liberia', de: 'Liberia' }, + { code: 'LY', en: 'Libya', fr: 'Libye', es: 'Libia', de: 'Libyen' }, + { code: 'LI', en: 'Liechtenstein', fr: 'Liechtenstein', es: 'Liechtenstein', de: 'Liechtenstein' }, + { code: 'LT', en: 'Lithuania', fr: 'Lituanie', es: 'Lituania', de: 'Litauen' }, + { code: 'LU', en: 'Luxembourg', fr: 'Luxembourg', es: 'Luxemburgo', de: 'Luxemburg' }, + { code: 'MO', en: 'Macao', fr: 'Macao', es: 'Macao', de: 'Macau' }, + { code: 'MK', en: 'North Macedonia', fr: 'Macédoine du Nord', es: 'Macedonia del Norte', de: 'Nordmazedonien' }, + { code: 'MG', en: 'Madagascar', fr: 'Madagascar', es: 'Madagascar', de: 'Madagaskar' }, + { code: 'MW', en: 'Malawi', fr: 'Malawi', es: 'Malawi', de: 'Malawi' }, + { code: 'MY', en: 'Malaysia', fr: 'Malaisie', es: 'Malasia', de: 'Malaysia' }, + { code: 'MV', en: 'Maldives', fr: 'Maldives', es: 'Maldivas', de: 'Malediven' }, + { code: 'ML', en: 'Mali', fr: 'Mali', es: 'Mali', de: 'Mali' }, + { code: 'MT', en: 'Malta', fr: 'Malte', es: 'Malta', de: 'Malta' }, + { code: 'MH', en: 'Marshall Islands', fr: 'Îles Marshall', es: 'Islas Marshall', de: 'Marshallinseln' }, + { code: 'MQ', en: 'Martinique', fr: 'Martinique', es: 'Martinica', de: 'Martinique' }, + { code: 'MR', en: 'Mauritania', fr: 'Mauritanie', es: 'Mauritania', de: 'Mauretanien' }, + { code: 'MU', en: 'Mauritius', fr: 'Maurice', es: 'Mauricio', de: 'Mauritius' }, + { code: 'YT', en: 'Mayotte', fr: 'Mayotte', es: 'Mayotte', de: 'Mayotte' }, + { code: 'MX', en: 'Mexico', fr: 'Mexique', es: 'México', de: 'Mexiko' }, + { code: 'FM', en: 'Micronesia, Federated States of', fr: 'Micronésie', es: 'Micronesia', de: 'Mikronesien' }, + { code: 'MD', en: 'Moldova', fr: 'Moldavie', es: 'Moldova', de: 'Moldawien' }, + { code: 'MC', en: 'Monaco', fr: 'Monaco', es: 'Mónaco', de: 'Monaco' }, + { code: 'MN', en: 'Mongolia', fr: 'Mongolie', es: 'Mongolia', de: 'Mongolei' }, + { code: 'ME', en: 'Montenegro', fr: 'Monténégro', es: 'Montenegro', de: 'Montenegro' }, + { code: 'MS', en: 'Montserrat', fr: 'Montserrat', es: 'Montserrat', de: 'Montserrat' }, + { code: 'MA', en: 'Morocco', fr: 'Maroc', es: 'Marruecos', de: 'Marokko' }, + { code: 'MZ', en: 'Mozambique', fr: 'Mozambique', es: 'Mozambique', de: 'Mosambik' }, + { code: 'MM', en: 'Myanmar', fr: 'Myanmar', es: 'Myanmar', de: 'Myanmar' }, + { code: 'NA', en: 'Namibia', fr: 'Namibie', es: 'Namibia', de: 'Namibia' }, + { code: 'NR', en: 'Nauru', fr: 'Nauru', es: 'Nauru', de: 'Nauru' }, + { code: 'NP', en: 'Nepal', fr: 'Népal', es: 'Nepal', de: 'Nepal' }, + { code: 'NL', en: 'Netherlands', fr: 'Pays-Bas', es: 'Países Bajos', de: 'Niederlande' }, + { code: 'NC', en: 'New Caledonia', fr: 'Nouvelle-Calédonie', es: 'Nueva Caledonia', de: 'Neukaledonien' }, + { code: 'NZ', en: 'New Zealand', fr: 'Nouvelle-Zélande', es: 'Nueva Zelanda', de: 'Neuseeland' }, + { code: 'NI', en: 'Nicaragua', fr: 'Nicaragua', es: 'Nicaragua', de: 'Nicaragua' }, + { code: 'NE', en: 'Niger', fr: 'Niger', es: 'Níger', de: 'Niger' }, + { code: 'NG', en: 'Nigeria', fr: 'Nigeria', es: 'Nigeria', de: 'Nigeria' }, + { code: 'NU', en: 'Niue', fr: 'Niue', es: 'Niue', de: 'Niue' }, + { code: 'NF', en: 'Norfolk Island', fr: 'Île Norfolk', es: 'Islas Norkfolk', de: 'Norfolkinsel' }, + { code: 'MP', en: 'Northern Mariana Islands', fr: 'Îles Mariannes du Nord', es: 'Islas Marianas del Norte', de: 'Nördliche Marianen' }, + { code: 'NO', en: 'Norway', fr: 'Norvège', es: 'Noruega', de: 'Norwegen' }, + { code: 'OM', en: 'Oman', fr: 'Oman', es: 'Omán', de: 'Oman' }, + { code: 'PK', en: 'Pakistan', fr: 'Pakistan', es: 'Pakistán', de: 'Pakistan' }, + { code: 'PW', en: 'Palau', fr: 'Palau', es: 'Islas Palaos', de: 'Palau' }, + { code: 'PS', en: 'Palestine, State of', fr: 'Palestine', es: 'Palestina', de: 'Staat Palästina' }, + { code: 'PA', en: 'Panama', fr: 'Panama', es: 'Panamá', de: 'Panama' }, + { code: 'PG', en: 'Papua New Guinea', fr: 'Papouasie-Nouvelle-Guinée', es: 'Papúa Nueva Guinea', de: 'Papua-Neuguinea' }, + { code: 'PY', en: 'Paraguay', fr: 'Paraguay', es: 'Paraguay', de: 'Paraguay' }, + { code: 'PE', en: 'Peru', fr: 'Pérou', es: 'Perú', de: 'Peru' }, + { code: 'PH', en: 'Philippines', fr: 'Philippines', es: 'Filipinas', de: 'Philippinen' }, + { code: 'PN', en: 'Pitcairn', fr: 'Pitcairn', es: 'Islas Pitcairn', de: 'Pitcairninseln' }, + { code: 'PL', en: 'Poland', fr: 'Pologne', es: 'Polonia', de: 'Polen' }, + { code: 'PT', en: 'Portugal', fr: 'Portugal', es: 'Portugal', de: 'Portugal' }, + { code: 'PR', en: 'Puerto Rico', fr: 'Puerto Rico', es: 'Puerto Rico', de: 'Puerto Rico' }, + { code: 'QA', en: 'Qatar', fr: 'Qatar', es: 'Qatar', de: 'Katar' }, + { code: 'RE', en: 'Réunion', fr: 'Réunion', es: 'Reunión', de: 'Réunion' }, + { code: 'RO', en: 'Romania', fr: 'Roumanie', es: 'Rumanía', de: 'Rumänien' }, + { code: 'RU', en: 'Russian Federation', fr: 'Russie', es: 'Rusia', de: 'Russische Föderation' }, + { code: 'RW', en: 'Rwanda', fr: 'Rwanda', es: 'Ruanda', de: 'Ruanda' }, + { code: 'BL', en: 'Saint Barthélemy', fr: 'Saint-Barthélemy', es: 'San Bartolomé', de: 'Saint-Barthélemy' }, + { code: 'SH', en: 'Saint Helena, Ascension and Tristan da Cunha', fr: 'Sainte-Hélène', es: 'Santa Elena', de: 'St. Helena' }, + { code: 'KN', en: 'Saint Kitts and Nevis', fr: 'Saint-Kitts-et-Nevis', es: 'San Cristóbal y Nieves', de: 'St. Kitts und Nevis' }, + { code: 'LC', en: 'Saint Lucia', fr: 'Sainte-Lucie', es: 'Santa Lucía', de: 'St. Lucia' }, + { code: 'MF', en: 'Saint Martin (French part)', fr: 'Saint-Martin (partie française)', es: 'San Martín (parte francesa)', de: 'Saint-Martin (franz. Teil)' }, + { code: 'PM', en: 'Saint Pierre and Miquelon', fr: 'Saint-Pierre-et-Miquelon', es: 'San Pedro y Miquelón', de: 'Saint-Pierre und Miquelon' }, + { code: 'VC', en: 'Saint Vincent and the Grenadines', fr: 'Saint-Vincent-et-les Grenadines', es: 'San Vicente y las Granadinas', de: 'St. Vincent und die Grenadinen' }, + { code: 'WS', en: 'Samoa', fr: 'Samoa', es: 'Samoa', de: 'Samoa' }, + { code: 'SM', en: 'San Marino', fr: 'Saint-Marin', es: 'San Marino', de: 'San Marino' }, + { code: 'ST', en: 'Sao Tome and Principe', fr: 'Sao Tomé-et-Principe', es: 'Santo Tomé y Príncipe', de: 'São Tomé und Príncipe' }, + { code: 'SA', en: 'Saudi Arabia', fr: 'Arabie Saoudite', es: 'Arabia Saudita', de: 'Saudi-Arabien' }, + { code: 'SN', en: 'Senegal', fr: 'Sénégal', es: 'Senegal', de: 'Senegal' }, + { code: 'RS', en: 'Serbia', fr: 'Serbie', es: 'Serbia y Montenegro', de: 'Serbien' }, + { code: 'SC', en: 'Seychelles', fr: 'Seychelles', es: 'Seychelles', de: 'Seychellen' }, + { code: 'SL', en: 'Sierra Leone', fr: 'Sierra Leone', es: 'Sierra Leona', de: 'Sierra Leone' }, + { code: 'SG', en: 'Singapore', fr: 'Singapour', es: 'Singapur', de: 'Singapur' }, + { code: 'SX', en: 'Sint Maarten (Dutch part)', fr: 'Saint-Martin (partie néerlandaise)', es: 'San Martín (parte neerlandesa)', de: 'Sint Maarten (niederl. Teil)' }, + { code: 'SK', en: 'Slovakia', fr: 'Slovaquie', es: 'Eslovaquia', de: 'Slowakei' }, + { code: 'SI', en: 'Slovenia', fr: 'Slovénie', es: 'Eslovenia', de: 'Slowenien' }, + { code: 'SB', en: 'Solomon Islands', fr: 'Îles Salomon', es: 'Islas Solomón', de: 'Salomonen' }, + { code: 'SO', en: 'Somalia', fr: 'Somalie', es: 'Somalia', de: 'Somalia' }, + { code: 'ZA', en: 'South Africa', fr: 'Afrique du Sud', es: 'Sudáfrica', de: 'Südafrika' }, + { code: 'GS', en: 'South Georgia and the South Sandwich Islands', fr: 'Géorgie du Sud et les îles Sandwich du Sud', es: 'Georgia del Sur e Islas Sandwich deSur', de: 'Südgeorgien und die Südlichen Sandwichinseln' }, + { code: 'SS', en: 'South Sudan', fr: 'Sud-Soudan', es: 'Sudán del Sur', de: 'Südsudan' }, + { code: 'ES', en: 'Spain', fr: 'Espagne', es: 'España', de: 'Spanien' }, + { code: 'LK', en: 'Sri Lanka', fr: 'Sri Lanka', es: 'Sri Lanka', de: 'Sri Lanka' }, + { code: 'SD', en: 'Sudan', fr: 'Soudan', es: 'Sudán', de: 'Sudan' }, + { code: 'SR', en: 'Suriname', fr: 'Suriname', es: 'Surinam', de: 'Suriname' }, + { code: 'SJ', en: 'Svalbard and Jan Mayen', fr: 'Svalbard et Jan Mayen', es: 'Islas Svalbard y Jan Mayen', de: 'Svalbard und Jan Mayen' }, + { code: 'SZ', en: 'Swaziland', fr: 'Eswatini', es: 'Suazilandia', de: 'Swasiland' }, + { code: 'SE', en: 'Sweden', fr: 'Suède', es: 'Suecia', de: 'Schweden' }, + { code: 'CH', en: 'Switzerland', fr: 'Suisse', es: 'Suiza', de: 'Schweiz' }, + { code: 'SY', en: 'Syrian Arab Republic', fr: 'Syrie', es: 'Siria', de: 'Syrien, Arabische Republik' }, + { code: 'TW', en: 'Taiwan', fr: 'Taiwan', es: 'Taiwán', de: 'Taiwan' }, + { code: 'TJ', en: 'Tajikistan', fr: 'Tadjikistan', es: 'Tayikistán', de: 'Tadschikistan' }, + { code: 'TZ', en: 'Tanzania', fr: 'Tanzanie', es: 'Tanzania', de: 'Tansania' }, + { code: 'TH', en: 'Thailand', fr: 'Thaïlande', es: 'Tailandia', de: 'Thailand' }, + { code: 'TL', en: 'Timor-Leste', fr: 'Timor-Leste', es: 'Timor-Leste', de: 'Osttimor ' }, + { code: 'TG', en: 'Togo', fr: 'Togo', es: 'Togo', de: 'Togo' }, + { code: 'TK', en: 'Tokelau', fr: 'Tokelau', es: 'Tokelau', de: 'Tokelau' }, + { code: 'TO', en: 'Tonga', fr: 'Tonga', es: 'Tonga', de: 'Tonga' }, + { code: 'TT', en: 'Trinidad and Tobago', fr: 'Trinité-et-Tobago', es: 'Trinidad y Tobago', de: 'Trinidad und Tobago' }, + { code: 'TN', en: 'Tunisia', fr: 'Tunisie', es: 'Túnez', de: 'Tunesien' }, + { code: 'TR', en: 'Turkey', fr: 'Turquie', es: 'Turquía', de: 'Türkei' }, + { code: 'TM', en: 'Turkmenistan', fr: 'Turkménistan', es: 'Turkmenistán', de: 'Turkmenistan' }, + { code: 'TC', en: 'Turks and Caicos Islands', fr: 'Îles Turques-et-Caïques', es: 'Islas Turcas y Caicos', de: 'Turks- und Caicosinseln' }, + { code: 'TV', en: 'Tuvalu', fr: 'Tuvalu', es: 'Tuvalu', de: 'Tuvalu' }, + { code: 'UG', en: 'Uganda', fr: 'Ouganda', es: 'Uganda', de: 'Uganda' }, + { code: 'UA', en: 'Ukraine', fr: 'Ukraine', es: 'Ucrania', de: 'Ukraine' }, + { code: 'AE', en: 'United Arab Emirates', fr: 'Émirats Arabes Unis', es: 'Emiratos Árabes Unidos', de: 'Vereinigte Arabische Emirate' }, + { code: 'GB', en: 'United Kingdom', fr: 'Royaume-Uni', es: 'Reino Unido', de: 'Vereinigtes Königreich Großbritannien und Nordirland' }, + { code: 'US', en: 'United States', fr: 'États-Unis', es: 'Estados Unidos de América', de: 'Vereinigte Staaten von Amerika' }, + { code: 'UM', en: 'United States Minor Outlying Islands', fr: 'Îles mineures éloignées des États-Unis', es: 'Islas Ultramarinas Menores de Estados Unidos', de: 'United States Minor Outlying Islands' }, + { code: 'UY', en: 'Uruguay', fr: 'Uruguay', es: 'Uruguay', de: 'Uruguay' }, + { code: 'UZ', en: 'Uzbekistan', fr: 'Ouzbékistan', es: 'Uzbekistán', de: 'Usbekistan' }, + { code: 'VU', en: 'Vanuatu', fr: 'Vanuatu', es: 'Vanuatu', de: 'Vanuatu' }, + { code: 'VE', en: 'Venezuela', fr: 'Venezuela', es: 'Venezuela', de: 'Venezuela' }, + { code: 'VN', en: 'Viet Nam', fr: 'Viêt Nam', es: 'Vietnam', de: 'Vietnam' }, + { code: 'VG', en: 'Virgin Islands, British', fr: 'British Virgin Islands', es: 'Islas Vírgenes Británicas', de: 'Britische Jungferninseln' }, + { code: 'VI', en: 'Virgin Islands, U.S.', fr: 'Îles Vierges américaines', es: 'Islas Vírgenes de los Estados Unidos de América', de: 'Amerikanische Jungferninseln' }, + { code: 'WF', en: 'Wallis and Futuna', fr: 'Wallis-et-Futuna', es: 'Wallis y Futuna', de: 'Wallis und Futuna' }, + { code: 'EH', en: 'Western Sahara', fr: 'Sahara occidental', es: 'Sahara Occidental', de: 'Westsahara' }, + { code: 'YE', en: 'Yemen', fr: 'Yémen', es: 'Yemen', de: 'Jemen' }, + { code: 'ZM', en: 'Zambia', fr: 'Zambie', es: 'Zambia', de: 'Sambia' }, + { code: 'ZW', en: 'Zimbabwe', fr: 'Zimbabwe', es: 'Zimbabue', de: 'Simbabwe' }, +]; + +const getCountryCodes = lang => { + // Add the lnew locale here so that the correct translations will be returned. + // If locale is unknown or the translation is missing, this will default to english coutnry name. + const codes = countryCodes.map(c => { + const countryName = c[lang] ? c[lang] : c['en']; + const counryCode = c.code; + + return { code: counryCode, name: countryName }; + }); + return codes; +}; + +export default getCountryCodes; diff --git a/src/translations/en.json b/src/translations/en.json index efb0599986..c637033bb6 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -88,12 +88,14 @@ "CheckoutPage.initiateOrderStripeError": "The payment processor gave the following errors: {stripeErrors}", "CheckoutPage.listingNotFoundError": "Unfortunately, the listing is no longer available.", "CheckoutPage.loadingData": "Loading checkout data…", + "CheckoutPage.paymentExpiredMessage": "Payment was not completed in 15 minutes. Please go back to {listingLink} and try again.", "CheckoutPage.paymentInfo": "You'll only be charged if your request is accepted by the provider.", "CheckoutPage.perDay": "per day", "CheckoutPage.perNight": "per night", "CheckoutPage.perUnit": "per unit", "CheckoutPage.priceBreakdownTitle": "Booking breakdown", "CheckoutPage.providerStripeAccountMissingError": "The listing author has not added their payment information and the listing cannot be booked at the moment.", + "CheckoutPage.retrievingStripePaymentIntentFailed": "Oops, something went wrong. Please refresh the page. If the error persists, go back to {listingLink} and try again after 15 minutes", "CheckoutPage.speculateFailedMessage": "Oops, something went wrong. Please refresh the page and try again.", "CheckoutPage.speculateTransactionError": "Failed to fetch breakdown information.", "CheckoutPage.title": "Book {listingTitle}", @@ -303,7 +305,9 @@ "InboxPage.stateDeclined": "Declined", "InboxPage.stateDelivered": "Delivered", "InboxPage.stateEnquiry": "Enquiry", + "InboxPage.stateExpired": "Payment expired", "InboxPage.statePending": "Pending", + "InboxPage.statePendingPayment": "Pending payment", "InboxPage.stateRequested": "Requested", "InboxPage.title": "Inbox", "LandingPage.schemaDescription": "Book a sauna using Saunatime or earn some income by sharing your sauna", @@ -572,7 +576,7 @@ "PayoutDetailsForm.organizationTitlePlaceholder": "CEO, Manager, Partner", "PayoutDetailsForm.owner": "Owner (25% or more)", "PayoutDetailsForm.ownershipPercentageLabel": "Ownership percentage", - "PayoutDetailsForm.ownershipPercentagePlaceholder":"100", + "PayoutDetailsForm.ownershipPercentagePlaceholder": "100", "PayoutDetailsForm.personalDetailsAdditionalOwnerTitle": "Additional owner details", "PayoutDetailsForm.personalDetailsTitle": "Personal details", "PayoutDetailsForm.personalEmailLabel": "Email", @@ -774,8 +778,30 @@ "StripeBankAccountTokenInputField.transitNumber.placeholder": "Type in transit number…", "StripeBankAccountTokenInputField.transitNumber.required": "Transit number is required", "StripeBankAccountTokenInputField.unsupportedCountry": "Country not supported: {country}", + "StripePaymentAddress.addressLine1Label": "Street address", + "StripePaymentAddress.addressLine1Placeholder": "123 Example Street", + "StripePaymentAddress.addressLine1Required": "Street address is required", + "StripePaymentAddress.addressLine2Label": "Apt, suite, building # {optionalText}", + "StripePaymentAddress.addressLine2Placeholder": "A 42", + "StripePaymentAddress.cityLabel": "City", + "StripePaymentAddress.cityPlaceholder": "Helsinki", + "StripePaymentAddress.cityRequired": "City is required", + "StripePaymentAddress.countryLabel": "Country", + "StripePaymentAddress.countryPlaceholder": "Select a country…", + "StripePaymentAddress.countryRequired": "Country is required", + "StripePaymentAddress.optionalText": "• optional", + "StripePaymentAddress.postalCodeLabel": "Postal code", + "StripePaymentAddress.postalCodePlaceholder": "00100", + "StripePaymentAddress.postalCodeRequired": "Postal code is required", + "StripePaymentAddress.stateLabel": "State {optionalText}", + "StripePaymentAddress.statePlaceholder": "Enter your state", + "StripePaymentForm.billingDetails": "Billing details", + "StripePaymentForm.billingDetailsNameLabel": "Card holder's name", + "StripePaymentForm.billingDetailsNamePlaceholder": "Enter your name…", + "StripePaymentForm.confirmPaymentError": "Payment has been made but we were unable to confirm the booking. Please try to confirm the booking request again! If the booking is not confirmed in time, the payment will be fully refunded.", "StripePaymentForm.creditCardDetails": "Credit card details", "StripePaymentForm.genericError": "Could not handle payment data. Please try again.", + "StripePaymentForm.handleCardPaymentError": "We are unable to authenticate your payment method. Please check your authentication details and try again.", "StripePaymentForm.messageHeading": "Message", "StripePaymentForm.messageLabel": "Say hello to your host {messageOptionalText}", "StripePaymentForm.messageOptionalText": "• optional", @@ -802,6 +828,7 @@ "StripePaymentForm.stripe.validation_error.invalid_swipe_data": "The card's swipe data is invalid.", "StripePaymentForm.stripe.validation_error.missing": "There is no card on a customer that is being charged.", "StripePaymentForm.stripe.validation_error.processing_error": "An error occurred while processing the card.", + "StripePaymentForm.submitConfirmPaymentInfo": "Confirm request", "StripePaymentForm.submitPaymentInfo": "Send request", "TermsOfServicePage.heading": "Terms of Service", "TermsOfServicePage.privacyTabTitle": "Privacy Policy", @@ -858,6 +885,8 @@ "TransactionPanel.orderDeclinedTitle": "{customerName}, your booking for {listingLink} has been declined.", "TransactionPanel.orderDeliveredTitle": "{customerName}, your booking for {listingLink} has been completed.", "TransactionPanel.orderEnquiredTitle": "You enquired about {listingLink}", + "TransactionPanel.orderPaymentExpiredTitle": "You did not confirm the payment in time", + "TransactionPanel.orderPaymentPendingTitle": "You have not confirmed payment yet", "TransactionPanel.orderPreauthorizedInfo": "{providerName} has been notified about the booking request. Sit back and relax.", "TransactionPanel.orderPreauthorizedSubtitle": "You have requested to book {listingLink}.", "TransactionPanel.orderPreauthorizedTitle": "Great success, {customerName}!", @@ -870,6 +899,9 @@ "TransactionPanel.saleDeclinedTitle": "The request from {customerName} to book {listingLink} has been declined.", "TransactionPanel.saleDeliveredTitle": "The booking from {customerName} for {listingLink} has been completed.", "TransactionPanel.saleEnquiredTitle": "{customerName} enquired about {listingLink}", + "TransactionPanel.salePaymentExpiredTitle": "Payment for {listingLink} was not confirmed in time", + "TransactionPanel.salePaymentPendingInfo": "{customerName} has 15 minutes to confirm the payment", + "TransactionPanel.salePaymentPendingTitle": "Payment for {listingLink} is not yet confirmed", "TransactionPanel.saleRequestedInfo": "{customerName} is waiting for your response.", "TransactionPanel.saleRequestedTitle": "{customerName} has requested to book {listingLink}.", "TransactionPanel.sendingMessageNotAllowed": "This user has been removed. Sending message to the user is not possible anymore.", diff --git a/src/util/dates.js b/src/util/dates.js index e8705974ce..5599df1801 100644 --- a/src/util/dates.js +++ b/src/util/dates.js @@ -114,6 +114,23 @@ export const daysBetween = (startDate, endDate) => { return days; }; +/** + * Calculate the number of minutes between the given dates + * + * @param {Date} startDate start of the time period + * @param {Date} endDate end of the time period. + * + * @throws Will throw if the end date is before the start date + * @returns {Number} number of minutes between the given Date objects + */ +export const minutesBetween = (startDate, endDate) => { + const minutes = moment(endDate).diff(startDate, 'minutes'); + if (minutes < 0) { + throw new Error('End Date cannot be before start Date'); + } + return minutes; +}; + /** * Format the given date to month id/string * diff --git a/src/util/dates.test.js b/src/util/dates.test.js index daab97c287..312e8e09dc 100644 --- a/src/util/dates.test.js +++ b/src/util/dates.test.js @@ -4,6 +4,7 @@ import { isSameDate, nightsBetween, daysBetween, + minutesBetween, formatDate, parseDateFromISO8601, stringifyDateToISO8601, @@ -80,6 +81,28 @@ describe('date utils', () => { }); }); + describe('minutesBetween()', () => { + it('should fail if end Date is before start Date', () => { + const start = new Date(2017, 0, 2); + const end = new Date(2017, 0, 1); + expect(() => minutesBetween(start, end)).toThrow('End Date cannot be before start Date'); + }); + it('should handle equal start and end Dates', () => { + const d = new Date(2017, 0, 1, 10, 35, 0); + expect(minutesBetween(d, d)).toEqual(0); + }); + it('should calculate minutes count for one hour', () => { + const start = new Date(2017, 0, 1, 10, 35, 0); + const end = new Date(2017, 0, 1, 11, 35, 0); + expect(minutesBetween(start, end)).toEqual(60); + }); + it('should calculate minutes', () => { + const start = new Date(2017, 0, 1, 10, 35, 0); + const end = new Date(2017, 0, 1, 10, 55, 0); + expect(minutesBetween(start, end)).toEqual(20); + }); + }); + describe('formatDate()', () => { /* NOTE: These are not really testing the formatting properly since diff --git a/src/util/log.js b/src/util/log.js index cf048cb8a4..2d91d80d5b 100644 --- a/src/util/log.js +++ b/src/util/log.js @@ -48,7 +48,7 @@ export const clearUserId = () => { }; const printAPIErrorsAsConsoleTable = apiErrors => { - if (typeof console.table === 'function') { + if (apiErrors != null && apiErrors.length > 0 && typeof console.table === 'function') { console.log('Errors returned by Marketplace API call:'); console.table(apiErrors.map(err => ({ status: err.status, code: err.code, ...err.meta }))); } diff --git a/src/util/test-data.js b/src/util/test-data.js index 9361ec29a5..62484c47d1 100644 --- a/src/util/test-data.js +++ b/src/util/test-data.js @@ -4,7 +4,8 @@ import { types as sdkTypes } from './sdkLoader'; import { nightsBetween } from '../util/dates'; import { TRANSITION_ACCEPT, - TRANSITION_REQUEST, + TRANSITION_CONFIRM_PAYMENT, + TRANSITION_REQUEST_PAYMENT, TX_TRANSITION_ACTOR_CUSTOMER, TX_TRANSITION_ACTOR_PROVIDER, } from '../util/transaction'; @@ -143,7 +144,7 @@ export const createTxTransition = options => { return { createdAt: new Date(Date.UTC(2017, 4, 1)), by: TX_TRANSITION_ACTOR_CUSTOMER, - transition: TRANSITION_REQUEST, + transition: TRANSITION_REQUEST_PAYMENT, ...options, }; }; @@ -164,7 +165,12 @@ export const createTransaction = options => { createTxTransition({ createdAt: new Date(Date.UTC(2017, 4, 1)), by: TX_TRANSITION_ACTOR_CUSTOMER, - transition: TRANSITION_REQUEST, + transition: TRANSITION_REQUEST_PAYMENT, + }), + createTxTransition({ + createdAt: new Date(Date.UTC(2017, 4, 1, 0, 0, 1)), + by: TX_TRANSITION_ACTOR_CUSTOMER, + transition: TRANSITION_CONFIRM_PAYMENT, }), createTxTransition({ createdAt: new Date(Date.UTC(2017, 5, 1)), diff --git a/src/util/transaction.js b/src/util/transaction.js index bf9207b0fe..21626f870e 100644 --- a/src/util/transaction.js +++ b/src/util/transaction.js @@ -10,13 +10,24 @@ import { ensureTransaction } from './data'; */ // When a customer makes a booking to a listing, a transaction is -// created with the initial request transition. -export const TRANSITION_REQUEST = 'transition/request'; +// created with the initial request-payment transition. +// At this transition a PaymentIntent is created by Marketplace API. +// After this transition, the actual payment must be made on client-side directly to Stripe. +export const TRANSITION_REQUEST_PAYMENT = 'transition/request-payment'; // A customer can also initiate a transaction with an enquiry, and // then transition that with a request. export const TRANSITION_ENQUIRE = 'transition/enquire'; -export const TRANSITION_REQUEST_AFTER_ENQUIRY = 'transition/request-after-enquiry'; +export const TRANSITION_REQUEST_PAYMENT_AFTER_ENQUIRY = 'transition/request-payment-after-enquiry'; + +// Stripe SDK might need to ask 3D security from customer, in a separate front-end step. +// Therefore we need to make another transition to Marketplace API, +// to tell that the payment is confirmed. +export const TRANSITION_CONFIRM_PAYMENT = 'transition/confirm-payment'; + +// If the payment is not confirmed in the time limit set in transaction process (by default 15min) +// the transaction will expire automatically. +export const TRANSITION_EXPIRE_PAYMENT = 'transition/expire-payment'; // When the provider accepts or declines a transaction from the // SalePage, it is transitioned with the accept or decline transition. @@ -73,6 +84,8 @@ export const TX_TRANSITION_ACTORS = [ */ const STATE_INITIAL = 'initial'; const STATE_ENQUIRY = 'enquiry'; +const STATE_PENDING_PAYMENT = 'pending-payment'; +const STATE_PAYMENT_EXPIRED = 'payment-expired'; const STATE_PREAUTHORIZED = 'preauthorized'; const STATE_DECLINED = 'declined'; const STATE_ACCEPTED = 'accepted'; @@ -105,15 +118,23 @@ const stateDescription = { [STATE_INITIAL]: { on: { [TRANSITION_ENQUIRE]: STATE_ENQUIRY, - [TRANSITION_REQUEST]: STATE_PREAUTHORIZED, + [TRANSITION_REQUEST_PAYMENT]: STATE_PENDING_PAYMENT, }, }, [STATE_ENQUIRY]: { on: { - [TRANSITION_REQUEST_AFTER_ENQUIRY]: STATE_PREAUTHORIZED, + [TRANSITION_REQUEST_PAYMENT_AFTER_ENQUIRY]: STATE_PENDING_PAYMENT, }, }, + [STATE_PENDING_PAYMENT]: { + on: { + [TRANSITION_EXPIRE_PAYMENT]: STATE_PAYMENT_EXPIRED, + [TRANSITION_CONFIRM_PAYMENT]: STATE_PREAUTHORIZED, + }, + }, + + [STATE_PAYMENT_EXPIRED]: {}, [STATE_PREAUTHORIZED]: { on: { [TRANSITION_DECLINE]: STATE_DECLINED, @@ -199,12 +220,15 @@ export const transitionsToRequested = getTransitionsToState(STATE_PREAUTHORIZED) const txLastTransition = tx => ensureTransaction(tx).attributes.lastTransition; -// DEPRECATED: use txIsDelivered instead -export const txIsCompleted = tx => txLastTransition(tx) === TRANSITION_COMPLETE; - export const txIsEnquired = tx => getTransitionsToState(STATE_ENQUIRY).includes(txLastTransition(tx)); +export const txIsPaymentPending = tx => + getTransitionsToState(STATE_PENDING_PAYMENT).includes(txLastTransition(tx)); + +export const txIsPaymentExpired = tx => + getTransitionsToState(STATE_PAYMENT_EXPIRED).includes(txLastTransition(tx)); + // Note: state name used in Marketplace API docs (and here) is actually preauthorized // However, word "requested" is used in many places so that we decided to keep it. export const txIsRequested = tx => @@ -277,10 +301,9 @@ export const isRelevantPastTransition = transition => { TRANSITION_ACCEPT, TRANSITION_CANCEL, TRANSITION_COMPLETE, + TRANSITION_CONFIRM_PAYMENT, TRANSITION_DECLINE, TRANSITION_EXPIRE, - TRANSITION_REQUEST, - TRANSITION_REQUEST_AFTER_ENQUIRY, TRANSITION_REVIEW_1_BY_CUSTOMER, TRANSITION_REVIEW_1_BY_PROVIDER, TRANSITION_REVIEW_2_BY_CUSTOMER,