diff --git a/package-lock.json b/package-lock.json index f790b47a7..016c4d6b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@ng-bootstrap/ng-bootstrap": "^20.0.0", "@popperjs/core": "^2.11.8", "@sentry/browser": "7.119.1", + "@stripe/stripe-js": "^9.12.1", "@types/debug": "4.1.12", "@types/hammerjs": "2.0.46", "@types/lodash": "4.17.20", @@ -8974,6 +8975,15 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@stripe/stripe-js": { + "version": "9.12.1", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.12.1.tgz", + "integrity": "sha512-KLXPvjA0BfS4dQnW+ddHjwtIXMnIfKYxqmeNMmQCfKjvYog0cSG+Z7ZvK4RH1+bV90GC07nUztWDRWmJLi5HZQ==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index 0fb6000ad..d91a763de 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@ng-bootstrap/ng-bootstrap": "^20.0.0", "@popperjs/core": "^2.11.8", "@sentry/browser": "7.119.1", + "@stripe/stripe-js": "^9.12.1", "@types/debug": "4.1.12", "@types/hammerjs": "2.0.46", "@types/lodash": "4.17.20", diff --git a/src/app/core/components/payment-intent-confirm/payment-intent-confirm.component.html b/src/app/core/components/payment-intent-confirm/payment-intent-confirm.component.html new file mode 100644 index 000000000..e4b13d51f --- /dev/null +++ b/src/app/core/components/payment-intent-confirm/payment-intent-confirm.component.html @@ -0,0 +1,30 @@ +
+
+ Storage Purchase + +
+
+ @switch (stage) { + @case ('loading') { + + } + @case ('success') { +

+ Success! {{ amountInGb }} GB of Permanent storage has been added to + your account. +

+ } + @case ('failure') { +

+ We couldn't confirm your payment. If you were charged, please contact + support. +

+ } + @case ('missing') { +

We couldn't find that purchase.

+ } + } +
+
diff --git a/src/app/core/components/payment-intent-confirm/payment-intent-confirm.component.spec.ts b/src/app/core/components/payment-intent-confirm/payment-intent-confirm.component.spec.ts new file mode 100644 index 000000000..a04d6e4bf --- /dev/null +++ b/src/app/core/components/payment-intent-confirm/payment-intent-confirm.component.spec.ts @@ -0,0 +1,123 @@ +import { MockBuilder, MockRender } from 'ng-mocks'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { DialogRef } from '@angular/cdk/dialog'; +import { AccountService } from '@shared/services/account/account.service'; +import { EventService } from '@shared/services/event/event.service'; +import { PaymentIntentConfirmComponent } from './payment-intent-confirm.component'; + +describe('PaymentIntentConfirmComponent', () => { + let mockAccountService: any; + let mockEventService: any; + let mockDialogRef: any; + let mockRoute: any; + let mockStripe: any; + + beforeEach(async () => { + mockAccountService = { + addStorageBytes: jasmine.createSpy('addStorageBytes'), + }; + + mockEventService = { + dispatch: jasmine.createSpy('dispatch'), + }; + + mockDialogRef = { + close: jasmine.createSpy('close'), + }; + + mockRoute = { + snapshot: { + queryParamMap: convertToParamMap({ + payment_intent_client_secret: 'pi_test_secret_abc', + }), + }, + }; + + mockStripe = { + retrievePaymentIntent: jasmine.createSpy('retrievePaymentIntent'), + }; + + await MockBuilder(PaymentIntentConfirmComponent) + .keep(HttpClientTestingModule, { export: true }) + .provide({ provide: AccountService, useValue: mockAccountService }) + .provide({ provide: EventService, useValue: mockEventService }) + .provide({ provide: DialogRef, useValue: mockDialogRef }) + .provide({ provide: ActivatedRoute, useValue: mockRoute }); + }); + + it('should create', () => { + const fixture = MockRender( + PaymentIntentConfirmComponent, + {}, + { detectChanges: false }, + ); + + expect(fixture.point.componentInstance).toBeTruthy(); + }); + + it('shows the missing state when no client secret query param is present', async () => { + mockRoute.snapshot.queryParamMap = convertToParamMap({}); + + const fixture = MockRender(PaymentIntentConfirmComponent); + await fixture.whenStable(); + + expect(fixture.point.componentInstance.stage).toBe('missing'); + }); + + it('credits storage and shows success when the PaymentIntent succeeded', async () => { + mockStripe.retrievePaymentIntent.and.returnValue( + Promise.resolve({ paymentIntent: { status: 'succeeded', amount: 1000 } }), + ); + + // detectChanges: false — prevents ngOnInit from firing (and hitting the + // real loadStripe()) before loadStripeClient is spied on below. + const fixture = MockRender( + PaymentIntentConfirmComponent, + {}, + { detectChanges: false }, + ); + spyOn( + fixture.point.componentInstance, + 'loadStripeClient' as any, + ).and.returnValue(Promise.resolve(mockStripe)); + + await fixture.point.componentInstance.ngOnInit(); + + expect(mockAccountService.addStorageBytes).toHaveBeenCalledWith(1073741824); + + expect(fixture.point.componentInstance.stage).toBe('success'); + }); + + it('shows the failure state when the PaymentIntent did not succeed', async () => { + mockStripe.retrievePaymentIntent.and.returnValue( + Promise.resolve({ paymentIntent: { status: 'requires_payment_method' } }), + ); + + const fixture = MockRender( + PaymentIntentConfirmComponent, + {}, + { detectChanges: false }, + ); + spyOn( + fixture.point.componentInstance, + 'loadStripeClient' as any, + ).and.returnValue(Promise.resolve(mockStripe)); + + await fixture.point.componentInstance.ngOnInit(); + + expect(mockAccountService.addStorageBytes).not.toHaveBeenCalled(); + expect(fixture.point.componentInstance.stage).toBe('failure'); + }); + + it('closes the dialog when the close button is clicked', () => { + const fixture = MockRender( + PaymentIntentConfirmComponent, + {}, + { detectChanges: false }, + ); + fixture.point.componentInstance.onDoneClick(); + + expect(mockDialogRef.close).toHaveBeenCalled(); + }); +}); diff --git a/src/app/core/components/payment-intent-confirm/payment-intent-confirm.component.ts b/src/app/core/components/payment-intent-confirm/payment-intent-confirm.component.ts new file mode 100644 index 000000000..52b8b18c4 --- /dev/null +++ b/src/app/core/components/payment-intent-confirm/payment-intent-confirm.component.ts @@ -0,0 +1,69 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { DialogRef } from '@angular/cdk/dialog'; +import { loadStripe, Stripe } from '@stripe/stripe-js'; +import { AccountService } from '@shared/services/account/account.service'; +import { EventService } from '@shared/services/event/event.service'; +import { SecretsService } from '@shared/services/secrets/secrets.service'; + +const PRICE_PER_GB = 10; +const BYTES_PER_GIB = 1073741824; + +type ConfirmationStage = 'loading' | 'success' | 'failure' | 'missing'; + +@Component({ + selector: 'pr-payment-intent-confirm', + templateUrl: './payment-intent-confirm.component.html', + standalone: false, +}) +export class PaymentIntentConfirmComponent implements OnInit { + public stage: ConfirmationStage = 'loading'; + public amountInGb = 0; + + constructor( + private route: ActivatedRoute, + private accountService: AccountService, + private event: EventService, + private dialogRef: DialogRef, + ) {} + + public onDoneClick(): void { + this.dialogRef.close(); + } + + public async ngOnInit(): Promise { + const clientSecret = this.route.snapshot.queryParamMap.get( + 'payment_intent_client_secret', + ); + + if (!clientSecret) { + this.stage = 'missing'; + return; + } + + const stripe = await this.loadStripeClient(); + if (!stripe) { + this.stage = 'failure'; + return; + } + + const { paymentIntent } = await stripe.retrievePaymentIntent(clientSecret); + + if (paymentIntent?.status === 'succeeded') { + const amountInUSD = Math.floor(paymentIntent.amount / 100); + const sizeInBytes = + Math.floor(amountInUSD / PRICE_PER_GB) * BYTES_PER_GIB; + + this.event.dispatch({ entity: 'account', action: 'purchase_storage' }); + this.accountService.addStorageBytes(sizeInBytes); + this.amountInGb = Math.floor(amountInUSD / PRICE_PER_GB); + this.stage = 'success'; + } else { + this.stage = 'failure'; + } + } + + protected async loadStripeClient(): Promise { + return await loadStripe(SecretsService.getStatic('STRIPE_API_KEY')); + } +} diff --git a/src/app/core/components/payment-intent-form/payment-intent-form.component.html b/src/app/core/components/payment-intent-form/payment-intent-form.component.html new file mode 100644 index 000000000..3600f77a5 --- /dev/null +++ b/src/app/core/components/payment-intent-form/payment-intent-form.component.html @@ -0,0 +1,71 @@ +
+ @if (waiting) { + + } + + @if (stage === 'amount') { +
+
+ @for (level of amountLevels; track level) { +
+ ${{ level }}
{{ level / 10 }} GB +
+ } +
+ $ +
+
+
+ + } + + @if (stage === 'payment') { +
+
+
+
+ + + +
+
+
+ @if (errorMessage) { +

{{ errorMessage }}

+ } + + } + + @if (stage === 'success') { +

+ Success! {{ amountInGb }} GB of Permanent storage has been added to your + account. +

+ } +
diff --git a/src/app/core/components/payment-intent-form/payment-intent-form.component.scss b/src/app/core/components/payment-intent-form/payment-intent-form.component.scss new file mode 100644 index 000000000..5f3599332 --- /dev/null +++ b/src/app/core/components/payment-intent-form/payment-intent-form.component.scss @@ -0,0 +1,90 @@ +@import 'variables'; + +:host { + display: block; +} + +button { + margin-bottom: 0; +} + +.input-group-vertical { + margin: 0px; + border-radius: 0.25rem; + background-color: white; + overflow: hidden; +} + +.input-vertical.checkbox { + font-size: 85%; + overflow: hidden; + + img { + height: 20px; + float: right; + } +} + +.input-vertical-error { + color: $danger; + padding-top: $grid-unit * 0.5; +} + +.success-message { + margin-top: $grid-unit; +} + +.pledge-buttons { + display: flex; + overflow: hidden; + border-radius: 0.25rem; + + .pledge-button { + padding: 0.85rem 0; + line-height: 1.5; + flex: 1 1 auto; + flex-wrap: wrap; + display: flex; + align-items: center; + justify-content: center; + background: $PR-orange; + color: white; + font-weight: 500; + cursor: pointer; + user-select: none; + transition: $btn-transition; + + span { + font-weight: 300; + font-size: 80%; + flex: 0 0 100%; + text-align: center; + line-height: 1.2; + } + + &:hover { + transition: $btn-transition; + background: darken($PR-orange, 10%); + } + + &.active { + transition: 0s all; + background: darken($PR-orange, 7%); + box-shadow: $btn-active-box-shadow; + } + + &:focus { + box-shadow: 0 0 0 $btn-focus-width rgba($PR-orange, 0.5); + } + + input { + display: inline-block; + width: 4em; + padding: 0.25rem; + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + appearance: none; + } + } + } +} diff --git a/src/app/core/components/payment-intent-form/payment-intent-form.component.spec.ts b/src/app/core/components/payment-intent-form/payment-intent-form.component.spec.ts new file mode 100644 index 000000000..6fc6d0672 --- /dev/null +++ b/src/app/core/components/payment-intent-form/payment-intent-form.component.spec.ts @@ -0,0 +1,170 @@ +import { MockBuilder, MockRender } from 'ng-mocks'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { FormsModule } from '@angular/forms'; +import { AccountService } from '@shared/services/account/account.service'; +import { MessageService } from '@shared/services/message/message.service'; +import { EventService } from '@shared/services/event/event.service'; +import { ApiService } from '@shared/services/api/api.service'; +import { StoragePurchaseIntentResponse } from '@shared/services/api/billing.repo'; +import { PaymentIntentFormComponent } from './payment-intent-form.component'; + +describe('PaymentIntentFormComponent', () => { + let mockAccountService: any; + let mockMessageService: any; + let mockEventService: any; + let mockApiService: any; + let mockPaymentElement: any; + let mockElements: any; + let mockStripe: any; + + beforeEach(async () => { + mockAccountService = { + addStorageBytes: jasmine.createSpy('addStorageBytes'), + }; + + mockMessageService = { + showError: jasmine.createSpy('showError'), + }; + + mockEventService = { + dispatch: jasmine.createSpy('dispatch'), + }; + + mockApiService = { + billing: { + createStoragePurchaseIntent: jasmine + .createSpy('createStoragePurchaseIntent') + .and.returnValue( + Promise.resolve( + new StoragePurchaseIntentResponse({ + clientSecret: 'pi_test_secret_abc', + }), + ), + ), + }, + }; + + mockPaymentElement = { + mount: jasmine.createSpy('mount'), + unmount: jasmine.createSpy('unmount'), + }; + + mockElements = { + create: jasmine.createSpy('create').and.returnValue(mockPaymentElement), + }; + + mockStripe = { + elements: jasmine.createSpy('elements').and.returnValue(mockElements), + confirmPayment: jasmine.createSpy('confirmPayment'), + }; + + await MockBuilder(PaymentIntentFormComponent) + .keep(HttpClientTestingModule, { export: true }) + .keep(FormsModule) + .provide({ provide: AccountService, useValue: mockAccountService }) + .provide({ provide: MessageService, useValue: mockMessageService }) + .provide({ provide: EventService, useValue: mockEventService }) + .provide({ provide: ApiService, useValue: mockApiService }); + }); + + it('should create', () => { + const fixture = MockRender(PaymentIntentFormComponent); + + expect(fixture.point.componentInstance).toBeTruthy(); + }); + + it('creates a storage purchase intent and moves to the payment stage', async () => { + const fixture = MockRender(PaymentIntentFormComponent); + const instance = fixture.point.componentInstance; + spyOn(instance, 'loadStripeClient' as any).and.returnValue( + Promise.resolve(mockStripe), + ); + + instance.amountSelection = 10; + await instance.continueToPayment(); + + expect( + mockApiService.billing.createStoragePurchaseIntent, + ).toHaveBeenCalledWith(10); + + expect(instance.stage).toBe('payment'); + }); + + it('shows a toast error when creating the purchase intent fails', async () => { + mockApiService.billing.createStoragePurchaseIntent.and.returnValue( + Promise.reject({ error: { errors: [{ message: 'Card declined.' }] } }), + ); + + const fixture = MockRender(PaymentIntentFormComponent); + const instance = fixture.point.componentInstance; + + instance.amountSelection = 10; + await instance.continueToPayment(); + + expect(mockMessageService.showError).toHaveBeenCalledWith({ + message: 'Card declined.', + }); + + expect(instance.stage).toBe('amount'); + }); + + it('adds storage on a successful payment confirmation', async () => { + mockStripe.confirmPayment.and.returnValue( + Promise.resolve({ paymentIntent: { status: 'succeeded' } }), + ); + + const fixture = MockRender(PaymentIntentFormComponent); + const instance = fixture.point.componentInstance; + spyOn(instance, 'loadStripeClient' as any).and.returnValue( + Promise.resolve(mockStripe), + ); + + instance.amountSelection = 10; + await instance.continueToPayment(); + await instance.submitPayment(); + + expect(mockAccountService.addStorageBytes).toHaveBeenCalledWith(1073741824); + + expect(mockEventService.dispatch).toHaveBeenCalledWith({ + entity: 'account', + action: 'purchase_storage', + }); + + expect(instance.stage).toBe('success'); + }); + + it('sets an inline error when Stripe returns an error', async () => { + mockStripe.confirmPayment.and.returnValue( + Promise.resolve({ error: { message: 'Your card was declined.' } }), + ); + + const fixture = MockRender(PaymentIntentFormComponent); + const instance = fixture.point.componentInstance; + spyOn(instance, 'loadStripeClient' as any).and.returnValue( + Promise.resolve(mockStripe), + ); + + instance.amountSelection = 10; + await instance.continueToPayment(); + await instance.submitPayment(); + + expect(instance.errorMessage).toBe('Your card was declined.'); + expect(instance.stage).toBe('payment'); + expect(mockAccountService.addStorageBytes).not.toHaveBeenCalled(); + }); + + it('unmounts the payment element on destroy', async () => { + const fixture = MockRender(PaymentIntentFormComponent); + const instance = fixture.point.componentInstance; + spyOn(instance, 'loadStripeClient' as any).and.returnValue( + Promise.resolve(mockStripe), + ); + + instance.amountSelection = 10; + await instance.continueToPayment(); + + instance.ngOnDestroy(); + + expect(mockPaymentElement.unmount).toHaveBeenCalled(); + }); +}); diff --git a/src/app/core/components/payment-intent-form/payment-intent-form.component.ts b/src/app/core/components/payment-intent-form/payment-intent-form.component.ts new file mode 100644 index 000000000..93df2b7ae --- /dev/null +++ b/src/app/core/components/payment-intent-form/payment-intent-form.component.ts @@ -0,0 +1,158 @@ +import { Component, ElementRef, OnDestroy, ViewChild } from '@angular/core'; +import { + loadStripe, + Stripe, + StripeElements, + StripePaymentElement, +} from '@stripe/stripe-js'; +import { ApiService } from '@shared/services/api/api.service'; +import { AccountService } from '@shared/services/account/account.service'; +import { MessageService } from '@shared/services/message/message.service'; +import { EventService } from '@shared/services/event/event.service'; +import { SecretsService } from '@shared/services/secrets/secrets.service'; + +const PRICE_PER_GB = 10; +const BYTES_PER_GIB = 1073741824; + +type PurchaseStage = 'amount' | 'payment' | 'success'; + +@Component({ + selector: 'pr-payment-intent-form', + templateUrl: './payment-intent-form.component.html', + styleUrls: ['./payment-intent-form.component.scss'], + standalone: false, +}) +export class PaymentIntentFormComponent implements OnDestroy { + @ViewChild('customAmountInput') customAmountInput: ElementRef; + + public stage: PurchaseStage = 'amount'; + public amountLevels = [10, 20, 50]; + public amountSelection: number | 'custom' = 10; + public customAmount = 10; + public waiting = false; + public errorMessage: string | null = null; + public amountInGb = 0; + + private stripe: Stripe | null = null; + private elements: StripeElements | null = null; + private paymentElement: StripePaymentElement | null = null; + private sizeInBytes = 0; + private paymentElementContainerRef: ElementRef | undefined; + + constructor( + private api: ApiService, + private accountService: AccountService, + private message: MessageService, + private event: EventService, + ) {} + + @ViewChild('paymentElementContainer') + get paymentElementContainer(): ElementRef | undefined { + return this.paymentElementContainerRef; + } + + set paymentElementContainer(ref: ElementRef | undefined) { + this.paymentElementContainerRef = ref; + if (ref && this.paymentElement) { + this.paymentElement.mount(ref.nativeElement); + } + } + + public ngOnDestroy(): void { + this.paymentElement?.unmount(); + } + + public chooseAmount(amount: number | 'custom'): void { + this.amountSelection = amount; + if (amount === 'custom') { + this.customAmountInput?.nativeElement.focus(); + } + } + + public getStorageAmount(amountInUSD: number): number { + return Math.floor(amountInUSD / PRICE_PER_GB); + } + + public getSelectedAmount(): number { + return this.amountSelection === 'custom' + ? Number(this.customAmount) + : this.amountSelection; + } + + public async continueToPayment(): Promise { + const amountInUSD = Math.floor(this.getSelectedAmount()); + if (!amountInUSD || amountInUSD < 1) { + return; + } + + this.waiting = true; + this.errorMessage = null; + + try { + const response = + await this.api.billing.createStoragePurchaseIntent(amountInUSD); + this.sizeInBytes = this.getStorageAmount(amountInUSD) * BYTES_PER_GIB; + + this.stripe = await this.loadStripeClient(); + if (!this.stripe) { + throw new Error('Unable to load Stripe.'); + } + + this.elements = this.stripe.elements({ + clientSecret: response.clientSecret, + }); + this.paymentElement = this.elements.create('payment'); + + this.stage = 'payment'; + } catch (err) { + this.message.showError({ message: this.getRequestErrorMessage(err) }); + } finally { + this.waiting = false; + } + } + + public async submitPayment(): Promise { + if (!this.stripe || !this.elements) { + return; + } + + this.waiting = true; + this.errorMessage = null; + + const result = await this.stripe.confirmPayment({ + elements: this.elements, + confirmParams: { return_url: this.buildReturnUrl() }, + redirect: 'if_required', + }); + + this.waiting = false; + + if (result.error) { + this.errorMessage = + result.error.message ?? 'Your payment could not be completed.'; + return; + } + + this.event.dispatch({ entity: 'account', action: 'purchase_storage' }); + this.accountService.addStorageBytes(this.sizeInBytes); + this.amountInGb = this.getStorageAmount( + Math.floor(this.getSelectedAmount()), + ); + this.stage = 'success'; + } + + protected async loadStripeClient(): Promise { + return await loadStripe(SecretsService.getStatic('STRIPE_API_KEY')); + } + + private buildReturnUrl(): string { + return `${window.location.origin}/app/(private//dialog:storage-purchase-confirm)`; + } + + private getRequestErrorMessage(err: any): string { + return ( + err?.error?.errors?.[0]?.message ?? + 'Something went wrong. Please try again.' + ); + } +} diff --git a/src/app/core/components/storage-dialog/storage-dialog.component.html b/src/app/core/components/storage-dialog/storage-dialog.component.html index 355bbe9a9..c5b725d88 100644 --- a/src/app/core/components/storage-dialog/storage-dialog.component.html +++ b/src/app/core/components/storage-dialog/storage-dialog.component.html @@ -59,7 +59,11 @@ >Click here to learn more about our endowment model.

- + @if (showPaymentIntentFlow) { + + } @else { + + }