mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
Server: Add support for Stripe yearly subscriptions
This commit is contained in:
parent
c76ed7f8ac
commit
f2547fed8d
@ -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 -->/;
|
||||
|
Loading…
Reference in New Issue
Block a user