Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<div class="dialog-content">
<div class="header">
<span>Storage Purchase</span>
<button class="btn" (click)="onDoneClick()">
<i class="material-icons">close</i>
</button>
</div>
<div class="content">
@switch (stage) {
@case ('loading') {
<pr-loading-spinner [isFullScreen]="true"></pr-loading-spinner>
}
@case ('success') {
<p class="success-message">
Success! {{ amountInGb }} GB of Permanent storage has been added to
your account.
</p>
}
@case ('failure') {
<p>
We couldn't confirm your payment. If you were charged, please contact
support.
</p>
}
@case ('missing') {
<p>We couldn't find that purchase.</p>
}
}
</div>
</div>
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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<void> {
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<Stripe | null> {
return await loadStripe(SecretsService.getStatic('STRIPE_API_KEY'));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<div class="payment-intent-form">
@if (waiting) {
<pr-loading-spinner [isFullScreen]="true"></pr-loading-spinner>
}

@if (stage === 'amount') {
<div class="input-group-vertical">
<div class="input-vertical pledge-buttons">
@for (level of amountLevels; track level) {
<div
class="pledge-button"
[ngClass]="{ active: amountSelection === level }"
(click)="chooseAmount(level)"
>
${{ level }} <br /><span>{{ level / 10 }} GB</span>
</div>
}
<div
class="pledge-button"
[ngClass]="{ active: amountSelection === 'custom' }"
(click)="chooseAmount('custom')"
>
$<input
#customAmountInput
type="number"
class="form-control"
[(ngModel)]="customAmount"
min="1"
/>
</div>
</div>
</div>
<button
class="btn btn-primary"
[disabled]="!getSelectedAmount() || waiting"
(click)="continueToPayment()"
>
Continue to Payment ({{ getStorageAmount(getSelectedAmount()) }} GB)
</button>
}

@if (stage === 'payment') {
<div class="input-group-vertical">
<div class="input-vertical" #paymentElementContainer></div>
<div class="input-vertical checkbox">
<div class="form-check">
<a href="https://stripe.com" target="_blank">
<img src="assets/img/powered_by_stripe@2x.png" alt="" />
</a>
</div>
</div>
</div>
@if (errorMessage) {
<p class="input-vertical-error">{{ errorMessage }}</p>
}
<button
class="btn btn-primary"
[disabled]="waiting"
(click)="submitPayment()"
>
Purchase {{ getStorageAmount(getSelectedAmount()) }} GB
</button>
}

@if (stage === 'success') {
<p class="success-message">
Success! {{ amountInGb }} GB of Permanent storage has been added to your
account.
</p>
}
</div>
Loading