Skip to content

Commit

Permalink
DON-1065: Move payment methods out from My Account to a separate page
Browse files Browse the repository at this point in the history
  • Loading branch information
bdsl committed Dec 18, 2024
1 parent bd90164 commit 4d6e7b2
Show file tree
Hide file tree
Showing 8 changed files with 354 additions and 217 deletions.
15 changes: 15 additions & 0 deletions src/app/app-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {Person} from "./person.model";
import {firstValueFrom} from "rxjs";
import {MandateComponent} from "./mandate/mandate.component";
import {Mandate} from "./mandate.model";
import {MyPaymentMethodsComponent} from "./my-payment-methods/my-payment-methods.component";

export const registerPath = 'register';
export const myAccountPath = 'my-account';
Expand Down Expand Up @@ -182,6 +183,20 @@ const routes: Routes = [
requireLogin,
],
},
{
path: 'my-account/payment-methods',
pathMatch: 'full',
resolve: {
person: async () => await firstValueFrom(inject(IdentityService).getLoggedInPerson()),
paymentMethods: async () => {
return await inject(DonationService).getPaymentMethods();
},
},
component: MyPaymentMethodsComponent,
canActivate: [
requireLogin,
],
},
{
path: ':campaignSlug/:fundSlug',
pathMatch: 'full',
Expand Down
25 changes: 16 additions & 9 deletions src/app/donation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {isPlatformServer} from '@angular/common';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import {Inject, Injectable, InjectionToken, makeStateKey, Optional, PLATFORM_ID, TransferState,} from '@angular/core';
import {SESSION_STORAGE, StorageService} from 'ngx-webstorage-service';
import {Observable, of} from 'rxjs';
import {firstValueFrom, Observable, of} from 'rxjs';
import {ConfirmationToken, PaymentIntent, PaymentMethod} from '@stripe/stripe-js';

import {COUNTRY_CODE} from './country-code.token';
Expand Down Expand Up @@ -170,17 +170,24 @@ export class DonationService {
);
}

