fastish accelerator checkout with square payment service

This commit is contained in:
Mononaut 2026-01-22 11:51:45 +00:00
parent 27ade6c20e
commit 1bed5650ba
No known key found for this signature in database
GPG key ID: A3F058E41374C04E
6 changed files with 956 additions and 388 deletions

View file

@ -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 },
];

View file

@ -488,17 +488,15 @@
</div>
</div>
}
}
<div class="row text-center mt-1">
<div class="row text-center mt-1 square-buttons-container" [class.visible]="step === 'cashapp' || step === 'applepay' || step === 'googlepay' || step === 'cardonfile'">
<div class="col-sm">
<div class="form-group w-100">
@if (step === 'applepay') {
<div id="apple-pay-button" class="apple-pay-button apple-pay-button-black" style="height: 50px" [style]="loadingApplePay ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
} @else if (step === 'cashapp') {
<div id="cash-app-pay" class="d-inline-block" style="height: 50px" [style]="loadingCashapp ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
} @else if (step === 'googlepay') {
<div id="google-pay-button" class="d-inline-block" style="height: 50px" [style]="loadingGooglePay ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
} @else if (step === 'cardonfile') {
<div id="apple-pay-button" class="apple-pay-button apple-pay-button-black" [class.visible]="step === 'applepay'" style="height: 50px" [style]="loadingApplePay ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
<div id="cash-app-pay" class="d-inline-block" [class.visible]="step === 'cashapp'" style="height: 50px" [style]="loadingCashapp ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
<div id="google-pay-button" class="d-inline-block" [class.visible]="step === 'googlepay'" style="height: 50px" [style]="loadingGooglePay ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
@if (step === 'cardonfile') {
<div class="ml-auto mr-auto paymentMethod mx-2 d-flex justify-content-center align-items-center" style="width: 200px; height: 55px" (click)="requestCardOnFilePayment()" [style]="loadingCardOnFile ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''">
@if (['VISA', 'MASTERCARD', 'JCB', 'DISCOVER', 'DISCOVER_DINERS', 'AMERICAN_EXPRESS'].includes(estimate?.availablePaymentMethods?.cardOnFile?.card?.brand)) {
<app-svg-images [name]="estimate?.availablePaymentMethods?.cardOnFile?.card?.brand" height="33" class="mr-2"></app-svg-images>
@ -522,7 +520,7 @@
}
</div>
</div>
@if (step === 'cashapp' || step === 'applepay' || step === 'googlepay' || step === 'cardonfile') {
<hr>
<div class="row mt-2 mb-2 text-center">
<div class="col-sm d-flex flex-column">
@ -541,7 +539,6 @@
<div class="col-sm">
<div class="form-group w-100">
<!-- Processing payment -->
<div id="cash-app-pay" class="d-inline-block" [style]="'opacity: 0; width: 0px; height: 0px; pointer-events: none;'"></div>
<div display="d-flex flex-row justify-content-center">
<span i18n="accelerator.payment-processing">We are processing your payment...</span>
<div class="ml-2 spinner-border text-light" style="width: 25px; height: 25px"></div>

View file

@ -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;
}
}
}

View file

@ -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<string, number>;
@ -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<void> {
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<void> {
async onApplePayClicked(event: Event, applePay: any, config: SquareInitConfig): Promise<void> {
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<void> {
async onGooglePayClicked(event: Event, googlePay: any, config: SquareInitConfig): Promise<void> {
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<void> {
async onCashAppTokenized(event: any, config: SquareInitConfig): Promise<void> {
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;
});
}
}
/**

View file

@ -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<typeof setTimeout> | null = null;
private updateResolver: ((value: void | PromiseLike<void>) => void) | null = null;
private cleanupTimer: ReturnType<typeof setTimeout> | null = null;
private cleanupResolver: ((value: void | PromiseLike<void>) => 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<Record<SquareCallbackEvent, Function>> = {};
private attachedMethods: { googlePay: boolean, applePay: boolean, cashApp: boolean } = { googlePay: false, applePay: false, cashApp: false };
constructor(private servicesApiService: ServicesApiServices) {}
async init(): Promise<void> {
await this.requestStage('setup');
}
async update(config: SquareInitConfig, now = false): Promise<void> {
log('[SquarePayment] Updating with config:', config);
await this.requestStage(now ? 'updatenow' : 'update', config);
}
async cleanup(): Promise<void> {
await this.requestStage('cleanup');
}
private async requestStage(stage: 'setup' | 'update' | 'updatenow' | 'cleanup', config?: SquareInitConfig): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void>((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<void> {
log('[SquarePayment] Queueing a cleanup...');
this.currentStage = 'cleanup';
this.detachMethods();
this.currentStep = 'waiting_to_cleanup';
clearTimeout(this.cleanupTimer);
await new Promise<void>((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<void> {
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<void>((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<void> {
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<string[]> {
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<string | void>[] = [
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
private async waitFor(condition: () => boolean, timeout: number): Promise<void> {
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<HTMLElement> {
const selector = `#${CSS.escape(id)}`;
const existing = document.querySelector<HTMLElement>(selector);
if (existing) {
return Promise.resolve(existing);
}
return new Promise<HTMLElement>((resolve, reject) => {
const observer = new MutationObserver(() => {
const el = document.querySelector<HTMLElement>(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);
}
});
}
}

View file

@ -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);
}
}