company_banner

Декларативный шопинг в интернете с помощью Payment Request API и Angular

    Как давно вы платили на веб-сайте в один клик с помощью Google Pay, Apple Pay или заранее заданной в браузере картой?

    У меня такое получается редко.

    Даже наоборот: каждый новый интернет-магазин предлагает мне очередную формочку. А я должен каждый раз покорно искать свою карту, чтобы перепечатать данные с нее на сайт. На следующий день я захочу оплатить что-нибудь в другом магазине и повторю этот процесс.

    Это не очень удобно. Особенно когда знаешь об альтернативе: в последние пару лет стандарт Payment Request API позволяет легко решать эту проблему в современных браузерах.

    Давайте разберемся, почему его не используют, и попробуем упростить работу с ним.



    О чем речь?


    Почти во всех современных браузерах реализован стандарт Payment Request API. Он позволяет вызвать модальное окно в браузере, через которое пользователь сможет провести платеж в считанные секунды. Вот так это может выглядеть в Chrome с обычной карточкой из браузера:



    А вот так — в Safari при оплате отпечатком пальца через Apple Pay:



    Это не только быстро, но и функционально: окно позволяет выводить информацию по всему заказу и по отдельным товарам и услугам внутри него, позволяет уточнить информацию о клиенте и детали по доставке. Все это кастомизируется при создании запроса, хотя и удобство у предоставляемого API довольно спорное.

    Как использовать в Angular?


    Angular не предоставляет абстракций для использования Payment Request API. Самый безопасный путь использования из коробки в Angular: достать Document из механизма Dependency Injection, получить из него объект Window и работать с window.PaymentRequest.

    import {DOCUMENT} from '@angular/common';
    import {Inject, Injectable} from '@angular/core';
     
    @Injectable()
    export class PaymentService {
       constructor(
           @Inject(DOCUMENT)
           private readonly documentRef: Document,
       ) {}
     
       pay(
           methodData: PaymentMethodData[],
           details: PaymentDetailsInit,
           options: PaymentOptions = {},
       ): Promise<PaymentResponse> {
           if (
               this.documentRef.defaultView === null ||
               !('PaymentRequest' in this.documentRef.defaultView)
           ) {
               return Promise.reject(new Error('PaymentRequest is not supported'));
           }
     
           const gateway = new PaymentRequest(methodData, details, options);
     
           return gateway
               .canMakePayment()
               .then(canPay =>
                   canPay
                       ? gateway.show()
                       : Promise.reject(
                             new Error('Payment Request cannot make the payment'),
                         ),
               );
       }
    }
    

    Если использовать Payment Request напрямую, то появляются все проблемы неявных зависимостей: тестировать код становится тяжелее, в SSR приложение взрывается, потому что Payment Request не существует. Надеемся на глобальный объект без каких-либо абстракций.

    Мы можем взять токен WINDOW из @ng-web-apis/common, чтобы безопасно получить глобальный объект из DI. Теперь добавим новый PAYMENT_REQUEST_SUPPORT. Он позволит проверять поддержку Payment Request API перед его использованием, и теперь у нас никогда не произойдет случайного вызова API в среде, которая его не поддерживает.

    export const PAYMENT_REQUEST_SUPPORT = new InjectionToken<boolean>(
       'Is Payment Request Api supported?',
       {
           factory: () => !!inject(WINDOW).PaymentRequest,
       },
    );
    

    export class PaymentRequestService {
       constructor(
           @Inject(PAYMENT_REQUEST_SUPPORT) private readonly supported: boolean,
           ...
        ) {}
     
        request(...): Promise<PaymentResponse> {
           if (!this.supported) {
               return Promise.reject(
                   new Error('Payment Request is not supported in your browser'),
               );
           } 
          ...
       }
    

    Давайте писать в стиле Angular


    С описанным выше подходом мы можем достаточно безопасно работать с платежами, но удобство работы все еще остается на том же уровне «голого» API-браузера: мы вызываем метод с тремя параметрами, собираем множество данных воедино и приводим их к нужному формату, чтобы наконец вызвать метод платежа.

    Но в мире Ангуляра мы привыкли к удобным абстракциям: механизму внедрения зависимостей, сервисам, директивам и стримам. Давайте посмотрим на декларативное решение, которое позволяет сделать использование Payment Request API быстрее и проще:



    В этом примере корзина представляет собой вот такой код:

    <div waPayment [paymentTotal]="total">
       <div
           *ngFor="let cartItem of shippingCart"
           waPaymentItem
           [paymentLabel]="cartItem.label"
           [paymentAmount]="cartItem.amount"
       >
           {{ cartItem.label }} ({{ cartItem.amount.value }} {{ cartItem.amount.currency }})
       </div>
     
       <b>Total:</b>  {{ totalSum }} ₽
     
       <button
           [disabled]="shippingCart.length === 0"
           (waPaymentSubmit)="onPayment($event)"
           (waPaymentError)="onPaymentError($event)"
       >
           Buy
       </button>
    </div>
    

    Все работает благодаря трем директивам:

    • waPayment директива, которая определяет область отдельного платежа в шаблоне и принимает в себя объект PaymentItem с информацией о названии платежа и его итоговой суммой
    • Каждый товар в корзине — директива waPaymentItem. Инпуты этой директивы позволяют собрать объект PaymentItem каждого отдельного товара декларативно.
    • Нажатие на кнопку запускает модальное окно Payment Request API в браузере. Ответом модального окна может быть PaymentResponse или ошибка. Директива waPaymentSubmit позволяет отлавливать оба этих исхода обычными ангуляровскими аутпутами.

    Так мы получаем простой и удобный интерфейс для открытия платежа и обработки его результата. Причем работает он по всем канонам Angular Way.

    Сами директивы связаны довольно простым образом:

    • Директива платежа собирает все товары внутри себя с помощью ContentChildren и имплементирует PaymentDetailsInit — один из обязательных аргументов при работе с Payment Request API.

    @Directive({
       selector: '[waPayment][paymentTotal]',
    })
    export class PaymentDirective implements PaymentDetailsInit {
       ...
       @ContentChildren(PaymentItemDirective)
       set paymentItems(items: QueryList<PaymentItem>) {
           this.displayItems = items.toArray();
       }
     
       displayItems?: PaymentItem[];
    }
    

    • Директива-аутпут, которая отслеживает клики по кнопке и эмитит итоговый результат платежа, вытаскивает директиву платежа из дерева Dependency Injection, а также методы платежей и дополнительные опции, которые задаются DI-токенами.

    @Directive({
       selector: '[waPaymentSubmit]',
    })
    export class PaymentSubmitDirective {
       @Output()
       waPaymentSubmit: Observable<PaymentResponse>;
     
       @Output()
       waPaymentError: Observable<Error | DOMException>;
     
       constructor(
           @Inject(PaymentDirective) paymentHost: PaymentDetailsInit,
           @Inject(PaymentRequestService) paymentRequest: PaymentRequestService,
           @Inject(ElementRef) {nativeElement}: ElementRef,
           @Inject(PAYMENT_METHODS) methods: PaymentMethodData[],
           @Inject(PAYMENT_OPTIONS) options: PaymentOptions,
       ) {
           const requests$ = fromEvent(nativeElement, 'click').pipe(
               switchMap(() =>
                   from(paymentRequest.request({...paymentHost}, methods, options)).pipe(
                       catchError(error => of(error)),
                   ),
               ),
               share(),
           );
     
           this.waPaymentSubmit = requests$.pipe(filter(response => !isError(response)));
           this.waPaymentError = requests$.pipe(filter(isError));
       }
    }
    

    Готовое решение


    Все описанные идеи мы собрали и реализовали в библиотеке @ng-web-apis/payment-request:


    Это готовое решение, которое позволяет работать с Payment Request API безопасно и быстро как через сервис, так и через директивы в описанном выше формате.

    Эту библиотеку мы опубликовали и поддерживаем от @ng-web-apis — опенсорсной группы, специализирующейся на реализации легких Angular-оберток для нативных Web API, преимущественно в декларативном стиле. На нашем сайте есть и другие реализации API, которые не поставляются в Angular из коробки, но могут заинтересовать вас.
    Tinkoff
    it’s Tinkoff — просто о сложном

    Похожие публикации

    Комментарии 4

      +1
      У вас получилось с этим окном реально перевести деньги? Я вижу только фронт. Куда отправлять полученные данные не ясно. Платежные API эквайрингов с которыми доводилось работать их не примут
        +1

        У Payment Request API есть серьёзный недостаток в виде невозможности использования его через iframe, что ведёт к необходимости держать форму на сайте мерчанта (вместо того чтобы встроить виджет от процессинга).
        Получаем привет в виде необходимости магазину проходить аудит PCI DSS.

          0
          Я, ещё будучи клиентом, ненавидел фреймы. Сайт сразу становится каким-то куском асинхронных модулей, как стилистически, так и приоритетам загрузки. Данный метод, хоть и сложен, но он более клиентоориентирован.
            0

            Фреймы надо уметь готовить. Есть методика, позволяющая фреймом наложить на форму ввода прозрачные текстовые поля с уведомлением сайта мерчанта об их фокусе и валидности. Таким образом мерчант получает возможность стилизовать всю форму самостоятельно при этом не нуждаясь в аудите PCI DSS.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое