From 30323e622b9db160e5ff6d40bb2d2ab02b97dd75 Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Tue, 13 Oct 2026 13:24:19 -0300 Subject: [PATCH 1/3] Direct checkout sells the monthly price only Paywall checkouts open on the monthly plan with no yearly option, and Plan Usage defaults back to Monthly (yearly stays one toggle away there). Co-Authored-By: Claude Opus 5.5 --- .../UpgradeCheckout/UpgradeCheckout.react.js | 46 ++----------------- .../UpgradeCheckout/UpgradeCheckout.scss | 44 ------------------ src/dashboard/AppPlan/AppPlan.react.js | 2 +- src/lib/tests/UpgradeCheckout.test.js | 41 ++++++----------- src/lib/upgradeEvents.js | 1 - 5 files changed, 21 insertions(+), 113 deletions(-) diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.react.js b/src/components/UpgradeCheckout/UpgradeCheckout.react.js index 51b48c4865..52bca987cd 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.react.js +++ b/src/components/UpgradeCheckout/UpgradeCheckout.react.js @@ -55,14 +55,6 @@ const GATE_OFFERS = { const detailText = detail => [detail.number, detail.text].filter(Boolean).join(' '); -const priceSummary = (plan, cycle) => { - if (cycle === Cycle.YEARLY) { - const yearly = Number(plan.pricePerYear) * 12; - return { perMonth: plan.pricePerYear, note: `Billed $${yearly}/year · save ${plan.savePercent}`, today: yearly }; - } - return { perMonth: plan.pricePerMonth, note: 'Billed monthly · cancel anytime', today: Number(plan.pricePerMonth) }; -}; - const getOwnerEmail = async context => { if (context.custom && context.custom.isOwner) { return AccountManager.currentUser().email; @@ -78,8 +70,8 @@ export const UpgradeCheckoutModal = ({ gate, onClose }) => { const plan = prices.find(p => p.name === offer.plan); const [dark] = useState(prefersDark); - // Yearly first, like the pricing page; monthly stays one click away. - const [cycle, setCycle] = useState(Cycle.YEARLY); + // Monthly only: a paywall sells the plan at its monthly price. Yearly stays on Plan Usage. + const cycle = Cycle.MONTHLY; const [paddle, setPaddle] = useState(null); const [ownerEmail, setOwnerEmail] = useState(null); const [frame, setFrame] = useState(null); @@ -125,21 +117,6 @@ export const UpgradeCheckoutModal = ({ gate, onClose }) => { }); }, [paddle, ownerEmail, frame]); - const changeCycle = next => { - if (next === cycle || completed) { - return; - } - setCycle(next); - funnel.current = { ...funnel.current, cycle: next }; - amplitudeLogEvent(UpgradeEvent.CHECKOUT_CYCLE_CHANGED, funnel.current); - if (opened.current) { - paddle.Checkout.updateCheckout({ - items: [{ priceId: paddlePriceId(plan, next), quantity: 1 }], - customData: { appId: context.applicationId, planId: paddlePlanId(plan, next) }, - }); - } - }; - const close = () => { if (completed) { // Reload so the paywall that brought the user here reflects the new plan. @@ -153,8 +130,6 @@ export const UpgradeCheckoutModal = ({ gate, onClose }) => { onClose(); }; - const price = priceSummary(plan, cycle); - return (
@@ -166,21 +141,10 @@ export const UpgradeCheckoutModal = ({ gate, onClose }) => {
{offer.headline}
{offer.subline ?
{offer.subline}
: null}
- ${price.perMonth} + ${plan.pricePerMonth} /month
-
{price.note}
- -
- - -
+
Billed monthly · cancel anytime
{plan.details.map((detail, idx) => ( @@ -193,7 +157,7 @@ export const UpgradeCheckoutModal = ({ gate, onClose }) => {
Total today - ${price.today} + tax + ${plan.pricePerMonth} + tax
{completed ? ( diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.scss b/src/components/UpgradeCheckout/UpgradeCheckout.scss index 84598b7fb2..f7027deb60 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.scss +++ b/src/components/UpgradeCheckout/UpgradeCheckout.scss @@ -8,8 +8,6 @@ --uc-text: #f9f9f9; --uc-muted: #9aa5b8; --uc-border: rgba(255, 255, 255, 0.1); - --uc-option: rgba(255, 255, 255, 0.03); - --uc-option-selected: rgba(39, 174, 96, 0.12); } .light { @@ -18,8 +16,6 @@ --uc-text: #10203a; --uc-muted: #5b6577; --uc-border: #e2e6ec; - --uc-option: #ffffff; - --uc-option-selected: rgba(39, 174, 96, 0.08); } .checkout { @@ -112,46 +108,6 @@ color: var(--uc-muted); } -.cycles { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin-top: 1.5rem; -} - -.cycle { - display: flex; - align-items: center; - gap: 0.625rem; - padding: 0.75rem 0.875rem; - border: 1px solid var(--uc-border); - border-radius: 8px; - background: var(--uc-option); - font-size: 0.9375rem; - cursor: pointer; - transition: border-color 0.15s ease, background 0.15s ease; - - input { - margin: 0; - accent-color: $cta-green; - } - - &:has(input:checked) { - border-color: $cta-green; - background: var(--uc-option-selected); - } -} - -.save { - margin-left: auto; - padding: 2px 8px; - border-radius: 999px; - background: rgba(39, 174, 96, 0.15); - color: $cta-green; - font-size: 0.75rem; - font-weight: 600; -} - .benefits { display: flex; flex-direction: column; diff --git a/src/dashboard/AppPlan/AppPlan.react.js b/src/dashboard/AppPlan/AppPlan.react.js index de13e76f29..29eb20f6e5 100644 --- a/src/dashboard/AppPlan/AppPlan.react.js +++ b/src/dashboard/AppPlan/AppPlan.react.js @@ -171,7 +171,7 @@ class AppPlan extends DashboardView { appPlanName: 'Free Plan', appPlanError: null, selectedPlan: prices[1], - billingCycle: 1, // 0 --> monthly || 1 --> yearly + billingCycle: 0, // 0 --> monthly || 1 --> yearly isLoadingPaddle: false, paddleError: null, paddle: null, diff --git a/src/lib/tests/UpgradeCheckout.test.js b/src/lib/tests/UpgradeCheckout.test.js index d561771900..ac2cc3e88e 100644 --- a/src/lib/tests/UpgradeCheckout.test.js +++ b/src/lib/tests/UpgradeCheckout.test.js @@ -82,18 +82,18 @@ describe('UpgradeCheckoutModal', () => { process.env.SENTRY_ENV = prevEnv; }); - it('opens the MVP yearly checkout right away for a custom domain paywall', async () => { + it('opens the MVP monthly checkout right away for a custom domain paywall', async () => { const tree = await mount( {}} />); expect(paddle.Checkout.open).toHaveBeenCalledTimes(1); const options = paddle.Checkout.open.mock.calls[0][0]; - expect(options.items).toEqual([{ priceId: 'pri_mvp_y', quantity: 1 }]); - expect(options.customData).toEqual({ appId: 'app-1', planId: 'mvp-y' }); + expect(options.items).toEqual([{ priceId: 'pri_mvp_m', quantity: 1 }]); + expect(options.customData).toEqual({ appId: 'app-1', planId: 'mvp-m' }); expect(options.customer).toEqual({ email: 'owner@example.com' }); expect(options.settings.frameTarget).toBe('upgrade-checkout-frame'); expect(loggedEvents('baas_checkout_opened')).toEqual([ - { app_id: 'app-1', plan: 'MVP', cycle: 'yearly', source: 'gate', gate: 'custom_domain' }, + { app_id: 'app-1', plan: 'MVP', cycle: 'monthly', source: 'gate', gate: 'custom_domain' }, ]); const text = textOf(tree); expect(text).toContain('Put your API and pages on your own domain'); @@ -101,24 +101,13 @@ describe('UpgradeCheckoutModal', () => { expect(text).toContain('Web hosting & custom domain'); }); - it('starts on yearly and switches the open checkout to monthly without reopening it', async () => { + it('sells the monthly price only, with no yearly option', async () => { const tree = await mount( {}} />); - // $15/month billed yearly is $180 today. - expect(textOf(tree)).toContain('180'); - const radios = tree.root.findAll(node => node.type === 'input' && node.props.type === 'radio'); - - // Yearly is listed first, monthly second. - renderer.act(() => radios[1].props.onChange()); + const text = textOf(tree); - expect(paddle.Checkout.open).toHaveBeenCalledTimes(1); - expect(paddle.Checkout.updateCheckout).toHaveBeenCalledWith({ - items: [{ priceId: 'pri_mvp_m', quantity: 1 }], - customData: { appId: 'app-1', planId: 'mvp-m' }, - }); - expect(loggedEvents('baas_checkout_cycle_changed')).toEqual([ - { app_id: 'app-1', plan: 'MVP', cycle: 'monthly', source: 'gate', gate: 'custom_domain' }, - ]); - expect(textOf(tree)).toContain('Billed monthly'); + expect(text).toContain('Billed monthly'); + expect(text).not.toMatch(/yearly|\/year|180/i); + expect(tree.root.findAll(node => node.type === 'input' && node.props.type === 'radio')).toHaveLength(0); }); it('logs the abandonment and closes when the user leaves without paying', async () => { @@ -128,7 +117,7 @@ describe('UpgradeCheckoutModal', () => { renderer.act(() => tree.root.findByProps({ 'aria-label': 'Close' }).props.onClick()); expect(loggedEvents('baas_checkout_closed')).toEqual([ - { app_id: 'app-1', plan: 'MVP', cycle: 'yearly', source: 'gate', gate: 'email_templates' }, + { app_id: 'app-1', plan: 'MVP', cycle: 'monthly', source: 'gate', gate: 'email_templates' }, ]); expect(paddle.Checkout.close).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled(); @@ -170,13 +159,13 @@ describe('UpgradeCheckoutModal', () => { it('sells web hosting on MVP from the Overview', async () => { const tree = await mount( {}} />); - expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_mvp_y'); + expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_mvp_m'); expect(textOf(tree)).toContain('Host your pages and use your own domain'); }); it('sells HTTPS on Pay As You Go and tells the buyer support turns it on', async () => { const tree = await mount( {}} />); - expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_payg_y'); + expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_payg_m'); expect(textOf(tree)).not.toContain('open a support ticket'); await renderer.act(async () => { @@ -191,18 +180,18 @@ describe('UpgradeCheckoutModal', () => { for (const gate of ['overview_plan_card', 'overview_plan_badge']) { paddle.Checkout.open.mockClear(); const tree = await mount( {}} />); - expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_mvp_y'); + expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_mvp_m'); expect(textOf(tree)).toContain('Take this app to production'); } }); it('offers the plan that actually unlocks each compliance badge', async () => { await mount( {}} />); - expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_payg_y'); + expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_payg_m'); paddle.Checkout.open.mockClear(); await mount( {}} />); - expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_ded_y'); + expect(paddle.Checkout.open.mock.calls[0][0].items[0].priceId).toBe('pri_ded_m'); }); }); diff --git a/src/lib/upgradeEvents.js b/src/lib/upgradeEvents.js index a46f3d5057..195db5219e 100644 --- a/src/lib/upgradeEvents.js +++ b/src/lib/upgradeEvents.js @@ -9,7 +9,6 @@ export const UpgradeEvent = { GATE_CLICKED: 'baas_upgrade_gate_clicked', CHECKOUT_OPENED: 'baas_checkout_opened', CHECKOUT_CLOSED: 'baas_checkout_closed', - CHECKOUT_CYCLE_CHANGED: 'baas_checkout_cycle_changed', }; // The paid feature that made the user hit the paywall. Also sent as ?gate= to plan-usage From 0d5da4e17fe94a2ae30e3684b4b4803bc80e5db6 Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Tue, 13 Oct 2026 13:24:20 -0300 Subject: [PATCH 2/3] Gate views only for paywalls reached by trying the feature Always-on buttons (Overview plan badge and card, MongoDB 8, web hosting, HTTPS, compliance) count only the click. Co-Authored-By: Claude Opus 5.5 --- src/components/UpgradeCheckout/UpgradeCheckout.react.js | 9 ++++++--- .../CustomParseOptions/CustomParseOptions.react.js | 2 +- src/dashboard/DomainSettings/DomainSettings.react.js | 6 +++--- src/dashboard/Notification/EmailPasswordReset.react.js | 2 +- src/dashboard/Notification/EmailVerification.react.js | 2 +- src/dashboard/Settings/Collaborators.react.js | 2 +- src/lib/tests/UpgradeCheckout.test.js | 9 +++++++-- 7 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.react.js b/src/components/UpgradeCheckout/UpgradeCheckout.react.js index 52bca987cd..e575db220a 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.react.js +++ b/src/components/UpgradeCheckout/UpgradeCheckout.react.js @@ -190,9 +190,12 @@ export const UpgradeCheckoutModal = ({ gate, onClose }) => { ); }; -// Paywall entry point: logs the gate view, and on click opens the direct checkout. +// Paywall entry point: on click, logs the gate click and opens the direct checkout. // `renderTrigger(open)` lets anchors and custom buttons keep their look. -export const UpgradeGateButton = ({ gate, renderTrigger, value = 'Upgrade Plan' }) => { +// `logView` also logs the gate view when the paywall shows. Only for paywalls the user reaches by +// trying to use the feature (opening its page); buttons that are always on screen, like the +// Overview ones, count only the click. +export const UpgradeGateButton = ({ gate, renderTrigger, value = 'Upgrade Plan', logView = false }) => { const context = useContext(CurrentApp); const [open, setOpen] = useState(false); const openCheckout = event => { @@ -205,7 +208,7 @@ export const UpgradeGateButton = ({ gate, renderTrigger, value = 'Upgrade Plan' return ( <> - + {logView ? : null} {renderTrigger ? renderTrigger(openCheckout) :
} @@ -558,7 +558,7 @@ class DomainSettings extends DashboardView { /> ) : ( - + )}
} diff --git a/src/dashboard/Notification/EmailPasswordReset.react.js b/src/dashboard/Notification/EmailPasswordReset.react.js index 7023f55609..011646325f 100644 --- a/src/dashboard/Notification/EmailPasswordReset.react.js +++ b/src/dashboard/Notification/EmailPasswordReset.react.js @@ -279,7 +279,7 @@ class EmailPasswordReset extends DashboardView { } input={
- +
} theme={Field.Theme.BLUE} diff --git a/src/dashboard/Notification/EmailVerification.react.js b/src/dashboard/Notification/EmailVerification.react.js index a2d87d95a0..ce25f708a2 100644 --- a/src/dashboard/Notification/EmailVerification.react.js +++ b/src/dashboard/Notification/EmailVerification.react.js @@ -471,7 +471,7 @@ class EmailVerification extends DashboardView { } input={
- +
} theme={Field.Theme.BLUE} diff --git a/src/dashboard/Settings/Collaborators.react.js b/src/dashboard/Settings/Collaborators.react.js index 9862ebff48..839c6c639c 100644 --- a/src/dashboard/Settings/Collaborators.react.js +++ b/src/dashboard/Settings/Collaborators.react.js @@ -389,7 +389,7 @@ export default class Collaborators extends React.Component {
{maxCollaborators === 0 || maxCollaborators === null || maxCollaborators === false ? (
- +
) : maxCollaborators !== true && maxCollaborators !== null && collaboratorUsage >= maxCollaborators ? ( { initPaddle.mockImplementation(() => Promise.resolve(fakePaddle())); }); - it('logs the paywall view, then the click, and opens the checkout in place', async () => { + it('logs the view only for paywalls reached by trying the feature', async () => { + await mount(); + expect(loggedEvents('baas_upgrade_gate_viewed')).toEqual([{ gate: 'email_templates', app_id: 'app-1' }]); + }); + + it('a button always on screen logs only the click, and opens the checkout in place', async () => { const tree = await mount( Upgrade database} /> ); - expect(loggedEvents('baas_upgrade_gate_viewed')).toEqual([{ gate: 'mongodb_8', app_id: 'app-1' }]); + expect(loggedEvents('baas_upgrade_gate_viewed')).toEqual([]); expect(initPaddle).not.toHaveBeenCalled(); const preventDefault = jest.fn(); From f411bff98d48eda8aca955f42bd56c70d28aa030 Mon Sep 17 00:00:00 2001 From: Alysson Melo Date: Thu, 15 Oct 2026 09:59:24 -0300 Subject: [PATCH 3/3] Log what Paddle reports from inside the BaaS checkout The Backend checkout only logged opened and closed, so a drop in payments could not tell "never saw the form" from "saw it and left" from "tried to pay and was declined". What Paddle shows the buyer happens inside its iframe, which no replay records. Both places that open the Paddle checkout, the paywall modal and the Plan Usage page, now log: baas_checkout_loaded, baas_payment_initiated, baas_payment_failed (with the error type, code and Paddle's message), and baas_checkout_event for everything else Paddle reports, under Paddle's own name (checkout.error, checkout.warning, checkout.failed, checkout.payment.selected, ...). Each carries the checkout's funnel context (app, plan, cycle, source, gate) and the che_ checkout id that Paddle support asks for. Each name and error code goes once per checkout; frame plumbing (ping.size, removespinner, frame.revealed) stays out. No card or email data is read. The decision is paddleEventToTrack in lib/upgradeEvents, tested on its own; trackPaddleEvent logs it only for a checkout the page opened. Same instrumentation as Containers (back4app2#1298) and the Agent (back4app-frontend#225). Co-Authored-By: Claude Opus 5.5 --- .../UpgradeCheckout/UpgradeCheckout.react.js | 5 +- src/dashboard/AppPlan/AppPlan.react.js | 5 +- src/lib/tests/UpgradeCheckout.test.js | 26 +++++++ src/lib/tests/upgradeEvents.test.js | 68 +++++++++++++++++++ src/lib/upgradeEvents.js | 68 ++++++++++++++++++- 5 files changed, 169 insertions(+), 3 deletions(-) diff --git a/src/components/UpgradeCheckout/UpgradeCheckout.react.js b/src/components/UpgradeCheckout/UpgradeCheckout.react.js index e575db220a..9db1006af5 100644 --- a/src/components/UpgradeCheckout/UpgradeCheckout.react.js +++ b/src/components/UpgradeCheckout/UpgradeCheckout.react.js @@ -6,7 +6,7 @@ import Icon from 'components/Icon/Icon.react'; import AccountManager from 'lib/AccountManager'; import { CurrentApp } from 'context/currentApp'; import { amplitudeLogEvent } from 'lib/amplitudeEvents'; -import { UpgradeEvent, UpgradeGate, UpgradeGateView, logGateClicked, planUsagePath } from 'lib/upgradeEvents'; +import { UpgradeEvent, UpgradeGate, UpgradeGateView, logGateClicked, planUsagePath, trackPaddleEvent } from 'lib/upgradeEvents'; import { Cycle, initPaddle, paddlePlanId, paddlePriceId, recordSubscription } from 'lib/paddleCheckout'; import { prices } from 'dashboard/AppPlan/AppPlan.react'; import styles from './UpgradeCheckout.scss'; @@ -79,10 +79,13 @@ export const UpgradeCheckoutModal = ({ gate, onClose }) => { const [error, setError] = useState(null); const funnel = useRef({ app_id: context.applicationId, plan: plan.name, cycle, source: 'gate', gate }); const opened = useRef(false); + // The Paddle events already logged for this checkout (the modal opens one). + const paddleSeen = useRef(new Set()); useEffect(() => { let active = true; initPaddle(async data => { + trackPaddleEvent(data, funnel.current, paddleSeen.current); if (data.name === 'checkout.completed') { setCompleted(true); await recordSubscription(data, { source: 'gate', gate }); diff --git a/src/dashboard/AppPlan/AppPlan.react.js b/src/dashboard/AppPlan/AppPlan.react.js index 29eb20f6e5..063b18bc20 100644 --- a/src/dashboard/AppPlan/AppPlan.react.js +++ b/src/dashboard/AppPlan/AppPlan.react.js @@ -20,7 +20,7 @@ import Icon from 'components/Icon/Icon.react'; import { initializePaddle } from '@paddle/paddle-js'; import B4aModal from 'components/B4aModal/B4aModal.react'; import { amplitudeLogEvent } from 'lib/amplitudeEvents'; -import { UpgradeEvent, getGateFromSearch } from 'lib/upgradeEvents'; +import { UpgradeEvent, getGateFromSearch, trackPaddleEvent } from 'lib/upgradeEvents'; const prices = [ { @@ -231,6 +231,8 @@ class AppPlan extends DashboardView { // Paddle calls this as a plain function, so keep a handle on the component for the funnel context. const component = this; const paddleEventCallback = async function (data) { + // Only for a checkout opened by handleOnClickPlan, which sets the context. + trackPaddleEvent(data, component.checkoutContext, component.paddleSeen); if (data.name === 'checkout.completed') { component.checkoutCompleted = true; window.dataLayer = window.dataLayer || []; @@ -352,6 +354,7 @@ class AppPlan extends DashboardView { gate, }; this.checkoutCompleted = false; + this.paddleSeen = new Set(); amplitudeLogEvent(UpgradeEvent.CHECKOUT_OPENED, this.checkoutContext); this.setState({ selectedPlan: plan, openCheckout: true }, () => { const priceId = this.state.billingCycle === 0 ? plan.monthlyPlanId : plan.annuallyPlanId; diff --git a/src/lib/tests/UpgradeCheckout.test.js b/src/lib/tests/UpgradeCheckout.test.js index c3c776bfa6..85618e16ee 100644 --- a/src/lib/tests/UpgradeCheckout.test.js +++ b/src/lib/tests/UpgradeCheckout.test.js @@ -144,6 +144,32 @@ describe('UpgradeCheckoutModal', () => { expect(loggedEvents('baas_checkout_closed')).toEqual([]); }); + it('logs what Paddle reports from inside the checkout, with the gate', async () => { + await mount( {}} />); + const funnel = { app_id: 'app-1', plan: 'MVP', cycle: 'monthly', source: 'gate', gate: 'custom_domain' }; + + await renderer.act(async () => { + await paddleCallback({ name: 'checkout.loaded', data: { id: 'che_1' } }); + await paddleCallback({ + name: 'checkout.warning', + data: { id: 'che_1' }, + error: { type: 'validation_error', code: 'postcode_required', detail: 'Postcode is required' }, + }); + }); + + expect(loggedEvents('baas_checkout_loaded')).toEqual([{ ...funnel, checkout_id: 'che_1' }]); + expect(loggedEvents('baas_checkout_event')).toEqual([ + { + ...funnel, + name: 'checkout.warning', + checkout_id: 'che_1', + error_type: 'validation_error', + error_code: 'postcode_required', + error_detail: 'Postcode is required', + }, + ]); + }); + it('follows the light or dark preference, including the Paddle frame', async () => { await mount( {}} />); expect(paddle.Checkout.open.mock.calls[0][0].settings.theme).toBe('light'); diff --git a/src/lib/tests/upgradeEvents.test.js b/src/lib/tests/upgradeEvents.test.js index 5f6992baa5..ee8621e676 100644 --- a/src/lib/tests/upgradeEvents.test.js +++ b/src/lib/tests/upgradeEvents.test.js @@ -10,7 +10,9 @@ const { UpgradeGateView, getGateFromSearch, logGateClicked, + paddleEventToTrack, planUsagePath, + trackPaddleEvent, } = require('../upgradeEvents'); describe('upgradeEvents', () => { @@ -48,4 +50,70 @@ describe('upgradeEvents', () => { it('keeps baas_ event names apart from the Agent upgrade_* events', () => { Object.values(UpgradeEvent).forEach(name => expect(name.startsWith('baas_')).toBe(true)); }); + + it('keeps the Paddle funnel steps under their own names, with the che_ id', () => { + expect(paddleEventToTrack({ name: 'checkout.loaded', data: { id: 'che_1' } }, new Set())).toEqual({ + eventType: 'baas_checkout_loaded', + properties: { checkout_id: 'che_1' }, + }); + expect( + paddleEventToTrack( + { + name: 'checkout.payment.failed', + data: { id: 'che_1' }, + error: { type: 'payment_error', code: 'declined', detail: 'Card declined' }, + }, + new Set() + ) + ).toEqual({ + eventType: 'baas_payment_failed', + properties: { checkout_id: 'che_1', error_type: 'payment_error', error_code: 'declined', error_detail: 'Card declined' }, + }); + }); + + it('sends every other Paddle event once, with the error Paddle showed', () => { + const seen = new Set(); + const error = { + name: 'checkout.error', + data: { id: 'che_1' }, + error: { type: 'validation_error', code: 'postcode_required', detail: 'Postcode is required' }, + }; + expect(paddleEventToTrack(error, seen)).toEqual({ + eventType: 'baas_checkout_event', + properties: { + name: 'checkout.error', + checkout_id: 'che_1', + error_type: 'validation_error', + error_code: 'postcode_required', + error_detail: 'Postcode is required', + }, + }); + expect(paddleEventToTrack(error, seen)).toBe(null); + // Same name, another error: a different thing the buyer saw. + const blocked = paddleEventToTrack({ type: 'checkout.error', error: { code: 'blocked', detail: 'x'.repeat(300) } }, seen); + expect(blocked.properties.name).toBe('checkout.error'); + expect(blocked.properties.error_detail).toHaveLength(200); + }); + + it('leaves out completion, close and the frame plumbing', () => { + const seen = new Set(); + ['checkout.completed', 'checkout.closed', 'checkout.ping.size', 'checkout.removespinner'].forEach(name => + expect(paddleEventToTrack({ name, data: { id: 'che_1' } }, seen)).toBe(null) + ); + expect(paddleEventToTrack({}, seen)).toBe(null); + }); + + it('logs a Paddle event with the checkout context, and nothing without one', () => { + const context = { app_id: 'app-1', plan: 'MVP', source: 'plan_page' }; + trackPaddleEvent({ name: 'checkout.payment.selected', data: { id: 'che_1' } }, context, new Set()); + expect(amplitudeLogEvent).toHaveBeenCalledWith('baas_checkout_event', { + ...context, + name: 'checkout.payment.selected', + checkout_id: 'che_1', + }); + + amplitudeLogEvent.mockClear(); + trackPaddleEvent({ name: 'checkout.loaded', data: { id: 'che_1' } }, undefined, new Set()); + expect(amplitudeLogEvent).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/upgradeEvents.js b/src/lib/upgradeEvents.js index 195db5219e..61ca01dd01 100644 --- a/src/lib/upgradeEvents.js +++ b/src/lib/upgradeEvents.js @@ -2,13 +2,79 @@ import { useEffect } from 'react'; import { amplitudeLogEvent } from './amplitudeEvents'; // Free -> paid funnel for the Backend product: -// gate viewed -> gate clicked -> checkout opened -> (closed | At Checkout - Subscription Successful). +// gate viewed -> gate clicked -> checkout opened -> loaded -> payment initiated +// -> (failed | closed | At Checkout - Subscription Successful). // Prefixed with baas_ so the Agent's upgrade_* charts keep counting only the Agent. export const UpgradeEvent = { GATE_VIEWED: 'baas_upgrade_gate_viewed', GATE_CLICKED: 'baas_upgrade_gate_clicked', CHECKOUT_OPENED: 'baas_checkout_opened', CHECKOUT_CLOSED: 'baas_checkout_closed', + // What happens INSIDE the Paddle frame: without these the funnel can't tell + // "never saw the form" from "saw it and left" from "tried to pay and was declined". + CHECKOUT_LOADED: 'baas_checkout_loaded', + PAYMENT_INITIATED: 'baas_payment_initiated', + PAYMENT_FAILED: 'baas_payment_failed', + // Everything else Paddle reports from the frame, under Paddle's own `name`: an error + // or warning shown to the buyer, a checkout that failed to load, the method picked. + CHECKOUT_EVENT: 'baas_checkout_event', +}; + +const PADDLE_FUNNEL_EVENTS = { + 'checkout.loaded': UpgradeEvent.CHECKOUT_LOADED, + 'checkout.payment.initiated': UpgradeEvent.PAYMENT_INITIATED, + 'checkout.payment.failed': UpgradeEvent.PAYMENT_FAILED, +}; + +// Not sent as baas_checkout_event: the funnel steps above, completion and close (the +// callbacks handle them), and frame plumbing that says nothing about the buyer +// (`checkout.ping.size` fires on every resize). +const PADDLE_NOT_FORWARDED = new Set([ + ...Object.keys(PADDLE_FUNNEL_EVENTS), + 'checkout.completed', + 'checkout.closed', + 'checkout.ping.size', + 'checkout.removespinner', + 'checkout.frame.revealed', +]); + +// What one Paddle.js event sends to Amplitude, or null. The che_ id rides along (it is +// what Paddle support asks for), and so does Paddle's error message, cut short. `seen` +// belongs to the checkout open now: each name and error code goes out once. +// No card or email data is read. +export const paddleEventToTrack = (event, seen) => { + const name = event && (event.name || event.type); + if (!name) { + return null; + } + const checkoutId = event.data && event.data.id; + const errorFields = event.error + ? { + error_type: event.error.type, + error_code: event.error.code, + error_detail: event.error.detail ? event.error.detail.slice(0, 200) : event.error.detail, + } + : {}; + if (PADDLE_FUNNEL_EVENTS[name]) { + return { eventType: PADDLE_FUNNEL_EVENTS[name], properties: { checkout_id: checkoutId, ...errorFields } }; + } + const seenKey = `${name}|${(event.error && event.error.code) || ''}`; + if (PADDLE_NOT_FORWARDED.has(name) || seen.has(seenKey)) { + return null; + } + seen.add(seenKey); + return { eventType: UpgradeEvent.CHECKOUT_EVENT, properties: { name, checkout_id: checkoutId, ...errorFields } }; +}; + +// Logs a Paddle.js event with the funnel context of the checkout it belongs to. +export const trackPaddleEvent = (event, context, seen) => { + if (!context) { + return; + } + const tracked = paddleEventToTrack(event, seen); + if (tracked) { + amplitudeLogEvent(tracked.eventType, { ...context, ...tracked.properties }); + } }; // The paid feature that made the user hit the paywall. Also sent as ?gate= to plan-usage