1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-11-27 08:21:03 +02:00

Server: Add support for Stripe yearly subscriptions

This commit is contained in:
Laurent Cozic 2021-07-31 14:42:56 +01:00
parent c76ed7f8ac
commit f2547fed8d
18 changed files with 363 additions and 100 deletions

View File

@ -848,7 +848,7 @@ footer .right-links a {
padding: 30px 20px; padding: 30px 20px;
padding-bottom: 30px; padding-bottom: 30px;
margin-bottom: 50px; margin-bottom: 50px;
margin-top: 60px; margin-top: 40px;
} }
.price-container p { .price-container p {
@ -871,7 +871,7 @@ footer .right-links a {
.price-container-blue { .price-container-blue {
background: linear-gradient(251.85deg, #0b4f99 -11.85%, #002d61 104.73%); background: linear-gradient(251.85deg, #0b4f99 -11.85%, #002d61 104.73%);
box-shadow: 0px 4px 16px rgba(105, 132, 172, 0.13); box-shadow: 0px 4px 16px rgba(105, 132, 172, 0.13);
margin-top: 40px; margin-top: 30px;
padding-top: 50px; padding-top: 50px;
color: white; color: white;
} }
@ -886,6 +886,40 @@ footer .right-links a {
margin-bottom: 1em; 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 { .price-row .plan-type {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -24,6 +24,11 @@
media="all" media="all"
onload="this.media='all'; this.onload = null" 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" /> <link rel="stylesheet" href="{{cssBaseUrl}}/site.css?t={{buildTime}}" as="style" />
<title>Joplin</title> <title>Joplin</title>
</head> </head>
@ -403,11 +408,6 @@
rel="preload" rel="preload"
as="script" as="script"
></script> ></script>
<script
src="{{jsBaseUrl}}/jquery-3.6.0.min.js"
rel="preload"
as="script"
></script>
<script src="{{jsBaseUrl}}/script.js?t={{buildTime}}"></script> <script src="{{jsBaseUrl}}/script.js?t={{buildTime}}"></script>
{{> analytics}} {{> analytics}}
</body> </body>

View File

@ -39,6 +39,12 @@ https://github.com/laurent22/joplin/blob/dev/{{{sourceMarkdownFile}}}
/> />
<link rel="stylesheet" href="{{cssBaseUrl}}/site.css?t={{buildTime}}" as="style" /> <link rel="stylesheet" href="{{cssBaseUrl}}/site.css?t={{buildTime}}" as="style" />
<title>{{pageTitle}}</title> <title>{{pageTitle}}</title>
<script
src="{{jsBaseUrl}}/jquery-3.6.0.min.js"
rel="preload"
as="script"
></script>
</head> </head>
<body class="website-env-{{env}}"> <body class="website-env-{{env}}">
<div class="container-fluid generic-template {{pageName}}-page" id="main-container"> <div class="container-fluid generic-template {{pageName}}-page" id="main-container">
@ -103,11 +109,6 @@ https://github.com/laurent22/joplin/blob/dev/{{{sourceMarkdownFile}}}
</footer> </footer>
</div> </div>
<script
src="{{jsBaseUrl}}/jquery-3.6.0.min.js"
rel="preload"
as="script"
></script>
<script src="{{jsBaseUrl}}/script.js?t={{buildTime}}"></script> <script src="{{jsBaseUrl}}/script.js?t={{buildTime}}"></script>
{{> analytics}} {{> analytics}}

View File

@ -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-container {{#featured}}price-container-blue{{/featured}}">
<div class="price-row"> <div class="price-row">
<div class="plan-type"> <div class="plan-type">
<img src="{{imageBaseUrl}}/{{iconName}}.png"/>&nbsp;{{title}} <img src="{{imageBaseUrl}}/{{iconName}}.png"/>&nbsp;{{title}}
</div> </div>
<div class="plan-price"> <div class="plan-price plan-price-monthly">
{{price}}<sub class="per-month">&nbsp;/month</sub> {{priceMonthly.formattedMonthlyAmount}}<sub class="per-month">&nbsp;/month</sub>
</div>
<div class="plan-price plan-price-yearly">
{{priceYearly.formattedMonthlyAmount}}<sub class="per-month">&nbsp;/month</sub>
</div>
</div>
<div class="plan-price-yearly-per-year">
<div>
({{priceYearly.formattedAmount}}<sub class="per-year">&nbsp;/year</sub>)
</div> </div>
</div> </div>
@ -25,19 +35,28 @@
<script> <script>
(function() { (function() {
const stripePriceId = '{{{stripePriceId}}}'; const stripePricesIds = {
monthly: '{{{priceMonthly.id}}}',
yearly: '{{{priceYearly.id}}}',
};
const planName = '{{{name}}}'; const planName = '{{{name}}}';
const buttonId = 'subscribeButton-' + planName; const buttonId = 'subscribeButton-' + planName;
const buttonElement = document.getElementById(buttonId); const buttonElement = document.getElementById(buttonId);
if (stripePriceId) { if (stripePricesIds.monthly) {
function handleResult() { function handleResult() {
console.info('Redirected to checkout'); console.info('Redirected to checkout');
} }
buttonElement.addEventListener("click", function(evt) { buttonElement.addEventListener("click", function(evt) {
evt.preventDefault(); evt.preventDefault();
const priceId = '{{{stripePriceId}}}';
const priceId = stripePricesIds[subscriptionPeriod];
if (!priceId) {
console.error('Invalid period: ' + subscriptionPeriod);
return;
}
createCheckoutSession(priceId).then(function(data) { createCheckoutSession(priceId).then(function(data) {
stripe.redirectToCheckout({ stripe.redirectToCheckout({

View File

@ -11,7 +11,23 @@
</div> </div>
</div> </div>
<div class="row"> <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 plan-group plan-prices-monthly">
{{#plans.basic}} {{#plans.basic}}
{{> plan}} {{> plan}}
{{/plans.basic}} {{/plans.basic}}
@ -33,22 +49,33 @@
<script src="https://js.stripe.com/v3/"></script> <script src="https://js.stripe.com/v3/"></script>
<script> <script>
var stripe = Stripe('{{{stripeConfig.publishableKey}}}'); let subscriptionPeriod = 'monthly';
var stripe = Stripe('{{{stripeConfig.publishableKey}}}');
var createCheckoutSession = function(priceId) { var createCheckoutSession = function(priceId) {
console.info('Creating Stripe session for price:', priceId); console.info('Creating Stripe session for price:', priceId);
return fetch("{{{stripeConfig.webhookBaseUrl}}}/stripe/createCheckoutSession", { return fetch("{{{stripeConfig.webhookBaseUrl}}}/stripe/createCheckoutSession", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
body: JSON.stringify({ body: JSON.stringify({
priceId: priceId 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> </script>
</div> </div>

View File

@ -1,8 +1,10 @@
import * as fs from 'fs-extra';
export interface Plan { export interface Plan {
name: string; name: string;
title: string; title: string;
price: string; priceMonthly: StripePublicConfigPrice;
stripePriceId: string; priceYearly: StripePublicConfigPrice;
featured: boolean; featured: boolean;
iconName: string; iconName: string;
featuresOn: string[]; featuresOn: string[];
@ -11,10 +13,30 @@ export interface Plan {
cfaUrl: string; 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 { export interface StripePublicConfig {
publishableKey: string; publishableKey: string;
basicPriceId: string; prices: StripePublicConfigPrice[];
proPriceId: string;
webhookBaseUrl: string; webhookBaseUrl: string;
} }
@ -43,6 +65,51 @@ export function getFeatureList(plan: Plan): PlanFeature[] {
return output; 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, 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. 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: { basic: {
name: 'basic', name: 'basic',
title: 'Basic', title: 'Basic',
price: '1.99€', priceMonthly: findPrice(stripeConfig.prices, {
stripePriceId: stripeConfig.basicPriceId, accountType: 1,
period: PricePeriod.Monthly,
}),
priceYearly: findPrice(stripeConfig.prices, {
accountType: 1,
period: PricePeriod.Yearly,
}),
featured: false, featured: false,
iconName: 'basic-icon', iconName: 'basic-icon',
featuresOn: [ featuresOn: [
@ -92,8 +165,14 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<string, Plan>
pro: { pro: {
name: 'pro', name: 'pro',
title: 'Pro', title: 'Pro',
price: '5.99€', priceMonthly: findPrice(stripeConfig.prices, {
stripePriceId: stripeConfig.proPriceId, accountType: 2,
period: PricePeriod.Monthly,
}),
priceYearly: findPrice(stripeConfig.prices, {
accountType: 2,
period: PricePeriod.Yearly,
}),
featured: true, featured: true,
iconName: 'pro-icon', iconName: 'pro-icon',
featuresOn: [ featuresOn: [
@ -115,8 +194,8 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<string, Plan>
business: { business: {
name: 'business', name: 'business',
title: 'Business', title: 'Business',
price: '49.99€', priceMonthly: { accountType: 3, formattedMonthlyAmount: '49.99€' } as any,
stripePriceId: '', priceYearly: { accountType: 3, formattedMonthlyAmount: '39.99€', formattedAmount: '479.88€' } as any,
featured: false, featured: false,
iconName: 'business-icon', iconName: 'business-icon',
featuresOn: [ featuresOn: [

View File

@ -107,7 +107,7 @@ async function main() {
]; ];
if (env === Env.Dev) { if (env === Env.Dev) {
corsAllowedDomains.push('http://localhost:8080'); corsAllowedDomains.push('http://localhost:8077');
} }
function acceptOrigin(origin: string): boolean { function acceptOrigin(origin: string): boolean {

View File

@ -1,7 +1,8 @@
import { rtrimSlashes } from '@joplin/lib/path-utils'; 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 * as pathUtils from 'path';
import { readFile } from 'fs-extra'; import { readFile } from 'fs-extra';
import { loadStripeConfig, StripePublicConfig } from '@joplin/lib/utils/joplinCloud';
export interface EnvVariables { export interface EnvVariables {
APP_NAME?: string; APP_NAME?: string;
@ -131,9 +132,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
const rootDir = pathUtils.dirname(__dirname); const rootDir = pathUtils.dirname(__dirname);
const packageJson = await readPackageJson(`${rootDir}/package.json`); const packageJson = await readPackageJson(`${rootDir}/package.json`);
const stripePublicConfigs = JSON.parse(await readFile(`${rootDir}/stripeConfig.json`, 'utf8')); const stripePublicConfig = loadStripeConfig(envType === Env.BuildTypes ? Env.Dev : envType, `${rootDir}/stripeConfig.json`);
const stripePublicConfig = stripePublicConfigs[envType === Env.BuildTypes ? Env.Dev : envType];
if (!stripePublicConfig) throw new Error('Could not load Stripe config');
const viewDir = `${rootDir}/src/views`; const viewDir = `${rootDir}/src/views`;
const appPort = env.APP_PORT ? Number(env.APP_PORT) : 22300; const appPort = env.APP_PORT ? Number(env.APP_PORT) : 22300;

View File

@ -20,6 +20,7 @@ describe('SubscriptionModel', function() {
test('should create a user and subscription', async function() { test('should create a user and subscription', async function() {
await models().subscription().saveUserAndSubscription( await models().subscription().saveUserAndSubscription(
'toto@example.com', 'toto@example.com',
'Toto',
AccountType.Pro, AccountType.Pro,
'STRIPE_USER_ID', 'STRIPE_USER_ID',
'STRIPE_SUB_ID' 'STRIPE_SUB_ID'
@ -30,6 +31,7 @@ describe('SubscriptionModel', function() {
expect(user.account_type).toBe(AccountType.Pro); expect(user.account_type).toBe(AccountType.Pro);
expect(user.email).toBe('toto@example.com'); expect(user.email).toBe('toto@example.com');
expect(user.full_name).toBe('Toto');
expect(getCanShareFolder(user)).toBe(1); expect(getCanShareFolder(user)).toBe(1);
expect(getMaxItemSize(user)).toBe(200 * MB); expect(getMaxItemSize(user)).toBe(200 * MB);

View File

@ -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(); 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 () => { return this.withTransaction(async () => {
const user = await this.models().user().save({ const user = await this.models().user().save({
account_type: accountType, account_type: accountType,
email, email,
full_name: fullName,
email_confirmed: 1, email_confirmed: 1,
password: uuidgen(), password: uuidgen(),
must_set_password: 1, must_set_password: 1,

View File

@ -1,3 +1,4 @@
import { findPrice, PricePeriod } from '@joplin/lib/utils/joplinCloud';
import { AccountType } from '../../models/UserModel'; import { AccountType } from '../../models/UserModel';
import { initStripe, stripeConfig } from '../../utils/stripe'; import { initStripe, stripeConfig } from '../../utils/stripe';
import { beforeAllDb, afterAllTests, beforeEachDb, models, koaAppContext, expectNotThrow } from '../../utils/testing/testUtils'; 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 = '') { async function createUserViaSubscription(userEmail: string, eventId: string = '') {
eventId = eventId || uuidgen(); eventId = eventId || uuidgen();
const stripeSessionId = 'sess_123'; const stripeSessionId = 'sess_123';
const stripePriceId = stripeConfig().proPriceId; const stripePrice = findPrice(stripeConfig().prices, { accountType: 2, period: PricePeriod.Monthly });
await models().keyValue().setValue(`stripeSessionToPriceId::${stripeSessionId}`, stripePriceId); await models().keyValue().setValue(`stripeSessionToPriceId::${stripeSessionId}`, stripePrice.id);
const ctx = await koaAppContext(); const ctx = await koaAppContext();
const stripe = initStripe(); const stripe = initStripe();

View File

@ -11,6 +11,7 @@ import getRawBody = require('raw-body');
import { AccountType } from '../../models/UserModel'; import { AccountType } from '../../models/UserModel';
import { initStripe, priceIdToAccountType, stripeConfig } from '../../utils/stripe'; import { initStripe, priceIdToAccountType, stripeConfig } from '../../utils/stripe';
import { Subscription } from '../../db'; import { Subscription } from '../../db';
import { findPrice, PricePeriod } from '@joplin/lib/utils/joplinCloud';
const logger = Logger.create('/stripe'); 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` // - 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) // - 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 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. // - The local website often is not configured to send email, but you can see them in the database, in the "emails" table.
// //
// # Simplified workflow // # Simplified workflow
@ -180,8 +181,17 @@ export const postHandlers: PostHandlers = {
const checkoutSession: Stripe.Checkout.Session = event.data.object as Stripe.Checkout.Session; const checkoutSession: Stripe.Checkout.Session = event.data.object as Stripe.Checkout.Session;
const userEmail = checkoutSession.customer_details.email || checkoutSession.customer_email; 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('Checkout session completed:', checkoutSession.id);
logger.info('User email:', userEmail); logger.info('User email:', userEmail);
logger.info('User name:', customerName);
let accountType = AccountType.Basic; let accountType = AccountType.Basic;
try { try {
@ -205,6 +215,7 @@ export const postHandlers: PostHandlers = {
await ctx.joplin.models.subscription().saveUserAndSubscription( await ctx.joplin.models.subscription().saveUserAndSubscription(
userEmail, userEmail,
customerName,
accountType, accountType,
stripeUserId, stripeUserId,
stripeSubscriptionId stripeSubscriptionId
@ -317,6 +328,8 @@ const getHandlers: Record<string, StripeRouteHandler> = {
}, },
checkoutTest: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => { checkoutTest: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => {
const basicPrice = findPrice(stripeConfig().prices, { accountType: 1, period: PricePeriod.Monthly });
return ` return `
<head> <head>
<title>Checkout</title> <title>Checkout</title>
@ -343,7 +356,7 @@ const getHandlers: Record<string, StripeRouteHandler> = {
<body> <body>
<button id="checkout">Subscribe</button> <button id="checkout">Subscribe</button>
<script> <script>
var PRICE_ID = ${JSON.stringify(stripeConfig().basicPriceId)}; var PRICE_ID = ${basicPrice.id};
function handleResult() { function handleResult() {
console.info('Redirected to checkout'); console.info('Redirected to checkout');

View File

@ -2,10 +2,10 @@ import { SubPath, redirect } from '../../utils/routeUtils';
import Router from '../../utils/Router'; import Router from '../../utils/Router';
import { RouteType } from '../../utils/types'; import { RouteType } from '../../utils/types';
import { AppContext } 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 config from '../../config';
import defaultView from '../../utils/defaultView'; import defaultView from '../../utils/defaultView';
import { stripeConfig, updateSubscriptionType } from '../../utils/stripe'; import { stripeConfig, stripePriceIdByUserId, updateSubscriptionType } from '../../utils/stripe';
import { bodyFields } from '../../utils/requestUtils'; import { bodyFields } from '../../utils/requestUtils';
import { NotificationKey } from '../../models/NotificationModel'; import { NotificationKey } from '../../models/NotificationModel';
import { AccountType } from '../../models/UserModel'; 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'); const view = defaultView('upgrade', 'Upgrade');
view.content = { view.content = {
planRows, planRows,
basicPrice: plans.basic.price, basicPrice: currentPrice,
proPrice: plans.pro.price, proPrice: upgradePrice,
postUrl: upgradeUrl(), postUrl: upgradeUrl(),
csrfTag: await createCsrfTag(ctx), csrfTag: await createCsrfTag(ctx),
showYearlyPrices: currentPrice.period === PricePeriod.Yearly,
}; };
view.cssFiles = ['index/upgrade']; view.cssFiles = ['index/upgrade'];
return view; return view;

View File

@ -1,11 +1,17 @@
import globalConfig from '../config'; import globalConfig from '../config';
import { StripeConfig } from './types'; import { StripeConfig } from './types';
import { Stripe } from 'stripe'; import { Stripe } from 'stripe';
import { Uuid } from '../db'; import { Subscription, Uuid } from '../db';
import { Models } from '../models/factory'; 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'); const stripeLib = require('stripe');
export interface SubscriptionInfo {
sub: Subscription;
stripeSub: Stripe.Subscription;
}
export function stripeConfig(): StripeConfig { export function stripeConfig(): StripeConfig {
return globalConfig().stripe; return globalConfig().stripe;
} }
@ -15,15 +21,33 @@ export function initStripe(): Stripe {
} }
export function priceIdToAccountType(priceId: string): AccountType { export function priceIdToAccountType(priceId: string): AccountType {
if (stripeConfig().basicPriceId === priceId) return AccountType.Basic; const price = findPrice(stripeConfig().prices, { priceId });
if (stripeConfig().proPriceId === priceId) return AccountType.Pro; return price.accountType;
throw new Error(`Unknown price ID: ${priceId}`);
} }
export function accountTypeToPriceId(accountType: AccountType): string { export function accountTypeToPriceId(accountType: AccountType): string {
if (accountType === AccountType.Basic) return stripeConfig().basicPriceId; const price = findPrice(stripeConfig().prices, { accountType, period: PricePeriod.Monthly });
if (accountType === AccountType.Pro) return stripeConfig().proPriceId; return price.id;
throw new Error(`Unknown account type: ${accountType}`); }
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) { 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); const user = await models.user().load(userId);
if (user.account_type === newAccountType) throw new Error(`Account type is already: ${newAccountType}`); if (user.account_type === newAccountType) throw new Error(`Account type is already: ${newAccountType}`);
const sub = await models.subscription().byUserId(userId); const { sub, stripeSub } = await subscriptionInfoByUserId(models, userId);
if (!sub) throw new Error(`No subscription for user: ${userId}`);
const stripe = initStripe(); const currentPrice = findPrice(stripeConfig().prices, { priceId: stripePriceIdByStripeSub(stripeSub) });
const upgradePrice = findPrice(stripeConfig().prices, { accountType: newAccountType, period: currentPrice.period });
const accountTypes = accountTypeOptions();
const stripeSub = await stripe.subscriptions.retrieve(sub.stripe_subscription_id);
const items: Stripe.SubscriptionUpdateParams.Item[] = []; const items: Stripe.SubscriptionUpdateParams.Item[] = [];
// First delete all the items that don't match the new account type. That // 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 // means for example deleting the "Joplin Cloud Pro" item if the new account
// type is "Basic" and vice versa. // type is "Basic" and vice versa.
for (const t of accountTypes) { for (const stripeSubItem of stripeSub.items.data) {
if (!t.value) continue; if (stripeSubItem.price.id === upgradePrice.id) throw new Error(`This account is already of type ${newAccountType}`);
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}`);
if (stripeSubItem.price.id !== upgradePrice.id) {
items.push({ items.push({
id: stripeSubItem.id, id: stripeSubItem.id,
deleted: true, 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 // sufficient to specify the price ID, and from that Stripe infers the
// product. // product.
items.push({ 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 }); await stripe.subscriptions.update(sub.stripe_subscription_id, { items });
} }

View File

@ -1,4 +1,5 @@
import { LoggerWrapper } from '@joplin/lib/Logger'; import { LoggerWrapper } from '@joplin/lib/Logger';
import { StripePublicConfig } from '@joplin/lib/utils/joplinCloud';
import * as Koa from 'koa'; import * as Koa from 'koa';
import { DbConnection, User, Uuid } from '../db'; import { DbConnection, User, Uuid } from '../db';
import { Models } from '../models/factory'; import { Models } from '../models/factory';
@ -75,13 +76,6 @@ export interface MailerConfig {
noReplyEmail: string; noReplyEmail: string;
} }
export interface StripePublicConfig {
publishableKey: string;
basicPriceId: string;
proPriceId: string;
webhookBaseUrl: string;
}
export interface StripeConfig extends StripePublicConfig { export interface StripeConfig extends StripePublicConfig {
secretKey: string; secretKey: string;
webhookSecret: string; webhookSecret: string;

View File

@ -6,10 +6,16 @@
<table class="table is-hoverable user-props-table"> <table class="table is-hoverable user-props-table">
<tbody> <tbody>
<tr> <tr>
<th class="basic-plan">Basic - {{basicPrice}}</th> <th class="basic-plan">Basic - {{basicPrice.formattedMonthlyAmount}} / month</th>
<th>Pro - {{proPrice}}</th> <th>Pro - {{proPrice.formattedMonthlyAmount}} / month</th>
</tr> </tr>
{{#showYearlyPrices}}
<tr>
<td>{{basicPrice.formattedAmount}} / year</td><td>{{proPrice.formattedAmount}} / year</td>
</tr>
{{/showYearlyPrices}}
{{#planRows}} {{#planRows}}
<tr> <tr>
<td class="basic-plan"> <td class="basic-plan">

View File

@ -1,14 +1,70 @@
{ {
"dev": { "dev": {
"publishableKey": "pk_test_51IvkOPLx4fybOTqJetV23Y5S9YHU9KoOtE6Ftur0waWoWahkHdENjDKSVcl7v3y8Y0Euv7Uwd7O7W4UFasRwd0wE00MPcprz9Q", "publishableKey": "pk_test_51IvkOPLx4fybOTqJetV23Y5S9YHU9KoOtE6Ftur0waWoWahkHdENjDKSVcl7v3y8Y0Euv7Uwd7O7W4UFasRwd0wE00MPcprz9Q",
"basicPriceId": "price_1JAx31Lx4fybOTqJRcGdsSfg", "webhookBaseUrl": "http://joplincloud.local:22300",
"proPriceId": "price_1JAx1eLx4fybOTqJ5VhkxaKC", "prices": [
"webhookBaseUrl": "http://joplincloud.local:22300" {
"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": { "prod": {
"publishableKey": "pk_live_51IvkOPLx4fybOTqJow8RFsWs0eDznPeBlXMw6s8SIDQeCM8bAFNYlBdDsyonAwRcJgBCoSlvFzAbhJgLFxzzTu4r0006aw846C", "publishableKey": "pk_live_51IvkOPLx4fybOTqJow8RFsWs0eDznPeBlXMw6s8SIDQeCM8bAFNYlBdDsyonAwRcJgBCoSlvFzAbhJgLFxzzTu4r0006aw846C",
"basicPriceId": "price_1JAzWBLx4fybOTqJw64zxJRJ", "webhookBaseUrl": "https://joplincloud.com",
"proPriceId": "price_1JB1OVLx4fybOTqJOvp3NGM6", "prices": [
"webhookBaseUrl": "https://joplincloud.com" {
"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"
}
]
} }
} }

View File

@ -3,24 +3,22 @@ import { insertContentIntoFile, rootDir } from '../tool-utils';
import { pressCarouselItems } from './utils/pressCarousel'; import { pressCarouselItems } from './utils/pressCarousel';
import { getMarkdownIt, loadMustachePartials, markdownToPageHtml, renderMustache } from './utils/render'; import { getMarkdownIt, loadMustachePartials, markdownToPageHtml, renderMustache } from './utils/render';
import { Env, OrgSponsor, PlanPageParams, Sponsors, TemplateParams } from './utils/types'; 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'; import { shuffle } from '@joplin/lib/array';
const dirname = require('path').dirname; const dirname = require('path').dirname;
const glob = require('glob'); const glob = require('glob');
const path = require('path'); const path = require('path');
const env = Env.Prod; const env = Env.Dev;
const buildTime = Date.now(); const buildTime = Date.now();
const websiteAssetDir = `${rootDir}/Assets/WebsiteAssets`; const websiteAssetDir = `${rootDir}/Assets/WebsiteAssets`;
const mainTemplateHtml = fs.readFileSync(`${websiteAssetDir}/templates/main-new.mustache`, 'utf8'); const mainTemplateHtml = fs.readFileSync(`${websiteAssetDir}/templates/main-new.mustache`, 'utf8');
const frontTemplateHtml = fs.readFileSync(`${websiteAssetDir}/templates/front.mustache`, 'utf8'); const frontTemplateHtml = fs.readFileSync(`${websiteAssetDir}/templates/front.mustache`, 'utf8');
const plansTemplateHtml = fs.readFileSync(`${websiteAssetDir}/templates/plans.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 partialDir = `${websiteAssetDir}/templates/partials`;
const stripeConfig = stripeConfigs[env];
let tocMd_: string = null; let tocMd_: string = null;
let tocHtml_: string = null; let tocHtml_: string = null;
const tocRegex_ = /<!-- TOC -->([^]*)<!-- TOC -->/; const tocRegex_ = /<!-- TOC -->([^]*)<!-- TOC -->/;