From f34e1f9c07c7422f39a0603222a86fe819d4c636 Mon Sep 17 00:00:00 2001
From: Cecilia Krum
Date: Mon, 27 Jul 2026 13:36:46 -0500
Subject: [PATCH] PER-10647: Stripe sketch for design
This walks through and calls Stripe. It's mainly for the purpose of getting design
feedback. Needs review and updates since it was written by Claude.
---
package-lock.json | 10 ++
package.json | 1 +
.../payment-intent-confirm.component.html | 30 ++++
.../payment-intent-confirm.component.spec.ts | 123 +++++++++++++
.../payment-intent-confirm.component.ts | 69 +++++++
.../payment-intent-form.component.html | 71 ++++++++
.../payment-intent-form.component.scss | 90 ++++++++++
.../payment-intent-form.component.spec.ts | 170 ++++++++++++++++++
.../payment-intent-form.component.ts | 158 ++++++++++++++++
.../storage-dialog.component.html | 6 +-
.../storage-dialog.component.spec.ts | 62 +++++++
.../storage-dialog.component.ts | 18 +-
src/app/core/core.module.ts | 4 +
src/app/core/core.routes.ts | 15 ++
.../shared/services/api/billing.repo.spec.ts | 17 ++
src/app/shared/services/api/billing.repo.ts | 27 +++
src/test.ts | 7 +-
17 files changed, 874 insertions(+), 4 deletions(-)
create mode 100644 src/app/core/components/payment-intent-confirm/payment-intent-confirm.component.html
create mode 100644 src/app/core/components/payment-intent-confirm/payment-intent-confirm.component.spec.ts
create mode 100644 src/app/core/components/payment-intent-confirm/payment-intent-confirm.component.ts
create mode 100644 src/app/core/components/payment-intent-form/payment-intent-form.component.html
create mode 100644 src/app/core/components/payment-intent-form/payment-intent-form.component.scss
create mode 100644 src/app/core/components/payment-intent-form/payment-intent-form.component.spec.ts
create mode 100644 src/app/core/components/payment-intent-form/payment-intent-form.component.ts
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 @@
+
+
+
+ @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 @@
+
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 {
+
+ }
Have a code to redeem for free storage? Click here to redeem it.
diff --git a/src/app/core/components/storage-dialog/storage-dialog.component.spec.ts b/src/app/core/components/storage-dialog/storage-dialog.component.spec.ts
index 5f106d962..9e014c496 100644
--- a/src/app/core/components/storage-dialog/storage-dialog.component.spec.ts
+++ b/src/app/core/components/storage-dialog/storage-dialog.component.spec.ts
@@ -5,6 +5,7 @@ import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { DialogRef } from '@angular/cdk/dialog';
import { EventService } from '@shared/services/event/event.service';
+import { FeatureFlagService } from '@root/app/feature-flag/services/feature-flag.service';
import { StorageDialogComponent } from './storage-dialog.component';
@NgModule()
@@ -18,6 +19,10 @@ class MockDialogRef {
describe('StorageDialogComponent', () => {
let mockActivatedRoute;
+ let mockFeatureFlagService: {
+ isEnabled: jasmine.Spy;
+ fetchFromApi: jasmine.Spy;
+ };
const paramMap = new BehaviorSubject(convertToParamMap({}));
const queryParamMap = new BehaviorSubject(convertToParamMap({}));
@@ -27,9 +32,19 @@ describe('StorageDialogComponent', () => {
queryParamMap: queryParamMap.asObservable(),
snapshot: { fragment: null },
};
+ mockFeatureFlagService = {
+ isEnabled: jasmine.createSpy('isEnabled').and.returnValue(false),
+ fetchFromApi: jasmine
+ .createSpy('fetchFromApi')
+ .and.returnValue(Promise.resolve()),
+ };
await MockBuilder(StorageDialogComponent, DummyModule)
.provide({ provide: DialogRef, useClass: MockDialogRef })
.provide({ provide: ActivatedRoute, useValue: mockActivatedRoute })
+ .provide({
+ provide: FeatureFlagService,
+ useValue: mockFeatureFlagService,
+ })
.keep(EventService);
});
@@ -80,4 +95,51 @@ describe('StorageDialogComponent', () => {
expect(eventCalled).toBeTrue();
});
+
+ it('defaults showPaymentIntentFlow to false and renders the legacy pledge form', async () => {
+ const fixture = MockRender(StorageDialogComponent);
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(fixture.point.componentInstance.showPaymentIntentFlow).toBe(false);
+ expect(fixture.nativeElement.querySelector('pr-new-pledge')).not.toBeNull();
+
+ expect(
+ fixture.nativeElement.querySelector('pr-payment-intent-form'),
+ ).toBeNull();
+ });
+
+ it('shows the PaymentIntent form when the flag is enabled', async () => {
+ mockFeatureFlagService.isEnabled.and.callFake(
+ (flag: string) => flag === 'storage-purchase-payment-intent',
+ );
+ const fixture = MockRender(StorageDialogComponent);
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(fixture.point.componentInstance.showPaymentIntentFlow).toBe(true);
+ expect(
+ fixture.nativeElement.querySelector('pr-payment-intent-form'),
+ ).not.toBeNull();
+
+ expect(fixture.nativeElement.querySelector('pr-new-pledge')).toBeNull();
+ });
+
+ it('re-confirms the flag after ngOnInit even if the constructor read a stale value', async () => {
+ // Simulates FeatureFlagService's fire-and-forget bootstrap fetch not
+ // having resolved yet when the dialog's constructor runs.
+ mockFeatureFlagService.isEnabled.and.returnValue(false);
+ const fixture = MockRender(StorageDialogComponent);
+
+ expect(fixture.point.componentInstance.showPaymentIntentFlow).toBe(false);
+
+ mockFeatureFlagService.isEnabled.and.callFake(
+ (flag: string) => flag === 'storage-purchase-payment-intent',
+ );
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(fixture.point.componentInstance.showPaymentIntentFlow).toBe(true);
+ expect(mockFeatureFlagService.fetchFromApi).toHaveBeenCalled();
+ });
});
diff --git a/src/app/core/components/storage-dialog/storage-dialog.component.ts b/src/app/core/components/storage-dialog/storage-dialog.component.ts
index c2ad1a0fe..330605a9c 100644
--- a/src/app/core/components/storage-dialog/storage-dialog.component.ts
+++ b/src/app/core/components/storage-dialog/storage-dialog.component.ts
@@ -4,6 +4,7 @@ import { Subscription } from 'rxjs';
import { unsubscribeAll } from '@shared/utilities/hasSubscriptions';
import { EventService } from '@shared/services/event/event.service';
import { DialogRef } from '@angular/cdk/dialog';
+import { FeatureFlagService } from '@root/app/feature-flag/services/feature-flag.service';
const dialogTabs = ['add', 'file', 'transaction', 'promo', 'gift'] as const;
@@ -19,18 +20,23 @@ export class StorageDialogComponent implements OnInit, OnDestroy {
public tabs = ['add', 'gift', 'promo', 'transaction', 'file'];
public subscriptions: Subscription[] = [];
public promoCode: string | undefined;
+ public showPaymentIntentFlow: boolean;
constructor(
private dialogRef: DialogRef,
private route: ActivatedRoute,
private event: EventService,
+ private feature: FeatureFlagService,
) {
if (([...dialogTabs] as string[]).includes(route.snapshot.fragment)) {
this.activeTab = route.snapshot.fragment as StorageDialogTab;
}
+ this.showPaymentIntentFlow = this.feature.isEnabled(
+ 'storage-purchase-payment-intent',
+ );
}
- public ngOnInit(): void {
+ public async ngOnInit(): Promise {
this.subscriptions.push(
this.route.queryParamMap.subscribe((params) => {
const param = params.get('promoCode');
@@ -39,6 +45,16 @@ export class StorageDialogComponent implements OnInit, OnDestroy {
}
}),
);
+
+ // The constructor's isEnabled() check above is a best-effort guess —
+ // FeatureFlagService's initial fetch is fire-and-forget at app bootstrap
+ // and may not have resolved yet if this dialog opens shortly after page
+ // load. Awaiting a fresh fetch here guarantees showPaymentIntentFlow
+ // reflects the real flag state once the dialog has actually rendered.
+ await this.feature.fetchFromApi();
+ this.showPaymentIntentFlow = this.feature.isEnabled(
+ 'storage-purchase-payment-intent',
+ );
}
public ngOnDestroy(): void {
diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts
index 8b0d32229..c6ede007d 100644
--- a/src/app/core/core.module.ts
+++ b/src/app/core/core.module.ts
@@ -60,6 +60,8 @@ import { AccountSecurityComponent } from './components/account-security/account-
import { ChangePasswordComponent } from './components/change-password/change-password.component';
import { TwoFactorAuthComponent } from './components/two-factor-auth/two-factor-auth.component';
import { RedeemGiftComponent } from './components/redeem-gift/redeem-gift.component';
+import { PaymentIntentFormComponent } from './components/payment-intent-form/payment-intent-form.component';
+import { PaymentIntentConfirmComponent } from './components/payment-intent-confirm/payment-intent-confirm.component';
@NgModule({
imports: [
@@ -116,6 +118,8 @@ import { RedeemGiftComponent } from './components/redeem-gift/redeem-gift.compon
ChangePasswordComponent,
TwoFactorAuthComponent,
RedeemGiftComponent,
+ PaymentIntentFormComponent,
+ PaymentIntentConfirmComponent,
],
providers: [
FolderViewService,
diff --git a/src/app/core/core.routes.ts b/src/app/core/core.routes.ts
index cf9166c7d..3dd66b4b7 100644
--- a/src/app/core/core.routes.ts
+++ b/src/app/core/core.routes.ts
@@ -25,6 +25,7 @@ import { ArchiveSettingsDialogComponent } from './components/archive-settings-di
import { MembersDialogComponent } from './components/members-dialog/members-dialog.component';
import { AccountSettingsDialogComponent } from './components/account-settings-dialog/account-settings-dialog.component';
import { InvitationsDialogComponent } from './components/invitations-dialog/invitations-dialog.component';
+import { PaymentIntentConfirmComponent } from './components/payment-intent-confirm/payment-intent-confirm.component';
const rootFolderResolve = {
rootFolder: RootFolderResolveService,
@@ -274,6 +275,20 @@ export const routes: RoutesWithData = [
path: 'storage',
redirectTo: '/app/(private//dialog:storage/)',
},
+ {
+ path: 'storage-purchase-confirm',
+ component: RoutedDialogWrapperComponent,
+ outlet: 'dialog',
+ data: {
+ title: 'Storage Purchase',
+ component: PaymentIntentConfirmComponent,
+ dialogOptions: { width: '1000px' },
+ },
+ },
+ {
+ path: 'storage-purchase-confirm',
+ redirectTo: '/app/(private//dialog:storage-purchase-confirm)',
+ },
{
path: 'search',
component: GlobalSearchResultsComponent,
diff --git a/src/app/shared/services/api/billing.repo.spec.ts b/src/app/shared/services/api/billing.repo.spec.ts
index 8eef90687..00cd095bc 100644
--- a/src/app/shared/services/api/billing.repo.spec.ts
+++ b/src/app/shared/services/api/billing.repo.spec.ts
@@ -9,6 +9,7 @@ import { HttpService } from '@shared/services/http/http.service';
import {
BillingRepo,
ClaimingPromoResponse,
+ StoragePurchaseIntentResponse,
} from '@shared/services/api/billing.repo';
import {
provideHttpClient,
@@ -71,4 +72,20 @@ describe('BillingRepo', () => {
expect(response).toEqual([mockResponse]);
});
+
+ it('creates a storage purchase intent and unwraps the data envelope', async () => {
+ const promise = repo.createStoragePurchaseIntent(10);
+
+ const req = httpMock.expectOne(apiUrl('/v2/storage-purchases'));
+
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual({ amountInUSD: 10 });
+
+ req.flush({ data: { clientSecret: 'pi_test_secret_abc' } });
+ const response = await promise;
+
+ expect(response).toEqual(
+ new StoragePurchaseIntentResponse({ clientSecret: 'pi_test_secret_abc' }),
+ );
+ });
});
diff --git a/src/app/shared/services/api/billing.repo.ts b/src/app/shared/services/api/billing.repo.ts
index 4dce97142..d8d74c8a4 100644
--- a/src/app/shared/services/api/billing.repo.ts
+++ b/src/app/shared/services/api/billing.repo.ts
@@ -90,6 +90,21 @@ export class BillingRepo extends BaseRepo {
this.httpV2.post('v2/billing/gift', data, GiftingResponse),
).toPromise();
}
+
+ public async createStoragePurchaseIntent(
+ amountInUSD: number,
+ ): Promise {
+ const data = { amountInUSD };
+
+ const response = await getFirst(
+ this.httpV2.post<{ data: { clientSecret: string } }>(
+ 'v2/storage-purchases',
+ data,
+ ),
+ ).toPromise();
+
+ return new StoragePurchaseIntentResponse(response.data);
+ }
}
export class BillingResponse extends BaseResponse {
@@ -136,6 +151,18 @@ export class GiftingResponse {
}
}
+export class StoragePurchaseIntentResponse {
+ clientSecret: string;
+
+ constructor(props: Object) {
+ for (const prop in props) {
+ if (Object.hasOwn(props, prop)) {
+ this[prop] = props[prop];
+ }
+ }
+ }
+}
+
export class ClaimingPromoResponse {
promoId: number;
code: string;
diff --git a/src/test.ts b/src/test.ts
index 10eca7084..b812e91e4 100644
--- a/src/test.ts
+++ b/src/test.ts
@@ -20,7 +20,10 @@ import {
import { CountUpDirective, CountUpModule } from 'ngx-countup';
import { installNgMocksMatchers } from './test/ng-mocks-matchers';
-window.Stripe = () => ({
+// Cast to `any`: @stripe/stripe-js augments the global `Window.Stripe` type
+// with the real (strict) Stripe.js typings, which this loose test mock
+// intentionally does not implement in full.
+window.Stripe = (() => ({
elements: () => ({
create: (card, options) => {},
}),
@@ -31,7 +34,7 @@ window.Stripe = () => ({
},
error: false,
}),
-});
+})) as any;
// Disable loading of external Google Maps API
window.doNotLoadGoogleMapsAPI = true;