You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Server: Add support for Stripe yearly subscriptions
This commit is contained in:
		| @@ -848,7 +848,7 @@ footer .right-links a { | ||||
| 	padding: 30px 20px; | ||||
| 	padding-bottom: 30px; | ||||
| 	margin-bottom: 50px; | ||||
| 	margin-top: 60px; | ||||
| 	margin-top: 40px; | ||||
| } | ||||
|  | ||||
| .price-container p { | ||||
| @@ -871,7 +871,7 @@ footer .right-links a { | ||||
| .price-container-blue { | ||||
| 	background: linear-gradient(251.85deg, #0b4f99 -11.85%, #002d61 104.73%); | ||||
| 	box-shadow: 0px 4px 16px rgba(105, 132, 172, 0.13); | ||||
| 	margin-top: 40px; | ||||
| 	margin-top: 30px; | ||||
| 	padding-top: 50px; | ||||
| 	color: white; | ||||
| } | ||||
| @@ -886,6 +886,40 @@ footer .right-links a { | ||||
| 	margin-bottom: 1em; | ||||
| } | ||||
|  | ||||
| .plan-group .plan-price-yearly-per-year { | ||||
| 	display: flex; | ||||
| 	justify-content: flex-end; | ||||
| 	margin-top: -20px; | ||||
| 	margin-bottom: 10px; | ||||
| } | ||||
|  | ||||
| .plan-group .plan-price-yearly-per-year .per-year { | ||||
| 	bottom: 0; | ||||
| } | ||||
|  | ||||
| .plan-group.plan-prices-monthly .plan-price-yearly { | ||||
| 	display: none; | ||||
| } | ||||
|  | ||||
| .plan-group.plan-prices-monthly .plan-price-yearly-per-year { | ||||
| 	display: none; | ||||
| } | ||||
|  | ||||
| .plan-group.plan-prices-yearly .plan-price-monthly { | ||||
| 	text-decoration: line-through; | ||||
| 	opacity: 0.7; | ||||
| 	font-size: 16px; | ||||
| 	font-weight: normal; | ||||
| } | ||||
|  | ||||
| .plan-group.plan-prices-yearly .account-type-3 .plan-price-monthly { | ||||
| 	display: none; | ||||
| } | ||||
|  | ||||
| .plan-group.plan-prices-yearly .plan-price-monthly .per-month { | ||||
| 	display: none; | ||||
| } | ||||
|  | ||||
| .price-row .plan-type { | ||||
| 	display: flex; | ||||
| 	align-items: center; | ||||
|   | ||||
| @@ -24,6 +24,11 @@ | ||||
| 			media="all" | ||||
| 			onload="this.media='all'; this.onload = null" | ||||
| 		/> | ||||
| 		<script | ||||
| 			src="{{jsBaseUrl}}/jquery-3.6.0.min.js" | ||||
| 			rel="preload" | ||||
| 			as="script" | ||||
| 		></script> | ||||
| 		<link rel="stylesheet" href="{{cssBaseUrl}}/site.css?t={{buildTime}}" as="style" /> | ||||
| 		<title>Joplin</title> | ||||
| 	</head> | ||||
| @@ -403,11 +408,6 @@ | ||||
| 			rel="preload" | ||||
| 			as="script" | ||||
| 		></script> | ||||
| 		<script | ||||
| 			src="{{jsBaseUrl}}/jquery-3.6.0.min.js" | ||||
| 			rel="preload" | ||||
| 			as="script" | ||||
| 		></script> | ||||
| 		<script src="{{jsBaseUrl}}/script.js?t={{buildTime}}"></script> | ||||
| 		{{> analytics}} | ||||
| 	</body> | ||||
|   | ||||
| @@ -39,6 +39,12 @@ https://github.com/laurent22/joplin/blob/dev/{{{sourceMarkdownFile}}} | ||||
| 		/> | ||||
| 		<link rel="stylesheet" href="{{cssBaseUrl}}/site.css?t={{buildTime}}" as="style" /> | ||||
| 		<title>{{pageTitle}}</title> | ||||
|  | ||||
| 		<script | ||||
| 			src="{{jsBaseUrl}}/jquery-3.6.0.min.js" | ||||
| 			rel="preload" | ||||
| 			as="script" | ||||
| 		></script> | ||||
| 	</head> | ||||
| 	<body class="website-env-{{env}}"> | ||||
| 		<div class="container-fluid generic-template {{pageName}}-page" id="main-container"> | ||||
| @@ -103,11 +109,6 @@ https://github.com/laurent22/joplin/blob/dev/{{{sourceMarkdownFile}}} | ||||
| 			</footer> | ||||
| 		</div> | ||||
|  | ||||
| 		<script | ||||
| 			src="{{jsBaseUrl}}/jquery-3.6.0.min.js" | ||||
| 			rel="preload" | ||||
| 			as="script" | ||||
| 		></script> | ||||
| 		<script src="{{jsBaseUrl}}/script.js?t={{buildTime}}"></script> | ||||
|  | ||||
| 		{{> analytics}} | ||||
|   | ||||
| @@ -1,12 +1,22 @@ | ||||
| <div class="col-12 col-lg-4"> | ||||
| <div class="col-12 col-lg-4 account-type-{{priceMonthly.accountType}}"> | ||||
| 	<div class="price-container {{#featured}}price-container-blue{{/featured}}"> | ||||
| 		<div class="price-row"> | ||||
| 			<div class="plan-type"> | ||||
| 				<img src="{{imageBaseUrl}}/{{iconName}}.png"/> {{title}} | ||||
| 			</div> | ||||
|  | ||||
| 			<div class="plan-price"> | ||||
| 				{{price}}<sub class="per-month"> /month</sub> | ||||
| 			<div class="plan-price plan-price-monthly"> | ||||
| 				{{priceMonthly.formattedMonthlyAmount}}<sub class="per-month"> /month</sub> | ||||
| 			</div> | ||||
|  | ||||
| 			<div class="plan-price plan-price-yearly"> | ||||
| 				{{priceYearly.formattedMonthlyAmount}}<sub class="per-month"> /month</sub> | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="plan-price-yearly-per-year"> | ||||
| 			<div> | ||||
| 				({{priceYearly.formattedAmount}}<sub class="per-year"> /year</sub>) | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| @@ -25,19 +35,28 @@ | ||||
|  | ||||
| 	<script> | ||||
| 		(function() { | ||||
| 			const stripePriceId = '{{{stripePriceId}}}'; | ||||
| 			const stripePricesIds = { | ||||
| 				monthly: '{{{priceMonthly.id}}}', | ||||
| 				yearly: '{{{priceYearly.id}}}', | ||||
| 			}; | ||||
| 			const planName = '{{{name}}}'; | ||||
| 			const buttonId = 'subscribeButton-' + planName; | ||||
| 			const buttonElement = document.getElementById(buttonId); | ||||
|  | ||||
| 			if (stripePriceId) { | ||||
| 			if (stripePricesIds.monthly) { | ||||
| 				function handleResult() { | ||||
| 					console.info('Redirected to checkout'); | ||||
| 				} | ||||
|  | ||||
| 				buttonElement.addEventListener("click", function(evt) { | ||||
| 					evt.preventDefault(); | ||||
| 					const priceId = '{{{stripePriceId}}}'; | ||||
|  | ||||
| 					const priceId = stripePricesIds[subscriptionPeriod]; | ||||
|  | ||||
| 					if (!priceId) { | ||||
| 						console.error('Invalid period: ' + subscriptionPeriod); | ||||
| 						return; | ||||
| 					} | ||||
|  | ||||
| 					createCheckoutSession(priceId).then(function(data) { | ||||
| 						stripe.redirectToCheckout({ | ||||
|   | ||||
| @@ -10,8 +10,24 @@ | ||||
| 				</p> | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<div style="display: flex; justify-content: center; margin-top: 1.2em">	 | ||||
| 			<div class="form-check form-check-inline"> | ||||
| 				<input id="pay-monthly-radio" class="form-check-input" type="radio" name="pay-radio" checked value="monthly"> | ||||
| 				<label style="font-weight: bold" class="form-check-label" for="pay-monthly-radio"> | ||||
| 					Pay Monthly | ||||
| 				</label> | ||||
| 			</div> | ||||
|  | ||||
| 			<div class="form-check form-check-inline"> | ||||
| 				<input id="pay-yearly-radio" class="form-check-input" type="radio" name="pay-radio" value="yearly"> | ||||
| 				<label style="font-weight: bold" class="form-check-label" for="pay-yearly-radio"> | ||||
| 					Pay Yearly | ||||
| 				</label> | ||||
| 			</div>	 | ||||
| 		</div> | ||||
| 		 | ||||
| 		<div class="row"> | ||||
| 		<div class="row plan-group plan-prices-monthly"> | ||||
| 			{{#plans.basic}} | ||||
| 				{{> plan}} | ||||
| 			{{/plans.basic}} | ||||
| @@ -33,22 +49,33 @@ | ||||
| 	<script src="https://js.stripe.com/v3/"></script> | ||||
|  | ||||
| 	<script> | ||||
| 	var stripe = Stripe('{{{stripeConfig.publishableKey}}}'); | ||||
| 		let subscriptionPeriod = 'monthly'; | ||||
| 		var stripe = Stripe('{{{stripeConfig.publishableKey}}}'); | ||||
|  | ||||
| 	var createCheckoutSession = function(priceId) { | ||||
| 		console.info('Creating Stripe session for price:', priceId); | ||||
| 		var createCheckoutSession = function(priceId) { | ||||
| 			console.info('Creating Stripe session for price:', priceId); | ||||
|  | ||||
| 		return fetch("{{{stripeConfig.webhookBaseUrl}}}/stripe/createCheckoutSession", { | ||||
| 			method: "POST", | ||||
| 			headers: { | ||||
| 				"Content-Type": "application/json" | ||||
| 			}, | ||||
| 			body: JSON.stringify({ | ||||
| 				priceId: priceId | ||||
| 			return fetch("{{{stripeConfig.webhookBaseUrl}}}/stripe/createCheckoutSession", { | ||||
| 				method: "POST", | ||||
| 				headers: { | ||||
| 					"Content-Type": "application/json" | ||||
| 				}, | ||||
| 				body: JSON.stringify({ | ||||
| 					priceId: priceId | ||||
| 				}) | ||||
| 			}).then(function(result) { | ||||
| 				return result.json(); | ||||
| 			}); | ||||
| 		}; | ||||
|  | ||||
| 		$(() => { | ||||
| 			$("input[name='pay-radio']").change(function() { | ||||
| 				const period = $("input[type='radio'][name='pay-radio']:checked").val(); | ||||
| 				subscriptionPeriod = period; | ||||
| 			 | ||||
| 				$('.plan-group').removeClass(period === 'monthly' ? 'plan-prices-yearly' : 'plan-prices-monthly'); | ||||
| 				$('.plan-group').addClass('plan-prices-' + period); | ||||
| 			}) | ||||
| 		}).then(function(result) { | ||||
| 			return result.json(); | ||||
| 		}); | ||||
| 	}; | ||||
| 	</script> | ||||
| </div> | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| import * as fs from 'fs-extra'; | ||||
|  | ||||
| export interface Plan { | ||||
| 	name: string; | ||||
| 	title: string; | ||||
| 	price: string; | ||||
| 	stripePriceId: string; | ||||
| 	priceMonthly: StripePublicConfigPrice; | ||||
| 	priceYearly: StripePublicConfigPrice; | ||||
| 	featured: boolean; | ||||
| 	iconName: string; | ||||
| 	featuresOn: string[]; | ||||
| @@ -11,10 +13,30 @@ export interface Plan { | ||||
| 	cfaUrl: string; | ||||
| } | ||||
|  | ||||
| export enum PricePeriod { | ||||
| 	Monthly = 'monthly', | ||||
| 	Yearly = 'yearly', | ||||
| } | ||||
|  | ||||
| export enum PriceCurrency { | ||||
| 	EUR = 'EUR', | ||||
| 	GBP = 'GBP', | ||||
| 	USD = 'USD', | ||||
| } | ||||
|  | ||||
| export interface StripePublicConfigPrice { | ||||
| 	accountType: number; // AccountType | ||||
| 	id: string; | ||||
| 	period: PricePeriod; | ||||
| 	amount: string; | ||||
| 	formattedAmount: string; | ||||
| 	formattedMonthlyAmount: string; | ||||
| 	currency: PriceCurrency; | ||||
| } | ||||
|  | ||||
| export interface StripePublicConfig { | ||||
| 	publishableKey: string; | ||||
| 	basicPriceId: string; | ||||
| 	proPriceId: string; | ||||
| 	prices: StripePublicConfigPrice[]; | ||||
| 	webhookBaseUrl: string; | ||||
| } | ||||
|  | ||||
| @@ -43,6 +65,51 @@ export function getFeatureList(plan: Plan): PlanFeature[] { | ||||
| 	return output; | ||||
| } | ||||
|  | ||||
| function formatPrice(amount: string | number, currency: PriceCurrency): string { | ||||
| 	amount = typeof amount === 'number' ? (Math.ceil(amount * 100) / 100).toFixed(2) : amount; | ||||
| 	if (currency === PriceCurrency.EUR) return `${amount}€`; | ||||
| 	if (currency === PriceCurrency.GBP) return `£${amount}`; | ||||
| 	if (currency === PriceCurrency.USD) return `$${amount}`; | ||||
| 	throw new Error(`Unsupported currency: ${currency}`); | ||||
| } | ||||
|  | ||||
| interface FindPriceQuery { | ||||
| 	accountType?: number; | ||||
| 	period?: PricePeriod; | ||||
| 	priceId?: string; | ||||
| } | ||||
|  | ||||
| export function loadStripeConfig(env: string, filePath: string): StripePublicConfig { | ||||
| 	const config: StripePublicConfig = JSON.parse(fs.readFileSync(filePath, 'utf8'))[env]; | ||||
| 	if (!config) throw new Error(`Invalid env: ${env}`); | ||||
|  | ||||
| 	config.prices = config.prices.map(p => { | ||||
| 		return { | ||||
| 			...p, | ||||
| 			formattedAmount: formatPrice(p.amount, p.currency), | ||||
| 			formattedMonthlyAmount: p.period === PricePeriod.Monthly ? formatPrice(p.amount, p.currency) : formatPrice(Number(p.amount) / 12, p.currency), | ||||
| 		}; | ||||
| 	}); | ||||
|  | ||||
| 	return config; | ||||
| } | ||||
|  | ||||
| export function findPrice(prices: StripePublicConfigPrice[], query: FindPriceQuery): StripePublicConfigPrice { | ||||
| 	let output: StripePublicConfigPrice = null; | ||||
|  | ||||
| 	if (query.accountType && query.period) { | ||||
| 		output = prices.filter(p => p.accountType === query.accountType).find(p => p.period === query.period); | ||||
| 	} else if (query.priceId) { | ||||
| 		output = prices.find(p => p.id === query.priceId); | ||||
| 	} else { | ||||
| 		throw new Error(`Invalid query: ${JSON.stringify(query)}`); | ||||
| 	} | ||||
|  | ||||
| 	if (!output) throw new Error(`Not found: ${JSON.stringify(query)}`); | ||||
|  | ||||
| 	return output; | ||||
| } | ||||
|  | ||||
| const businessAccountEmailBody = `Hello, | ||||
|  | ||||
| This is an automatically generated email. The Business feature is coming soon, and in the meantime we offer a business discount if you would like to register multiple users. | ||||
| @@ -69,8 +136,14 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<string, Plan> | ||||
| 		basic: { | ||||
| 			name: 'basic', | ||||
| 			title: 'Basic', | ||||
| 			price: '1.99€', | ||||
| 			stripePriceId: stripeConfig.basicPriceId, | ||||
| 			priceMonthly: findPrice(stripeConfig.prices, { | ||||
| 				accountType: 1, | ||||
| 				period: PricePeriod.Monthly, | ||||
| 			}), | ||||
| 			priceYearly: findPrice(stripeConfig.prices, { | ||||
| 				accountType: 1, | ||||
| 				period: PricePeriod.Yearly, | ||||
| 			}), | ||||
| 			featured: false, | ||||
| 			iconName: 'basic-icon', | ||||
| 			featuresOn: [ | ||||
| @@ -92,8 +165,14 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<string, Plan> | ||||
| 		pro: { | ||||
| 			name: 'pro', | ||||
| 			title: 'Pro', | ||||
| 			price: '5.99€', | ||||
| 			stripePriceId: stripeConfig.proPriceId, | ||||
| 			priceMonthly: findPrice(stripeConfig.prices, { | ||||
| 				accountType: 2, | ||||
| 				period: PricePeriod.Monthly, | ||||
| 			}), | ||||
| 			priceYearly: findPrice(stripeConfig.prices, { | ||||
| 				accountType: 2, | ||||
| 				period: PricePeriod.Yearly, | ||||
| 			}), | ||||
| 			featured: true, | ||||
| 			iconName: 'pro-icon', | ||||
| 			featuresOn: [ | ||||
| @@ -115,8 +194,8 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<string, Plan> | ||||
| 		business: { | ||||
| 			name: 'business', | ||||
| 			title: 'Business', | ||||
| 			price: '49.99€', | ||||
| 			stripePriceId: '', | ||||
| 			priceMonthly: { accountType: 3, formattedMonthlyAmount: '49.99€' } as any, | ||||
| 			priceYearly: { accountType: 3, formattedMonthlyAmount: '39.99€', formattedAmount: '479.88€' } as any, | ||||
| 			featured: false, | ||||
| 			iconName: 'business-icon', | ||||
| 			featuresOn: [ | ||||
|   | ||||
| @@ -107,7 +107,7 @@ async function main() { | ||||
| 	]; | ||||
|  | ||||
| 	if (env === Env.Dev) { | ||||
| 		corsAllowedDomains.push('http://localhost:8080'); | ||||
| 		corsAllowedDomains.push('http://localhost:8077'); | ||||
| 	} | ||||
|  | ||||
| 	function acceptOrigin(origin: string): boolean { | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import { rtrimSlashes } from '@joplin/lib/path-utils'; | ||||
| import { Config, DatabaseConfig, DatabaseConfigClient, Env, MailerConfig, RouteType, StripeConfig, StripePublicConfig } from './utils/types'; | ||||
| import { Config, DatabaseConfig, DatabaseConfigClient, Env, MailerConfig, RouteType, StripeConfig } from './utils/types'; | ||||
| import * as pathUtils from 'path'; | ||||
| import { readFile } from 'fs-extra'; | ||||
| import { loadStripeConfig, StripePublicConfig } from '@joplin/lib/utils/joplinCloud'; | ||||
|  | ||||
| export interface EnvVariables { | ||||
| 	APP_NAME?: string; | ||||
| @@ -131,9 +132,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any | ||||
| 	const rootDir = pathUtils.dirname(__dirname); | ||||
|  | ||||
| 	const packageJson = await readPackageJson(`${rootDir}/package.json`); | ||||
| 	const stripePublicConfigs = JSON.parse(await readFile(`${rootDir}/stripeConfig.json`, 'utf8')); | ||||
| 	const stripePublicConfig = stripePublicConfigs[envType === Env.BuildTypes ? Env.Dev : envType]; | ||||
| 	if (!stripePublicConfig) throw new Error('Could not load Stripe config'); | ||||
| 	const stripePublicConfig = loadStripeConfig(envType === Env.BuildTypes ? Env.Dev : envType, `${rootDir}/stripeConfig.json`); | ||||
|  | ||||
| 	const viewDir = `${rootDir}/src/views`; | ||||
| 	const appPort = env.APP_PORT ? Number(env.APP_PORT) : 22300; | ||||
|   | ||||
| @@ -20,6 +20,7 @@ describe('SubscriptionModel', function() { | ||||
| 	test('should create a user and subscription', async function() { | ||||
| 		await models().subscription().saveUserAndSubscription( | ||||
| 			'toto@example.com', | ||||
| 			'Toto', | ||||
| 			AccountType.Pro, | ||||
| 			'STRIPE_USER_ID', | ||||
| 			'STRIPE_SUB_ID' | ||||
| @@ -30,6 +31,7 @@ describe('SubscriptionModel', function() { | ||||
|  | ||||
| 		expect(user.account_type).toBe(AccountType.Pro); | ||||
| 		expect(user.email).toBe('toto@example.com'); | ||||
| 		expect(user.full_name).toBe('Toto'); | ||||
| 		expect(getCanShareFolder(user)).toBe(1); | ||||
| 		expect(getMaxItemSize(user)).toBe(200 * MB); | ||||
|  | ||||
|   | ||||
| @@ -50,11 +50,12 @@ export default class SubscriptionModel extends BaseModel<Subscription> { | ||||
| 		return this.db(this.tableName).select(this.defaultFields).where('user_id', '=', userId).where('is_deleted', '=', 0).first(); | ||||
| 	} | ||||
|  | ||||
| 	public async saveUserAndSubscription(email: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string) { | ||||
| 	public async saveUserAndSubscription(email: string, fullName: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string) { | ||||
| 		return this.withTransaction(async () => { | ||||
| 			const user = await this.models().user().save({ | ||||
| 				account_type: accountType, | ||||
| 				email, | ||||
| 				full_name: fullName, | ||||
| 				email_confirmed: 1, | ||||
| 				password: uuidgen(), | ||||
| 				must_set_password: 1, | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import { findPrice, PricePeriod } from '@joplin/lib/utils/joplinCloud'; | ||||
| import { AccountType } from '../../models/UserModel'; | ||||
| import { initStripe, stripeConfig } from '../../utils/stripe'; | ||||
| import { beforeAllDb, afterAllTests, beforeEachDb, models, koaAppContext, expectNotThrow } from '../../utils/testing/testUtils'; | ||||
| @@ -7,8 +8,8 @@ import { postHandlers } from './stripe'; | ||||
| async function createUserViaSubscription(userEmail: string, eventId: string = '') { | ||||
| 	eventId = eventId || uuidgen(); | ||||
| 	const stripeSessionId = 'sess_123'; | ||||
| 	const stripePriceId = stripeConfig().proPriceId; | ||||
| 	await models().keyValue().setValue(`stripeSessionToPriceId::${stripeSessionId}`, stripePriceId); | ||||
| 	const stripePrice = findPrice(stripeConfig().prices, { accountType: 2, period: PricePeriod.Monthly }); | ||||
| 	await models().keyValue().setValue(`stripeSessionToPriceId::${stripeSessionId}`, stripePrice.id); | ||||
|  | ||||
| 	const ctx = await koaAppContext(); | ||||
| 	const stripe = initStripe(); | ||||
|   | ||||
| @@ -11,6 +11,7 @@ import getRawBody = require('raw-body'); | ||||
| import { AccountType } from '../../models/UserModel'; | ||||
| import { initStripe, priceIdToAccountType, stripeConfig } from '../../utils/stripe'; | ||||
| import { Subscription } from '../../db'; | ||||
| import { findPrice, PricePeriod } from '@joplin/lib/utils/joplinCloud'; | ||||
|  | ||||
| const logger = Logger.create('/stripe'); | ||||
|  | ||||
| @@ -99,7 +100,7 @@ export const postHandlers: PostHandlers = { | ||||
| 	// - Start the Stripe CLI tool: `stripe listen --forward-to http://joplincloud.local:22300/stripe/webhook` | ||||
| 	// - Copy the webhook secret, and paste it in joplin-credentials/server.env (under STRIPE_WEBHOOK_SECRET) | ||||
| 	// - Start the local Joplin Server, `npm run start-dev`, running under http://joplincloud.local:22300 | ||||
| 	// - Start the workflow from http://localhost:8080/plans/ | ||||
| 	// - Start the workflow from http://localhost:8077/plans/ | ||||
| 	// - The local website often is not configured to send email, but you can see them in the database, in the "emails" table. | ||||
| 	// | ||||
| 	// # Simplified workflow | ||||
| @@ -180,8 +181,17 @@ 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; | ||||
|  | ||||
| 				let customerName = ''; | ||||
| 				try { | ||||
| 					const customer = await stripe.customers.retrieve(checkoutSession.customer as string) as Stripe.Customer; | ||||
| 					customerName = customer.name; | ||||
| 				} catch (error) { | ||||
| 					logger.error('Could not fetch customer information:', error); | ||||
| 				} | ||||
|  | ||||
| 				logger.info('Checkout session completed:', checkoutSession.id); | ||||
| 				logger.info('User email:', userEmail); | ||||
| 				logger.info('User name:', customerName); | ||||
|  | ||||
| 				let accountType = AccountType.Basic; | ||||
| 				try { | ||||
| @@ -205,6 +215,7 @@ export const postHandlers: PostHandlers = { | ||||
|  | ||||
| 				await ctx.joplin.models.subscription().saveUserAndSubscription( | ||||
| 					userEmail, | ||||
| 					customerName, | ||||
| 					accountType, | ||||
| 					stripeUserId, | ||||
| 					stripeSubscriptionId | ||||
| @@ -317,6 +328,8 @@ const getHandlers: Record<string, StripeRouteHandler> = { | ||||
| 	}, | ||||
|  | ||||
| 	checkoutTest: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => { | ||||
| 		const basicPrice = findPrice(stripeConfig().prices, { accountType: 1, period: PricePeriod.Monthly }); | ||||
|  | ||||
| 		return ` | ||||
| 			<head> | ||||
| 				<title>Checkout</title> | ||||
| @@ -343,7 +356,7 @@ const getHandlers: Record<string, StripeRouteHandler> = { | ||||
| 			<body> | ||||
| 				<button id="checkout">Subscribe</button> | ||||
| 				<script> | ||||
| 					var PRICE_ID = ${JSON.stringify(stripeConfig().basicPriceId)}; | ||||
| 					var PRICE_ID = ${basicPrice.id}; | ||||
|  | ||||
| 					function handleResult() { | ||||
| 						console.info('Redirected to checkout'); | ||||
|   | ||||
| @@ -2,10 +2,10 @@ import { SubPath, redirect } from '../../utils/routeUtils'; | ||||
| import Router from '../../utils/Router'; | ||||
| import { RouteType } from '../../utils/types'; | ||||
| import { AppContext } from '../../utils/types'; | ||||
| import { getFeatureList, getPlans } from '@joplin/lib/utils/joplinCloud'; | ||||
| import { findPrice, getFeatureList, getPlans, PricePeriod } from '@joplin/lib/utils/joplinCloud'; | ||||
| import config from '../../config'; | ||||
| import defaultView from '../../utils/defaultView'; | ||||
| import { stripeConfig, updateSubscriptionType } from '../../utils/stripe'; | ||||
| import { stripeConfig, stripePriceIdByUserId, updateSubscriptionType } from '../../utils/stripe'; | ||||
| import { bodyFields } from '../../utils/requestUtils'; | ||||
| import { NotificationKey } from '../../models/NotificationModel'; | ||||
| import { AccountType } from '../../models/UserModel'; | ||||
| @@ -46,13 +46,21 @@ router.get('upgrade', async (_path: SubPath, ctx: AppContext) => { | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	const priceId = await stripePriceIdByUserId(ctx.joplin.models, ctx.joplin.owner.id); | ||||
| 	const currentPrice = findPrice(stripeConfig().prices, { priceId }); | ||||
| 	const upgradePrice = findPrice(stripeConfig().prices, { | ||||
| 		accountType: AccountType.Pro, | ||||
| 		period: currentPrice.period, | ||||
| 	}); | ||||
|  | ||||
| 	const view = defaultView('upgrade', 'Upgrade'); | ||||
| 	view.content = { | ||||
| 		planRows, | ||||
| 		basicPrice: plans.basic.price, | ||||
| 		proPrice: plans.pro.price, | ||||
| 		basicPrice: currentPrice, | ||||
| 		proPrice: upgradePrice, | ||||
| 		postUrl: upgradeUrl(), | ||||
| 		csrfTag: await createCsrfTag(ctx), | ||||
| 		showYearlyPrices: currentPrice.period === PricePeriod.Yearly, | ||||
| 	}; | ||||
| 	view.cssFiles = ['index/upgrade']; | ||||
| 	return view; | ||||
|   | ||||
| @@ -1,11 +1,17 @@ | ||||
| import globalConfig from '../config'; | ||||
| import { StripeConfig } from './types'; | ||||
| import { Stripe } from 'stripe'; | ||||
| import { Uuid } from '../db'; | ||||
| import { Subscription, Uuid } from '../db'; | ||||
| import { Models } from '../models/factory'; | ||||
| import { AccountType, accountTypeOptions } from '../models/UserModel'; | ||||
| import { AccountType } from '../models/UserModel'; | ||||
| import { findPrice, PricePeriod } from '@joplin/lib/utils/joplinCloud'; | ||||
| const stripeLib = require('stripe'); | ||||
|  | ||||
| export interface SubscriptionInfo { | ||||
| 	sub: Subscription; | ||||
| 	stripeSub: Stripe.Subscription; | ||||
| } | ||||
|  | ||||
| export function stripeConfig(): StripeConfig { | ||||
| 	return globalConfig().stripe; | ||||
| } | ||||
| @@ -15,15 +21,33 @@ export function initStripe(): Stripe { | ||||
| } | ||||
|  | ||||
| export function priceIdToAccountType(priceId: string): AccountType { | ||||
| 	if (stripeConfig().basicPriceId === priceId) return AccountType.Basic; | ||||
| 	if (stripeConfig().proPriceId === priceId) return AccountType.Pro; | ||||
| 	throw new Error(`Unknown price ID: ${priceId}`); | ||||
| 	const price = findPrice(stripeConfig().prices, { priceId }); | ||||
| 	return price.accountType; | ||||
| } | ||||
|  | ||||
| export function accountTypeToPriceId(accountType: AccountType): string { | ||||
| 	if (accountType === AccountType.Basic) return stripeConfig().basicPriceId; | ||||
| 	if (accountType === AccountType.Pro) return stripeConfig().proPriceId; | ||||
| 	throw new Error(`Unknown account type: ${accountType}`); | ||||
| 	const price = findPrice(stripeConfig().prices, { accountType, period: PricePeriod.Monthly }); | ||||
| 	return price.id; | ||||
| } | ||||
|  | ||||
| export async function subscriptionInfoByUserId(models: Models, userId: Uuid): Promise<SubscriptionInfo> { | ||||
| 	const sub = await models.subscription().byUserId(userId); | ||||
| 	if (!sub) throw new Error('Could not retrieve subscription info'); | ||||
|  | ||||
| 	const stripe = initStripe(); | ||||
| 	const stripeSub = await stripe.subscriptions.retrieve(sub.stripe_subscription_id); | ||||
| 	if (!stripeSub) throw new Error('Could not retrieve Stripe subscription'); | ||||
|  | ||||
| 	return { sub, stripeSub }; | ||||
| } | ||||
|  | ||||
| export async function stripePriceIdByUserId(models: Models, userId: Uuid): Promise<string> { | ||||
| 	const { stripeSub } = await subscriptionInfoByUserId(models, userId); | ||||
| 	return stripePriceIdByStripeSub(stripeSub); | ||||
| } | ||||
|  | ||||
| export function stripePriceIdByStripeSub(stripeSub: Stripe.Subscription): string { | ||||
| 	return stripeSub.items.data[0].price.id; | ||||
| } | ||||
|  | ||||
| export async function cancelSubscription(models: Models, userId: Uuid) { | ||||
| @@ -37,29 +61,20 @@ export async function updateSubscriptionType(models: Models, userId: Uuid, newAc | ||||
| 	const user = await models.user().load(userId); | ||||
| 	if (user.account_type === newAccountType) throw new Error(`Account type is already: ${newAccountType}`); | ||||
|  | ||||
| 	const sub = await models.subscription().byUserId(userId); | ||||
| 	if (!sub) throw new Error(`No subscription for user: ${userId}`); | ||||
| 	const { sub, stripeSub } = await subscriptionInfoByUserId(models, userId); | ||||
|  | ||||
| 	const stripe = initStripe(); | ||||
|  | ||||
| 	const accountTypes = accountTypeOptions(); | ||||
|  | ||||
| 	const stripeSub = await stripe.subscriptions.retrieve(sub.stripe_subscription_id); | ||||
| 	const currentPrice = findPrice(stripeConfig().prices, { priceId: stripePriceIdByStripeSub(stripeSub) }); | ||||
| 	const upgradePrice = findPrice(stripeConfig().prices, { accountType: newAccountType, period: currentPrice.period }); | ||||
|  | ||||
| 	const items: Stripe.SubscriptionUpdateParams.Item[] = []; | ||||
|  | ||||
| 	// First delete all the items that don't match the new account type. That | ||||
| 	// means for example deleting the "Joplin Cloud Pro" item if the new account | ||||
| 	// type is "Basic" and vice versa. | ||||
| 	for (const t of accountTypes) { | ||||
| 		if (!t.value) continue; | ||||
|  | ||||
| 		const priceId = accountTypeToPriceId(t.value); | ||||
| 		const stripeSubItem = stripeSub.items.data.find(d => d.price.id === priceId); | ||||
|  | ||||
| 		if (stripeSubItem) { | ||||
| 			if (accountTypeToPriceId(newAccountType) === priceId) throw new Error(`This account is already of type ${newAccountType}`); | ||||
| 	for (const stripeSubItem of stripeSub.items.data) { | ||||
| 		if (stripeSubItem.price.id === upgradePrice.id) throw new Error(`This account is already of type ${newAccountType}`); | ||||
|  | ||||
| 		if (stripeSubItem.price.id !== upgradePrice.id) { | ||||
| 			items.push({ | ||||
| 				id: stripeSubItem.id, | ||||
| 				deleted: true, | ||||
| @@ -71,8 +86,18 @@ export async function updateSubscriptionType(models: Models, userId: Uuid, newAc | ||||
| 	// sufficient to specify the price ID, and from that Stripe infers the | ||||
| 	// product. | ||||
| 	items.push({ | ||||
| 		price: accountTypeToPriceId(newAccountType), | ||||
| 		price: upgradePrice.id, | ||||
| 	}); | ||||
|  | ||||
| 	// Note that we only update the Stripe subscription here (or attempt to do | ||||
| 	// so). The local subscription object will only be updated when we get the | ||||
| 	// `customer.subscription.updated` event back from Stripe. | ||||
| 	// | ||||
| 	// It shouldn't have a big impact since it's only for a short time, but it | ||||
| 	// means in the meantime the account type will not be changed and, for | ||||
| 	// example, the user could try to upgrade the account a second time. | ||||
| 	// Although that attempt would most likely fail due the checks above and | ||||
| 	// the checks in subscriptions.update(). | ||||
| 	const stripe = initStripe(); | ||||
| 	await stripe.subscriptions.update(sub.stripe_subscription_id, { items }); | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import { LoggerWrapper } from '@joplin/lib/Logger'; | ||||
| import { StripePublicConfig } from '@joplin/lib/utils/joplinCloud'; | ||||
| import * as Koa from 'koa'; | ||||
| import { DbConnection, User, Uuid } from '../db'; | ||||
| import { Models } from '../models/factory'; | ||||
| @@ -75,13 +76,6 @@ export interface MailerConfig { | ||||
| 	noReplyEmail: string; | ||||
| } | ||||
|  | ||||
| export interface StripePublicConfig { | ||||
| 	publishableKey: string; | ||||
| 	basicPriceId: string; | ||||
| 	proPriceId: string; | ||||
| 	webhookBaseUrl: string; | ||||
| } | ||||
|  | ||||
| export interface StripeConfig extends StripePublicConfig { | ||||
| 	secretKey: string; | ||||
| 	webhookSecret: string; | ||||
|   | ||||
| @@ -6,10 +6,16 @@ | ||||
| 	<table class="table is-hoverable user-props-table"> | ||||
| 		<tbody> | ||||
| 			<tr> | ||||
| 				<th class="basic-plan">Basic - {{basicPrice}}</th> | ||||
| 				<th>Pro - {{proPrice}}</th> | ||||
| 				<th class="basic-plan">Basic - {{basicPrice.formattedMonthlyAmount}} / month</th> | ||||
| 				<th>Pro - {{proPrice.formattedMonthlyAmount}} / month</th> | ||||
| 			</tr> | ||||
|  | ||||
| 			{{#showYearlyPrices}} | ||||
| 			<tr> | ||||
| 				<td>{{basicPrice.formattedAmount}} / year</td><td>{{proPrice.formattedAmount}} / year</td> | ||||
| 			</tr> | ||||
| 			{{/showYearlyPrices}} | ||||
|  | ||||
| 			{{#planRows}} | ||||
| 				<tr> | ||||
| 					<td class="basic-plan"> | ||||
|   | ||||
| @@ -1,14 +1,70 @@ | ||||
| { | ||||
| 	"dev": { | ||||
| 		"publishableKey": "pk_test_51IvkOPLx4fybOTqJetV23Y5S9YHU9KoOtE6Ftur0waWoWahkHdENjDKSVcl7v3y8Y0Euv7Uwd7O7W4UFasRwd0wE00MPcprz9Q", | ||||
| 		"basicPriceId": "price_1JAx31Lx4fybOTqJRcGdsSfg", | ||||
| 		"proPriceId": "price_1JAx1eLx4fybOTqJ5VhkxaKC", | ||||
| 		"webhookBaseUrl": "http://joplincloud.local:22300" | ||||
| 		"webhookBaseUrl": "http://joplincloud.local:22300", | ||||
| 		"prices": [ | ||||
| 			{ | ||||
| 				"accountType": 1, | ||||
| 				"id": "price_1JAx31Lx4fybOTqJRcGdsSfg", | ||||
| 				"period": "monthly", | ||||
| 				"amount": "1.99", | ||||
| 				"currency": "EUR" | ||||
| 			}, | ||||
| 			{ | ||||
| 				"accountType": 1, | ||||
| 				"id": "price_1JIb4fLx4fybOTqJHnOUPVdf", | ||||
| 				"period": "yearly", | ||||
| 				"amount": "17.88", | ||||
| 				"currency": "EUR" | ||||
| 			}, | ||||
| 			{ | ||||
| 				"accountType": 2, | ||||
| 				"id": "price_1JAx1eLx4fybOTqJ5VhkxaKC", | ||||
| 				"period": "monthly", | ||||
| 				"amount": "5.99", | ||||
| 				"currency": "EUR" | ||||
| 			}, | ||||
| 			{ | ||||
| 				"accountType": 2, | ||||
| 				"id": "price_1JJFp7Lx4fybOTqJ0f4w2UvY", | ||||
| 				"period": "yearly", | ||||
| 				"amount": "57.48", | ||||
| 				"currency": "EUR" | ||||
| 			} | ||||
| 		]		 | ||||
| 	}, | ||||
| 	"prod": { | ||||
| 		"publishableKey": "pk_live_51IvkOPLx4fybOTqJow8RFsWs0eDznPeBlXMw6s8SIDQeCM8bAFNYlBdDsyonAwRcJgBCoSlvFzAbhJgLFxzzTu4r0006aw846C", | ||||
| 		"basicPriceId": "price_1JAzWBLx4fybOTqJw64zxJRJ", | ||||
| 		"proPriceId": "price_1JB1OVLx4fybOTqJOvp3NGM6", | ||||
| 		"webhookBaseUrl": "https://joplincloud.com" | ||||
| 		"webhookBaseUrl": "https://joplincloud.com", | ||||
| 		"prices": [ | ||||
| 			{ | ||||
| 				"accountType": 1, | ||||
| 				"id": "price_1JAzWBLx4fybOTqJw64zxJRJ", | ||||
| 				"period": "monthly", | ||||
| 				"amount": "1.99", | ||||
| 				"currency": "EUR" | ||||
| 			}, | ||||
| 			{ | ||||
| 				"accountType": 1, | ||||
| 				"id": "price_1JJIPZLx4fybOTqJHvxiQ7bV", | ||||
| 				"period": "yearly", | ||||
| 				"amount": "17.88", | ||||
| 				"currency": "EUR" | ||||
| 			}, | ||||
| 			{ | ||||
| 				"accountType": 2, | ||||
| 				"id": "price_1JB1OVLx4fybOTqJOvp3NGM6", | ||||
| 				"period": "monthly", | ||||
| 				"amount": "5.99", | ||||
| 				"currency": "EUR" | ||||
| 			}, | ||||
| 			{ | ||||
| 				"accountType": 2, | ||||
| 				"id": "price_1JJIQ7Lx4fybOTqJsQNv1QUp", | ||||
| 				"period": "yearly", | ||||
| 				"amount": "57.48", | ||||
| 				"currency": "EUR" | ||||
| 			} | ||||
| 		] | ||||
| 	} | ||||
| } | ||||
| @@ -3,24 +3,22 @@ import { insertContentIntoFile, rootDir } from '../tool-utils'; | ||||
| import { pressCarouselItems } from './utils/pressCarousel'; | ||||
| import { getMarkdownIt, loadMustachePartials, markdownToPageHtml, renderMustache } from './utils/render'; | ||||
| import { Env, OrgSponsor, PlanPageParams, Sponsors, TemplateParams } from './utils/types'; | ||||
| import { getPlans, StripePublicConfig } from '@joplin/lib/utils/joplinCloud'; | ||||
| import { getPlans, loadStripeConfig } from '@joplin/lib/utils/joplinCloud'; | ||||
| import { shuffle } from '@joplin/lib/array'; | ||||
| const dirname = require('path').dirname; | ||||
| const glob = require('glob'); | ||||
| const path = require('path'); | ||||
|  | ||||
| const env = Env.Prod; | ||||
| const env = Env.Dev; | ||||
| const buildTime = Date.now(); | ||||
|  | ||||
| const websiteAssetDir = `${rootDir}/Assets/WebsiteAssets`; | ||||
| const mainTemplateHtml = fs.readFileSync(`${websiteAssetDir}/templates/main-new.mustache`, 'utf8'); | ||||
| const frontTemplateHtml = fs.readFileSync(`${websiteAssetDir}/templates/front.mustache`, 'utf8'); | ||||
| const plansTemplateHtml = fs.readFileSync(`${websiteAssetDir}/templates/plans.mustache`, 'utf8'); | ||||
| const stripeConfigs: Record<Env, StripePublicConfig> = JSON.parse(fs.readFileSync(`${rootDir}/packages/server/stripeConfig.json`, 'utf8')); | ||||
| const stripeConfig = loadStripeConfig(env, `${rootDir}/packages/server/stripeConfig.json`); | ||||
| const partialDir = `${websiteAssetDir}/templates/partials`; | ||||
|  | ||||
| const stripeConfig = stripeConfigs[env]; | ||||
|  | ||||
| let tocMd_: string = null; | ||||
| let tocHtml_: string = null; | ||||
| const tocRegex_ = /<!-- TOC -->([^]*)<!-- TOC -->/; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user