Payments: Support more providers (#1641)

This commit is contained in:
Alexander Zinchuk 2022-02-25 22:52:29 +02:00
parent f61db93008
commit d6a9ba5683
30 changed files with 324 additions and 79 deletions

View File

@ -116,6 +116,7 @@ export function buildPaymentForm(form: GramJs.payments.PaymentForm) {
needCountry: nativeData.need_country, needCountry: nativeData.need_country,
needZip: nativeData.need_zip, needZip: nativeData.need_zip,
publishableKey: nativeData.publishable_key, publishableKey: nativeData.publishable_key,
publicToken: nativeData?.public_token,
}, },
}; };
} }

View File

@ -3,7 +3,13 @@ import { Api as GramJs } from '../../../lib/gramjs';
import { invokeRequest } from './client'; import { invokeRequest } from './client';
import { buildInputPeer, buildShippingInfo } from '../gramjsBuilders'; import { buildInputPeer, buildShippingInfo } from '../gramjsBuilders';
import { buildShippingOptions, buildPaymentForm, buildReceipt } from '../apiBuilders/payments'; import { buildShippingOptions, buildPaymentForm, buildReceipt } from '../apiBuilders/payments';
import { ApiChat } from '../../types'; import { ApiChat, OnApiUpdate } from '../../types';
let onUpdate: OnApiUpdate;
export function init(_onUpdate: OnApiUpdate) {
onUpdate = _onUpdate;
}
export async function validateRequestedInfo({ export async function validateRequestedInfo({
chat, chat,
@ -40,7 +46,7 @@ export async function validateRequestedInfo({
}; };
} }
export function sendPaymentForm({ export async function sendPaymentForm({
chat, chat,
messageId, messageId,
formId, formId,
@ -55,7 +61,7 @@ export function sendPaymentForm({
requestedInfoId?: string; requestedInfoId?: string;
shippingOptionId?: string; shippingOptionId?: string;
}) { }) {
return invokeRequest(new GramJs.payments.SendPaymentForm({ const result = await invokeRequest(new GramJs.payments.SendPaymentForm({
formId: BigInt(formId), formId: BigInt(formId),
peer: buildInputPeer(chat.id, chat.accessHash), peer: buildInputPeer(chat.id, chat.accessHash),
msgId: messageId, msgId: messageId,
@ -65,7 +71,18 @@ export function sendPaymentForm({
save: credentials.save, save: credentials.save,
data: new GramJs.DataJSON({ data: JSON.stringify(credentials.data) }), data: new GramJs.DataJSON({ data: JSON.stringify(credentials.data) }),
}), }),
}), true); }));
if (result instanceof GramJs.payments.PaymentVerificationNeeded) {
onUpdate({
'@type': 'updatePaymentVerificationNeeded',
url: result.url,
});
return undefined;
}
return Boolean(result);
} }
export async function getPaymentForm({ export async function getPaymentForm({

View File

@ -18,6 +18,7 @@ import { init as initStickers } from './methods/symbols';
import { init as initManagement } from './methods/management'; import { init as initManagement } from './methods/management';
import { init as initTwoFaSettings } from './methods/twoFaSettings'; import { init as initTwoFaSettings } from './methods/twoFaSettings';
import { init as initCalls } from './methods/calls'; import { init as initCalls } from './methods/calls';
import { init as initPayments } from './methods/payments';
import * as methods from './methods'; import * as methods from './methods';
let onUpdate: OnApiUpdate; let onUpdate: OnApiUpdate;
@ -34,6 +35,7 @@ export async function initApi(_onUpdate: OnApiUpdate, initialArgs: ApiInitialArg
initManagement(handleUpdate); initManagement(handleUpdate);
initTwoFaSettings(handleUpdate); initTwoFaSettings(handleUpdate);
initCalls(handleUpdate); initCalls(handleUpdate);
initPayments(handleUpdate);
await initClient(handleUpdate, initialArgs); await initClient(handleUpdate, initialArgs);
} }

View File

@ -188,7 +188,11 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
if (update.message instanceof GramJs.MessageService) { if (update.message instanceof GramJs.MessageService) {
const { action } = update.message; const { action } = update.message;
if (action instanceof GramJs.MessageActionChatEditTitle) { if (action instanceof GramJs.MessageActionPaymentSent) {
onUpdate({
'@type': 'updatePaymentStateCompleted',
});
} else if (action instanceof GramJs.MessageActionChatEditTitle) {
onUpdate({ onUpdate({
'@type': 'updateChat', '@type': 'updateChat',
id: message.chatId, id: message.chatId,

View File

@ -32,12 +32,15 @@ export interface ApiPaymentForm {
currency?: string; currency?: string;
prices?: ApiLabeledPrice[]; prices?: ApiLabeledPrice[];
}; };
nativeParams: { nativeParams: ApiPaymentFormNativeParams;
needCardholderName: boolean; }
needCountry: boolean;
needZip: boolean; export interface ApiPaymentFormNativeParams {
publishableKey: string; needCardholderName?: boolean;
}; needCountry?: boolean;
needZip?: boolean;
publishableKey?: string;
publicToken?: string;
} }
export interface ApiLabeledPrice { export interface ApiLabeledPrice {

View File

@ -381,6 +381,15 @@ export type ApiUpdatePeerBlocked = {
isBlocked: boolean; isBlocked: boolean;
}; };
export type ApiUpdatePaymentVerificationNeeded = {
'@type': 'updatePaymentVerificationNeeded';
url: string;
};
export type ApiUpdatePaymentStateCompleted = {
'@type': 'updatePaymentStateCompleted';
};
export type ApiUpdatePrivacy = { export type ApiUpdatePrivacy = {
'@type': 'updatePrivacy'; '@type': 'updatePrivacy';
key: 'phoneNumber' | 'lastSeen' | 'profilePhoto' | 'forwards' | 'chatInvite'; key: 'phoneNumber' | 'lastSeen' | 'profilePhoto' | 'forwards' | 'chatInvite';
@ -467,7 +476,7 @@ export type ApiUpdate = (
ApiUpdateServerTimeOffset | ApiUpdateShowInvite | ApiUpdateMessageReactions | ApiUpdateServerTimeOffset | ApiUpdateShowInvite | ApiUpdateMessageReactions |
ApiUpdateGroupCallParticipants | ApiUpdateGroupCallConnection | ApiUpdateGroupCall | ApiUpdateGroupCallStreams | ApiUpdateGroupCallParticipants | ApiUpdateGroupCallConnection | ApiUpdateGroupCall | ApiUpdateGroupCallStreams |
ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId | ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId |
ApiUpdatePendingJoinRequests ApiUpdatePendingJoinRequests | ApiUpdatePaymentVerificationNeeded | ApiUpdatePaymentStateCompleted
); );
export type OnApiUpdate = (update: ApiUpdate) => void; export type OnApiUpdate = (update: ApiUpdate) => void;

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

View File

@ -22,7 +22,7 @@
@media (max-width: 600px) { @media (max-width: 600px) {
// Force rendering in the composite layer to fix the z-index rendering issue // Force rendering in the composite layer to fix the z-index rendering issue
transform: translate3d(0, 0, 10px); transform: translate3d(0, 0, 0.625rem);
transform-style: preserve-3d; transform-style: preserve-3d;
} }
} }

View File

@ -7,7 +7,7 @@
width: 100%; width: 100%;
height: 3.375rem; height: 3.375rem;
max-width: 274px; max-width: 274px;
transform: translate3d(-50%, 0, 10px); transform: translate3d(-50%, 0, 0.625rem);
transition: opacity 0.3s ease-in; transition: opacity 0.3s ease-in;
pointer-events: none; pointer-events: none;

View File

@ -2,9 +2,10 @@ import React, {
FC, memo, useCallback, useState, useRef, useEffect, FC, memo, useCallback, useState, useRef, useEffect,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import useFocusAfterAnimation from '../../hooks/useFocusAfterAnimation';
import { formatCardNumber } from '../middle/helpers/inputFormatters'; import { formatCardNumber } from '../middle/helpers/inputFormatters';
import { detectCardType, CardType } from '../common/helpers/detectCardType'; import { detectCardType, CardType } from '../common/helpers/detectCardType';
import useFocusAfterAnimation from '../../hooks/useFocusAfterAnimation';
import useLang from '../../hooks/useLang';
import InputText from '../ui/InputText'; import InputText from '../ui/InputText';
@ -22,6 +23,7 @@ export type OwnProps = {
}; };
const CardInput : FC<OwnProps> = ({ value, error, onChange }) => { const CardInput : FC<OwnProps> = ({ value, error, onChange }) => {
const lang = useLang();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const cardNumberRef = useRef<HTMLInputElement>(null); const cardNumberRef = useRef<HTMLInputElement>(null);
@ -51,7 +53,7 @@ const CardInput : FC<OwnProps> = ({ value, error, onChange }) => {
<span className="left-addon">{cardIcon}</span> <span className="left-addon">{cardIcon}</span>
<InputText <InputText
ref={cardNumberRef} ref={cardNumberRef}
label="Card number" label={lang('PaymentCardNumber')}
onChange={handleChange} onChange={handleChange}
value={value} value={value}
inputMode="numeric" inputMode="numeric"

View File

@ -63,13 +63,21 @@
width: 1.5rem; width: 1.5rem;
} }
i.stripe-provider { i.provider {
background: url("../../assets/stripe-logo.png") no-repeat center; background: no-repeat center;
background-size: 2rem; background-size: 2rem;
border-radius: 1rem; border-radius: 1rem;
height: 1.5rem; height: 1.5rem;
} }
i.provider.stripe {
background-image: url("../../assets/stripe-logo.png");
}
i.provider.smartglocal {
background-image: url("../../assets/smartglocal-logo.png");
}
.info { .info {
.title { .title {
font-size: 1rem; font-size: 1rem;

View File

@ -5,6 +5,7 @@ import React, {
import { LangCode, Price } from '../../types'; import { LangCode, Price } from '../../types';
import { formatCurrency } from '../../util/formatCurrency'; import { formatCurrency } from '../../util/formatCurrency';
import buildClassName from '../../util/buildClassName';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import './Checkout.scss'; import './Checkout.scss';
@ -70,12 +71,16 @@ const Checkout: FC<OwnProps> = ({
) } ) }
</div> </div>
<div className="invoice-info"> <div className="invoice-info">
{paymentMethod && renderCheckoutItem('icon-card', paymentMethod, 'Payment method')} {paymentMethod && renderCheckoutItem('icon-card', paymentMethod, lang('PaymentCheckoutMethod'))}
{paymentProvider && renderCheckoutItem('stripe-provider', paymentProvider, 'Payment provider')} {paymentProvider && renderCheckoutItem(
{shippingAddress && renderCheckoutItem('icon-location', shippingAddress, 'Shipping address')} buildClassName('provider', paymentProvider.toLowerCase()),
{name && renderCheckoutItem('icon-user', name, 'Name')} paymentProvider,
{phone && renderCheckoutItem('icon-phone', phone, 'Phone number')} lang('PaymentCheckoutProvider'),
{shippingMethod && renderCheckoutItem('icon-truck', shippingMethod, 'Shipping method')} )}
{shippingAddress && renderCheckoutItem('icon-location', shippingAddress, lang('PaymentShippingAddress'))}
{name && renderCheckoutItem('icon-user', name, lang('PaymentCheckoutName'))}
{phone && renderCheckoutItem('icon-phone', phone, lang('PaymentCheckoutPhoneNumber'))}
{shippingMethod && renderCheckoutItem('icon-truck', shippingMethod, lang('PaymentCheckoutShippingMethod'))}
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,13 @@
.ConfirmPayment {
display: flex;
height: 100%;
border-bottom-left-radius: var(--border-radius-default-small);
border-bottom-right-radius: var(--border-radius-default-small);
overflow: hidden;
&__content {
width: 100%;
height: 100%;
border: none;
}
}

View File

@ -0,0 +1,27 @@
import React, { FC, memo } from '../../lib/teact/teact';
import useLang from '../../hooks/useLang';
import './ConfirmPayment.scss';
export type OwnProps = {
url: string;
};
const ConfirmPayment: FC<OwnProps> = ({ url }) => {
const lang = useLang();
return (
<div className="ConfirmPayment">
<iframe
src={url}
title={lang('Checkout.WebConfirmation.Title')}
allow="payment"
sandbox="allow-forms allow-scripts allow-same-origin allow-top-navigation"
className="ConfirmPayment__content"
/>
</div>
);
};
export default memo(ConfirmPayment);

View File

@ -5,6 +5,7 @@ import React, {
import { formatCardExpiry } from '../middle/helpers/inputFormatters'; import { formatCardExpiry } from '../middle/helpers/inputFormatters';
import InputText from '../ui/InputText'; import InputText from '../ui/InputText';
import useLang from '../../hooks/useLang';
const MAX_FIELD_LENGTH = 5; const MAX_FIELD_LENGTH = 5;
@ -15,6 +16,7 @@ export type OwnProps = {
}; };
const ExpiryInput : FC<OwnProps> = ({ value, error, onChange }) => { const ExpiryInput : FC<OwnProps> = ({ value, error, onChange }) => {
const lang = useLang();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const expiryInputRef = useRef<HTMLInputElement>(null); const expiryInputRef = useRef<HTMLInputElement>(null);
@ -33,7 +35,7 @@ const ExpiryInput : FC<OwnProps> = ({ value, error, onChange }) => {
return ( return (
<InputText <InputText
label="Expiry date" label={lang('PaymentCardExpireDate')}
ref={expiryInputRef} ref={expiryInputRef}
onChange={handleChange} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}

View File

@ -84,7 +84,7 @@ const PaymentInfo: FC<OwnProps> = ({
/> />
{ needCardholderName && ( { needCardholderName && (
<InputText <InputText
label="Name on card" label={lang('PaymentCardName')}
onChange={handleCardholderChange} onChange={handleCardholderChange}
value={state.cardholder} value={state.cardholder}
inputMode="text" inputMode="text"
@ -98,7 +98,7 @@ const PaymentInfo: FC<OwnProps> = ({
error={formErrors.expiry} error={formErrors.expiry}
/> />
<InputText <InputText
label="CVV code" label={lang('lng_payments_card_cvc')}
onChange={handleCvvChange} onChange={handleCvvChange}
value={state.cvv} value={state.cvv}
inputMode="numeric" inputMode="numeric"
@ -111,8 +111,8 @@ const PaymentInfo: FC<OwnProps> = ({
) : undefined } ) : undefined }
{ needCountry && ( { needCountry && (
<Select <Select
label="Country" label={lang('PaymentShippingCountry')}
placeholder="Country" placeholder={lang('PaymentShippingCountry')}
onChange={handleCountryChange} onChange={handleCountryChange}
value={state.billingCountry} value={state.billingCountry}
hasArrow={Boolean(true)} hasArrow={Boolean(true)}
@ -134,7 +134,7 @@ const PaymentInfo: FC<OwnProps> = ({
) } ) }
{ needZip && ( { needZip && (
<InputText <InputText
label="Post Code" label={lang('PaymentShippingZipPlaceholder')}
onChange={handleBillingPostCodeChange} onChange={handleBillingPostCodeChange}
value={state.billingZip} value={state.billingZip}
inputMode="text" inputMode="text"

View File

@ -5,8 +5,8 @@
.header { .header {
position: relative; position: relative;
border-top-left-radius: 10px; border-top-left-radius: var(--border-radius-default-small);
border-top-right-radius: 10px; border-top-right-radius: var(--border-radius-default-small);
width: 100%; width: 100%;
padding: 0.25rem 1rem; padding: 0.25rem 1rem;
display: flex; display: flex;
@ -49,8 +49,8 @@
.footer { .footer {
position: relative; position: relative;
border-bottom-left-radius: 10px; border-bottom-left-radius: var(--border-radius-default-small);
border-bottom-right-radius: 10px; border-bottom-right-radius: var(--border-radius-default-small);
width: 100%; width: 100%;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: var(--color-background); background: var(--color-background);

View File

@ -19,10 +19,13 @@ import Button from '../ui/Button';
import Modal from '../ui/Modal'; import Modal from '../ui/Modal';
import Transition from '../ui/Transition'; import Transition from '../ui/Transition';
import Spinner from '../ui/Spinner'; import Spinner from '../ui/Spinner';
import ConfirmPayment from './ConfirmPayment';
import './PaymentModal.scss'; import './PaymentModal.scss';
const DEFAULT_PROVIDER = 'stripe'; const DEFAULT_PROVIDER = 'stripe';
const DONATE_PROVIDER = 'smartglocal';
const SUPPORTED_PROVIDERS = new Set([DEFAULT_PROVIDER, DONATE_PROVIDER]);
export type OwnProps = { export type OwnProps = {
isOpen: boolean; isOpen: boolean;
@ -43,6 +46,7 @@ type StateProps = {
needCardholderName?: boolean; needCardholderName?: boolean;
needCountry?: boolean; needCountry?: boolean;
needZip?: boolean; needZip?: boolean;
confirmPaymentUrl?: string;
}; };
type GlobalStateProps = Pick<GlobalState['payment'], ( type GlobalStateProps = Pick<GlobalState['payment'], (
@ -73,6 +77,7 @@ const Invoice: FC<OwnProps & StateProps & GlobalStateProps> = ({
needCardholderName, needCardholderName,
needCountry, needCountry,
needZip, needZip,
confirmPaymentUrl,
error, error,
}) => { }) => {
const { const {
@ -86,6 +91,7 @@ const Invoice: FC<OwnProps & StateProps & GlobalStateProps> = ({
const [paymentState, paymentDispatch] = usePaymentReducer(); const [paymentState, paymentDispatch] = usePaymentReducer();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const lang = useLang(); const lang = useLang();
const canRenderFooter = step !== PaymentStep.ConfirmPayment;
useEffect(() => { useEffect(() => {
if (step || error) { if (step || error) {
@ -210,6 +216,12 @@ const Invoice: FC<OwnProps & StateProps & GlobalStateProps> = ({
currency={currency} currency={currency}
/> />
); );
case PaymentStep.ConfirmPayment:
return (
<ConfirmPayment
url={confirmPaymentUrl!}
/>
);
default: default:
return undefined; return undefined;
} }
@ -266,6 +278,8 @@ const Invoice: FC<OwnProps & StateProps & GlobalStateProps> = ({
return lang('PaymentCardInfo'); return lang('PaymentCardInfo');
case PaymentStep.Checkout: case PaymentStep.Checkout:
return lang('PaymentCheckout'); return lang('PaymentCheckout');
case PaymentStep.ConfirmPayment:
return lang('Checkout.WebConfirmation.Title');
default: default:
return ''; return '';
} }
@ -331,16 +345,18 @@ const Invoice: FC<OwnProps & StateProps & GlobalStateProps> = ({
<Spinner color="gray" /> <Spinner color="gray" />
</div> </div>
)} )}
<div className="footer"> {canRenderFooter && (
<Button <div className="footer">
type="submit" <Button
onClick={handleButtonClick} type="submit"
disabled={isLoading} onClick={handleButtonClick}
isLoading={isLoading} disabled={isLoading}
> isLoading={isLoading}
{buttonText} >
</Button> {buttonText}
</div> </Button>
</div>
)}
{error && !error.field && renderError()} {error && !error.field && renderError()}
</Modal> </Modal>
); );
@ -359,9 +375,10 @@ export default memo(withGlobal<OwnProps>(
nativeParams, nativeParams,
passwordMissing, passwordMissing,
error, error,
confirmPaymentUrl,
} = global.payment; } = global.payment;
const isProviderError = Boolean(invoice && (!nativeProvider || nativeProvider !== DEFAULT_PROVIDER)); const isProviderError = Boolean(invoice && (!nativeProvider || !SUPPORTED_PROVIDERS.has(nativeProvider)));
const { needCardholderName, needCountry, needZip } = (nativeParams || {}); const { needCardholderName, needCountry, needZip } = (nativeParams || {});
const { const {
nameRequested, nameRequested,
@ -397,6 +414,7 @@ export default memo(withGlobal<OwnProps>(
needCountry, needCountry,
needZip, needZip,
error, error,
confirmPaymentUrl,
}; };
}, },
)(Invoice)); )(Invoice));

View File

@ -47,7 +47,7 @@ const Shipping: FC<OwnProps> = ({
return ( return (
<div className="Shipping"> <div className="Shipping">
<form> <form>
<p>Select shipping method</p> <p>{lang('PaymentShippingMethod')}</p>
<RadioGroup <RadioGroup
name="shipping-options" name="shipping-options"
options={options} options={options}

View File

@ -102,36 +102,36 @@ const ShippingInfo: FC<OwnProps> = ({
<h5>{lang('PaymentShippingAddress')}</h5> <h5>{lang('PaymentShippingAddress')}</h5>
<InputText <InputText
ref={inputRef} ref={inputRef}
label="Address1 (Street)" label={lang('PaymentShippingAddress1Placeholder')}
onChange={handleAddress1Change} onChange={handleAddress1Change}
value={state.streetLine1} value={state.streetLine1}
inputMode="text" inputMode="text"
error={formErrors.streetLine1} error={formErrors.streetLine1}
/> />
<InputText <InputText
label="Address2 (Street)" label={lang('PaymentShippingAddress2Placeholder')}
onChange={handleAddress2Change} onChange={handleAddress2Change}
value={state.streetLine2} value={state.streetLine2}
inputMode="text" inputMode="text"
error={formErrors.streetLine2} error={formErrors.streetLine2}
/> />
<InputText <InputText
label="City" label={lang('PaymentShippingCityPlaceholder')}
onChange={handleCityChange} onChange={handleCityChange}
value={state.city} value={state.city}
inputMode="text" inputMode="text"
error={formErrors.city} error={formErrors.city}
/> />
<InputText <InputText
label="State" label={lang('PaymentShippingStatePlaceholder')}
onChange={handleStateChange} onChange={handleStateChange}
value={state.state} value={state.state}
inputMode="text" inputMode="text"
error={formErrors.state} error={formErrors.state}
/> />
<Select <Select
label="Country" label={lang('PaymentShippingCountry')}
placeholder="Country" placeholder={lang('PaymentShippingCountry')}
onChange={handleCountryChange} onChange={handleCountryChange}
value={state.countryIso2} value={state.countryIso2}
hasArrow={Boolean(true)} hasArrow={Boolean(true)}
@ -150,7 +150,7 @@ const ShippingInfo: FC<OwnProps> = ({
</Select> </Select>
<InputText <InputText
label="Post Code" label={lang('PaymentShippingZipPlaceholder')}
onChange={handlePostCodeChange} onChange={handlePostCodeChange}
value={state.postCode} value={state.postCode}
inputMode="text" inputMode="text"
@ -163,7 +163,7 @@ const ShippingInfo: FC<OwnProps> = ({
) : undefined } ) : undefined }
{ needName && ( { needName && (
<InputText <InputText
label="Full name" label={lang('PaymentShippingName')}
onChange={handleFullNameChange} onChange={handleFullNameChange}
value={state.fullName} value={state.fullName}
inputMode="text" inputMode="text"
@ -172,7 +172,7 @@ const ShippingInfo: FC<OwnProps> = ({
) } ) }
{ needEmail && ( { needEmail && (
<InputText <InputText
label="Email" label={lang('PaymentShippingEmailPlaceholder')}
onChange={handleEmailChange} onChange={handleEmailChange}
value={state.email} value={state.email}
inputMode="email" inputMode="email"
@ -181,7 +181,7 @@ const ShippingInfo: FC<OwnProps> = ({
) } ) }
{ needPhone && ( { needPhone && (
<InputText <InputText
label="Phone number" label={lang('PaymentShippingPhoneNumber')}
onChange={handlePhoneChange} onChange={handlePhoneChange}
value={state.phone} value={state.phone}
inputMode="tel" inputMode="tel"

View File

@ -16,6 +16,8 @@ export const DEBUG_GRAMJS = false;
export const PAGE_TITLE = 'Telegram'; export const PAGE_TITLE = 'Telegram';
export const INACTIVE_MARKER = ' [Inactive]'; export const INACTIVE_MARKER = ' [Inactive]';
export const DEBUG_PAYMENT_SMART_GLOCAL = false;
export const SESSION_USER_KEY = 'user_auth'; export const SESSION_USER_KEY = 'user_auth';
export const LEGACY_SESSION_KEY = 'GramJs:sessionId'; export const LEGACY_SESSION_KEY = 'GramJs:sessionId';

View File

@ -26,6 +26,7 @@ import {
ApiAvailableReaction, ApiAvailableReaction,
ApiAppConfig, ApiAppConfig,
ApiSponsoredMessage, ApiSponsoredMessage,
ApiPaymentFormNativeParams,
} from '../api/types'; } from '../api/types';
import { import {
FocusDirection, FocusDirection,
@ -426,16 +427,15 @@ export type GlobalState = {
}; };
nativeProvider?: string; nativeProvider?: string;
providerId?: string; providerId?: string;
nativeParams?: { nativeParams?: ApiPaymentFormNativeParams;
needCardholderName: boolean;
needCountry: boolean;
needZip: boolean;
publishableKey: string;
};
stripeCredentials?: { stripeCredentials?: {
type: string; type: string;
id: string; id: string;
}; };
smartGlocalCredentials?: {
type: string;
token: string;
};
passwordMissing?: boolean; passwordMissing?: boolean;
savedCredentials?: { savedCredentials?: {
id: string; id: string;
@ -448,6 +448,7 @@ export type GlobalState = {
description: string; description: string;
}; };
isPaymentModalOpen?: boolean; isPaymentModalOpen?: boolean;
confirmPaymentUrl?: string;
}; };
chatCreation?: { chatCreation?: {

View File

@ -34,3 +34,4 @@ import './apiUpdaters/misc';
import './apiUpdaters/settings'; import './apiUpdaters/settings';
import './apiUpdaters/twoFaSettings'; import './apiUpdaters/twoFaSettings';
import './apiUpdaters/calls'; import './apiUpdaters/calls';
import './apiUpdaters/payments';

View File

@ -12,10 +12,13 @@ import {
selectPaymentChatId, selectPaymentChatId,
selectChat, selectChat,
selectPaymentFormId, selectPaymentFormId,
selectProviderPublicToken,
selectSmartGlocalCredentials,
} from '../../selectors'; } from '../../selectors';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { getStripeError } from '../../helpers'; import { getStripeError } from '../../helpers';
import { buildQueryString } from '../../../util/requestQuery'; import { buildQueryString } from '../../../util/requestQuery';
import { DEBUG_PAYMENT_SMART_GLOCAL } from '../../../config';
import { import {
updateShippingOptions, updateShippingOptions,
@ -27,6 +30,7 @@ import {
setReceipt, setReceipt,
clearPayment, clearPayment,
closeInvoice, closeInvoice,
setSmartGlocalCardInfo,
} from '../../reducers'; } from '../../reducers';
addReducer('validateRequestedInfo', (global, actions, payload) => { addReducer('validateRequestedInfo', (global, actions, payload) => {
@ -132,13 +136,23 @@ addReducer('clearReceipt', (global) => {
}); });
addReducer('sendCredentialsInfo', (global, actions, payload) => { addReducer('sendCredentialsInfo', (global, actions, payload) => {
const publishableKey = selectProviderPublishableKey(global); const { nativeProvider } = global.payment;
if (!publishableKey) {
return;
}
const { credentials } = payload; const { credentials } = payload;
const { data } = credentials; const { data } = credentials;
void sendStripeCredentials(data, publishableKey);
if (nativeProvider === 'stripe') {
const publishableKey = selectProviderPublishableKey(global);
if (!publishableKey) {
return;
}
void sendStripeCredentials(data, publishableKey);
} else if (nativeProvider === 'smartglocal') {
const publicToken = selectProviderPublicToken(global);
if (!publicToken) {
return;
}
void sendSmartGlocalCredentials(data, publicToken);
}
}); });
addReducer('sendPaymentForm', (global, actions, payload) => { addReducer('sendPaymentForm', (global, actions, payload) => {
@ -148,15 +162,16 @@ addReducer('sendPaymentForm', (global, actions, payload) => {
const messageId = selectPaymentMessageId(global); const messageId = selectPaymentMessageId(global);
const formId = selectPaymentFormId(global); const formId = selectPaymentFormId(global);
const requestInfoId = selectPaymentRequestId(global); const requestInfoId = selectPaymentRequestId(global);
const publishableKey = selectProviderPublishableKey(global); const { nativeProvider } = global.payment;
const stripeCredentials = selectStripeCredentials(global); const publishableKey = nativeProvider === 'stripe'
if (!chat || !messageId || !publishableKey || !formId) { ? selectProviderPublishableKey(global) : selectProviderPublicToken(global);
if (!chat || !messageId || !publishableKey || !formId || !nativeProvider) {
return; return;
} }
void sendPaymentForm(chat, messageId, formId, { void sendPaymentForm(chat, messageId, nativeProvider, formId, {
save: saveCredentials, save: saveCredentials,
data: stripeCredentials, data: nativeProvider === 'stripe' ? selectStripeCredentials(global) : selectSmartGlocalCredentials(global),
}, requestInfoId, shippingOptionId); }, requestInfoId, shippingOptionId);
}); });
@ -212,9 +227,67 @@ async function sendStripeCredentials(
setGlobal(global); setGlobal(global);
} }
async function sendSmartGlocalCredentials(
data: {
cardNumber: string;
cardholder?: string;
expiryMonth: string;
expiryYear: string;
cvv: string;
},
publicToken: string,
) {
const params = {
card: {
number: data.cardNumber.replace(/[^\d]+/g, ''),
expiration_month: data.expiryMonth,
expiration_year: data.expiryYear,
security_code: data.cvv.replace(/[^\d]+/g, ''),
},
};
const url = DEBUG_PAYMENT_SMART_GLOCAL
? 'https://tgb-playground.smart-glocal.com/cds/v1/tokenize/card'
: 'https://tgb.smart-glocal.com/cds/v1/tokenize/card';
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-PUBLIC-TOKEN': publicToken,
},
body: JSON.stringify(params),
});
const result = await response.json();
if (result.status !== 'ok') {
// TODO после получения документации сделать аналог getStripeError(result.error);
const error = { description: 'payment error' };
const global = getGlobal();
setGlobal({
...global,
payment: {
...global.payment,
error: {
...error,
},
},
});
return;
}
let global = setSmartGlocalCardInfo(getGlobal(), {
type: 'card',
token: result.data.token,
});
global = setPaymentStep(global, PaymentStep.Checkout);
setGlobal(global);
}
async function sendPaymentForm( async function sendPaymentForm(
chat: ApiChat, chat: ApiChat,
messageId: number, messageId: number,
nativeProvider: string,
formId: string, formId: string,
credentials: any, credentials: any,
requestedInfoId?: string, requestedInfoId?: string,
@ -223,7 +296,8 @@ async function sendPaymentForm(
const result = await callApi('sendPaymentForm', { const result = await callApi('sendPaymentForm', {
chat, messageId, formId, credentials, requestedInfoId, shippingOptionId, chat, messageId, formId, credentials, requestedInfoId, shippingOptionId,
}); });
if (result) {
if (result === true) {
const global = clearPayment(getGlobal()); const global = clearPayment(getGlobal());
setGlobal(closeInvoice(global)); setGlobal(closeInvoice(global));
} }

View File

@ -1,11 +1,11 @@
import { import { addReducer, getGlobal, setGlobal } from '../../../lib/teact/teactn';
addReducer, getGlobal, setGlobal,
} from '../../../lib/teact/teactn';
import { ApiUpdate } from '../../../api/types'; import { ApiUpdate } from '../../../api/types';
import { ApiPrivacyKey } from '../../../types'; import { ApiPrivacyKey, PaymentStep } from '../../../types';
import { addBlockedContact, removeBlockedContact } from '../../reducers'; import {
addBlockedContact, removeBlockedContact, setConfirmPaymentUrl, setPaymentStep,
} from '../../reducers';
addReducer('apiUpdate', (global, actions, update: ApiUpdate) => { addReducer('apiUpdate', (global, actions, update: ApiUpdate) => {
switch (update['@type']) { switch (update['@type']) {
@ -32,6 +32,12 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => {
case 'updatePrivacy': case 'updatePrivacy':
global.settings.privacy[update.key as ApiPrivacyKey] = update.rules; global.settings.privacy[update.key as ApiPrivacyKey] = update.rules;
break; break;
case 'updatePaymentVerificationNeeded':
global = setConfirmPaymentUrl(getGlobal(), update.url);
global = setPaymentStep(global, PaymentStep.ConfirmPayment);
setGlobal(global);
break;
} }
return undefined; return undefined;

View File

@ -0,0 +1,15 @@
import { addReducer } from '../../../lib/teact/teactn';
import { ApiUpdate } from '../../../api/types';
import { clearPayment } from '../../reducers';
addReducer('apiUpdate', (global, actions, update: ApiUpdate) => {
switch (update['@type']) {
case 'updatePaymentStateCompleted': {
return clearPayment(global);
}
}
return undefined;
});

View File

@ -75,6 +75,21 @@ export function setStripeCardInfo(global: GlobalState, cardInfo: { type: string;
}; };
} }
export function setSmartGlocalCardInfo(
global: GlobalState,
cardInfo: { type: string; token: string },
): GlobalState {
return {
...global,
payment: {
...global.payment,
smartGlocalCredentials: {
...cardInfo,
},
},
};
}
export function setPaymentForm(global: GlobalState, form: ApiPaymentForm): GlobalState { export function setPaymentForm(global: GlobalState, form: ApiPaymentForm): GlobalState {
return { return {
...global, ...global,
@ -85,6 +100,16 @@ export function setPaymentForm(global: GlobalState, form: ApiPaymentForm): Globa
}; };
} }
export function setConfirmPaymentUrl(global: GlobalState, url?: string): GlobalState {
return {
...global,
payment: {
...global.payment,
confirmPaymentUrl: url,
},
};
}
export function setReceipt( export function setReceipt(
global: GlobalState, global: GlobalState,
receipt?: ApiReceipt, receipt?: ApiReceipt,

View File

@ -17,9 +17,17 @@ export function selectPaymentRequestId(global: GlobalState) {
} }
export function selectProviderPublishableKey(global: GlobalState) { export function selectProviderPublishableKey(global: GlobalState) {
return global.payment.nativeParams ? global.payment.nativeParams.publishableKey : undefined; return global.payment.nativeParams?.publishableKey;
}
export function selectProviderPublicToken(global: GlobalState) {
return global.payment.nativeParams?.publicToken;
} }
export function selectStripeCredentials(global: GlobalState) { export function selectStripeCredentials(global: GlobalState) {
return global.payment.stripeCredentials; return global.payment.stripeCredentials;
} }
export function selectSmartGlocalCredentials(global: GlobalState) {
return global.payment.smartGlocalCredentials;
}

View File

@ -318,6 +318,7 @@ export enum PaymentStep {
Shipping, Shipping,
PaymentInfo, PaymentInfo,
Checkout, Checkout,
ConfirmPayment,
} }
export const UPLOADING_WALLPAPER_SLUG = 'UPLOADING_WALLPAPER_SLUG'; export const UPLOADING_WALLPAPER_SLUG = 'UPLOADING_WALLPAPER_SLUG';

View File

@ -69,6 +69,7 @@ const READABLE_ERROR_MESSAGES: Record<string, string> = {
ADMIN_RANK_INVALID: 'The specified admin rank is invalid', ADMIN_RANK_INVALID: 'The specified admin rank is invalid',
FRESH_CHANGE_ADMINS_FORBIDDEN: 'You were just elected admin, you can\'t add or modify other admins yet', FRESH_CHANGE_ADMINS_FORBIDDEN: 'You were just elected admin, you can\'t add or modify other admins yet',
INPUT_USER_DEACTIVATED: 'The specified user was deleted', INPUT_USER_DEACTIVATED: 'The specified user was deleted',
BOT_PRECHECKOUT_TIMEOUT: 'The request for payment has expired',
}; };
export const SHIPPING_ERRORS: Record<string, ApiFieldError> = { export const SHIPPING_ERRORS: Record<string, ApiFieldError> = {