diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 0d2280aaa..d52f8ca67 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -29,6 +29,7 @@ import { ShortenStringPipe } from '@app/shared/pipes/shorten-string-pipe/shorten import { CapAddressPipe } from '@app/shared/pipes/cap-address-pipe/cap-address-pipe'; import { AppPreloadingStrategy } from '@app/app.preloading-strategy'; import { ServicesApiServices } from '@app/services/services-api.service'; +import { SquarePaymentService } from '@app/services/square-payment.service'; import { DatePipe } from '@angular/common'; const providers = [ @@ -53,6 +54,7 @@ const providers = [ AppPreloadingStrategy, ServicesApiServices, PreloadService, + SquarePaymentService, { provide: HTTP_INTERCEPTORS, useClass: HttpCacheInterceptor, multi: true }, { provide: ZONE_SERVICE, useClass: ZoneService }, ]; diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html index 339a0a196..3484e7eb2 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html @@ -488,17 +488,15 @@ } + } -
+
- @if (step === 'applepay') { -
- } @else if (step === 'cashapp') { -
- } @else if (step === 'googlepay') { -
- } @else if (step === 'cardonfile') { +
+
+
+ @if (step === 'cardonfile') {
@if (['VISA', 'MASTERCARD', 'JCB', 'DISCOVER', 'DISCOVER_DINERS', 'AMERICAN_EXPRESS'].includes(estimate?.availablePaymentMethods?.cardOnFile?.card?.brand)) { @@ -522,7 +520,7 @@ }
- + @if (step === 'cashapp' || step === 'applepay' || step === 'googlepay' || step === 'cardonfile') {
@@ -541,7 +539,6 @@
-
We are processing your payment...
diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss index 27a7e0e90..c7bc0d78c 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss @@ -237,4 +237,30 @@ flex-direction: row; gap: 5px; } +} + +.square-buttons-container { + visibility: hidden; + position: absolute; + pointer-events: none; + + &.visible { + visibility: visible; + position: static; + pointer-events: auto; + } + + #apple-pay-button, + #cash-app-pay, + #google-pay-button { + visibility: hidden; + position: absolute; + pointer-events: none; + + &.visible { + visibility: visible; + position: static; + pointer-events: auto; + } + } } \ No newline at end of file diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts index 7231b25a9..e05e8f3cc 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -13,6 +13,7 @@ import { EnterpriseService } from '@app/services/enterprise.service'; import { ApiService } from '@app/services/api.service'; import { isDevMode } from '@angular/core'; import { StorageService } from '@app/services/storage.service'; +import { SquarePaymentService, SquareInitConfig } from '@app/services/square-payment.service'; export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp' | 'applePay' | 'googlePay' | 'cardOnFile'; @@ -93,7 +94,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy { accelerationResponse: { receiptUrl: string | null } | undefined; private _step: CheckoutStep = 'summary'; - simpleMode: boolean = true; timeoutTimer: any; authSubscription$: Subscription; @@ -122,10 +122,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy { loadingApplePay = false; loadingGooglePay = false; loadingCardOnFile = false; - payments: any; - cashAppPay: any; - applePay: any; - googlePay: any; conversionsSubscription: Subscription; conversions: Record; @@ -142,7 +138,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy { private cd: ChangeDetectorRef, private authService: AuthServiceMempool, private enterpriseService: EnterpriseService, - private storageService: StorageService + private storageService: StorageService, + private squarePaymentService: SquarePaymentService, ) { this.isProdDomain = this.stateService.isProdDomain; @@ -172,12 +169,12 @@ export class AccelerateCheckout implements OnInit, OnDestroy { const urlParams = new URLSearchParams(window.location.search); if (urlParams.get('cash_request_id')) { // Redirected from cashapp this.moveToStep('processing', true); - this.insertSquare(); - this.setupSquare(); } else { this.moveToStep('summary', true); } + this.registerSquareCallbacks(); + this.conversionsSubscription = this.stateService.conversions$.subscribe( async (conversions) => { this.conversions = conversions; @@ -185,6 +182,24 @@ export class AccelerateCheckout implements OnInit, OnDestroy { ); } + registerSquareCallbacks(): void { + this.squarePaymentService.registerCallback('error', (error: any) => { + console.error('Square Payment Error', error); + this.accelerateError = 'cannot_setup_square'; + this.cd.markForCheck(); + }); + this.squarePaymentService.registerCallback('ready', () => { + this.loadingCashapp = false; + this.loadingApplePay = false; + this.loadingGooglePay = false; + this.loadingCardOnFile = false; + this.cd.markForCheck(); + }); + this.squarePaymentService.registerCallback('cashAppTokenized', this.onCashAppTokenized.bind(this)); + this.squarePaymentService.registerCallback('googlePayClicked', this.onGooglePayClicked.bind(this)); + this.squarePaymentService.registerCallback('applePayClicked', this.onApplePayClicked.bind(this)); + } + ngOnDestroy(): void { if (this.estimateSubscription) { this.estimateSubscription.unsubscribe(); @@ -192,6 +207,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { if (this.authSubscription$) { this.authSubscription$.unsubscribe(); } + this.squarePaymentService.cleanup(); } ngOnChanges(changes: SimpleChanges): void { @@ -213,8 +229,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.fetchEstimate(); } if (this._step === 'checkout') { - this.insertSquare(); this.enterpriseService.goal(8); + this.setupSquare(); this.scrollToElementWithTimeout('acceleratePreviewAnchor', 'start', 100); } if (this._step === 'checkout' && this.canPayWithBitcoin) { @@ -238,6 +254,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.loadingCardOnFile = true; this.setupSquare(); this.scrollToElementWithTimeout('confirm-title', 'center', 100); + } else if (this._step === 'processing') { + this.setupSquare(); } else if (this._step === 'paid') { this.timePaid = Date.now(); this.timeoutTimer = setTimeout(() => { @@ -249,6 +267,28 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.hasDetails.emit(this._step === 'quote'); } + setupSquare(): void { + this.squarePaymentService.init(); + this.updateSquarePayments(); + } + + updateSquarePayments(): void { + if (this.canPayWithGooglePay || this.canPayWithApplePay || this.canPayWithCashapp) { + this.squarePaymentService.update( + { + availableMethods: { + googlePay: this.canPayWithGooglePay, + applePay: this.canPayWithApplePay, + cashApp: this.canPayWithCashapp, + }, + txid: this.tx.txid, + costUSD: this.cost / 100_000_000 * this.conversions.USD, + }, + true, + ); + } + } + closeModal(): void { this.completed.emit(true); this.moveToStep('summary', true); @@ -410,303 +450,177 @@ export class AccelerateCheckout implements OnInit, OnDestroy { }); } - /** - * Square - */ - insertSquare(): void { - if (!this.isProdDomain && !isDevMode()) { - return; - } - if (window['Square']) { - return; - } - let statsUrl = 'https://sandbox.web.squarecdn.com/v1/square.js'; - if (this.isProdDomain) { - statsUrl = '/square/v1/square.js'; - } - - (function(): void { - const d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; - g.type='text/javascript'; g.src=statsUrl; s.parentNode.insertBefore(g, s); - })(); - } - setupSquare(): void { - if (!this.isProdDomain && !isDevMode()) { - return; - } - const init = (): void => { - this.initSquare(); - }; - - if (!window['Square']) { - console.debug('Square.js failed to load properly. Retrying.'); - setTimeout(this.setupSquare.bind(this), 100); - } else { - init(); - } - } - async initSquare(): Promise { - try { - 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(); - } else if (this._step === 'cardonfile') { - this.loadingCardOnFile = false; - } - }, - error: () => { - console.debug('Error loading Square Payments'); - this.accelerateError = 'cannot_setup_square'; - } - }); - } catch (e) { - console.debug('Error loading Square Payments', e); - this.accelerateError = 'cannot_setup_square'; - } - } - /** * APPLE PAY */ - async requestApplePayPayment(): Promise { + async onApplePayClicked(event: Event, applePay: any, config: SquareInitConfig): Promise { if (this.processing) { return; } - this.processing = true; - - if (this.applePay) { - this.applePay.destroy(); + if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) { + return; + } + event.preventDefault(); + try { + // lock the checkout UI and show a loading spinner until the square modals are finished + this.isCheckoutLocked++; + this.isTokenizing++; + const tokenResult = await applePay.tokenize(); + if (tokenResult?.status === 'OK') { + const card = tokenResult.details?.card; + if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { + console.error(`Cannot retrieve payment card details`); + this.accelerateError = 'apple_pay_no_card_details'; + this.processing = false; + return; } - - const costUSD = this.cost / 100_000_000 * this.conversions.USD; - const paymentRequest = this.payments.paymentRequest({ - countryCode: 'US', - currencyCode: 'USD', - total: { - amount: costUSD.toFixed(2), - label: 'Total', - }, - }); - - try { - this.applePay = await this.payments.applePay(paymentRequest); - const applePayButton = document.getElementById('apple-pay-button'); - if (!applePayButton) { - console.error(`Unable to find apple pay button id='apple-pay-button'`); - // Try again - setTimeout(this.requestApplePayPayment.bind(this), 500); + const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); + // keep checkout in loading state until the acceleration request completes + this.isTokenizing++; + this.isCheckoutLocked++; + this.servicesApiService.accelerateWithApplePay$( + this.tx.txid, + tokenResult.token, + cardTag, + `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, + config.costUSD, + this.referralCode, + ).subscribe({ + next: (response) => { + this.storageService.removeItem('referralCode'); + this.accelerationResponse = response; this.processing = false; - return; - } - this.loadingApplePay = false; - applePayButton.addEventListener('click', async event => { - if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) { - return; - } - event.preventDefault(); - try { - // lock the checkout UI and show a loading spinner until the square modals are finished - this.isCheckoutLocked++; - this.isTokenizing++; - const tokenResult = await this.applePay.tokenize(); - if (tokenResult?.status === 'OK') { - const card = tokenResult.details?.card; - if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { - console.error(`Cannot retrieve payment card details`); - this.accelerateError = 'apple_pay_no_card_details'; - this.processing = false; - return; - } - const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); - // keep checkout in loading state until the acceleration request completes - this.isTokenizing++; - this.isCheckoutLocked++; - this.servicesApiService.accelerateWithApplePay$( - this.tx.txid, - tokenResult.token, - cardTag, - `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, - costUSD, - this.referralCode - ).subscribe({ - next: (response) => { - this.storageService.removeItem('referralCode'); // Consume localStorage referralCode - this.accelerationResponse = response; - this.processing = false; - this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); - this.audioService.playSound('ascend-chime-cartoon'); - if (this.applePay) { - this.applePay.destroy(); - } - this.paymentReceipt.emit(this.accelerationResponse?.receiptUrl); - setTimeout(() => { - this.isTokenizing--; - this.isCheckoutLocked--; - this.moveToStep('paid', true); - }, 1000); - }, - error: (response) => { - this.processing = false; - this.accelerateError = response.error; - if (!(response.status === 403 && response.error === 'not_available')) { - setTimeout(() => { - this.isTokenizing--; - this.isCheckoutLocked--; - // 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')}`, ``)); - }, 10000); - } - } - }); - } else { - this.processing = false; - let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; - if (tokenResult.errors) { - errorMessage += ` and errors: ${JSON.stringify( - tokenResult.errors, - )}`; - } - throw new Error(errorMessage); - } - } finally { - // always unlock the checkout once we're finished + this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); + this.audioService.playSound('ascend-chime-cartoon'); + this.squarePaymentService.cleanup(); + this.paymentReceipt.emit(this.accelerationResponse?.receiptUrl); + setTimeout(() => { this.isTokenizing--; this.isCheckoutLocked--; + this.moveToStep('paid', true); + }, 1000); + }, + error: (response) => { + this.processing = false; + this.accelerateError = response.error; + if (!(response.status === 403 && response.error === 'not_available')) { + setTimeout(() => { + this.isTokenizing--; + this.isCheckoutLocked--; + // 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')}`, ``)); + }, 10000); } - }); - } catch (e) { - this.processing = false; - console.error(e); + } + }); + } else { + this.processing = false; + let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; + if (tokenResult.errors) { + errorMessage += ` and errors: ${JSON.stringify( + tokenResult.errors, + )}`; } + throw new Error(errorMessage); + } + } finally { + // always unlock the checkout once we're finished + this.isTokenizing--; + this.isCheckoutLocked--; + } } /** * GOOGLE PAY */ - async requestGooglePayPayment(): Promise { + async onGooglePayClicked(event: Event, googlePay: any, config: SquareInitConfig): Promise { if (this.processing) { return; } - this.processing = true; - - if (this.googlePay) { - this.googlePay.destroy(); + if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) { + return; + } + event.preventDefault(); + try { + // lock the checkout UI and show a loading spinner until the square modals are finished + this.isCheckoutLocked++; + this.isTokenizing++; + const tokenResult = await googlePay.tokenize(); + if (tokenResult?.status === 'OK') { + const card = tokenResult.details?.card; + if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { + console.error(`Cannot retrieve payment card details`); + this.accelerateError = 'apple_pay_no_card_details'; + this.processing = false; + return; } - - const costUSD = this.cost / 100_000_000 * this.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 => { - if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) { - return; - } - event.preventDefault(); - try { - // lock the checkout UI and show a loading spinner until the square modals are finished - this.isCheckoutLocked++; - this.isTokenizing++; - 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 retrieve payment card details`); - this.accelerateError = 'apple_pay_no_card_details'; - this.processing = false; - return; - } - const verificationToken = await this.$verifyBuyer(this.payments, tokenResult.token, tokenResult.details, costUSD.toFixed(2)); - if (!verificationToken || !verificationToken.token) { - console.error(`SCA verification failed`); - this.accelerateError = 'SCA Verification Failed. Payment Declined.'; - this.processing = false; - return; - } - const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); - // keep checkout in loading state until the acceleration request completes - this.isCheckoutLocked++; - this.isTokenizing++; - this.servicesApiService.accelerateWithGooglePay$( - this.tx.txid, - tokenResult.token, - verificationToken.token, - cardTag, - `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, - costUSD, - verificationToken.userChallenged, - this.referralCode - ).subscribe({ - next: (response) => { - this.storageService.removeItem('referralCode'); // Consume localStorage referralCode - this.accelerationResponse = response; - this.processing = false; - this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); - this.audioService.playSound('ascend-chime-cartoon'); - if (this.googlePay) { - this.googlePay.destroy(); - } - this.paymentReceipt.emit(this.accelerationResponse?.receiptUrl); - setTimeout(() => { - this.isTokenizing--; - this.isCheckoutLocked--; - this.moveToStep('paid', true); - }, 1000); - }, - error: (response) => { - this.processing = false; - this.accelerateError = response.error; - this.isTokenizing--; - this.isCheckoutLocked--; - 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')}`, ``)); - }, 10000); - } - } - }); - } else { - this.processing = false; - let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; - if (tokenResult.errors) { - errorMessage += ` and errors: ${JSON.stringify( - tokenResult.errors, - )}`; - } - throw new Error(errorMessage); - } - } finally { - // always unlock the checkout once we're finished + const verificationToken = await this.squarePaymentService.verifyBuyer(tokenResult.token, tokenResult.details, config.costUSD.toFixed(2)); + if (!verificationToken || !verificationToken.token) { + console.error(`SCA verification failed`); + this.accelerateError = 'SCA Verification Failed. Payment Declined.'; + this.processing = false; + return; + } + const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); + // keep checkout in loading state until the acceleration request completes + this.isCheckoutLocked++; + this.isTokenizing++; + this.servicesApiService.accelerateWithGooglePay$( + this.tx.txid, + tokenResult.token, + verificationToken.token, + cardTag, + `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, + config.costUSD, + verificationToken.userChallenged, + this.referralCode, + ).subscribe({ + next: (response) => { + this.storageService.removeItem('referralCode'); + this.accelerationResponse = response; + this.processing = false; + this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); + this.audioService.playSound('ascend-chime-cartoon'); + this.squarePaymentService.cleanup(); + this.paymentReceipt.emit(this.accelerationResponse?.receiptUrl); + setTimeout(() => { + this.isTokenizing--; + this.isCheckoutLocked--; + this.moveToStep('paid', true); + }, 1000); + }, + error: (response) => { + this.processing = false; + this.accelerateError = response.error; this.isTokenizing--; this.isCheckoutLocked--; + 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')}`, ``)); + }, 10000); + } } }); + } else { + this.processing = false; + let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; + if (tokenResult.errors) { + errorMessage += ` and errors: ${JSON.stringify( + tokenResult.errors, + )}`; + } + throw new Error(errorMessage); + } + } finally { + // always unlock the checkout once we're finished + this.isTokenizing--; + this.isCheckoutLocked--; + } } /** @@ -749,7 +663,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } } }; - const verificationToken = await this.$verifyBuyer(this.payments, cardOnFile.card.card_id, verificationDetails, costUSD.toFixed(2)); + const verificationToken = await this.squarePaymentService.verifyBuyer(cardOnFile.card.card_id, verificationDetails, costUSD.toFixed(2)); if (!verificationToken || !verificationToken.token) { console.error(`SCA verification failed`); this.accelerateError = 'SCA Verification Failed. Payment Declined.'; @@ -811,109 +725,53 @@ export class AccelerateCheckout implements OnInit, OnDestroy { /** * CASHAPP */ - async requestCashAppPayment(): Promise { + async onCashAppTokenized(event: any, config: SquareInitConfig): Promise { if (this.processing) { return; } - this.processing = true; - - if (this.cashAppPay) { - this.cashAppPay.destroy(); + const { tokenResult, error } = event.detail; + if (error) { + this.processing = false; + this.accelerateError = error; + } else if (tokenResult.status === 'OK') { + this.servicesApiService.accelerateWithCashApp$( + this.tx.txid, + tokenResult.token, + tokenResult.details.cashAppPay.cashtag, + tokenResult.details.cashAppPay.referenceId, + config.costUSD, + this.referralCode, + ).subscribe({ + next: (response) => { + this.storageService.removeItem('referralCode'); + this.accelerationResponse = response; + this.processing = false; + this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); + this.audioService.playSound('ascend-chime-cartoon'); + this.squarePaymentService.cleanup(); + this.paymentReceipt.emit(this.accelerationResponse?.receiptUrl); + setTimeout(() => { + this.moveToStep('paid', true); + if (window.history.replaceState) { + const urlParams = new URLSearchParams(window.location.search); + window.history.replaceState(null, null, window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, '')); + } + }, 1000); + }, + error: (response) => { + this.processing = false; + 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')}`, ``)); + }, 10000); + } } - - const redirectHostname = document.location.hostname === 'localhost' ? `http://localhost:4200`: `https://${document.location.hostname}`; - const costUSD = this.cost / 100_000_000 * this.conversions.USD; - const paymentRequest = this.payments.paymentRequest({ - countryCode: 'US', - currencyCode: 'USD', - total: { - amount: costUSD.toFixed(2), - label: 'Total', - pending: true, - productUrl: `${redirectHostname}/tx/${this.tx.txid}`, - } - }); - this.cashAppPay = await this.payments.cashAppPay(paymentRequest, { - redirectURL: `${redirectHostname}/tx/${this.tx.txid}`, - referenceId: `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}` - }); - - await this.cashAppPay.attach(`#cash-app-pay`, { theme: 'dark' }); - this.loadingCashapp = false; - - this.cashAppPay.addEventListener('ontokenization', event => { - const { tokenResult, error } = event.detail; - if (error) { - this.processing = false; - this.accelerateError = error; - } else if (tokenResult.status === 'OK') { - this.servicesApiService.accelerateWithCashApp$( - this.tx.txid, - tokenResult.token, - tokenResult.details.cashAppPay.cashtag, - tokenResult.details.cashAppPay.referenceId, - costUSD, - this.referralCode - ).subscribe({ - next: (response) => { - this.storageService.removeItem('referralCode'); // Consume localStorage referralCode - this.accelerationResponse = response; - this.processing = false; - this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); - this.audioService.playSound('ascend-chime-cartoon'); - if (this.cashAppPay) { - this.cashAppPay.destroy(); - } - this.paymentReceipt.emit(this.accelerationResponse?.receiptUrl); - setTimeout(() => { - this.moveToStep('paid', true); - if (window.history.replaceState) { - const urlParams = new URLSearchParams(window.location.search); - window.history.replaceState(null, null, window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, '')); - } - }, 1000); - }, - error: (response) => { - this.processing = false; - 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')}`, ``)); - }, 10000); - } - } - }); - } - }); - } - - /** - * https://developer.squareup.com/docs/sca-overview - */ - async $verifyBuyer(payments, token, details, amount): Promise<{token: string, userChallenged: boolean}> { - const verificationDetails = { - amount: amount, - currencyCode: 'USD', - intent: 'CHARGE', - billingContact: { - givenName: details.card?.billing?.givenName, - familyName: details.card?.billing?.familyName, - phone: details.card?.billing?.phone, - addressLines: details.card?.billing?.addressLines, - city: details.card?.billing?.city, - state: details.card?.billing?.state, - countryCode: details.card?.billing?.countryCode, - }, - }; - - const verificationResults = await payments.verifyBuyer( - token, - verificationDetails, - ); - return verificationResults; + }); + } } /** diff --git a/frontend/src/app/services/square-payment.service.ts b/frontend/src/app/services/square-payment.service.ts new file mode 100644 index 000000000..773740c02 --- /dev/null +++ b/frontend/src/app/services/square-payment.service.ts @@ -0,0 +1,679 @@ +import { Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { ServicesApiServices } from '@app/services/services-api.service'; +import { log } from '@app/shared/logger.utils'; + +type SquarePaymentMethod = 'googlePay' | 'applePay' | 'cashApp' | 'cardOnFile'; +type SquareCallbackEvent = 'ready' | 'loading' | 'updating' | 'error' | 'cashAppTokenized' | 'googlePayClicked' | 'applePayClicked'; +type SquareLifecycleStep = + | 'start' + // setup + | 'loading_sdk' + | 'setting_up_payments' + | 'payments_ready' + | 'waiting_to_update' + | 'updating_methods' + // ready + | 'ready' + // cleanup + | 'waiting_to_cleanup' + | 'cleaning_up' +type SquareLifecycleStage = 'start' | 'setup' | 'update' | 'updatenow' | 'ready' | 'cleanup'; + +export interface SquareInitConfig { + availableMethods: { + googlePay: boolean; + applePay: boolean; + cashApp: boolean; + }; + txid: string; + costUSD: number; +} + +@Injectable({ providedIn: 'root' }) +export class SquarePaymentService { + private currentStage: SquareLifecycleStage = 'start'; + private currentStep: SquareLifecycleStep = 'start'; + private nextStage: 'setup' | 'cleanup' | 'update' | 'updatenow' | null = null; + + private fresh = true; + private updateTimer: ReturnType | null = null; + private updateResolver: ((value: void | PromiseLike) => void) | null = null; + private cleanupTimer: ReturnType | null = null; + private cleanupResolver: ((value: void | PromiseLike) => void) | null = null; + + private payments: any = null; + private googlePay: any = null; + private applePay: any = null; + private cashAppPay: any = null; + private googlePayRequest: any = null; + private applePayRequest: any = null; + private cashAppPayRequest: any = null; + + private config: SquareInitConfig | null = null; + private newConfig: SquareInitConfig | null = null; + + private callbacks: Partial> = {}; + private attachedMethods: { googlePay: boolean, applePay: boolean, cashApp: boolean } = { googlePay: false, applePay: false, cashApp: false }; + + constructor(private servicesApiService: ServicesApiServices) {} + + async init(): Promise { + await this.requestStage('setup'); + } + + async update(config: SquareInitConfig, now = false): Promise { + log('[SquarePayment] Updating with config:', config); + await this.requestStage(now ? 'updatenow' : 'update', config); + } + + async cleanup(): Promise { + await this.requestStage('cleanup'); + } + + private async requestStage(stage: 'setup' | 'update' | 'updatenow' | 'cleanup', config?: SquareInitConfig): Promise { + log('[SquarePayment] Requesting stage:', stage); + this.newConfig = config; + switch (stage) { + case 'setup': + switch (this.currentStage) { + case 'start': + await this.executeStage('setup'); + break; + case 'setup': + case 'update': + case 'updatenow': + case 'ready': + // already setup or setting up + this.nextStage = null; + break; + case 'cleanup': + this.nextStage = 'setup'; + this.cancelCleanup(); + break; + } + break; + case 'update': + case 'updatenow': { + const updateStage = stage as 'update' | 'updatenow'; + switch (this.currentStage) { + case 'start': + case 'setup': + if (this.currentStep === 'payments_ready') { + this.executeStage(updateStage); + } else { + log('[SquarePayment] Setting next stage to update (currently )...', this.currentStage); + this.nextStage = updateStage; + } + break; + case 'update': + case 'updatenow': + this.nextStage = updateStage; + if (this.currentStep === 'waiting_to_update') { + log('[SquarePayment] Cancelling queued update...'); + // haven't actually started updating yet, so we can just cancel the timer, resolve the previous update early, and try again + this.cancelUpdate(); + } + break; + case 'ready': + await this.executeStage(updateStage); + break; + } + } break; + case 'cleanup': + switch (this.currentStage) { + case 'setup': + this.nextStage = 'cleanup'; + break; + case 'update': + case 'updatenow': + this.nextStage = 'cleanup'; + await this.cancelUpdate(); + break; + case 'ready': + await this.executeStage('cleanup'); + break; + } + break; + } + } + + private async executeStage(stage: 'setup' | 'update' | 'updatenow' | 'cleanup'): Promise { + log('[SquarePayment] Executing stage:', stage); + switch (stage) { + case 'setup': + await this.startSetup(); + break; + case 'update': + await this.startUpdate(); + break; + case 'updatenow': + await this.startUpdate(true); + break; + case 'cleanup': + await this.startCleanup(); + break; + } + if (this.nextStage) { + log('[SquarePayment] Moving to next stage:', this.nextStage); + const stage = this.nextStage; + this.nextStage = null; + await this.executeStage(stage); + } + } + + private async startSetup(): Promise { + this.fresh = true; + this.emitCallback('loading'); + this.currentStage = 'setup'; + this.currentStep = 'loading_sdk'; + await this.loadSquareSdk(); + this.currentStep = 'setting_up_payments'; + await this.setupPayments(); + this.currentStep = 'payments_ready'; + } + + private async startUpdate(now = false): Promise { + log('[SquarePayment] Starting an update...'); + this.currentStage = 'update'; + this.currentStep = 'waiting_to_update'; + clearTimeout(this.updateTimer); + this.emitCallback('loading'); + this.fresh = false; + if (now) { + log('[SquarePayment] Running update immediately...'); + this.currentStep = 'updating_methods'; + const successfulMethods = await this.updatePaymentMethods(); + this.emitCallback('ready', successfulMethods); + this.currentStep = 'ready'; + this.currentStage = 'ready'; + } else { + // wait for 2 seconds to debounce frequent updates + log('[SquarePayment] Queueing update...'); + await new Promise((resolve) => { + this.updateResolver = resolve; + this.updateTimer = setTimeout(async () => { + log('[SquarePayment] Running queued update...'); + this.currentStep = 'updating_methods'; + const successfulMethods = await this.updatePaymentMethods(); + this.emitCallback('ready', successfulMethods); + this.currentStep = 'ready'; + this.currentStage = 'ready'; + this.updateResolver = null; + resolve(); + }, 2000); + }); + } + log('[SquarePayment] Update finished'); + } + + private cancelUpdate(): void { + log('[SquarePayment] Cancelling queued update...'); + const resolve = this.updateResolver; + this.updateResolver = null; + clearTimeout(this.updateTimer); + if (resolve) { + resolve(); + } + } + + private async startCleanup(): Promise { + log('[SquarePayment] Queueing a cleanup...'); + this.currentStage = 'cleanup'; + + this.detachMethods(); + + this.currentStep = 'waiting_to_cleanup'; + clearTimeout(this.cleanupTimer); + await new Promise((resolve) => { + this.cleanupResolver = resolve; + this.cleanupTimer = setTimeout(async () => { + log('[SquarePayment] Running queued cleanup...'); + this.currentStep = 'cleaning_up'; + await Promise.all([ + this.cleanupMethods(), + this.cleanupPaymentsAndIframes(), + ]); + this.currentStep = 'start'; + this.currentStage = 'start'; + this.cleanupResolver = null; + resolve(); + }, 30000); + }); + log('[SquarePayment] Cleanup finished'); + } + + private cancelCleanup(): void { + log('[SquarePayment] Cancelling queued cleanup...'); + const resolve = this.cleanupResolver; + this.cleanupResolver = null; + clearTimeout(this.cleanupTimer); + if (resolve) { + resolve(); + } + } + + registerCallback(event: SquareCallbackEvent, callback: Function): void { + this.callbacks[event] = callback; + } + + unregisterCallback(event: SquareCallbackEvent): void { + delete this.callbacks[event]; + } + + unregisterAllCallbacks(): void { + this.callbacks = {}; + } + + private emitCallback(event: SquareCallbackEvent, ...args: any[]): void { + const callback = this.callbacks[event]; + if (callback) { + callback(...args); + } + } + + private async loadSquareSdk(): Promise { + log('[SquarePayment] Loading Square SDK...'); + + if (window['Square']) { + log('[SquarePayment] Square SDK already loaded'); + return; + } + + const isProd = document.location.hostname === 'mempool.space'; + const scriptUrl = isProd + ? '/square/v1/square.js' + : 'https://sandbox.web.squarecdn.com/v1/square.js'; + + await new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = scriptUrl; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load Square SDK')); + document.head.appendChild(script); + }); + + await this.waitFor(() => !!window['Square'], 10000); + log('[SquarePayment] Square SDK loaded'); + } + + private async setupPayments(): Promise { + log('[SquarePayment] Setting up payments...'); + + const setupResult = await firstValueFrom(this.servicesApiService.setupSquare$()); + if (!setupResult) { + throw new Error('Failed to get Square setup configuration'); + } + const { squareAppId, squareLocationId } = setupResult; + + this.payments = window['Square'].payments(squareAppId, squareLocationId); + log('[SquarePayment] Payments object created', this.payments); + } + + private async updatePaymentMethods(): Promise { + if (!this.payments || !this.newConfig) { + log('[SquarePayment] No payments object or config, skipping method initialization'); + return; + } + log('[SquarePayment] Initializing payment methods...'); + + const txidChanged = this.newConfig.txid !== this.config?.txid; + this.config = this.newConfig; + + const promises: Promise[] = [ + this.config?.availableMethods.googlePay ? this.updateGooglePay(txidChanged).then(() => 'googlepay') : this.removeGooglePay(), + this.config?.availableMethods.applePay ? this.updateApplePay(txidChanged).then(() => 'applepay') : this.removeApplePay(), + this.config?.availableMethods.cashApp ? this.updateCashApp().then(() => 'cashapp') : this.removeCashApp(), + ]; + + const results = await Promise.allSettled(promises); + return results.map(result => result.status === 'fulfilled' ? result.value : null).filter(Boolean) as string[]; + } + + private async updateGooglePay(txidChanged: boolean): Promise { + log('[SquarePayment] Updating Google Pay...'); + if (!this.googlePay || txidChanged) { + this.removeGooglePay(); + log('[SquarePayment] Creating Google Pay request...'); + try { + this.googlePayRequest = this.payments.paymentRequest({ + countryCode: 'US', + currencyCode: 'USD', + total: { amount: this.config.costUSD.toFixed(2), label: 'Total' }, + }); + this.googlePay = await Promise.race([ + this.payments.googlePay(this.googlePayRequest, { + referenceId: this.buildReferenceId(), + }), + this.timeout(8000) + ]); + if (!this.googlePay) { + throw new Error('Google Pay request timed out'); + } + log('[SquarePayment] Google Pay request created', this.googlePayRequest); + } catch (e) { + console.error('[SquarePayment] Google Pay init failed:', e); + this.googlePay = null; + this.googlePayRequest = null; + throw e; + } + } else { + log(`[SquarePayment] Updating Google Pay amount`, this.googlePayRequest); + try { + this.googlePayRequest.update({ total: { amount: this.config.costUSD.toFixed(2), label: 'Total' } }); + } catch (e) { + console.error('[SquarePayment] Google Pay amount update failed:', e); + throw e; + } + } + if (!this.attachedMethods.googlePay) { + try { + const button = await this.waitForElementById('google-pay-button'); + await this.googlePay.attach(`#google-pay-button`, { + buttonType: 'pay', + buttonSizeMode: 'fill', + }); + button.addEventListener('click', event => { + this.emitCallback('googlePayClicked', event, this.googlePay, this.config); + }); + this.attachedMethods.googlePay = true; + } catch (e) { + console.error('[SquarePayment] Google Pay attach failed:', e); + this.googlePay = null; + this.googlePayRequest = null; + throw e; + } + } + } + + private async removeGooglePay(): Promise { + if (this.googlePay) { + try { + if (this.attachedMethods.googlePay) { + this.googlePay.detach(); + this.attachedMethods.googlePay = false; + } + this.googlePay.destroy(); + } catch (e) { + console.error('[SquarePayment] Google Pay destroy failed:', e); + } finally { + this.googlePay = null; + this.googlePayRequest = null; + } + } + } + + private async updateApplePay(txidChanged: boolean): Promise { + log('[SquarePayment] Updating Apple Pay...'); + if (!this.applePay || txidChanged) { + this.removeApplePay(); + log('[SquarePayment] Creating Apple Pay request...'); + try { + this.applePayRequest = this.payments.paymentRequest({ + countryCode: 'US', + currencyCode: 'USD', + total: { amount: this.config.costUSD.toFixed(2), label: 'Total' }, + }); + this.applePay = await Promise.race([ + this.payments.applePay(this.applePayRequest), + this.timeout(8000) + ]); + if (!this.applePay) { + throw new Error('Apple Pay request timed out'); + } + } catch (e) { + console.error('[SquarePayment] Apple Pay init failed:', e); + this.applePay = null; + this.applePayRequest = null; + throw e; + } + } else { + log(`[SquarePayment] Updating Apple Pay amount`); + try { + this.applePayRequest.update({ total: { amount: this.config.costUSD.toFixed(2), label: 'Total' } }); + } catch (e) { + console.error('[SquarePayment] Apple Pay amount update failed:', e); + throw e; + } + } + if (!this.attachedMethods.applePay) { + try { + const button = await this.waitForElementById('apple-pay-button'); + button.addEventListener('click', async event => { + this.emitCallback('applePayClicked', event, this.applePay, this.config); + }); + this.attachedMethods.applePay = true; + } catch (e) { + console.error('[SquarePayment] Apple Pay attach failed:', e); + this.applePay = null; + this.applePayRequest = null; + throw e; + } + } + } + + private async removeApplePay(): Promise { + if (this.applePay) { + try { + if (this.attachedMethods.applePay) { + this.attachedMethods.applePay = false; + } + this.applePay.destroy(); + } catch (e) { + console.error('[SquarePayment] Apple Pay destroy failed:', e); + } finally { + this.applePay = null; + this.applePayRequest = null; + } + } + } + + private async updateCashApp(): Promise { + this.removeCashApp(); + log('[SquarePayment] (Re)Creating Cash App...'); + try { + const redirectHostname = document.location.hostname === 'localhost' + ? 'http://localhost:4200' + : `https://${document.location.hostname}`; + + this.cashAppPayRequest = this.payments.paymentRequest({ + countryCode: 'US', + currencyCode: 'USD', + total: { + amount: this.config.costUSD.toFixed(2), + label: 'Total', + pending: true, + productUrl: `${redirectHostname}/tx/${this.config.txid}`, + }, + }); + + this.cashAppPay = await Promise.race([ + this.payments.cashAppPay(this.cashAppPayRequest, { + redirectURL: `${redirectHostname}/tx/${this.config.txid}`, + referenceId: this.buildReferenceId(), + }), + this.timeout(8000) + ]); + if (!this.cashAppPay) { + throw new Error('Cash App Pay request timed out'); + } + + this.cashAppPay.addEventListener('ontokenization', event => { + this.emitCallback('cashAppTokenized', event, this.config); + }); + } catch (e) { + console.error('[SquarePayment] Cash App init failed:', e); + this.cashAppPay = null; + this.cashAppPayRequest = null; + throw e; + } + if (!this.attachedMethods.cashApp) { + try { + await this.waitForElementById('cash-app-pay'); + await this.cashAppPay.attach(`#cash-app-pay`, { theme: 'dark' }); + this.attachedMethods.cashApp = true; + } catch (e) { + console.error('[SquarePayment] Cash App Pay attach failed:', e); + this.cashAppPay = null; + this.cashAppPayRequest = null; + throw e; + } + } + } + + private async removeCashApp(): Promise { + log('[SquarePayment] Removing Cash App...'); + if (this.cashAppPay) { + try { + if (this.attachedMethods.cashApp) { + this.cashAppPay.detach(); + this.attachedMethods.cashApp = false; + } + this.cashAppPay.destroy(); + } catch (e) { + console.error('[SquarePayment] Cash App Pay destroy failed:', e); + } finally { + this.cashAppPay = null; + this.cashAppPayRequest = null; + } + } + } + + private detachMethods(): void { + if (this.attachedMethods.googlePay) { + this.googlePay.detach(); + this.attachedMethods.googlePay = false; + } + this.attachedMethods.applePay = false; + if (this.attachedMethods.cashApp) { + this.cashAppPay.detach(); + this.attachedMethods.cashApp = false; + } + } + + private async cleanupMethods(): Promise { + await this.removeGooglePay(); + await this.removeApplePay(); + await this.removeCashApp(); + } + + private cleanupPaymentsAndIframes(): void { + log('[SquarePayment] Cleaning up payments and iframes...'); + + this.payments = null; + + // remove Square from window + delete window['Square']; + + // clean up main square script + document.querySelectorAll('head > script').forEach(script => { + const src = script.getAttribute('src') || ''; + if ( + src.includes('square') || + src.includes('squareup') || + src.includes('squarecdn') || + src.includes('cash.app') || + src.includes('pay.google.com') + ) { + log('[SquarePayment] Removing script', src); + script.remove(); + } + }); + + // clean up iframes + document.querySelectorAll('body > iframe').forEach(iframe => { + const src = iframe.getAttribute('src') || ''; + if ( + src.includes('square') || + src.includes('squareup') || + src.includes('squarecdn') || + src.includes('cash.app') || + src.includes('pay.google.com') + ) { + log('[SquarePayment] Removing iframe', src); + iframe.remove(); + } + }); + + // clean up payment method scripts + document.querySelectorAll('script[id^="square-payments-"]').forEach(script => { + log('[SquarePayment] Removing payment method script', script.getAttribute('id')); + script.remove(); + }); + } + + public async verifyBuyer(token: string, details: any, amount: string): Promise<{ token: string; userChallenged: boolean } | null> { + try { + const verificationDetails = { + amount, + currencyCode: 'USD', + intent: 'CHARGE', + billingContact: { + givenName: details.card?.billing?.givenName, + familyName: details.card?.billing?.familyName, + phone: details.card?.billing?.phone, + addressLines: details.card?.billing?.addressLines, + city: details.card?.billing?.city, + state: details.card?.billing?.state, + countryCode: details.card?.billing?.countryCode, + }, + }; + + return await this.payments.verifyBuyer(token, verificationDetails); + } catch (e) { + console.error('[SquarePayment] Buyer verification failed:', e); + return null; + } + } + + private buildReferenceId(): string { + return `accelerator-${this.config?.txid?.substring(0, 15)}-${Math.round(Date.now() / 1000)}`; + } + + private async timeout(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + private async waitFor(condition: () => boolean, timeout: number): Promise { + return new Promise((resolve, reject) => { + const start = Date.now(); + const check = () => { + if (condition()) { + resolve(); + } else if (Date.now() - start > timeout) { + reject(new Error('Timeout waiting for condition')); + } else { + setTimeout(check, 100); + } + }; + check(); + }); + } + + private waitForElementById(id: string, timeout = 10000): Promise { + const selector = `#${CSS.escape(id)}`; + const existing = document.querySelector(selector); + if (existing) { + return Promise.resolve(existing); + } + return new Promise((resolve, reject) => { + const observer = new MutationObserver(() => { + const el = document.querySelector(selector); + if (el) { + observer.disconnect(); + resolve(el); + } + }); + observer.observe(document, { + childList: true, + subtree: true, + }); + if (timeout != null) { + setTimeout(() => { + observer.disconnect(); + reject(new Error(`timeout waiting for ${selector}`)); + }, timeout); + } + }); + } +} diff --git a/frontend/src/app/shared/logger.utils.ts b/frontend/src/app/shared/logger.utils.ts new file mode 100644 index 000000000..53b02b752 --- /dev/null +++ b/frontend/src/app/shared/logger.utils.ts @@ -0,0 +1,6 @@ +window['DEV_MODE'] = localStorage.getItem('dev_mode') === 'true'; +export function log(...msgs: any[]): void { + if (window['DEV_MODE']) { + console.log(...msgs); + } +} \ No newline at end of file