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:
parent
c76ed7f8ac
commit
f2547fed8d
@ -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;
|
||||||
|
@ -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>
|
||||||
|
@ -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}}
|
||||||
|
@ -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"/> {{title}}
|
<img src="{{imageBaseUrl}}/{{iconName}}.png"/> {{title}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="plan-price">
|
<div class="plan-price plan-price-monthly">
|
||||||
{{price}}<sub class="per-month"> /month</sub>
|
{{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>
|
||||||
</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({
|
||||||
|
@ -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>
|
||||||
|
@ -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: [
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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();
|
||||||
|
@ -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');
|
||||||
|
@ -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;
|
||||||
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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">
|
||||||
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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 -->/;
|
||||||
|
Loading…
Reference in New Issue
Block a user