diff --git a/Assets/WebsiteAssets/css/site.css b/Assets/WebsiteAssets/css/site.css index decb069861..6cc76ea376 100644 --- a/Assets/WebsiteAssets/css/site.css +++ b/Assets/WebsiteAssets/css/site.css @@ -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; diff --git a/Assets/WebsiteAssets/templates/front.mustache b/Assets/WebsiteAssets/templates/front.mustache index e7f8faf63a..772dc22a7c 100644 --- a/Assets/WebsiteAssets/templates/front.mustache +++ b/Assets/WebsiteAssets/templates/front.mustache @@ -24,6 +24,11 @@ media="all" onload="this.media='all'; this.onload = null" /> + Joplin @@ -403,11 +408,6 @@ rel="preload" as="script" > - {{> analytics}} diff --git a/Assets/WebsiteAssets/templates/main-new.mustache b/Assets/WebsiteAssets/templates/main-new.mustache index f8a19dfc17..3c720668b4 100644 --- a/Assets/WebsiteAssets/templates/main-new.mustache +++ b/Assets/WebsiteAssets/templates/main-new.mustache @@ -39,6 +39,12 @@ https://github.com/laurent22/joplin/blob/dev/{{{sourceMarkdownFile}}} /> {{pageTitle}} + +
@@ -103,11 +109,6 @@ https://github.com/laurent22/joplin/blob/dev/{{{sourceMarkdownFile}}}
- {{> analytics}} diff --git a/Assets/WebsiteAssets/templates/partials/plan.mustache b/Assets/WebsiteAssets/templates/partials/plan.mustache index 413fbe337b..afd4ba1267 100644 --- a/Assets/WebsiteAssets/templates/partials/plan.mustache +++ b/Assets/WebsiteAssets/templates/partials/plan.mustache @@ -1,12 +1,22 @@ -
+
 {{title}}
-
- {{price}} /month +
+ {{priceMonthly.formattedMonthlyAmount}} /month +
+ +
+ {{priceYearly.formattedMonthlyAmount}} /month +
+
+ +
+
+ ({{priceYearly.formattedAmount}} /year)
@@ -25,19 +35,28 @@
diff --git a/packages/lib/utils/joplinCloud.ts b/packages/lib/utils/joplinCloud.ts index 026d89dc8a..fcb8967836 100644 --- a/packages/lib/utils/joplinCloud.ts +++ b/packages/lib/utils/joplinCloud.ts @@ -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 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 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 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: [ diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index f1f2c8e21b..42a676c95a 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -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 { diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index cc3aac0de9..a25a51d4a5 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -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; diff --git a/packages/server/src/models/SubscriptionModel.test.ts b/packages/server/src/models/SubscriptionModel.test.ts index 9ee12f43a2..8b53971428 100644 --- a/packages/server/src/models/SubscriptionModel.test.ts +++ b/packages/server/src/models/SubscriptionModel.test.ts @@ -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); diff --git a/packages/server/src/models/SubscriptionModel.ts b/packages/server/src/models/SubscriptionModel.ts index 029713d6c9..f7c0943f74 100644 --- a/packages/server/src/models/SubscriptionModel.ts +++ b/packages/server/src/models/SubscriptionModel.ts @@ -50,11 +50,12 @@ 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, 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, diff --git a/packages/server/src/routes/index/stripe.test.ts b/packages/server/src/routes/index/stripe.test.ts index 091018a74d..4d09f99135 100644 --- a/packages/server/src/routes/index/stripe.test.ts +++ b/packages/server/src/routes/index/stripe.test.ts @@ -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(); diff --git a/packages/server/src/routes/index/stripe.ts b/packages/server/src/routes/index/stripe.ts index 8164c784d1..cfc2fca9b1 100644 --- a/packages/server/src/routes/index/stripe.ts +++ b/packages/server/src/routes/index/stripe.ts @@ -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 = { }, checkoutTest: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => { + const basicPrice = findPrice(stripeConfig().prices, { accountType: 1, period: PricePeriod.Monthly }); + return ` Checkout @@ -343,7 +356,7 @@ const getHandlers: Record = {