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..6deaec831 --- /dev/null +++ b/client/js/nonprofits/donate/DonationSubmitter/PlausibleCallback.ts @@ -0,0 +1,36 @@ +// License: LGPL-3.0-or-later +import { Callback } from "../../../../../app/javascript/common/Callbacks"; +import DonationSubmitter from './'; + +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..87d803b57 100644 --- a/client/js/nonprofits/donate/DonationSubmitter/index.spec.ts +++ b/client/js/nonprofits/donate/DonationSubmitter/index.spec.ts @@ -1,21 +1,41 @@ // 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,6 +72,12 @@ 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", () => { @@ -106,10 +132,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(); @@ -156,13 +188,19 @@ 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", () => { @@ -221,6 +259,12 @@ describe('DonationSubmitter', () => { expect(updated).toHaveBeenCalledTimes(2); }) + + it('has not ran callbacks', () => { + const {runCallbacks} = prepare(); + expect(runCallbacks).not.toHaveBeenCalled(); + + }); }) describe("when savedCard and then errored", () => { @@ -278,6 +322,12 @@ describe('DonationSubmitter', () => { expect(updated).toHaveBeenCalledTimes(3); }) + + it('has not ran callbacks', () => { + const {runCallbacks} = prepare(); + expect(runCallbacks).not.toHaveBeenCalled(); + + }); }); @@ -329,13 +379,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); @@ -380,6 +435,14 @@ 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])) + + + }); }) diff --git a/client/js/nonprofits/donate/DonationSubmitter/index.ts b/client/js/nonprofits/donate/DonationSubmitter/index.ts index e3d10553b..f65ec6a5e 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'; -export default class DonationSubmitter implements EventTarget { +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 { 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); @@ -40,7 +56,17 @@ export default class DonationSubmitter implements EventTarget { 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(); } @@ -67,6 +93,7 @@ export default class DonationSubmitter implements EventTarget { } private handleCompleted = (_evt: Event) => { + this.postSuccess(); this.dispatchEvent(new Event('updated')); } diff --git a/client/js/nonprofits/donate/DonationSubmitter/types.ts b/client/js/nonprofits/donate/DonationSubmitter/types.ts index cf5311fa2..92a5630d8 100644 --- a/client/js/nonprofits/donate/DonationSubmitter/types.ts +++ b/client/js/nonprofits/donate/DonationSubmitter/types.ts @@ -1,4 +1,3 @@ - // License: LGPL-3.0-or-later import 'whatwg-fetch'; @@ -20,4 +19,59 @@ export function commonFetch(url: string, props: any): Promise { }), credentials: 'include' }); -} \ No newline at end of file +} + +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 e14a05bd6..fd74bc614 100644 --- a/client/js/nonprofits/donate/payment-step.js +++ b/client/js/nonprofits/donate/payment-step.js @@ -30,7 +30,9 @@ function init(state) { 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 @@ -182,11 +184,6 @@ function init(state) { , state.paid$ ) - flyd.map( - R.apply((donationResponse) => postSuccess(donationResp$)) - , state.paid$ - ) - onInit(); return state @@ -219,19 +216,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) => {