Confirming your payment
@@ -541,7 +535,7 @@
+
+
}
@else if (step === 'paid') {
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..0ef193ce8 100644
--- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss
+++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss
@@ -20,6 +20,16 @@
background-color: var(--secondary);
border-radius: 10px;
cursor: pointer;
+
+ &.card-on-file {
+ width: 200px;
+ height: 48px;
+ background: black;
+
+ &:hover {
+ background-color: rgb(51, 51, 51);
+ }
+ }
}
.default-slot:not(:only-child) {
@@ -155,8 +165,9 @@
background: var(--bg);
margin-top: 0.5rem;
padding: 0.5rem;
+ padding-bottom: 0;
@media (max-width: 575px) {
- padding-bottom: 1.25rem;
+ padding-bottom: 0;
}
}
@@ -237,4 +248,65 @@
flex-direction: row;
gap: 5px;
}
+}
+
+.checkout-payment-area {
+ visibility: hidden;
+ position: absolute;
+ pointer-events: none;
+ width: 100%;
+
+ &.visible {
+ visibility: visible;
+ position: static;
+ pointer-events: auto;
+ }
+}
+
+.square-checkout-buttons {
+ .square-buttons {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 1em;
+ min-height: 50px;
+ }
+
+ #cash-app-pay {
+ width: 182.5px;
+ }
+
+ .square-loading-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ min-width: 50px;
+ min-height: 50px;
+ background: var(--bg);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 10;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.3s ease;
+ }
+
+ .square-buttons.loading .square-loading-overlay {
+ opacity: 1;
+ pointer-events: auto;
+ }
+
+ .square-button {
+ width: 240px;
+ height: 48px;
+ }
+
+ #google-pay-button, #cash-app-pay {
+ min-height: 50px;
+ }
}
\ 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 1b9bdb14d..24a6c0994 100644
--- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts
+++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts
@@ -12,6 +12,8 @@ import { IAuth, AuthServiceMempool } from '@app/services/auth.service';
import { EnterpriseService } from '@app/services/enterprise.service';
import { ApiService } from '@app/services/api.service';
import { isDevMode } from '@angular/core';
+import { SquarePaymentService, SquareInitConfig } from '@app/services/square-payment.service';
+import { log } from '@app/shared/logger.utils';
export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp' | 'applePay' | 'googlePay' | 'cardOnFile';
@@ -49,7 +51,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' | 'googlepay' | 'cardonfile' | 'processing' | 'paid' | 'success';
+type CheckoutStep = 'quote' | 'summary' | 'checkout' | 'cardonfile' | 'processing' | 'paid' | 'success';
@Component({
selector: 'app-accelerate-checkout',
@@ -77,6 +79,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
calculating = true;
processing = false;
+ squarePaymentsReady = false;
isCheckoutLocked = 0; // reference counter, 0 = unlocked, >0 = locked
isTokenizing = 0; // reference counter, 0 = false, >0 = true
selectedOption: 'wait' | 'accel';
@@ -91,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;
@@ -116,16 +118,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
maxRateOptions: RateOption[] = [];
// square
- loadingCashapp = false;
- loadingApplePay = false;
- loadingGooglePay = false;
loadingCardOnFile = false;
- payments: any;
- cashAppPay: any;
- applePay: any;
- googlePay: any;
conversionsSubscription: Subscription;
conversions: Record;
+ availableMethods: Record = {};
+ firstLoad = true;
// btcpay
loadingBtcpayInvoice = false;
@@ -140,6 +137,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
private cd: ChangeDetectorRef,
private authService: AuthServiceMempool,
private enterpriseService: EnterpriseService,
+ private squarePaymentService: SquarePaymentService,
) {
this.isProdDomain = this.stateService.isProdDomain;
@@ -169,19 +167,64 @@ 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.squarePaymentService.init();
+
this.conversionsSubscription = this.stateService.conversions$.subscribe(
async (conversions) => {
this.conversions = conversions;
+ if (this.step !== 'checkout') {
+ this.updateSquarePayments();
+ }
}
);
}
+ 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('loading', () => {
+ this.squarePaymentsReady = false;
+ this.cd.markForCheck();
+ });
+ this.squarePaymentService.registerCallback('ready', (availableMethods: string[]) => {
+ this.availableMethods = {};
+ for (const method of availableMethods) {
+ this.availableMethods[method] = true;
+ }
+ this.squarePaymentsReady = true;
+ 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));
+ }
+
+ 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,
+ },
+ this.step === 'checkout' || this.firstLoad,
+ );
+ this.firstLoad = false;
+ }
+ }
+
ngOnDestroy(): void {
if (this.estimateSubscription) {
this.estimateSubscription.unsubscribe();
@@ -189,6 +232,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
if (this.authSubscription$) {
this.authSubscription$.unsubscribe();
}
+ this.squarePaymentService.cleanup();
}
ngOnChanges(changes: SimpleChanges): void {
@@ -210,7 +254,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.fetchEstimate();
}
if (this._step === 'checkout') {
- this.insertSquare();
this.enterpriseService.goal(8);
this.scrollToElementWithTimeout('acceleratePreviewAnchor', 'start', 100);
}
@@ -219,22 +262,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.invoice = undefined;
this.requestBTCPayInvoice();
this.scrollToElementWithTimeout('acceleratePreviewAnchor', 'start', 100);
- } else if (this._step === 'cashapp') {
- this.loadingCashapp = true;
- this.setupSquare();
- this.scrollToElementWithTimeout('confirm-title', 'center', 100);
- } else if (this._step === 'applepay' && this.applePayEnabled) {
- this.loadingApplePay = true;
- this.setupSquare();
- this.scrollToElementWithTimeout('confirm-title', 'center', 100);
- } else if (this._step === 'googlepay' && this.googlePayEnabled) {
- this.loadingGooglePay = true;
- this.setupSquare();
- this.scrollToElementWithTimeout('confirm-title', 'center', 100);
- } else if (this._step === 'cardonfile' && this.cardOnFileEnabled) {
- this.loadingCardOnFile = true;
- this.setupSquare();
- this.scrollToElementWithTimeout('confirm-title', 'center', 100);
} else if (this._step === 'paid') {
this.timePaid = Date.now();
this.timeoutTimer = setTimeout(() => {
@@ -348,7 +375,9 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
}
return of(null);
})
- ).subscribe();
+ ).subscribe(() => {
+ this.updateSquarePayments();
+ });
}
validateChoice(): void {
@@ -374,6 +403,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.userBid = Math.max(0, fee);
this.cost = this.userBid + this.estimate.mempoolBaseFee + this.estimate.vsizeFee;
this.validateChoice();
+ this.updateSquarePayments();
}
}
@@ -407,299 +437,173 @@ 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
+ ).subscribe({
+ next: (response) => {
+ 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
- ).subscribe({
- next: (response) => {
- 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
- ).subscribe({
- next: (response) => {
- 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
+ ).subscribe({
+ next: (response) => {
+ 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--;
+ }
}
/**
@@ -742,7 +646,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.';
@@ -802,107 +706,51 @@ 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
+ ).subscribe({
+ next: (response) => {
+ 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
- ).subscribe({
- next: (response) => {
- 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