Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DON-1112: Add user-friendly validation for amount errors on regular g… #1821

Merged
merged 4 commits into from
Jan 13, 2025
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ import {IdentityService} from '../../identity.service';
import {ConversionTrackingService} from '../../conversionTracking.service';
import {PageMetaService} from '../../page-meta.service';
import {Person} from '../../person.model';
import {PostcodeService} from '../../postcode.service';
import {billingPostcodeRegExp, postcodeFormatHelpRegExp, postcodeRegExp, PostcodeService} from '../../postcode.service';
import {retryStrategy} from '../../observable-retry';
import {StripeService} from '../../stripe.service';
import {getStripeFriendlyError, StripeService} from '../../stripe.service';
import {getCurrencyMaxValidator} from '../../validators/currency-max';
import {getCurrencyMinValidator} from '../../validators/currency-min';
import {EMAIL_REGEXP} from '../../validators/patterns';
Expand Down Expand Up @@ -186,17 +186,6 @@ export class DonationStartFormComponent implements AfterContentChecked, AfterCon

tipPercentage = 15;
tipValue: number | undefined;
/**
* Used just to take raw input and put together an all-caps, spaced UK postcode, assuming the
* input was valid (even if differently formatted). Loosely based on https://stackoverflow.com/a/10701634/2803757
* with an additional tweak to allow (and trim) surrounding spaces.
*/
private postcodeFormatHelpRegExp = new RegExp('^\\s*([A-Z]{1,2}\\d{1,2}[A-Z]?)\\s*(\\d[A-Z]{2})\\s*$');
// Based on the simplified pattern suggestions in https://stackoverflow.com/a/51885364/2803757
private postcodeRegExp = new RegExp('^([A-Z][A-HJ-Y]?\\d[A-Z\\d]? \\d[A-Z]{2}|GIR 0A{2})$');

// Intentionally looser to support most countries' formats.
private billingPostcodeRegExp = new RegExp('^[0-9a-zA-Z -]{2,8}$');

private idCaptchaCode?: string;
private stripeResponseErrorCode?: string; // stores error codes returned by Stripe after callout
Expand Down Expand Up @@ -777,7 +766,7 @@ export class DonationStartFormComponent implements AfterContentChecked, AfterCon
this.paymentReadinessTracker.onStripeCardChange(state);

if (state.error) {
this.stripeError = this.getStripeFriendlyError(state.error, 'card_change');
this.stripeError = getStripeFriendlyError(state.error, 'card_change');
this.toast.showError(this.stripeError);
this.stripeResponseErrorCode = state.error.code;
} else {
Expand Down Expand Up @@ -1439,70 +1428,17 @@ export class DonationStartFormComponent implements AfterContentChecked, AfterCon

private handleStripeError(
error: StripeError | {message: string, code: string, decline_code?: string} | undefined,
context: string,
context: 'method_setup'| 'card_change'| 'confirm',
) {
this.submitting = false;
this.stripeError = this.getStripeFriendlyError(error, context);
this.stripeError = getStripeFriendlyError(error, context);
this.toast.showError(this.stripeError);
this.stripeResponseErrorCode = error?.code;

this.jumpToStep('Payment details');
this.goToFirstVisibleError();
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

function below moved to stripe.service.ts to allow sharing with new component. Not sure if that's the best place to move it to - not sure if its idiomatic or not to put extra stuff in the file alongside the service class in Angular.

/**
* @param error
* @param context 'method_setup', 'card_change' or 'confirm'.
*/
private getStripeFriendlyError(
error: StripeError | {message: string, code: string, decline_code?: string, description?: string} | undefined,
context: string,
): string {


let prefix = '';
switch (context) {
case 'method_setup':
prefix = 'Payment setup failed: ';
break;
case 'card_change':
prefix = 'Payment method update failed: ';
break;
case 'confirm':
prefix = 'Payment processing failed: ';
}

if (! error || (! error.message && ! error.code)) {
if (error && error.hasOwnProperty('description')) {
// @ts-ignore - not sure why TS doesn't recognise that it must have a description because I just checked
// with hasOwnProperty.
return `${prefix}${error!.description}`;
}
return `${prefix}Sorry, we encountered an error. Please try again in a moment or contact us if this message persists.`;
}

let friendlyError = error.message;

let customMessage = false;
if (error.code === 'card_declined' && error.decline_code === 'generic_decline') {
// Probably a custom Radar rule -> relatively likely to be an incorrect postcode.
friendlyError = `The payment was declined. Please ensure details provided (including postcode) match your card. Contact your bank or [email protected] if the problem persists.`;
customMessage = true;
}

if (error.code === 'card_declined' && error.decline_code === 'invalid_amount') {
// We've seen e.g. HSBC in Nov '23 decline large donations with this code.
friendlyError = 'The payment was declined. You might need to contact your bank before making a donation of this amount.';
customMessage = true;
}

if (customMessage && context === 'confirm') {
prefix = ''; // Don't show extra context info in the most common `context`, when showing our already-long custom copy.
}

return `${prefix}${friendlyError}`;
}

private isBillingPostcodePossiblyInvalid() {
return this.stripeResponseErrorCode === 'card_declined';
}
Expand Down Expand Up @@ -2016,7 +1952,7 @@ export class DonationStartFormComponent implements AfterContentChecked, AfterCon

// Uppercase it in-place, then we can use patterns that assume upper case.
homePostcode = homePostcode.toUpperCase();
var parts = homePostcode.match(this.postcodeFormatHelpRegExp);
var parts = homePostcode.match(postcodeFormatHelpRegExp);
if (parts === null) {
// If the input doesn't even match the much looser pattern here, it's going to fail
// the validator check in a moment and there's nothing we can/should do with it
Expand Down Expand Up @@ -2113,7 +2049,7 @@ export class DonationStartFormComponent implements AfterContentChecked, AfterCon

return [
requiredNotBlankValidator,
Validators.pattern(this.postcodeRegExp),
Validators.pattern(postcodeRegExp),
];
}

Expand All @@ -2128,7 +2064,7 @@ export class DonationStartFormComponent implements AfterContentChecked, AfterCon
]);
this.paymentGroup.controls.billingPostcode!.setValidators([
requiredNotBlankValidator,
Validators.pattern(this.billingPostcodeRegExp),
Validators.pattern(billingPostcodeRegExp),
]);
}

