From 5b2d98b1bc1236d20ea16cd64283b97e4a43e0ec Mon Sep 17 00:00:00 2001 From: Eric Schultz Date: Wed, 31 Aug 2022 13:47:00 -0500 Subject: [PATCH] Move postSuccess into DonationSubmitter --- app/javascript/common/Callbacks/run.ts | 1 - .../donate/DonationSubmitter/EventStack.ts | 2 +- .../PlausibleCallback.spec.ts | 81 ++++++++++++++ .../DonationSubmitter/PlausibleCallback.ts | 36 +++++++ .../donate/DonationSubmitter/index.spec.ts | 100 ++++++++++++++---- .../donate/DonationSubmitter/index.ts | 53 +++++++--- .../donate/DonationSubmitter/types.ts | 69 ++++++++++++ client/js/nonprofits/donate/payment-step.js | 29 +---- 8 files changed, 313 insertions(+), 58 deletions(-) create mode 100644 client/js/nonprofits/donate/DonationSubmitter/PlausibleCallback.spec.ts create mode 100644 client/js/nonprofits/donate/DonationSubmitter/PlausibleCallback.ts create mode 100644 client/js/nonprofits/donate/DonationSubmitter/types.ts diff --git a/app/javascript/common/Callbacks/run.ts b/app/javascript/common/Callbacks/run.ts index 3346f8bf6..39199a71a 100644 --- a/app/javascript/common/Callbacks/run.ts +++ b/app/javascript/common/Callbacks/run.ts @@ -1,7 +1,6 @@ // License: LGPL-3.0-or-later import type { CallbackClass } from "./types"; - /** * A very simple function for conditionally running callbacks. Move into own file because we can mock it for CallbackController * @param input The input properties to every callback diff --git a/client/js/nonprofits/donate/DonationSubmitter/EventStack.ts b/client/js/nonprofits/donate/DonationSubmitter/EventStack.ts index 8e1ddd48b..5efbd4618 100644 --- a/client/js/nonprofits/donate/DonationSubmitter/EventStack.ts +++ b/client/js/nonprofits/donate/DonationSubmitter/EventStack.ts @@ -1,6 +1,6 @@ // License: LGPL-3.0-or-later import last from 'lodash/last'; -import isEqual from 'lodash/isEqual' +import findLast from 'lodash/findLast'; /** * EventStack is a simple class that records objects based upon their type property. One way of using it is diff --git a/client/js/nonprofits/donate/DonationSubmitter/PlausibleCallback.spec.ts b/client/js/nonprofits/donate/DonationSubmitter/PlausibleCallback.spec.ts new file mode 100644 index 000000000..a79859d53 --- /dev/null +++ b/client/js/nonprofits/donate/DonationSubmitter/PlausibleCallback.spec.ts @@ -0,0 +1,81 @@ +// License: LGPL-3.0-or-later +import PlausibleCallback from './PlausibleCallback'; + + +describe('PlausibleCallback', () => { + describe('.canRun', () => { + it('false when getPlausible is undefined', () => { + const c = new PlausibleCallback({ props: {}} as any); + expect(c.canRun()).toEqual(false) + }) + + it('false when getPlausible returns undefined', () => { + const c = new PlausibleCallback({ props: {getPlausible: ():any => undefined}} as any); + expect(c.canRun()).toEqual(false) + }) + + it('true when returns plausible function', () => { + const realPlausibleFunc = jest.fn(); + const c = new PlausibleCallback({ props: {getPlausible: ():any => realPlausibleFunc}} as any); + expect(c.canRun()).toEqual(true); + }) + }) + + describe('.run', () => { + function build(result?:{charge?:{amount?:number}}) { + const realPlausibleFunc = jest.fn(); + return { + plausible: realPlausibleFunc, + obj: new PlausibleCallback({ props: {getPlausible: ():any => realPlausibleFunc}, result} as any) + }; + + } + + it('calls plausible with no amount when result is undefined', async () => { + const {plausible, obj} = build(); + await obj.run(); + expect(plausible).toHaveBeenCalledWith('payment_succeeded', { + props: { + amount: undefined, + } + }); + }) + + it('calls plausible with no amount when charge is undefined', async () => { + const {plausible, obj} = build({}); + await obj.run(); + expect(plausible).toHaveBeenCalledWith('payment_succeeded', { + props: { + amount: undefined, + } + }); + }) + + it('calls plausible with no amount when charge.amount is undefined', async () => { + const {plausible, obj} = build({charge:{}}); + await obj.run(); + expect(plausible).toHaveBeenCalledWith('payment_succeeded', { + props: { + amount: undefined, + } + }); + }) + + it('calls plausible with amount/100 when charge.amount is defined', async () => { + const {plausible, obj} = build({charge:{amount: 1000}}); + await obj.run(); + expect(plausible).toHaveBeenCalledWith('payment_succeeded', { + props: { + amount: 10, + } + }); + }) + }); + + describe('.catchError', () => { + it('does not rethrow errors', () => { + const c = new PlausibleCallback({} as any); + expect(() => c.catchError(new Error())).not.toThrow(); + }) + }) +}); \ No newline at end of file diff --git a/client/js/nonprofits/donate/DonationSubmitter/PlausibleCallback.ts b/client/js/nonprofits/donate/DonationSubmitter/PlausibleCallback.ts new file mode 100644 index 000000000..9bd6d7538 --- /dev/null +++ b/client/js/nonprofits/donate/DonationSubmitter/PlausibleCallback.ts @@ -0,0 +1,36 @@ +import { Callback } from "../../../../../app/javascript/common/Callbacks"; +import DonationSubmitter from './'; + +// License: LGPL-3.0-or-later +export interface PlausibleFunction { + (eventType: string, val: any): void +} + +export interface GetPlausible { + + (): PlausibleFunction | undefined +} + + +export default class PlausibleCallback extends Callback { + + private get plausibleFunction(): PlausibleFunction { + return this.props.props.getPlausible() + } + canRun(): boolean { + return !!(this.props.props.getPlausible && this.props.props.getPlausible()) + } + + run(): void { + this.plausibleFunction('payment_succeeded', { + props: { + amount: this.props.result?.charge?.amount && (this.props.result.charge.amount / 100) + } + }); + } + + catchError(e: unknown): void { + console.log(e); + } + +} \ No newline at end of file diff --git a/client/js/nonprofits/donate/DonationSubmitter/index.spec.ts b/client/js/nonprofits/donate/DonationSubmitter/index.spec.ts index f8bde6252..7ceed272a 100644 --- a/client/js/nonprofits/donate/DonationSubmitter/index.spec.ts +++ b/client/js/nonprofits/donate/DonationSubmitter/index.spec.ts @@ -1,21 +1,42 @@ // License: LGPL-3.0-or-later + import DonationSubmitter from '.'; +import run from '../../../../../app/javascript/common/Callbacks/run'; +import PlausibleCallback from './PlausibleCallback'; +import {waitFor} from '@testing-library/dom'; + +jest.mock('../../../../../app/javascript/common/Callbacks/run', () => jest.fn()); describe('DonationSubmitter', () => { - - function SetupDonationSubmitter(updated=jest.fn()) { + beforeEach(() => { + jest.clearAllMocks(); + }) + + function SetupDonationSubmitter(updated=jest.fn(), getPlausible=jest.fn()) { + const runCallbacks = run as jest.Mock; + const ret = { - submitter: new DonationSubmitter(), + submitter: new DonationSubmitter({getPlausible}), updated, + getPlausible, + runCallbacks, }; ret.submitter.addEventListener('updated', ret.updated) - - + + return ret; } + + it('has only one postSuccess callback', () => { + const ret = SetupDonationSubmitter() + expect(Array.from(ret.submitter.callbacks().keys())).toStrictEqual(['success']) + + expect(ret.submitter.callbacks('success')).toStrictEqual({before: [], after: [PlausibleCallback]}) + }) + describe("before anything happens", () => { function prepare(): ReturnType { @@ -52,10 +73,16 @@ describe('DonationSubmitter', () => { const {updated} = prepare() expect(updated).not.toHaveBeenCalled() }) + + it('has not ran callbacks', () => { + const {runCallbacks} = prepare(); + expect(runCallbacks).not.toHaveBeenCalled(); + + }); }) describe("when beginSubmit and then savedCard", () => { - + function prepare(): ReturnType { const func = jest.fn(() => { }) @@ -67,7 +94,7 @@ describe('DonationSubmitter', () => { it('is loading', () => { const {submitter: state} = prepare() - + expect(state.loading).toBe(true); }) @@ -106,10 +133,16 @@ describe('DonationSubmitter', () => { expect(updated).toHaveBeenCalledTimes(2); }) + + it('has not ran callbacks', () => { + const {runCallbacks} = prepare(); + expect(runCallbacks).not.toHaveBeenCalled(); + + }); }) describe("when beginSubmit and then completed", () => { - + const donationResult = { }; function prepare(): ReturnType { const mocked = SetupDonationSubmitter(); @@ -122,7 +155,7 @@ describe('DonationSubmitter', () => { it('is loading', () => { const {submitter: state} = prepare() - + expect(state.loading).toBe(false); }) @@ -156,17 +189,23 @@ describe('DonationSubmitter', () => { expect(updated).toHaveBeenCalledTimes(3); }) - it('calling completed twice only fires it once', () => { + it('calling completed twice only fires it once', async () => { const {submitter: state, updated} = prepare(); state.reportCompleted(donationResult); expect(updated).toHaveBeenCalledTimes(3) }) + it('has ran callbacks', async () => { + const {runCallbacks, submitter:state} = prepare(); + expect(runCallbacks).toHaveBeenCalledWith(state, []); + + await waitFor(() => expect(runCallbacks).toHaveBeenCalledWith(state, [PlausibleCallback])) + }); }) describe("when beginSubmit and then errored", () => { - + const error = "Error message" function prepare(): ReturnType { @@ -219,8 +258,14 @@ describe('DonationSubmitter', () => { state.reportError(error); expect(updated).toHaveBeenCalledTimes(2); - + }) + + it('has not ran callbacks', () => { + const {runCallbacks} = prepare(); + expect(runCallbacks).not.toHaveBeenCalled(); + + }); }) describe("when savedCard and then errored", () => { @@ -278,12 +323,18 @@ describe('DonationSubmitter', () => { expect(updated).toHaveBeenCalledTimes(3); }) + + it('has not ran callbacks', () => { + const {runCallbacks} = prepare(); + expect(runCallbacks).not.toHaveBeenCalled(); + + }); }); describe("when errored and then re-attempted", () => { const error = "Error message"; - + function prepare(): ReturnType { const mocked = SetupDonationSubmitter(); mocked.submitter.reportBeginSubmit(); @@ -295,7 +346,7 @@ describe('DonationSubmitter', () => { it('is loading', () => { const {submitter: state} = prepare() - + expect(state.loading).toBe(true); }) @@ -329,13 +380,18 @@ describe('DonationSubmitter', () => { expect(updated).toHaveBeenCalledTimes(4); }); + it('has not ran callbacks', () => { + const {runCallbacks} = prepare(); + expect(runCallbacks).not.toHaveBeenCalled(); + + }); }) - describe("when errored and then re-attempted", () => { + describe("when errored and then succeeded", () => { const error = "Error message"; const donationResult:any = { charge: undefined }; function prepare(): ReturnType { - const mocked = SetupDonationSubmitter(jest.fn()); + const mocked = SetupDonationSubmitter(jest.fn(), jest.fn()); mocked.submitter.reportBeginSubmit(); mocked.submitter.reportSavedCard(); mocked.submitter.reportError(error); @@ -347,7 +403,7 @@ describe('DonationSubmitter', () => { it('is loading', () => { const {submitter: state} = prepare() - + expect(state.loading).toBe(false); }) @@ -380,7 +436,15 @@ describe('DonationSubmitter', () => { expect(updated).toHaveBeenCalledTimes(6); }); + + it('has ran callbacks', async () => { + const {runCallbacks, submitter:state} = prepare(); + expect(runCallbacks).toHaveBeenCalledWith(state, []); + await waitFor(() => expect(runCallbacks).toHaveBeenCalledWith(state, [PlausibleCallback])) + + + }); }) - + }) \ No newline at end of file diff --git a/client/js/nonprofits/donate/DonationSubmitter/index.ts b/client/js/nonprofits/donate/DonationSubmitter/index.ts index e3d10553b..de233ddaa 100644 --- a/client/js/nonprofits/donate/DonationSubmitter/index.ts +++ b/client/js/nonprofits/donate/DonationSubmitter/index.ts @@ -1,15 +1,31 @@ // License: LGPL-3.0-or-later -import StateManager, {DonationResult} from "./StateManager"; +import noop from 'lodash/noop'; + +import StateManager, { DonationResult } from "./StateManager"; +import { CallbackControllerBuilder } from '../../../../../app/javascript/common/Callbacks'; + + +import PlausibleCallback, { GetPlausible } from './PlausibleCallback'; +import type { CallbackAccessor, CallbackFilters, CallbackMap, CallbackClass } from "../../../../../app/javascript/common/Callbacks/types"; + +interface DonationSubmitterProps { + getPlausible?: GetPlausible, +} + +type ActionNames = 'success' + +export default class DonationSubmitter implements EventTarget, CallbackAccessor { -export default class DonationSubmitter implements EventTarget { - private stateManager = new StateManager(); private eventTarget = new EventTarget(); - constructor() { + private callbackController = new CallbackControllerBuilder('success').withInputType(); + + constructor(public readonly props: DonationSubmitterProps) { + this.callbackController.addAfterCallback('success', PlausibleCallback); this.stateManager.addEventListener('beginSubmit', this.handleBeginSubmit); this.stateManager.addEventListener('savedCard', this.handleSavedCard); @@ -24,11 +40,11 @@ export default class DonationSubmitter implements EventTarget { return this.stateManager.loading; } - get error():string|undefined { + get error(): string | undefined { return this.stateManager.error; } - get progress(): number|undefined { + get progress(): number | undefined { return this.stateManager.progress; } @@ -36,15 +52,25 @@ export default class DonationSubmitter implements EventTarget { return this.stateManager.completed; } - get result(): DonationResult|undefined { + get result(): DonationResult | undefined { return this.stateManager.result; } - public reportBeginSubmit():void { + private async postSuccess(): Promise { + await this.callbackController.run('success', this, noop); + } + + callbacks(): CallbackMap; + callbacks(actionName: ActionNames): CallbackFilters> | undefined; + callbacks(actionName?: ActionNames): CallbackMap | CallbackFilters> | undefined { + return this.callbackController.callbacks(actionName); + } + + public reportBeginSubmit(): void { this.stateManager.reportBeginSubmit(); } - public reportError(error:string):void { + public reportError(error: string): void { this.stateManager.reportError(error); } @@ -52,7 +78,7 @@ export default class DonationSubmitter implements EventTarget { this.stateManager.reportSavedCard(); } - public reportCompleted(result:DonationResult): void { + public reportCompleted(result: DonationResult) { this.stateManager.reportCompleted(result); } @@ -67,17 +93,18 @@ export default class DonationSubmitter implements EventTarget { } private handleCompleted = (_evt: Event) => { + this.postSuccess(); this.dispatchEvent(new Event('updated')); } - + private handleErrored = (_evt: Event) => { this.dispatchEvent(new Event('updated')); } - + private handleSavedCard = (_evt: Event) => { this.dispatchEvent(new Event('updated')); } - + private handleBeginSubmit = (_evt: Event) => { this.dispatchEvent(new Event('updated')); } diff --git a/client/js/nonprofits/donate/DonationSubmitter/types.ts b/client/js/nonprofits/donate/DonationSubmitter/types.ts new file mode 100644 index 000000000..4539d99b9 --- /dev/null +++ b/client/js/nonprofits/donate/DonationSubmitter/types.ts @@ -0,0 +1,69 @@ +// License: LGPL-3.0-or-later +import 'whatwg-fetch'; +import DonationSubmitter from '.'; +import { Callback } from './callbacks'; + +// we add CSRF to the window type + +type WindowAndCsrf = Window & {_csrf:string}; +export function windowWithCSRF(): WindowAndCsrf { + return window as unknown as WindowAndCsrf; +} + + + +export function HasAPostDonationError(obj:any) : obj is PostDonationErrorResult { + return obj.hasOwnProperty('error'); +} + + +export interface PostCampaignGift { + result:PostDonationResult + campaign_gift_option_id?:number +} + +export interface PostCampaignGiftResult { + id:number +} + + +interface DonationType { + amount:number, + nonprofit_id:number, + campaign_id?:number, + feeCovering?:boolean, + supporter_id:number + fee_covered?:boolean + token: string + recurring: boolean +} + +type PostDonationProps = DonationType + +interface PostRecurringDonationProps { + recurring_donation: DonationType; +} + +export interface PostDonationResult { + charge?:{amount:number} + payment:any + donation:{id: number, [fields:string]:any} + activity: Array +} + +interface PostDonationErrorResult { + error: any; +} + +interface PostRecurringDonationResult extends PostDonationResult { + recurring_donation:any; +} + +class PostDonationError extends Error { + constructor(message:string, readonly error:any){ + super(message); + } +} + + + diff --git a/client/js/nonprofits/donate/payment-step.js b/client/js/nonprofits/donate/payment-step.js index f0f9e718a..ff215d527 100644 --- a/client/js/nonprofits/donate/payment-step.js +++ b/client/js/nonprofits/donate/payment-step.js @@ -28,9 +28,11 @@ function init(state) { const coverFees$ = flyd.map(params => (params.manual_cover_fees || params.hide_cover_fees_option) ? false : true, params$) const hideCoverFeesOption$ = flyd.map(params => params.hide_cover_fees_option, params$) - + const donationAmountCalculator = new DonationAmountCalculator(app.nonprofit.feeStructure); - const donationSubmitter = new DonationSubmitter() + + const donationSubmitter = new DonationSubmitter({getPlausible:() => window['plausible']}) + // Give a donation of value x, this returns x + estimated fees (using fee coverage formula) if fee coverage is selected OR // x if fee coverage is not selected @@ -49,7 +51,6 @@ function init(state) { function handleDonationAmountCalcEvent(e) { updateFromDonationAmountCalculator(); } - state.loading$ = flyd.stream(); state.error$ = flyd.stream(); // Control progress bar for card payment @@ -96,14 +97,11 @@ function init(state) { donationAmountCalculator.removeEventListener('updated', handleDonationAmountCalcEvent) donationSubmitter.removeEventListener('updated', handleDonationSubmitterChanged) } - flyd.combine((donation$, coverFees$) => { donationAmountCalculator.inputAmount = donation$().amount donationAmountCalculator.coverFees = coverFees$(); }, [state.donation$, coverFees$]); - - state.cardForm = cardForm.init({ path: '/cards', card$, payload$, donationTotal$: state.donationTotal$, coverFees$, potentialFees$: state.potentialFees$, @@ -174,7 +172,6 @@ function init(state) { flyd.map((error) => { donationSubmitter.reportError(error); }, state.cardForm.error$) - flyd.map((error) => { donationSubmitter.reportError(error); }, state.sepaForm.error$) @@ -185,11 +182,6 @@ function init(state) { , state.paid$ ) - flyd.map( - R.apply((donationResponse) => postSuccess(donationResp$)) - , state.paid$ - ) - onInit(); return state @@ -222,19 +214,6 @@ const postTracking = (utmParams, donationResponse) => { } } -const postSuccess = (donationResponse) => { - try { - const plausible = window['plausible']; - if (plausible) { - const resp = donationResponse() - plausible('payment_succeeded', {props: {amount: resp && resp.charge && resp.charge.amount && (resp.charge.amount / 100)}}); - } - } - catch(e) { - console.error(e) - } -} - var posting = false // hack switch to prevent any kind of charge double post // Post either a recurring or one-time donation const postDonation = (donation) => {