diff --git a/Assets/WebsiteAssets/templates/plans.mustache b/Assets/WebsiteAssets/templates/plans.mustache index 471357e703..f86f56266b 100644 --- a/Assets/WebsiteAssets/templates/plans.mustache +++ b/Assets/WebsiteAssets/templates/plans.mustache @@ -216,6 +216,11 @@ applyPeriod(period); }); + // Keep the ?source= label so it can be sent to Stripe at checkout. + // Only overwrite when the param is present, to preserve an earlier one. + const sourceParam = urlQuery.get('source'); + if (sourceParam) localStorage.setItem('source', sourceParam); + setupBetaHandling(urlQuery); if (urlQuery.get('period') === 'monthly') { // Nothing - this is the default diff --git a/packages/app-desktop/gui/JoplinCloudLoginScreen.tsx b/packages/app-desktop/gui/JoplinCloudLoginScreen.tsx index b55024ebcf..745910635f 100644 --- a/packages/app-desktop/gui/JoplinCloudLoginScreen.tsx +++ b/packages/app-desktop/gui/JoplinCloudLoginScreen.tsx @@ -108,7 +108,7 @@ const JoplinCloudScreenComponent = (props: Props) => { ) : null}

{state.active === 'LINK_USED' ?
: null} - {state.active !== 'COMPLETED' ? : null} + {state.active !== 'COMPLETED' ? : null}
props.dispatch({ type: 'NAV_BACK' })} /> diff --git a/packages/app-desktop/gui/JoplinCloudSignUpCallToAction.tsx b/packages/app-desktop/gui/JoplinCloudSignUpCallToAction.tsx index 308a6915d8..69f2761f8a 100644 --- a/packages/app-desktop/gui/JoplinCloudSignUpCallToAction.tsx +++ b/packages/app-desktop/gui/JoplinCloudSignUpCallToAction.tsx @@ -2,10 +2,16 @@ import { _ } from '@joplin/lib/locale'; import * as React from 'react'; import bridge from '../services/bridge'; -const JoplinCloudSignUpCallToAction = () => { +interface Props { + source?: string; +} + +const JoplinCloudSignUpCallToAction = (props: Props) => { + + const source = props.source ?? 'desktop-app'; const onJoplinCloudSignUpClick = async () => { - await bridge().openExternal('https://joplinapp.org/plans/'); + await bridge().openExternal(`https://joplinapp.org/plans/?source=${encodeURIComponent(source)}`); }; return
diff --git a/packages/app-desktop/gui/SyncWizard/Dialog.tsx b/packages/app-desktop/gui/SyncWizard/Dialog.tsx index 7e601c946f..1eabd5ddb9 100644 --- a/packages/app-desktop/gui/SyncWizard/Dialog.tsx +++ b/packages/app-desktop/gui/SyncWizard/Dialog.tsx @@ -214,7 +214,7 @@ export default function(props: Props) { function renderSignUpArea(info: SyncTargetInfo) { if (info.name !== 'joplinCloud') return null; - return ; + return ; } function renderSyncTarget(info: SyncTargetInfo) { diff --git a/packages/server/src/migrations/20260718100000_subscription_source.ts b/packages/server/src/migrations/20260718100000_subscription_source.ts new file mode 100644 index 0000000000..19b2e8d6ce --- /dev/null +++ b/packages/server/src/migrations/20260718100000_subscription_source.ts @@ -0,0 +1,13 @@ +import { DbConnection } from '../db'; + +export const up = async (db: DbConnection) => { + await db.schema.alterTable('subscriptions', (table) => { + table.text('source').defaultTo('').notNullable(); + }); +}; + +export const down = async (db: DbConnection) => { + await db.schema.alterTable('subscriptions', (table) => { + table.dropColumn('source'); + }); +}; diff --git a/packages/server/src/models/SubscriptionModel.ts b/packages/server/src/models/SubscriptionModel.ts index c5e2a95478..5707680723 100644 --- a/packages/server/src/models/SubscriptionModel.ts +++ b/packages/server/src/models/SubscriptionModel.ts @@ -144,7 +144,7 @@ export default class SubscriptionModel extends BaseModel { return this.db(this.tableName).select(this.defaultFields).where('user_id', '=', userId).where('is_deleted', '=', 0).first(); } - public async saveUserAndSubscription(email: string, fullName: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string) { + public async saveUserAndSubscription(email: string, fullName: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string, source = '') { return this.withTransaction(async () => { const user = await this.models().user().save({ account_type: accountType, @@ -160,6 +160,7 @@ export default class SubscriptionModel extends BaseModel { stripe_user_id: stripeUserId, stripe_subscription_id: stripeSubscriptionId, last_payment_time: Date.now(), + source, }); return { user, subscription }; diff --git a/packages/server/src/routes/index/stripe.test.ts b/packages/server/src/routes/index/stripe.test.ts index 111ff2abb4..3e5f85b22b 100644 --- a/packages/server/src/routes/index/stripe.test.ts +++ b/packages/server/src/routes/index/stripe.test.ts @@ -12,6 +12,7 @@ import { Hour, Month, Second } from '@joplin/utils/time'; interface StripeOptions { userEmail?: string; + source?: string; } function mockStripe(options: StripeOptions = null) { @@ -26,8 +27,10 @@ function mockStripe(options: StripeOptions = null) { return { name: 'Toto', email: options.userEmail, + metadata: { source: options.source || '' }, }; }, + update: jest.fn(), }, subscriptions: { cancel: jest.fn(), @@ -60,11 +63,12 @@ interface WebhookOptions { userEmail?: string; accountType?: AccountType; quantity?: number; + source?: string; } async function simulateWebhook(ctx: AppContext, type: string, object: Record, options: WebhookOptions = {}) { options = { - stripe: mockStripe({ userEmail: options.userEmail }), + stripe: mockStripe({ userEmail: options.userEmail, source: options.source }), eventId: uuidgen(), ...options, }; @@ -139,6 +143,24 @@ describe('index/stripe', () => { expect(sub.last_payment_failed_time).toBe(0); }); + test('should store the signup source on the subscription', async () => { + const ctx = await koaAppContext(); + await createUserViaSubscription(ctx, { userEmail: 'toto@example.com', source: 'desktop-wizard' }); + + const user = await models().user().loadByEmail('toto@example.com'); + const sub = await models().subscription().byUserId(user.id); + expect(sub.source).toBe('desktop-wizard'); + }); + + test('should default the source to an empty string when none is provided', async () => { + const ctx = await koaAppContext(); + await createUserViaSubscription(ctx, { userEmail: 'toto@example.com' }); + + const user = await models().user().loadByEmail('toto@example.com'); + const sub = await models().subscription().byUserId(user.id); + expect(sub.source).toBe(''); + }); + test('should not process the same event twice', async () => { const ctx = await koaAppContext(); await createUserViaSubscription(ctx, { userEmail: 'toto@example.com', eventId: 'evt_1' }); diff --git a/packages/server/src/routes/index/stripe.ts b/packages/server/src/routes/index/stripe.ts index bedd7250e4..e966e4545a 100644 --- a/packages/server/src/routes/index/stripe.ts +++ b/packages/server/src/routes/index/stripe.ts @@ -63,7 +63,7 @@ async function getSubscriptionInfo(event: Stripe.Event, ctx: AppContext): Promis return { sub, stripeSub }; } -export const handleSubscriptionCreated = async (stripe: Stripe, models: Models, customerName: string, userEmail: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string) => { +export const handleSubscriptionCreated = async (stripe: Stripe, models: Models, customerName: string, userEmail: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string, source: string) => { const existingUser = await models.user().loadByEmail(userEmail); if (existingUser) { @@ -94,6 +94,7 @@ export const handleSubscriptionCreated = async (stripe: Stripe, models: Models, stripe_user_id: stripeUserId, stripe_subscription_id: stripeSubscriptionId, last_payment_time: Date.now(), + source, }); } else { if (sub.stripe_subscription_id === stripeSubscriptionId) { @@ -117,6 +118,7 @@ export const handleSubscriptionCreated = async (stripe: Stripe, models: Models, accountType, stripeUserId, stripeSubscriptionId, + source, ); } }; @@ -279,7 +281,7 @@ export const postHandlers: PostHandlers = { const checkoutSession: Stripe.Checkout.Session = event.data.object as Stripe.Checkout.Session; const userEmail = checkoutSession.customer_details.email || checkoutSession.customer_email; const customer = await stripe.customers.retrieve(checkoutSession.customer as string) as Stripe.Customer; - await stripe.customers.update(customer.id, { metadata: { source: checkoutSession.metadata.source } }); + await stripe.customers.update(customer.id, { metadata: { source: checkoutSession.metadata?.source || '' } }); logger.info('Checkout session completed:', checkoutSession.id); logger.info('User email:', userEmail); }, @@ -309,6 +311,7 @@ export const postHandlers: PostHandlers = { accountType, stripeUserId, stripeSubscriptionId, + customer.metadata?.source || '', ); const subscription = await models.subscription().byStripeSubscriptionId(stripeSubscriptionId); diff --git a/packages/server/src/services/database/types.ts b/packages/server/src/services/database/types.ts index b277010611..b5a0c6b568 100644 --- a/packages/server/src/services/database/types.ts +++ b/packages/server/src/services/database/types.ts @@ -251,6 +251,7 @@ export interface Subscription { is_deleted?: number; trial_end?: number; current_period_end?: number; + source?: string; } export interface UserFlag extends WithDates { @@ -502,6 +503,7 @@ export const databaseSchema: DatabaseTables = { is_deleted: { type: 'number', defaultValue: 0 }, trial_end: { type: 'string', defaultValue: 0 }, current_period_end: { type: 'string', defaultValue: 0 }, + source: { type: 'string', defaultValue: '' }, }, user_flags: { id: { type: 'number', defaultValue: null },