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' ? 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 },