FE-576: Multicurrency (#228)

This commit is contained in:
Ildar Galeev 2018-04-02 15:48:21 +03:00 committed by GitHub
parent 0975019efa
commit 4dfe657df2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 384 additions and 206 deletions

View File

@ -1,20 +1,21 @@
import { IntegrationType, InvoiceInitConfig, InvoiceTemplateInitConfig } from 'checkout/config';
import { getAmount } from 'checkout/components/app/modal-container/modal/amount-resolver';
import { ConfigState, ModelState } from 'checkout/state';
import toNumber from 'lodash-es/toNumber';
import { IntegrationType, InvoiceInitConfig, InvoiceTemplateInitConfig } from 'checkout/config';
import { ConfigState, ModelState } from 'checkout/state';
import { createInvoiceWithTemplate } from 'checkout/backend';
import { PaymentSubject } from './payment-subject';
import { Amount, resolveAmount } from 'checkout/utils';
const resolveAmount = (c: ConfigState, m: ModelState, formAmount: string): number =>
formAmount ? toNumber(formAmount) * 100 : getAmount(m, c.initConfig.amount).value;
const infoToAmount = (amountInfo: Amount, formAmount: string): number =>
formAmount ? toNumber(formAmount) * 100 : amountInfo.value;
const resolveInvoiceTemplate = (c: ConfigState, m: ModelState, formAmount: string): Promise<PaymentSubject> => {
const endpoint = c.appConfig.capiEndpoint;
const initConfig = c.initConfig as InvoiceTemplateInitConfig;
const amount = resolveAmount(c, m, formAmount);
const amountInfo = resolveAmount(m, c.initConfig.amount);
const amount = infoToAmount(amountInfo, formAmount);
const metadata = m.invoiceTemplate.metadata;
const params = {amount, metadata, currency: 'RUB'}; // TODO fix hardcoded currency
return createInvoiceWithTemplate(endpoint, initConfig.invoiceTemplateAccessToken, initConfig.invoiceTemplateID, params)
const params = {amount, metadata, currency: amountInfo.currencyCode};
const {invoiceTemplateAccessToken, invoiceTemplateID} = c.initConfig as InvoiceTemplateInitConfig;
return createInvoiceWithTemplate(endpoint, invoiceTemplateAccessToken, invoiceTemplateID, params)
.then((invoiceAndToken) => ({
integrationType: IntegrationType.invoiceTemplate,
invoiceID: invoiceAndToken.invoice.id,
@ -22,13 +23,12 @@ const resolveInvoiceTemplate = (c: ConfigState, m: ModelState, formAmount: strin
}));
};
const resolveInvoice = (c: InvoiceInitConfig): Promise<PaymentSubject> => {
return Promise.resolve({
const resolveInvoice = (c: InvoiceInitConfig): Promise<PaymentSubject> =>
Promise.resolve({
integrationType: IntegrationType.invoice,
invoiceID: c.invoiceID,
accessToken: c.invoiceAccessToken
});
};
export const getPaymentSubject = (c: ConfigState, m: ModelState, formAmount: string): Promise<PaymentSubject> => {
switch (c.initConfig.integrationType) {

View File

@ -1,7 +1,7 @@
import { PaymentToolDetails } from './payment-tool-details';
import { DigitalWalletDetails, DigitalWalletDetailsType } from './digital-wallet-details';
import { PaymentToolDetailsType } from './payment-tool-details-type';
import { applyMixins } from 'checkout/utils';
import { applyMixins } from 'checkout/utils/apply-mixins';
export class PaymentToolDetailsDigitalWallet implements PaymentToolDetails, DigitalWalletDetails {
detailsType: PaymentToolDetailsType;

View File

@ -1,63 +0,0 @@
import { ModelState } from 'checkout/state';
import {
CostType,
InvoiceChangeType,
InvoiceCreated,
InvoiceTemplateLineCostFixed,
InvoiceTemplateMultiLine,
InvoiceTemplateSingleLine
} from 'checkout/backend';
import { Amount, findChange } from 'checkout/utils';
const getAmountFromSingleLine = (model: ModelState, configAmount: number | null): Amount | null => {
const details = model.invoiceTemplate.details as InvoiceTemplateSingleLine;
const price = details.price;
if (price) {
switch (price.costType) {
case CostType.InvoiceTemplateLineCostFixed:
const fixed = price as InvoiceTemplateLineCostFixed;
return {
value: fixed.amount,
currencyCode: fixed.currency
};
case CostType.InvoiceTemplateLineCostRange:
case CostType.InvoiceTemplateLineCostUnlim:
return configAmount ? {
value: configAmount,
currencyCode: 'RUB' // TODO fix hardcoded currency
} : null;
}
}
};
const getAmountFromMultiLine = (details: InvoiceTemplateMultiLine): Amount => ({
value: details.cart.reduce((p, c) => p + (c.price * c.quantity), 0),
currencyCode: details.currency
});
const getAmountFromInvoiceTemplate = (model: ModelState, configAmount: number | null): Amount => {
switch (model.invoiceTemplate.details.templateType) {
case 'InvoiceTemplateSingleLine':
return getAmountFromSingleLine(model, configAmount);
case 'InvoiceTemplateMultiLine':
return getAmountFromMultiLine(model.invoiceTemplate.details as InvoiceTemplateMultiLine);
}
};
const getAmountFromInvoice = (invoiceCreated: InvoiceCreated): Amount => {
const {invoice: {amount, currency}} = invoiceCreated;
return {
value: amount,
currencyCode: currency
};
};
export const getAmount = (m: ModelState, configAmount: number | null): Amount | null => {
if (!m.invoiceEvents && !m.invoiceTemplate) {
return;
}
const invoiceCreated = findChange(m.invoiceEvents, InvoiceChangeType.InvoiceCreated);
return invoiceCreated
? getAmountFromInvoice(invoiceCreated as InvoiceCreated)
: getAmountFromInvoiceTemplate(m, configAmount);
};

View File

@ -1,9 +1,8 @@
import { InvoiceTemplateLineCostRange, InvoiceTemplateLineCostUnlim } from 'checkout/backend';
import { formatAmount, getSymbol } from 'checkout/utils';
const toUnlimPlaceholder = (localeString: string): string => {
return `${localeString} ${getSymbol('RUB')}`;
};
const toUnlimPlaceholder = (localeString: string, currency: string): string =>
`${localeString} ${getSymbol(currency)}`;
const toRangePlaceholder = (cost: InvoiceTemplateLineCostRange): string => {
const range = cost.range;
@ -18,7 +17,7 @@ export const getPlaceholder = (cost: InvoiceTemplateLineCostRange | InvoiceTempl
}
switch (cost.costType) {
case 'InvoiceTemplateLineCostUnlim':
return toUnlimPlaceholder(localeString);
return toUnlimPlaceholder(localeString, 'RUB'); // TODO unlim cost type does't support currency
case 'InvoiceTemplateLineCostRange':
return toRangePlaceholder(cost as InvoiceTemplateLineCostRange);
}

View File

@ -4,8 +4,7 @@ import * as formStyles from '../../form-container.scss';
import * as styles from './interaction-terminal-form.scss';
import { State } from 'checkout/state';
import { Header } from '../../header';
import { getAmount } from '../../../amount-resolver';
import { formatAmount } from 'checkout/utils';
import { formatAmount, resolveAmount } from 'checkout/utils';
import { bindActionCreators, Dispatch } from 'redux';
import { setViewInfoHeight } from 'checkout/actions';
import { PaymentTerminalReceipt } from 'checkout/backend';
@ -16,7 +15,7 @@ import { ReceiptInfo } from './receipt-info';
const mapStateToProps = (state: State) => ({
locale: state.config.locale,
amount: formatAmount(getAmount(state.model, state.config.initConfig.amount))
amount: formatAmount(resolveAmount(state.model, state.config.initConfig.amount, true))
});
const mapDispatchToProps = (dispatch: Dispatch<any>) => ({

View File

@ -2,8 +2,7 @@ import * as React from 'react';
import { connect } from 'react-redux';
import { ModelState, State } from 'checkout/state';
import { InitConfig, IntegrationType } from 'checkout/config';
import { getAmount } from '../../amount-resolver';
import { formatAmount } from 'checkout/utils';
import { formatAmount, resolveAmount } from 'checkout/utils';
import { Button } from 'checkout/components';
import { Locale } from 'checkout/locale';
@ -21,7 +20,7 @@ const PayButtonDef: React.SFC<PayButtonProps> = (props) => (
);
const toInvoiceLabel = (locale: Locale, initConfig: InitConfig, model: ModelState): string => {
const amount = formatAmount(getAmount(model, initConfig.amount));
const amount = formatAmount(resolveAmount(model, initConfig.amount));
const amountLabel = amount ? ` ${amount.value} ${amount.symbol}` : '';
return `${locale['form.button.pay.label']}${amountLabel}`;
};

View File

@ -21,8 +21,7 @@ import { toFieldsConfig } from '../fields-config';
import { payTerminalEuroset, prepareToPay, setViewInfoError, setViewInfoHeight } from 'checkout/actions';
import { TerminalFormProps } from './terminal-form-props';
import { NextButton } from './next-button';
import { getAmount } from '../../amount-resolver';
import { findNamed, formatAmount } from 'checkout/utils';
import { findNamed, formatAmount, resolveAmount } from 'checkout/utils';
import { AmountInfo } from './amount-info';
const toTerminalFormInfo = (modals: ModalState[]) => {
@ -37,7 +36,7 @@ const mapStateToProps = (state: State) => ({
formValues: get(state.form, 'terminalForm.values'),
config: state.config,
model: state.model,
amount: formatAmount(getAmount(state.model, state.config.initConfig.amount))
amount: formatAmount(resolveAmount(state.model, state.config.initConfig.amount))
});
const mapDispatchToProps = (dispatch: Dispatch<any>) => ({

View File

@ -1,111 +1,65 @@
import * as React from 'react';
import * as cx from 'classnames';
import { connect } from 'react-redux';
import * as styles from './info.scss';
import { InitConfig } from 'checkout/config';
import { ModelState, State } from 'checkout/state';
import { getAmount } from '../amount-resolver';
import { Amount, formatAmount } from 'checkout/utils';
import { State } from 'checkout/state';
import {
formatAmount,
FormattedAmount,
resolveAmount
} from 'checkout/utils';
import { Locale } from 'checkout/locale';
interface InfoState {
amount: Amount;
help?: boolean;
}
export interface InfoProps {
initConfig: InitConfig;
model: ModelState;
locale: Locale;
name: string;
description: string;
email: string;
formattedAmount: FormattedAmount;
}
const mapStateToProps = (state: State) => ({
initConfig: state.config.initConfig,
locale: state.config.locale,
model: state.model
});
const mapStateToProps = (s: State) => {
const {config: {initConfig, locale}} = s;
return {
locale,
name: initConfig.name,
description: initConfig.description,
email: initConfig.email,
formattedAmount: formatAmount(resolveAmount(s.model, initConfig.amount))
};
};
class InfoDef extends React.Component<InfoProps, InfoState> {
constructor(props: InfoProps) {
super(props);
this.state = {
amount: null,
help: true
};
this.toggleHelp = this.toggleHelp.bind(this);
}
componentDidMount() {
this.setState({
amount: getAmount(this.props.model, this.props.initConfig.amount)
});
}
componentWillReceiveProps(props: InfoProps) {
this.setState({
amount: getAmount(props.model, this.props.initConfig.amount)
});
}
toggleHelp() {
this.setState({
help: !this.state.help
});
}
render() {
const locale = this.props.locale;
const name = this.props.initConfig.name;
const description = this.props.initConfig.description;
const email = this.props.initConfig.email;
const dueDate = false;
const recurrent = false;
const formattedAmount = formatAmount(this.state.amount);
return (
<div className={styles.info}>
<div>
{name ? <h4 className={styles.company_name} id='company-name-label'>{name}</h4> : false}
{formattedAmount ?
<h1 className={styles.amount}>
{formattedAmount.value}
<span>&nbsp;{formattedAmount.symbol}</span>
</h1> : false}
{description ?
<div>
<div className={styles.order}>{locale['info.order.label']}</div>
<div className={styles.product_description} id='product-description'>{description}</div>
</div>
: false}
{email ?
<div>
<div className={styles.order}>{locale['info.email.label']}</div>
<div className={styles.email}>{email}</div>
</div>
: false}
{dueDate ? <div className={styles.dueDate}>{locale['info.dueTime.text']} 23:56</div> : false}
</div>
{recurrent ? <div>
<div className={styles.subscription} onClick={this.toggleHelp}>
<span>{locale['info.subscription.label']}</span>
<svg width='15' height='15'>
<g transform='translate(1 1)' fill='none'>
<circle cx='7' cy='7' r='7'/>
<text>
<tspan x='4' y='10'>?</tspan>
</text>
</g>
</svg>
const InfoDef: React.SFC<InfoProps> = (props) => {
const {
formattedAmount,
locale,
name,
description,
email
} = props;
return (
<div className={styles.info}>
<div>
{name ? <h4 className={styles.company_name} id='company-name-label'>{name}</h4> : false}
{formattedAmount ?
<h1 className={styles.amount}>
{formattedAmount.value}
<span>&nbsp;{formattedAmount.symbol}</span>
</h1> : false}
{description ?
<div>
<div className={styles.order}>{locale['info.order.label']}</div>
<div className={styles.product_description} id='product-description'>{description}</div>
</div>
<p className={cx(styles.help, {
[styles._hide]: !this.state.help
})}>
{locale['info.subscription.help.text']}
</p>
</div> : false}
: false}
{email ?
<div>
<div className={styles.order}>{locale['info.email.label']}</div>
<div className={styles.email}>{email}</div>
</div>
: false}
</div>
);
}
}
</div>
);
};
export const Info = connect(mapStateToProps)(InfoDef);

View File

@ -1,20 +0,0 @@
import { findCurrency, format } from 'currency-formatter';
export interface Amount {
value: number;
currencyCode: string;
}
export interface FormattedAmount {
value: string;
symbol: string;
}
export const getSymbol = (currencyCode: string): string => {
return findCurrency(currencyCode).symbol;
};
export const formatAmount = (amount: Amount): FormattedAmount | null => (amount ? {
value: format(amount.value / 100, {decimal: ', ', thousand: ' '}),
symbol: getSymbol(amount.currencyCode)
} : null);

View File

@ -0,0 +1,4 @@
export interface Amount {
value: number;
currencyCode: string;
}

View File

@ -0,0 +1,10 @@
import { format } from 'currency-formatter';
import { Amount } from './amount';
import { FormattedAmount } from './formatted-amount';
import { getSymbol } from './get-symbol';
export const formatAmount = (amount: Amount): FormattedAmount | null =>
(amount && amount.value ? {
value: format(amount.value / 100, {decimal: ', ', thousand: ' '}),
symbol: getSymbol(amount.currencyCode)
} : null);

View File

@ -0,0 +1,4 @@
export interface FormattedAmount {
value: string;
symbol: string;
}

View File

@ -0,0 +1,3 @@
import { findCurrency } from 'currency-formatter';
export const getSymbol = (currencyCode: string): string => findCurrency(currencyCode).symbol;

View File

@ -0,0 +1,5 @@
export * from './amount';
export * from './format-amount';
export * from './formatted-amount';
export * from './get-symbol';
export * from './resolve-amount';

View File

@ -0,0 +1,42 @@
import { getAmountFromMultiLine } from './get-amount-from-multi-line';
it('should return amount', () => {
const multiLine = {
cart: [
{
cost: 100000,
price: 100000,
product: 'Product 1',
quantity: 1
},
{
cost: 400000,
price: 200000,
product: 'Product 2',
quantity: 2,
taxMode: {
rate: '0%',
type: 'InvoiceLineTaxVAT'
}
},
{
cost: 500000,
price: 500000,
product: 'Product 3',
quantity: 1,
taxMode: {
rate: '18%',
type: 'InvoiceLineTaxVAT'
}
}
],
currency: 'RUB',
templateType: 'InvoiceTemplateMultiLine'
} as any;
const actual = getAmountFromMultiLine(multiLine);
const expected = {
currencyCode: 'RUB',
value: 1000000
};
expect(actual).toEqual(expected);
});

View File

@ -0,0 +1,7 @@
import { InvoiceTemplateMultiLine } from 'checkout/backend';
import { Amount } from '../amount';
export const getAmountFromMultiLine = (details: InvoiceTemplateMultiLine): Amount => ({
value: details.cart.reduce((p, c) => p + (c.price * c.quantity), 0),
currencyCode: details.currency
});

View File

@ -0,0 +1,51 @@
import { InvoiceTemplateLineCostFixed } from 'checkout/backend';
import { getAmountFromSingleLine } from './get-amount-from-single-line';
it('InvoiceTemplateLineCostFixed should return amount', () => {
const singleLine = {
price: {
costType: 'InvoiceTemplateLineCostFixed',
amount: 149900,
currency: 'RUB'
}
} as any;
const actual = getAmountFromSingleLine(singleLine, 111);
const expected = {
currencyCode: 'RUB',
value: 149900
};
expect(actual).toEqual(expected);
});
it('InvoiceTemplateLineCostRange should return amount', () => {
const singleLine = {
price: {
costType: 'InvoiceTemplateLineCostRange',
range: {
lowerBound: 1000,
upperBound: 2000
},
currency: 'RUB'
}
} as any;
const actual = getAmountFromSingleLine(singleLine, 111);
const expected = {
currencyCode: 'RUB',
value: 111
};
expect(actual).toEqual(expected);
});
it('InvoiceTemplateLineCostUnlim should return amount', () => {
const singleLine = {
price: {
costType: 'InvoiceTemplateLineCostUnlim'
}
} as any;
const actual = getAmountFromSingleLine(singleLine, 111);
const expected = {
currencyCode: 'RUB',
value: 111
};
expect(actual).toEqual(expected);
});

View File

@ -0,0 +1,32 @@
import {
CostType,
InvoiceTemplateLineCostFixed,
InvoiceTemplateLineCostRange,
InvoiceTemplateSingleLine
} from 'checkout/backend';
import { Amount } from '../amount';
export const getAmountFromSingleLine = (details: InvoiceTemplateSingleLine, configAmount: number): Amount => {
const price = details.price;
if (!price) {
return null;
}
switch (price.costType) {
case CostType.InvoiceTemplateLineCostFixed:
const fixed = price as InvoiceTemplateLineCostFixed;
return {
value: fixed.amount,
currencyCode: fixed.currency
};
case CostType.InvoiceTemplateLineCostRange:
return {
value: configAmount,
currencyCode: (price as InvoiceTemplateLineCostRange).currency
};
case CostType.InvoiceTemplateLineCostUnlim:
return {
value: configAmount,
currencyCode: 'RUB' // TODO unlim cost type does't support currency
};
}
};

View File

@ -0,0 +1 @@
export * from './resolve-amount';

View File

@ -0,0 +1,60 @@
import { resolveAmount } from './resolve-amount';
import { resolveInvoiceTemplate } from './resolve-invoice-template';
import { resolveInvoice } from './resolve-invoice';
import { findChange } from '../../event-utils';
jest.mock('./resolve-invoice-template');
jest.mock('../../event-utils');
jest.mock('./resolve-invoice');
it('null params should return null', () => {
const actual = resolveAmount(null, null);
expect(actual).toBeNull();
});
it('empty invoiceEvents and invoiceTemplate should return null', () => {
const actual = resolveAmount({
invoiceEvents: null,
invoiceTemplate: null
}, null);
expect(actual).toBeNull();
});
describe('resolveInvoiceTemplate', () => {
const resolveInvoiceTemplateMocked = resolveInvoiceTemplate as any;
it('non empty invoiceTemplate should call resolveInvoiceTemplate', () => {
resolveInvoiceTemplateMocked.mockReturnValueOnce(null);
resolveAmount({
invoiceTemplate: {},
invoiceEvents: []
} as any, 111);
expect(resolveInvoiceTemplateMocked).toBeCalledWith({}, 111);
});
});
describe('resolveInvoice', () => {
const findChangeMocked = findChange as any;
const resolveInvoiceMocked = resolveInvoice as any;
const findChangeResult = 'findChangeMocked result';
beforeEach(() => {
findChangeMocked.mockReturnValueOnce(findChangeResult);
resolveInvoiceMocked.mockReturnValueOnce(null);
});
it('non empty invoiceEvents and empty invoiceTemplate should call resolveInvoice', () => {
resolveAmount({
invoiceEvents: []
} as any, 111);
expect(resolveInvoiceMocked).toBeCalledWith(findChangeResult);
});
it('setting invoiceEventsFirst flag to true should call resolveInvoice', () => {
resolveAmount({
invoiceTemplate: {},
invoiceEvents: []
} as any, 111, true);
expect(resolveInvoiceMocked).toBeCalledWith(findChangeResult);
});
});

View File

@ -0,0 +1,15 @@
import { findChange } from '../../event-utils';
import { ModelState } from 'checkout/state';
import { InvoiceChangeType, InvoiceCreated } from 'checkout/backend';
import { Amount } from '../amount';
import { resolveInvoice } from './resolve-invoice';
import { resolveInvoiceTemplate } from './resolve-invoice-template';
export const resolveAmount = (m: ModelState, configAmount: number, invoiceEventsFirst: boolean = false): Amount => {
if (!m || (!m.invoiceEvents && !m.invoiceTemplate)) {
return null;
}
return m.invoiceTemplate && !invoiceEventsFirst
? resolveInvoiceTemplate(m.invoiceTemplate, configAmount)
: resolveInvoice(findChange(m.invoiceEvents, InvoiceChangeType.InvoiceCreated) as InvoiceCreated);
};

View File

@ -0,0 +1,32 @@
import { getAmountFromSingleLine } from './get-amount-from-single-line';
import { getAmountFromMultiLine } from './get-amount-from-multi-line';
import { resolveInvoiceTemplate } from './resolve-invoice-template';
import { TemplateType } from 'checkout/backend';
jest.mock('./get-amount-from-single-line');
jest.mock('./get-amount-from-multi-line');
const getAmountFromSingleLineMocked = getAmountFromSingleLine as any;
const getAmountFromMultiLineMocked = getAmountFromMultiLine as any;
it('InvoiceTemplateSingleLine should call getAmountFromSingleLine', () => {
const singleLine = {
details: {
templateType: TemplateType.InvoiceTemplateSingleLine
}
} as any;
getAmountFromSingleLineMocked.mockReturnValueOnce(singleLine.details);
resolveInvoiceTemplate(singleLine, 111);
expect(getAmountFromSingleLineMocked).toBeCalledWith(singleLine.details, 111);
});
it('InvoiceTemplateMultiLine should call getAmountFromMultiLine', () => {
const multiLine = {
details: {
templateType: TemplateType.InvoiceTemplateMultiLine
}
} as any;
getAmountFromMultiLineMocked.mockReturnValueOnce(multiLine.details);
resolveInvoiceTemplate(multiLine, 111);
expect(getAmountFromMultiLineMocked).toBeCalledWith(multiLine.details);
});

View File

@ -0,0 +1,18 @@
import {
InvoiceTemplate,
InvoiceTemplateMultiLine,
InvoiceTemplateSingleLine,
TemplateType
} from 'checkout/backend';
import { Amount } from '../amount';
import { getAmountFromSingleLine } from './get-amount-from-single-line';
import { getAmountFromMultiLine } from './get-amount-from-multi-line';
export const resolveInvoiceTemplate = (invoiceTemplate: InvoiceTemplate, configAmount: number): Amount => {
switch (invoiceTemplate.details.templateType) {
case TemplateType.InvoiceTemplateSingleLine:
return getAmountFromSingleLine(invoiceTemplate.details as InvoiceTemplateSingleLine, configAmount);
case TemplateType.InvoiceTemplateMultiLine:
return getAmountFromMultiLine(invoiceTemplate.details as InvoiceTemplateMultiLine);
}
};

View File

@ -0,0 +1,16 @@
import { resolveInvoice } from './resolve-invoice';
it('should return amount', () => {
const invoiceCreated = {
invoice: {
amount: 10000,
currency: 'RUB'
}
} as any;
const actual = resolveInvoice(invoiceCreated);
const expected = {
currencyCode: 'RUB',
value: 10000
};
expect(actual).toEqual(expected);
});

View File

@ -0,0 +1,10 @@
import { InvoiceCreated } from 'checkout/backend';
import { Amount } from '../amount';
export const resolveInvoice = (invoiceCreated: InvoiceCreated): Amount => {
const {invoice: {amount, currency}} = invoiceCreated;
return {
value: amount,
currencyCode: currency
};
};

View File

@ -7,3 +7,4 @@ export * from './find-named';
export * from './find-previous';
export * from './uri-serializer';
export * from './get-nocache-value';
export * from './amount-formatter';