getPaymentMethods(
personId?: string,
jwt?: string,
{cacheBust}: { cacheBust?: boolean} = {cacheBust: false}
): Observable<{ data: PaymentMethod[] }> {
async getPaymentMethods(
{cacheBust}: { cacheBust?: boolean } = {cacheBust: false}
): Promise<PaymentMethod[]> {
const jwt = this.identityService.getJWT();
const person = await firstValueFrom(this.identityService.getLoggedInPerson());

if (!person) {
throw new Error("logged in person required");
}

const cacheBuster = cacheBust ? ("?t=" + new Date().getTime()) : '';

return this.http.get<{ data: PaymentMethod[] }>(
`${environment.donationsApiPrefix}/people/${personId}/payment_methods${cacheBuster}`,
const response = await firstValueFrom(this.http.get<{ data: PaymentMethod[] }>(
`${environment.donationsApiPrefix}/people/${person.id}/payment_methods${cacheBuster}`,
getPersonAuthHttpOptions(jwt),
);
));

return response.data;
}

create(donation: Donation, personId?: string, jwt?: string): Observable<DonationCreatedResponse> {
Expand Down
75 changes: 0 additions & 75 deletions src/app/my-account/my-account.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,81 +47,6 @@ <h2>Your Details</h2>
</biggive-page-section>

@if (person) {
<biggive-page-section>
<h2>Your Payment Methods</h2>
@if (hasDonationFunds) {
<h3>Donation Funds</h3>
}
@if (hasDonationFunds) {
<div>
<p>Available balance: <strong>{{ ((person.cash_balance?.gbp || 0) / 100) | exactCurrency:'GBP' }}</strong></p>
</div>
}
@if (registerErrorDescription) {
<p class="error" aria-live="assertive">
<fa-icon [icon]="faExclamationTriangle"></fa-icon>
{{ registerErrorDescription }}
</p>
}
@if (registerSucessMessage) {
<p class="update-success" aria-live="assertive">
{{ registerSucessMessage }}
</p>
}
@if (paymentMethods === undefined) {
<div>
<mat-spinner color="accent" [diameter]="40" aria-label="Loading payment methods"></mat-spinner>
</div>
}
<h3>
Saved Cards
</h3>
@if (hasSavedPaymentMethods) {
<table id="paymentMethods">
@for (method of paymentMethods; track method.id) {
<tr>
<td class="cardBrand">
<div class="cardBrand">{{(method.card?.brand || method.type).toUpperCase()}}</div>
@if (method.card !== undefined) {
<div>
Card Ending: {{method.card.last4}}
</div>
}
@if (method.card !== undefined) {
<div class="cardExpiry">
Expiry Date: {{method.card.exp_month.toString().padStart(2, "0")}}/{{method.card.exp_year}}
</div>
}
</td>
<td class="cardExpiry">
@if (method.card !== undefined) {
<div>
Expiry Date: {{method.card.exp_month.toString().padStart(2, "0")}}/{{method.card.exp_year}}
</div>
}
</td>
@if (method.card !== undefined) {
<td class="cardUpdateButton"
>
<!-- href must be set to make link interactable with keyboard-->
<a href="javascript:void(0);" (click)="updateCard(method.id, method.card, method.billing_details)">
Edit
</a>
</td>
}
<td class="cardDeleteButton">
<a href="javascript:void(0);" (click)="deleteMethod(method)">Delete</a>
</td>
</tr>
}
</table>
}
@if (paymentMethods !== undefined && paymentMethods.length === 0) {
<div>
No saved cards
</div>
}
</biggive-page-section>
}
</div>
</main>
140 changes: 7 additions & 133 deletions src/app/my-account/my-account.component.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import { Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import {PageMetaService} from '../page-meta.service';
import {DatePipe, isPlatformBrowser} from '@angular/common';
import {DatePipe} from '@angular/common';
import {IdentityService} from "../identity.service";
import {Person} from "../person.model";
import {Router} from "@angular/router";
import {PaymentMethod} from "@stripe/stripe-js";
import {DonationService} from "../donation.service";
import {UpdateCardModalComponent} from "../update-card-modal/update-card-modal.component";
import {MatDialog} from "@angular/material/dialog";
import {faExclamationTriangle} from "@fortawesome/free-solid-svg-icons";
import { HttpErrorResponse } from "@angular/common/http";
import {flags} from "../featureFlags";
import { HighlightCard } from '../highlight-cards/HighlightCard';
import {environment} from "../../environments/environment";
Expand All @@ -20,15 +16,12 @@ import {environment} from "../../environments/environment";
styleUrl: './my-account.component.scss',
providers: [DatePipe]
})
export class MyAccountComponent implements OnDestroy, OnInit {
export class MyAccountComponent implements OnInit {
public person: Person;

public paymentMethods: PaymentMethod[]|undefined = undefined;
registerErrorDescription: string | undefined;
registerSucessMessage: string | undefined;

protected readonly faExclamationTriangle = faExclamationTriangle;

private savedCardsTimer: undefined | ReturnType<typeof setTimeout>; // https://stackoverflow.com/a/56239226
protected readonly flags = flags;

protected readonly actions: HighlightCard[];
Expand All @@ -37,8 +30,6 @@ export class MyAccountComponent implements OnDestroy, OnInit {
private pageMeta: PageMetaService,
public dialog: MatDialog,
private identityService: IdentityService,
private donationService: DonationService,
@Inject(PLATFORM_ID) private platformId: Object,
private router: Router,
) {
this.identityService = identityService;
Expand All @@ -49,11 +40,11 @@ export class MyAccountComponent implements OnDestroy, OnInit {
color: 'brand-mhf-turquoise',
image: new URL(environment.donateUriPrefix + '/assets/images/turquoise-texture.jpg')
},
headerText: 'Transfer Donation Funds',
bodyText: 'Use a bank transfer to make donations to charities on our platform.',
headerText: 'Payment Methods',
bodyText: 'View and manage your payment methods',
button: {
text: '',
href: new URL(environment.donateUriPrefix + '/transfer-funds')
href: new URL(environment.donateUriPrefix + '/my-account/payment-methods')
}
},
{
Expand Down Expand Up @@ -98,124 +89,7 @@ export class MyAccountComponent implements OnDestroy, OnInit {
await this.router.navigate(['']);
} else {
this.person = person;
this.loadPaymentMethods();

if (isPlatformBrowser(this.platformId)) {
this.savedCardsTimer = setTimeout(this.checkForPaymentCardsIfNotLoaded, 5_000);
}
}
});
}

protected get hasSavedPaymentMethods()
{
return this.paymentMethods !== undefined && this.paymentMethods.length > 0;
}

/**
* We only check for GBP balances for now, as we only support UK bank transfers rn
*/
protected get hasDonationFunds()
{
return this.person.cash_balance?.gbp
}

ngOnDestroy() {
if (isPlatformBrowser(this.platformId) && this.savedCardsTimer) {
clearTimeout(this.savedCardsTimer);
}
}

loadPaymentMethods() {
// not so keen on the component using the donation service and the identity service together like this
// would rather call one service and have it do everything for us. Not sure what service would be best to put
// this code in.
this.donationService.getPaymentMethods(this.person.id, this.jwtAsString(), {cacheBust: true})
.subscribe((response: { data: PaymentMethod[] }) => {
this.paymentMethods = response.data;
}
);
}

deleteMethod(method: PaymentMethod) {
this.paymentMethods = undefined;

this.donationService.deleteStripePaymentMethod(this.person, method, this.jwtAsString()).subscribe(
this.loadPaymentMethods.bind(this),
error => {
this.loadPaymentMethods.bind(this)()
alert(error.error.error)
}
)
}

private jwtAsString() {
return this.identityService.getJWT() as string;
}

updateCard(methodId: string, card: PaymentMethod.Card, billingDetails: PaymentMethod.BillingDetails) {
const updateCardDialog = this.dialog.open(UpdateCardModalComponent);
updateCardDialog.componentInstance.setPaymentMethod(card, billingDetails);
updateCardDialog.afterClosed().subscribe((data: unknown) => {
if (data === "null") {
return;
}

const formValue = updateCardDialog.componentInstance.form.value;

const paymentMethodsBeforeUpdate = this.paymentMethods;
this.paymentMethods = undefined;
this.donationService.updatePaymentMethod(
this.person,
this.jwtAsString(),
methodId,
{
expiry: this.parseExpiryDate(formValue.expiryDate),
countryCode: (formValue.billingCountry),
postalCode: formValue.postalCode,
}
).subscribe({
next: () => {
this.registerSucessMessage = "Saved new details for card ending " + card.last4;
this.registerErrorDescription = undefined;
this.loadPaymentMethods()
},
error: (resp: HttpErrorResponse) => {
this.registerSucessMessage = undefined;
this.registerErrorDescription = resp.error.error ?
`Could not update card ending ${card.last4}. ${resp.error.error}` :
("Sorry, update failed for card ending " + card.last4)
this.paymentMethods = paymentMethodsBeforeUpdate;
},
})
})
}

private parseExpiryDate(expiryDate: string): {year: number, month: number} {
const [month, year] = expiryDate.split('/')
.map((number: string) => parseInt(number));

if (! year) {
throw new Error("Failed to parse expiry date, missing year");
}


if (! month) {
throw new Error("Failed to parse expiry date, missing month");
}

return {year, month}
}

/**
* To work around a bug where it seems sometimes new payment cards are not showing on this page, if there are none
* loaded at this point we check again with the server.
*/
private checkForPaymentCardsIfNotLoaded = () => {
if (this.paymentMethods && this.paymentMethods.length > 0) {
return;
}

this.loadPaymentMethods();
};
}
7 changes: 7 additions & 0 deletions src/app/my-payment-methods/my-payment-methods.component.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { MyPaymentMethodsComponent } from './my-payment-methods.component'

describe('MyPaymentMethodsComponent', () => {
it('should mount', () => {
cy.mount(MyPaymentMethodsComponent)
})
})
Loading

0 comments on commit 4d6e7b2

Please sign in to comment.