Expand Down
12 changes: 12 additions & 0 deletions src/app/postcode.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ import { environment } from '../environments/environment';
import { GiftAidAddress } from './gift-aid-address.model';
import { GiftAidAddressSuggestion } from './gift-aid-address-suggestion.model';

/**
* Used just to take raw input and put together an all-caps, spaced UK postcode, assuming the
* input was valid (even if differently formatted). Loosely based on https://stackoverflow.com/a/10701634/2803757
* with an additional tweak to allow (and trim) surrounding spaces.
*/
export const postcodeFormatHelpRegExp = new RegExp('^\\s*([A-Z]{1,2}\\d{1,2}[A-Z]?)\\s*(\\d[A-Z]{2})\\s*$');
// Based on the simplified pattern suggestions in https://stackoverflow.com/a/51885364/2803757
export const postcodeRegExp = new RegExp('^([A-Z][A-HJ-Y]?\\d[A-Z\\d]? \\d[A-Z]{2}|GIR 0A{2})$');

// Intentionally looser to support most countries' formats.
export const billingPostcodeRegExp = new RegExp('^[0-9a-zA-Z -]{2,8}$');

@Injectable({
providedIn: 'root',
})
Expand Down
33 changes: 30 additions & 3 deletions src/app/regular-giving/regular-giving.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,22 @@
</div>
<p>This amount will be taken from your account today and once every month in future</p>

@if (amountErrorMessage) {
<div
class="error"
aria-live="polite"
>
{{ this.amountErrorMessage }}
</div>
}

<div style="text-align: center">
<button style="width: 40%;"
type="button"
class="continue b-w-100 b-rt-0"
mat-raised-button
color="primary"
(click)="next()"
(click)="this.selectStep(1)"
>Continue
</button>
</div>
Expand Down Expand Up @@ -109,13 +118,22 @@
</biggive-text-input>
}

@if (paymentInfoErrorMessage) {
<div
class="error"
aria-live="polite"
>
{{ paymentInfoErrorMessage }}
</div>
}

<div style="text-align: center">
<button style="width: 40%;"
type="button"
class="continue b-w-100 b-rt-0"
mat-raised-button
color="primary"
(click)="next()"
(click)="this.selectStep(2)"
>Continue
</button>
</div>
Expand Down Expand Up @@ -150,7 +168,16 @@
<mat-icon class="b-va-bottom" aria-hidden="false" aria-label="Open in new tab">open_in_new</mat-icon>
read our Privacy Statement.</a>
</p>
<p>(todo: add link to regular giving terms & conditions)</p>
<p>(todo-regular-giving: add link to regular giving terms & conditions)</p>

@if (submitErrorMessage) {
<div
class="error"
aria-live="polite"
>
{{ submitErrorMessage }}
</div>
}

@if (!submitting) {
<button
Expand Down
6 changes: 6 additions & 0 deletions src/app/regular-giving/regular-giving.component.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
@use '@angular/material' as mat;
@import '../../abstract';

// code below duplicated from donation-start-container.component.scss
Expand Down Expand Up @@ -130,3 +131,8 @@ table#personal-details, table#paymentMethods {
vertical-align: bottom;
}
}

.error, .stripeError {
color: mat.m2-get-color-from-palette($donate-warn);
margin: 1rem 0;
}
Loading
Loading