Merge pull request #5368 from mempool/nymkappa/google-pay

[accelerator] add support for Google Pay payment
This commit is contained in:
wiz
2024-07-26 10:43:50 -05:00
committed by GitHub
8 changed files with 209 additions and 169 deletions

View File

@@ -389,21 +389,29 @@
</div>
}
</div>
@if (canPayWithCashapp || canPayWithApplePay) {
@if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay) {
<div class="col-sm text-center flex-grow-0 d-flex flex-column justify-content-center align-items-center">
<p class="text-nowrap">&mdash;<span i18n="or">OR</span>&mdash;</p>
</div>
}
}
@if (canPayWithCashapp || canPayWithApplePay) {
@if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay) {
<div class="col-sm text-center d-flex flex-column justify-content-center align-items-center">
<p><ng-container i18n="transaction.pay|Pay button label">Pay</ng-container>&nbsp;<app-fiat [value]="cost"></app-fiat> with</p>
@if (canPayWithCashapp) {
<img class="paymentMethod mx-2" style="width: 200px" src="/resources/cash-app.svg" height=55 (click)="moveToStep('cashapp')">
}
@if (canPayWithApplePay) {
@if (canPayWithCashapp) { <hr class="w-25 mt-2 mb-2"> }
<img style="cursor: pointer;" src="/resources/apple-pay.svg" height=55 (click)="moveToStep('applepay')">
@if (canPayWithCashapp) { <span class="mt-1 mb-1"></span> }
<div class="paymentMethod mx-2" style="width: 200px; height: 55px">
<img src="/resources/apple-pay.png" height=37 (click)="moveToStep('applepay')">
</div>
}
@if (canPayWithGooglePay) {
@if (canPayWithCashapp || canPayWithApplePay) { <span class="mt-1 mb-1"></span> }
<div class="paymentMethod mx-2" style="width: 200px; height: 55px">
<img src="/resources/google-pay.png" height=37 (click)="moveToStep('googlepay')">
</div>
}
</div>
}
@@ -427,7 +435,7 @@
<button type="button" class="mt-1 btn btn-secondary btn-sm rounded-pill align-self-center" style="width: 200px" (click)="moveToStep('summary')" i18n="go-back">Go back</button>
</div>
</div>
} @else if (step === 'cashapp' || step === 'applepay') {
} @else if (step === 'cashapp' || step === 'applepay' || step === 'googlepay') {
<!-- Show checkout page -->
<div class="row mb-md-1 text-center" id="confirm-title">
<div class="col-sm" id="confirm-payment-title">
@@ -443,7 +451,7 @@
</div>
</div>
@if (step === 'cashapp' && !loadingCashapp || step === 'applepay' && !loadingApplePay) {
@if (step === 'cashapp' && !loadingCashapp || step === 'applepay' && !loadingApplePay || step === 'googlepay' && !loadingGooglePay) {
<div class="row text-center mt-1">
<div class="col-sm">
<div class="form-group w-100">
@@ -463,11 +471,13 @@
<div class="col-sm">
<div class="form-group w-100">
@if (step === 'applepay') {
<div id="apple-pay-button" class="apple-pay-button apple-pay-button-white" [style]="loadingApplePay ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
<div id="apple-pay-button" class="apple-pay-button apple-pay-button-black" style="height: 50px" [style]="loadingApplePay ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
} @else if (step === 'cashapp') {
<div id="cash-app-pay" class="d-inline-block" [style]="loadingCashapp ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
<div id="cash-app-pay" class="d-inline-block" style="height: 50px" [style]="loadingCashapp ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
} @else if (step === 'googlepay') {
<div id="google-pay-button" class="d-inline-block" style="height: 50px" [style]="loadingGooglePay ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
}
@if (loadingCashapp || loadingApplePay) {
@if (loadingCashapp || loadingApplePay || loadingGooglePay) {
<div display="d-flex flex-row justify-content-center">
<span i18n="accelerator.loading-payment-method">Loading payment method...</span>
<div class="ml-2 spinner-border text-light" style="width: 25px; height: 25px"></div>

View File

@@ -1,7 +1,8 @@
/* eslint-disable no-console */
import { Component, OnInit, OnDestroy, Output, EventEmitter, Input, ChangeDetectorRef, SimpleChanges, HostListener } from '@angular/core';
import { Subscription, tap, of, catchError, Observable, switchMap } from 'rxjs';
import { ServicesApiServices } from '../../services/services-api.service';
import { md5, nextRoundNumber, insecureRandomUUID } from '../../shared/common.utils';
import { md5, insecureRandomUUID } from '../../shared/common.utils';
import { StateService } from '../../services/state.service';
import { AudioService } from '../../services/audio.service';
import { ETA, EtaService } from '../../services/eta.service';
@@ -11,7 +12,7 @@ import { IAuth, AuthServiceMempool } from '../../services/auth.service';
import { EnterpriseService } from '../../services/enterprise.service';
import { ApiService } from '../../services/api.service';
export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp';
export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp' | 'applePay' | 'googlePay';
export type AccelerationEstimate = {
hasAccess: boolean;
@@ -24,7 +25,7 @@ export type AccelerationEstimate = {
mempoolBaseFee: number;
vsizeFee: number;
pools: number[];
availablePaymentMethods: {[method: string]: {min: number, max: number}};
availablePaymentMethods: Record<PaymentMethod, {min: number, max: number}>;
unavailable?: boolean;
options: { // recommended bid options
fee: number; // recommended userBid in sats
@@ -47,7 +48,7 @@ export const MIN_BID_RATIO = 1;
export const DEFAULT_BID_RATIO = 2;
export const MAX_BID_RATIO = 4;
type CheckoutStep = 'quote' | 'summary' | 'checkout' | 'cashapp' | 'applepay' | 'processing' | 'paid' | 'success';
type CheckoutStep = 'quote' | 'summary' | 'checkout' | 'cashapp' | 'applepay' | 'googlepay' | 'processing' | 'paid' | 'success';
@Component({
selector: 'app-accelerate-checkout',
@@ -62,6 +63,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
@Input() scrollEvent: boolean;
@Input() cashappEnabled: boolean = true;
@Input() applePayEnabled: boolean = false;
@Input() googlePayEnabled: boolean = true;
@Input() advancedEnabled: boolean = false;
@Input() forceMobile: boolean = false;
@Input() showDetails: boolean = false;
@@ -83,14 +85,12 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
private _step: CheckoutStep = 'summary';
simpleMode: boolean = true;
paymentMethod: 'cashapp' | 'btcpay';
timeoutTimer: any;
authSubscription$: Subscription;
auth: IAuth | null = null;
// accelerator stuff
square: { appId: string, locationId: string};
accelerationUUID: string;
accelerationSubscription: Subscription;
difficultySubscription: Subscription;
@@ -112,14 +112,14 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
// square
loadingCashapp = false;
loadingApplePay = false;
cashappError = false;
cashappSubmit: any;
loadingGooglePay = false;
payments: any;
cashAppPay: any;
applePay: any;
googlePay: any;
conversionsSubscription: Subscription;
conversions: any;
conversions: Record<string, number>;
// btcpay
loadingBtcpayInvoice = false;
invoice = undefined;
@@ -137,13 +137,13 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.accelerationUUID = insecureRandomUUID();
// Check if Apple Pay available
// @ts-ignore https://developer.apple.com/documentation/apple_pay_on_the_web/apple_pay_js_api/checking_for_apple_pay_availability#overview
if (window.ApplePaySession) {
// https://developer.apple.com/documentation/apple_pay_on_the_web/apple_pay_js_api/checking_for_apple_pay_availability#overview
if (window['ApplePaySession']) {
this.applePayEnabled = true;
}
}
ngOnInit() {
ngOnInit(): void {
this.authSubscription$ = this.authService.getAuth$().subscribe((auth) => {
if (this.auth?.user?.userId !== auth?.user?.userId) {
this.auth = auth;
@@ -168,13 +168,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.moveToStep('summary');
}
this.servicesApiService.setupSquare$().subscribe(ids => {
this.square = {
appId: ids.squareAppId,
locationId: ids.squareLocationId
};
});
this.conversionsSubscription = this.stateService.conversions$.subscribe(
async (conversions) => {
this.conversions = conversions;
@@ -182,7 +175,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
);
}
ngOnDestroy() {
ngOnDestroy(): void {
if (this.estimateSubscription) {
this.estimateSubscription.unsubscribe();
}
@@ -202,7 +195,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
}
}
moveToStep(step: CheckoutStep) {
moveToStep(step: CheckoutStep): void {
this._step = step;
if (this.timeoutTimer) {
clearTimeout(this.timeoutTimer);
@@ -228,13 +221,18 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.insertSquare();
this.setupSquare();
this.scrollToElementWithTimeout('confirm-title', 'center', 100);
} else if (this._step === 'googlepay' && this.googlePayEnabled) {
this.loadingGooglePay = true;
this.insertSquare();
this.setupSquare();
this.scrollToElementWithTimeout('confirm-title', 'center', 100);
} else if (this._step === 'paid') {
this.timePaid = Date.now();
this.timeoutTimer = setTimeout(() => {
if (this.step === 'paid') {
this.accelerateError = 'internal_server_error';
}
}, 120000)
}, 120000);
}
this.hasDetails.emit(this._step === 'quote');
}
@@ -252,7 +250,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.scrollToElement(id, position);
}, timeout);
}
scrollToElement(id: string, position: ScrollLogicalPosition) {
scrollToElement(id: string, position: ScrollLogicalPosition): void {
const acceleratePreviewAnchor = document.getElementById(id);
if (acceleratePreviewAnchor) {
this.cd.markForCheck();
@@ -267,7 +265,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
/**
* Accelerator
*/
fetchEstimate() {
fetchEstimate(): void {
if (this.estimateSubscription) {
this.estimateSubscription.unsubscribe();
}
@@ -331,7 +329,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
}
}),
catchError((response) => {
catchError(() => {
this.estimate = undefined;
this.quoteError = `cannot_accelerate_tx`;
this.estimateSubscription.unsubscribe();
@@ -402,8 +400,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
* Square
*/
insertSquare(): void {
//@ts-ignore
if (window.Square) {
if (window['Square']) {
return;
}
let statsUrl = 'https://sandbox.web.squarecdn.com/v1/square.js';
@@ -415,19 +412,17 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
statsUrl = 'https://web.squarecdn.com/v1/square.js';
}
(function() {
(function(): void {
const d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
// @ts-ignore
g.type='text/javascript'; g.src=statsUrl; s.parentNode.insertBefore(g, s);
})();
}
setupSquare() {
const init = () => {
setupSquare(): void {
const init = (): void => {
this.initSquare();
};
//@ts-ignore
if (!window.Square) {
if (!window['Square']) {
console.debug('Square.js failed to load properly. Retrying in 1 second.');
setTimeout(init, 1000);
} else {
@@ -436,29 +431,37 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
}
async initSquare(): Promise<void> {
try {
//@ts-ignore
this.payments = window.Square.payments(this.square.appId, this.square.locationId)
const urlParams = new URLSearchParams(window.location.search);
if (this._step === 'cashapp' || urlParams.get('cash_request_id')) {
await this.requestCashAppPayment();
} else if (this._step === 'applepay') {
await this.requestApplePayPayment();
}
this.servicesApiService.setupSquare$().subscribe({
next: async (ids) => {
this.payments = window['Square'].payments(ids.squareAppId, ids.squareLocationId);
const urlParams = new URLSearchParams(window.location.search);
if (this._step === 'cashapp' || urlParams.get('cash_request_id')) {
await this.requestCashAppPayment();
} else if (this._step === 'applepay') {
await this.requestApplePayPayment();
} else if (this._step === 'googlepay') {
await this.requestGooglePayPayment();
}
},
error: () => {
console.debug('Error loading Square Payments');
this.accelerateError = 'cannot_setup_square';
}
});
} catch (e) {
console.debug('Error loading Square Payments', e);
this.cashappError = true;
return;
this.accelerateError = 'cannot_setup_square';
}
}
/**
* APPLE PAY
*/
async requestApplePayPayment() {
async requestApplePayPayment(): Promise<void> {
if (this.conversionsSubscription) {
this.conversionsSubscription.unsubscribe();
}
this.conversionsSubscription = this.stateService.conversions$.subscribe(
async (conversions) => {
this.conversions = conversions;
@@ -505,6 +508,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.accelerationUUID
).subscribe({
next: () => {
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
if (this.applePay) {
this.applePay.destroy();
@@ -542,13 +546,100 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
}
/**
* CASHAPP
* GOOGLE PAY
*/
async requestCashAppPayment() {
async requestGooglePayPayment(): Promise<void> {
if (this.conversionsSubscription) {
this.conversionsSubscription.unsubscribe();
}
this.conversionsSubscription = this.stateService.conversions$.subscribe(
async (conversions) => {
this.conversions = conversions;
if (this.googlePay) {
this.googlePay.destroy();
}
const costUSD = this.cost / 100_000_000 * conversions.USD;
const paymentRequest = this.payments.paymentRequest({
countryCode: 'US',
currencyCode: 'USD',
total: {
amount: costUSD.toFixed(2),
label: 'Total'
}
});
this.googlePay = await this.payments.googlePay(paymentRequest , {
referenceId: `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
});
await this.googlePay.attach(`#google-pay-button`, {
buttonType: 'pay',
buttonSizeMode: 'fill',
});
this.loadingGooglePay = false;
document.getElementById('google-pay-button').addEventListener('click', async event => {
event.preventDefault();
const tokenResult = await this.googlePay.tokenize();
if (tokenResult?.status === 'OK') {
const card = tokenResult.details?.card;
if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
console.error(`Cannot retreive payment card details`);
this.accelerateError = 'apple_pay_no_card_details';
return;
}
const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
this.servicesApiService.accelerateWithGooglePay$(
this.tx.txid,
tokenResult.token,
cardTag,
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
this.accelerationUUID
).subscribe({
next: () => {
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
if (this.googlePay) {
this.googlePay.destroy();
}
setTimeout(() => {
this.moveToStep('paid');
}, 1000);
},
error: (response) => {
this.accelerateError = response.error;
if (!(response.status === 403 && response.error === 'not_available')) {
setTimeout(() => {
// Reset everything by reloading the page :D, can be improved
const urlParams = new URLSearchParams(window.location.search);
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
}, 3000);
}
}
});
} else {
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
if (tokenResult.errors) {
errorMessage += ` and errors: ${JSON.stringify(
tokenResult.errors,
)}`;
}
throw new Error(errorMessage);
}
});
}
);
}
/**
* CASHAPP
*/
async requestCashAppPayment(): Promise<void> {
if (this.conversionsSubscription) {
this.conversionsSubscription.unsubscribe();
}
this.conversionsSubscription = this.stateService.conversions$.subscribe(
async (conversions) => {
this.conversions = conversions;
@@ -566,18 +657,14 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
label: 'Total',
pending: true,
productUrl: `${redirectHostname}/tracker/${this.tx.txid}`,
},
button: { shape: 'semiround', size: 'small', theme: 'light'}
}
});
this.cashAppPay = await this.payments.cashAppPay(paymentRequest, {
redirectURL: `${redirectHostname}/tracker/${this.tx.txid}`,
referenceId: `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
button: { shape: 'semiround', size: 'small', theme: 'light'}
referenceId: `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`
});
if (this.step === 'cashapp') {
await this.cashAppPay.attach(`#cash-app-pay`, { theme: 'light', size: 'small', shape: 'semiround' })
}
await this.cashAppPay.attach(`#cash-app-pay`, { theme: 'dark' });
this.loadingCashapp = false;
this.cashAppPay.addEventListener('ontokenization', event => {
@@ -626,7 +713,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
/**
* BTCPay
*/
async requestBTCPayInvoice() {
async requestBTCPayInvoice(): Promise<void> {
this.servicesApiService.generateBTCPayAcceleratorInvoice$(this.tx.txid, this.userBid).pipe(
switchMap(response => {
return this.servicesApiService.retreiveInvoice$(response.btcpayInvoiceId);
@@ -656,53 +743,60 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
/**
* UI events
*/
selectedOptionChanged(event) {
selectedOptionChanged(event): void {
this.selectedOption = event.target.id;
}
get step() {
get step(): CheckoutStep {
return this._step;
}
get paymentMethods() {
return Object.keys(this.estimate?.availablePaymentMethods || {});
get paymentMethods(): PaymentMethod[] {
return Object.keys(this.estimate?.availablePaymentMethods || {}) as PaymentMethod[];
}
get couldPayWithBitcoin() {
get couldPayWithBitcoin(): boolean {
return !!this.estimate?.availablePaymentMethods?.bitcoin;
}
get couldPayWithCashapp() {
get couldPayWithCashapp(): boolean {
if (!this.cashappEnabled) {
return false;
}
return !!this.estimate?.availablePaymentMethods?.cashapp;
}
get couldPayWithApplePay() {
get couldPayWithApplePay(): boolean {
if (!this.applePayEnabled) {
return false;
}
return !!this.estimate?.availablePaymentMethods?.applePay;
}
get couldPayWithBalance() {
get couldPayWithGooglePay(): boolean {
if (!this.googlePayEnabled) {
return false;
}
return !!this.estimate?.availablePaymentMethods?.googlePay;
}
get couldPayWithBalance(): boolean {
if (!this.hasAccessToBalanceMode) {
return false;
}
return !!this.estimate?.availablePaymentMethods?.balance;
}
get couldPay() {
return this.couldPayWithBalance || this.couldPayWithBitcoin || this.couldPayWithCashapp || this.couldPayWithApplePay;
get couldPay(): boolean {
return this.couldPayWithBalance || this.couldPayWithBitcoin || this.couldPayWithCashapp || this.couldPayWithApplePay || this.couldPayWithGooglePay;
}
get canPayWithBitcoin() {
get canPayWithBitcoin(): boolean {
const paymentMethod = this.estimate?.availablePaymentMethods?.bitcoin;
return paymentMethod && this.cost >= paymentMethod.min && this.cost <= paymentMethod.max;
}
get canPayWithCashapp() {
get canPayWithCashapp(): boolean {
if (!this.cashappEnabled || !this.conversions) {
return false;
}
@@ -718,7 +812,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
return false;
}
get canPayWithApplePay() {
get canPayWithApplePay(): boolean {
if (!this.applePayEnabled || !this.conversions) {
return false;
}
@@ -734,7 +828,23 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
return false;
}
get canPayWithBalance() {
get canPayWithGooglePay(): boolean {
if (!this.googlePayEnabled || !this.conversions) {
return false;
}
const paymentMethod = this.estimate?.availablePaymentMethods?.googlePay;
if (paymentMethod) {
const costUSD = (this.cost / 100_000_000 * this.conversions.USD);
if (costUSD >= paymentMethod.min && costUSD <= paymentMethod.max) {
return true;
}
}
return false;
}
get canPayWithBalance(): boolean {
if (!this.hasAccessToBalanceMode) {
return false;
}
@@ -742,11 +852,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
return paymentMethod && this.cost >= paymentMethod.min && this.cost <= paymentMethod.max && this.cost <= this.estimate?.userBalance;
}
get canPay() {
return this.canPayWithBalance || this.canPayWithBitcoin || this.canPayWithCashapp || this.canPayWithApplePay;
get canPay(): boolean {
return this.canPayWithBalance || this.canPayWithBitcoin || this.canPayWithCashapp || this.canPayWithApplePay || this.canPayWithGooglePay;
}
get hasAccessToBalanceMode() {
get hasAccessToBalanceMode(): boolean {
return this.isLoggedIn() && this.estimate?.hasAccess;
}

View File

@@ -7,7 +7,7 @@ export interface WebsocketResponse {
backend?: 'esplora' | 'electrum' | 'none';
block?: BlockExtended;
blocks?: BlockExtended[];
conversions?: any;
conversions?: Record<string, number>;
txConfirmed?: string;
historicalDate?: string;
mempoolInfo?: MempoolInfo;

View File

@@ -144,6 +144,10 @@ export class ServicesApiServices {
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/applePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, accelerationUUID: accelerationUUID });
}
accelerateWithGooglePay$(txInput: string, token: string, cardTag: string, referenceId: string, accelerationUUID: string) {
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/googlePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, accelerationUUID: accelerationUUID });
}
getAccelerations$(): Observable<Acceleration[]> {
return this.httpClient.get<Acceleration[]>(`${this.stateService.env.SERVICES_API}/accelerator/accelerations`);
}

View File

@@ -138,7 +138,7 @@ export class StateService {
blocksSubject$ = new BehaviorSubject<BlockExtended[]>([]);
blocks$: Observable<BlockExtended[]>;
transactions$ = new BehaviorSubject<TransactionStripped[]>(null);
conversions$ = new ReplaySubject<any>(1);
conversions$ = new ReplaySubject<Record<string, number>>(1);
bsqPrice$ = new ReplaySubject<number>(1);
mempoolInfo$ = new ReplaySubject<MempoolInfo>(1);
mempoolBlocks$ = new ReplaySubject<MempoolBlock[]>(1);