faster accelerator checkout with square payment service

This commit is contained in:
Mononaut 2026-01-22 11:51:45 +00:00
parent d5877c2d02
commit 3d2cf3008e
No known key found for this signature in database
GPG key ID: A3F058E41374C04E
6 changed files with 1099 additions and 492 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

@ -365,81 +365,6 @@
<app-active-acceleration-box [miningStats]="miningStats" [pools]="estimate.pools" [chartOnly]="true" class="ml-2"></app-active-acceleration-box>
</div>
</div>
<div class="payment-area" style="font-size: 14px;">
<div class="row text-center justify-content-center mx-2">
<span i18n="accelerator.payment-to-mempool-space">Payment to mempool.space for acceleration of txid <a [routerLink]="'/tx/' + tx.txid" target="_blank">{{ tx.txid.substr(0, 10) }}..{{ tx.txid.substr(-10) }}</a></span>
</div>
@if (canPayWithBalance || !(canPayWithBitcoin || canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay)) {
<div class="row">
<div class="col-sm text-center d-flex flex-column justify-content-center align-items-center">
<p><ng-container i18n="accelerator.your-account-will-be-debited">Your account will be debited no more than</ng-container>&nbsp;<small style="font-family: monospace;">{{ cost | number }}</small>&nbsp;<span class="symbol" i18n="shared.sats">sats</span></p>
<div class="d-flex justify-content-center" [class.grayOut]="!canPayWithBalance || quoteError || accelerateError || showSuccess">
<ng-container *ngTemplateOutlet="accountPayButton"></ng-container>
</div>
</div>
</div>
} @else {
<div class="row">
@if (canPayWithBitcoin) {
<div class="col-sm text-center d-flex flex-column justify-content-center align-items-center">
@if (invoice) {
<p><ng-container i18n="transaction.pay|Pay button label">Pay</ng-container>&nbsp;<span><small style="font-family: monospace;">{{ ((invoice.btcDue * 100_000_000) || cost) | number }}</small>&nbsp;<span class="symbol" i18n="shared.sats">sats</span></span></p>
<app-bitcoin-invoice style="width: 100%;" [invoice]="invoice" [minimal]="true" (completed)="bitcoinPaymentCompleted()"></app-bitcoin-invoice>
} @else if (btcpayInvoiceFailed) {
<div class="btcpay-invoice">
<fa-icon style="font-size: 20px; color: var(--red)" [icon]="['fas', 'circle-xmark']"></fa-icon>
<span i18n="accelerator.failed-to-load-invoice">Failed to load invoice</span>
@if (!loadingBtcpayInvoice) {
<button class="btn btn-sm btn-secondary mt-0 mt-md-1" (click)="requestBTCPayInvoice()">Retry ↻</button>
}
</div>
} @else {
<p i18n="accelerator.loading-invoice">Loading invoice...</p>
<div class="d-flex align-items-center justify-content-center" style="width: 100%; height: 292px;">
<div class="m-4 spinner-border text-light" style="width: 25px; height: 25px"></div>
</div>
}
</div>
@if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay || canPayWithCardOnFile) {
<div class="col-sm text-center flex-grow-0 d-flex flex-column justify-content-center align-items-center">
<p class="text-nowrap">&mdash;&mdash;<span i18n="or"> OR </span>&mdash;&mdash;</p>
</div>
}
}
@if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay || canPayWithCardOnFile) {
<div class="col-sm text-center d-flex flex-column justify-content-center align-items-center">
<p><ng-container i18n="transaction.pay|Pay button label">Pay</ng-container>&nbsp;<app-fiat [value]="cost"></app-fiat> with</p>
@if (canPayWithCashapp) {
<img class="paymentMethod mx-2" style="width: 200px" src="/resources/cash-app.svg" height=55 (click)="moveToStep('cashapp')">
}
@if (canPayWithApplePay) {
@if (canPayWithCashapp) { <span class="mt-1 mb-1"></span> }
<div class="paymentMethod mx-2" style="width: 200px; height: 55px" (click)="moveToStep('applepay')">
<img src="/resources/apple-pay.png" height=37>
</div>
}
@if (canPayWithGooglePay) {
@if (canPayWithCashapp || canPayWithApplePay) { <span class="mt-1 mb-1"></span> }
<div class="paymentMethod mx-2" style="width: 200px; height: 55px" (click)="moveToStep('googlepay')">
<img src="/resources/google-pay.png" height=37>
</div>
}
@if (canPayWithCardOnFile) {
@if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay) { <span class="mt-1 mb-1"></span> }
<div class="paymentMethod mx-2 d-flex justify-content-center align-items-center" style="width: 200px; height: 55px" (click)="moveToStep('cardonfile')">
@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>
} @else {
<app-svg-images name="OTHER_BRAND" height="33" class="mr-2"></app-svg-images>
}
<span style="font-size: 22px; padding-bottom: 3px">{{ estimate?.availablePaymentMethods?.cardOnFile?.card?.last_4 }}</span>
</div>
}
</div>
}
</div>
}
</div>
</ng-container>
<ng-template #loadingCheckout>
<div class="row">
@ -450,14 +375,90 @@
</div>
</div>
</ng-template>
}
<div class="checkout-payment-area" [class.visible]="step === 'checkout'">
<div class="payment-area" style="font-size: 14px;">
<div class="row text-center justify-content-center mx-2">
<span i18n="accelerator.payment-to-mempool-space">Payment to mempool.space for acceleration of txid <a [routerLink]="'/tx/' + tx.txid" target="_blank">{{ tx?.txid?.substr(0, 10) }}..{{ tx?.txid?.substr(-10) }}</a></span>
</div>
@if (canPayWithBalance || !(canPayWithBitcoin || canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay)) {
<div class="row">
<div class="col-sm text-center d-flex flex-column justify-content-center align-items-center">
<p><ng-container i18n="accelerator.your-account-will-be-debited">Your account will be debited no more than</ng-container>&nbsp;<small style="font-family: monospace;">{{ cost | number }}</small>&nbsp;<span class="symbol" i18n="shared.sats">sats</span></p>
<div class="d-flex justify-content-center" [class.grayOut]="!canPayWithBalance || quoteError || accelerateError || showSuccess">
<ng-container *ngTemplateOutlet="accountPayButton"></ng-container>
</div>
</div>
</div>
} @else {
<div class="row">
@if (canPayWithBitcoin) {
<div class="col-sm text-center d-flex flex-column justify-content-center align-items-center">
@if (invoice) {
<p><ng-container i18n="transaction.pay|Pay button label">Pay</ng-container>&nbsp;<span><small style="font-family: monospace;">{{ ((invoice.btcDue * 100_000_000) || cost) | number }}</small>&nbsp;<span class="symbol" i18n="shared.sats">sats</span></span></p>
<app-bitcoin-invoice style="width: 100%;" [invoice]="invoice" [minimal]="true" (completed)="bitcoinPaymentCompleted()"></app-bitcoin-invoice>
} @else if (btcpayInvoiceFailed) {
<div class="btcpay-invoice">
<fa-icon style="font-size: 20px; color: var(--red)" [icon]="['fas', 'circle-xmark']"></fa-icon>
<span i18n="accelerator.failed-to-load-invoice">Failed to load invoice</span>
@if (!loadingBtcpayInvoice) {
<button class="btn btn-sm btn-secondary mt-0 mt-md-1" (click)="requestBTCPayInvoice()">Retry ↻</button>
}
</div>
} @else {
<p i18n="accelerator.loading-invoice">Loading invoice...</p>
<div class="d-flex align-items-center justify-content-center" style="width: 100%; height: 292px;">
<div class="m-4 spinner-border text-light" style="width: 25px; height: 25px"></div>
</div>
}
</div>
@if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay || canPayWithCardOnFile) {
<div class="col-sm text-center flex-grow-0 d-flex flex-column justify-content-center align-items-center">
<p class="text-nowrap">&mdash;&mdash;<span i18n="or"> OR </span>&mdash;&mdash;</p>
</div>
}
}
@if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay || canPayWithCardOnFile) {
<div class="col-sm text-center d-flex flex-column justify-content-center align-items-center square-checkout-buttons">
<p><ng-container i18n="transaction.pay|Pay button label">Pay</ng-container>&nbsp;<app-fiat [value]="cost"></app-fiat> with</p>
<div class="square-buttons" [class.loading]="!squarePaymentsReady">
<div class="square-loading-overlay">
<div class="spinner-border text-light" style="width: 25px; height: 25px;"></div>
</div>
<div id="cash-app-pay" class="square-button" [class.d-none]="!canPayWithCashapp || !availableMethods.cashapp"></div>
<div id="apple-pay-button" class="apple-pay-button apple-pay-button-black square-button" [class.d-none]="!canPayWithApplePay || !availableMethods.applepay"></div>
<div id="google-pay-button" class="square-button" [class.d-none]="!canPayWithGooglePay || !availableMethods.googlepay"></div>
@if (canPayWithCardOnFile) {
<div class="paymentMethod mx-2 d-flex justify-content-center align-items-center card-on-file" (click)="moveToStep('cardonfile')">
@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>
} @else {
<app-svg-images name="OTHER_BRAND" height="33" class="mr-2"></app-svg-images>
}
<span style="font-size: 22px; padding-bottom: 3px">{{ estimate?.availablePaymentMethods?.cardOnFile?.card?.last_4 }}</span>
</div>
}
@if (isTokenizing > 0) {
<div class="d-flex flex-row justify-content-center">
<div class="ml-2 spinner-border text-light" style="width: 25px; height: 25px;"></div>
</div>
}
</div>
</div>
}
</div>
}
</div>
<hr>
<div class="row mt-2 mb-2 text-center">
<div class="col-sm d-flex flex-column">
<button type="button" class="mt-1 btn btn-secondary btn-sm rounded-pill align-self-center" style="width: 200px" (click)="moveToStep('summary')" i18n="go-back">Go back</button>
</div>
</div>
} @else if (step === 'cashapp' || step === 'applepay' || step === 'googlepay' || step === 'cardonfile') {
</div>
@if (step === 'cardonfile') {
<!-- Show checkout page -->
<div class="row mb-md-1 text-center" id="confirm-title">
<div class="col-sm" id="confirm-payment-title">
@ -473,7 +474,7 @@
</div>
</div>
@if (step === 'cashapp' && !loadingCashapp || step === 'applepay' && !loadingApplePay || step === 'googlepay' && !loadingGooglePay || step === 'cardonfile' && !loadingCardOnFile) {
@if (!loadingCardOnFile) {
<div class="row text-center mt-1">
<div class="col-sm">
<div class="form-group w-100">
@ -492,27 +493,19 @@
<div class="row text-center mt-1">
<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 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>
} @else {
<app-svg-images name="OTHER_BRAND" height="33" class="mr-2"></app-svg-images>
}
<span style="font-size: 22px; padding-bottom: 3px">{{ estimate?.availablePaymentMethods?.cardOnFile?.card?.last_4 }}</span>
</div>
}
@if (loadingCashapp || loadingApplePay || loadingGooglePay || loadingCardOnFile) {
<div display="d-flex flex-row justify-content-center">
<span i18n="accelerator.loading-payment-method">Loading payment method...</span>
<div class="ml-2 spinner-border text-light" style="width: 25px; height: 25px"></div>
<div class="ml-auto mr-auto paymentMethod mx-2 d-flex justify-content-center align-items-center card-on-file" (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>
} @else {
<app-svg-images name="OTHER_BRAND" height="33" class="mr-2"></app-svg-images>
}
<span style="font-size: 22px; padding-bottom: 3px">{{ estimate?.availablePaymentMethods?.cardOnFile?.card?.last_4 }}</span>
</div>
@if (loadingCardOnFile) {
<div display="d-flex flex-row justify-content-center">
<span i18n="accelerator.loading-payment-method">Loading payment method...</span>
<div class="ml-2 spinner-border text-light" style="width: 25px; height: 25px"></div>
</div>
}
</div>
@if (isTokenizing > 0) {
@ -530,7 +523,8 @@
</div>
</div>
}
@else if (step === 'processing') {
@if (step === 'processing') {
<div class="row mb-1 text-center">
<div class="col-sm">
<h1 style="font-size: larger;"><ng-content select="[slot='processing-title']"></ng-content><span class="default-slot" i18n="accelerator.confirming-your-payment">Confirming your payment</span></h1>
@ -541,7 +535,7 @@
<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 id="cash-app-pay-processing" 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>
@ -549,6 +543,12 @@
</div>
</div>
</div>
<hr>
<div class="row mt-2 mb-2 text-center">
<div class="col-sm d-flex flex-column">
<button type="button" class="mt-1 btn btn-secondary btn-sm rounded-pill align-self-center" style="width: 200px" (click)="moveToStep('summary')" i18n="go-back">Go back</button>
</div>
</div>
}
@else if (step === 'paid') {
<div class="row mb-1 text-center">

View file

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

View file

@ -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<string, number>;
availableMethods: Record<string, boolean> = {};
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<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
).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<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
).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<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
).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;
});
}
}
/**

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