Skip to content

Commit

Permalink
Recurring Payments Block: Minimum transaction amounts (#14802)
Browse files Browse the repository at this point in the history
Use Stripe's minimum transaction amounts for currencies supported by Jetpack and WordPress.com.
  • Loading branch information
Robert “Beau” Collins authored Mar 13, 2020
1 parent fd4e0ae commit 771f691
Show file tree
Hide file tree
Showing 2 changed files with 146 additions and 52 deletions.
88 changes: 54 additions & 34 deletions extensions/blocks/recurring-payments/edit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import classnames from 'classnames';
import SubmitButton from '../../shared/submit-button';
import apiFetch from '@wordpress/api-fetch';
import { __, sprintf } from '@wordpress/i18n';
import { trimEnd, pick } from 'lodash';
import formatCurrency, { getCurrencyDefaults } from '@automattic/format-currency';
import { pick } from 'lodash';
import formatCurrency from '@automattic/format-currency';
import { addQueryArgs, getQueryArg, isURL } from '@wordpress/url';
import { compose } from '@wordpress/compose';
import { withSelect } from '@wordpress/data';
Expand All @@ -28,7 +28,13 @@ import { Fragment, Component } from '@wordpress/element';
*/
import getJetpackExtensionAvailability from '../../shared/get-jetpack-extension-availability';
import StripeNudge from '../../shared/components/stripe-nudge';
import { icon, SUPPORTED_CURRENCY_LIST } from '.';
import {
icon,
isPriceValid,
minimumTransactionAmountForCurrency,
removeInvalidProducts,
CURRENCY_OPTIONS,
} from '.';

const API_STATE_LOADING = 0;
const API_STATE_CONNECTED = 1;
Expand All @@ -50,7 +56,7 @@ class MembershipsButtonEdit extends Component {
products: [],
siteSlug: '',
editedProductCurrency: 'USD',
editedProductPrice: 5,
editedProductPrice: minimumTransactionAmountForCurrency( 'USD' ),
editedProductPriceValid: true,
editedProductTitle: '',
editedProductTitleValid: true,
Expand Down Expand Up @@ -99,7 +105,14 @@ class MembershipsButtonEdit extends Component {
const connected = result.connected_account_id
? API_STATE_CONNECTED
: API_STATE_NOTCONNECTED;
this.setState( { connected, connectURL, products, shouldUpgrade, upgradeURL, siteSlug } );
this.setState( {
connected,
connectURL,
shouldUpgrade,
upgradeURL,
siteSlug,
products: removeInvalidProducts( products ),
} );
},
result => {
const connectURL = null;
Expand All @@ -109,23 +122,25 @@ class MembershipsButtonEdit extends Component {
}
);
};
getCurrencyList = SUPPORTED_CURRENCY_LIST.map( value => {
const { symbol } = getCurrencyDefaults( value );
// if symbol is equal to the code (e.g., 'CHF' === 'CHF'), don't duplicate it.
// trim the dot at the end, e.g., 'kr.' becomes 'kr'
const label = symbol === value ? value : `${ value } ${ trimEnd( symbol, '.' ) }`;
return { value, label };
} );

handleCurrencyChange = editedProductCurrency => this.setState( { editedProductCurrency } );
handleCurrencyChange = editedProductCurrency =>
this.setState( {
editedProductCurrency,
editedProductPriceValid: isPriceValid( editedProductCurrency, this.state.editedProductPrice ),
} );
handleRenewIntervalChange = editedProductRenewInterval =>
this.setState( { editedProductRenewInterval } );

handlePriceChange = price => {
price = parseFloat( price );
const editedProductPrice = parseFloat( price );
const editedProductPriceValid = isPriceValid(
this.state.editedProductCurrency,
editedProductPrice
);

this.setState( {
editedProductPrice: price,
editedProductPriceValid: ! isNaN( price ) && price >= 5,
editedProductPrice,
editedProductPriceValid,
} );
};

Expand All @@ -142,8 +157,7 @@ class MembershipsButtonEdit extends Component {
}
if (
! this.state.editedProductPrice ||
isNaN( this.state.editedProductPrice ) ||
this.state.editedProductPrice < 5
! isPriceValid( this.state.editedProductCurrency, this.state.editedProductPrice )
) {
this.setState( { editedProductPriceValid: false } );
return;
Expand Down Expand Up @@ -212,31 +226,37 @@ class MembershipsButtonEdit extends Component {
return;
}

const minPrice = formatCurrency(
minimumTransactionAmountForCurrency( this.state.editedProductCurrency ),
this.state.editedProductCurrency
);
const minimumPriceNote = sprintf( __( 'Minimum allowed price is %s.' ), minPrice );
return (
<div>
<div className="membership-button__price-container">
<SelectControl
className="membership-button__field membership-button__field-currency"
label={ __( 'Currency', 'jetpack' ) }
onChange={ this.handleCurrencyChange }
options={ this.getCurrencyList }
options={ CURRENCY_OPTIONS }
value={ this.state.editedProductCurrency }
/>
<TextControl
label={ __( 'Price', 'jetpack' ) }
className={ classnames( {
'membership-membership-button__field': true,
'membership-button__field-price': true,
'membership-button__field-error': ! this.state.editedProductPriceValid,
} ) }
onChange={ this.handlePriceChange }
placeholder={ formatCurrency( 0, this.state.editedProductCurrency ) }
required
min="5.00"
step="1"
type="number"
value={ this.state.editedProductPrice || '' }
/>
<div className="membership-membership-button__field membership-button__field-price">
<TextControl
label={ __( 'Price', 'jetpack' ) }
className={ classnames( {
'membership-button__field-error': ! this.state.editedProductPriceValid,
} ) }
onChange={ this.handlePriceChange }
placeholder={ minPrice }
required
min="0"
step="1"
type="number"
value={ this.state.editedProductPrice || '' }
/>
<p>{ minimumPriceNote }</p>
</div>
</div>
<TextControl
className={ classnames( {
Expand Down
110 changes: 92 additions & 18 deletions extensions/blocks/recurring-payments/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* External dependencies
*/
import { Path, Rect, SVG, G } from '@wordpress/components';
import { getCurrencyDefaults } from '@automattic/format-currency';
import { trimEnd } from 'lodash';

/**
* Internal dependencies
Expand Down Expand Up @@ -66,21 +68,93 @@ export const settings = {
},
};

// These are Stripe Settlement currencies https://stripe.com/docs/currencies since memberships supports only Stripe ATM.
export const SUPPORTED_CURRENCY_LIST = [
'USD',
'AUD',
'BRL',
'CAD',
'CHF',
'DKK',
'EUR',
'GBP',
'HKD',
'JPY',
'MXN',
'NOK',
'NZD',
'SEK',
'SGD',
];
/**
* Currencies we support and Stripe's minimum amount for a transaction in that currency.
*
* https://stripe.com/docs/currencies#minimum-and-maximum-charge-amounts
*
* @type { [currency: string]: number }
*/
export const SUPPORTED_CURRENCIES = {
USD: 0.5,
AUD: 0.5,
BRL: 0.5,
CAD: 0.5,
CHF: 0.5,
DKK: 2.5,
EUR: 0.5,
GBP: 0.3,
HKD: 4.0,
INR: 0.5,
JPY: 50,
MXN: 10,
NOK: 3.0,
NZD: 0.5,
PLN: 2.0,
SEK: 3.0,
SGD: 0.5,
};

/**
* Compute a list of currency value and display labels.
*
* - `value` is the currency's three character code
* - `label` is the user facing representation.
*
* @typedef {{value: string, label: string}} CurrencyDetails
*
* @type Array<CurrencyDetails>
*/
export const CURRENCY_OPTIONS = Object.keys( SUPPORTED_CURRENCIES ).map( value => {
const { symbol } = getCurrencyDefaults( value );
const label = symbol === value ? value : `${ value } ${ trimEnd( symbol, '.' ) }`;
return { value, label };
} );

/**
* Returns the minimum transaction amount for the given currency. If currency is not one of the
* known types it returns ...
*
* @param {string} currency_code three character currency code to get minimum charge for
* @return {number} Minimum charge amount for the given currency_code
*/
export function minimumTransactionAmountForCurrency( currency_code ) {
const minimum = SUPPORTED_CURRENCIES[ currency_code ];
return minimum;
}

/**
* True if the price is a number and at least the minimum allowed amount.
*
* @param {string} currency Currency for the given price.
* @param {number} price Price to check.
* @return {boolean} true if valid price
*/
export function isPriceValid( currency, price ) {
return ! isNaN( price ) && price >= minimumTransactionAmountForCurrency( currency );
}

/**
* Removes products with prices below their minimums.
*
* TS compatible typedef, but JSDoc lint doesn't like it.
* typedef {{
* buyer_can_change_amount: ?boolean
* connected_account_product_id: string
* connected_destination_account_id: string
* currency: string
* description: string
* id: number
* interval: string
* multiple_per_user: ?boolean
* price: string
* site_id: string
* title: string
* }} Product
*
* @param {Array<Product>} products List of membership products.
* @return {Array<Product>} List of producits with invalid products removed.
*/
export function removeInvalidProducts( products ) {
return products.filter( product => isPriceValid( product.currency, product.price ) );
}

0 comments on commit 771f691

Please sign in to comment.