mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-30 10:36:35 +02:00
Server: Add Stripe integration
This commit is contained in:
parent
c88e4f6628
commit
770af6a53b
121
packages/server/package-lock.json
generated
121
packages/server/package-lock.json
generated
@ -901,6 +901,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@koa/cors": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@koa/cors/-/cors-3.1.0.tgz",
|
||||
"integrity": "sha512-7ulRC1da/rBa6kj6P4g2aJfnET3z8Uf3SWu60cjbtxTA5g8lxRdX/Bd2P92EagGwwAhANeNw8T8if99rJliR6Q==",
|
||||
"requires": {
|
||||
"vary": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"@rmp135/sql-ts": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@rmp135/sql-ts/-/sql-ts-1.7.0.tgz",
|
||||
@ -1386,8 +1394,7 @@
|
||||
"@types/node": {
|
||||
"version": "12.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.5.tgz",
|
||||
"integrity": "sha512-9fq4jZVhPNW8r+UYKnxF1e2HkDWOWKM5bC2/7c9wPV835I0aOrVbS/Hw/pWPk2uKrNXQqg9Z959Kz+IYDd5p3w==",
|
||||
"dev": true
|
||||
"integrity": "sha512-9fq4jZVhPNW8r+UYKnxF1e2HkDWOWKM5bC2/7c9wPV835I0aOrVbS/Hw/pWPk2uKrNXQqg9Z959Kz+IYDd5p3w=="
|
||||
},
|
||||
"@types/nodemailer": {
|
||||
"version": "6.4.1",
|
||||
@ -2034,6 +2041,11 @@
|
||||
"resolved": "https://registry.npmjs.org/bulma-prefers-dark/-/bulma-prefers-dark-0.1.0-beta.0.tgz",
|
||||
"integrity": "sha512-EeDW8pQrkYEOXo2l3WykfghbUzi8jlQWGI+Cu2HwmXwQHMcoGF6yiKYCNShttN+8z3atq8fLWh3B7pqXUV4fBA=="
|
||||
},
|
||||
"bytes": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
|
||||
"integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="
|
||||
},
|
||||
"cache-base": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz",
|
||||
@ -2098,6 +2110,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"call-bind": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
|
||||
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
|
||||
"requires": {
|
||||
"function-bind": "^1.1.1",
|
||||
"get-intrinsic": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@ -3222,8 +3243,7 @@
|
||||
"function-bind": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
|
||||
"dev": true
|
||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
||||
},
|
||||
"gauge": {
|
||||
"version": "2.7.4",
|
||||
@ -3246,6 +3266,16 @@
|
||||
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
||||
"dev": true
|
||||
},
|
||||
"get-intrinsic": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
|
||||
"integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
|
||||
"requires": {
|
||||
"function-bind": "^1.1.1",
|
||||
"has": "^1.0.3",
|
||||
"has-symbols": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"get-package-type": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
|
||||
@ -3410,7 +3440,6 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
|
||||
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"function-bind": "^1.1.1"
|
||||
}
|
||||
@ -3420,6 +3449,11 @@
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
|
||||
},
|
||||
"has-symbols": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
|
||||
"integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw=="
|
||||
},
|
||||
"has-unicode": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
|
||||
@ -6335,6 +6369,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"object-inspect": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz",
|
||||
"integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw=="
|
||||
},
|
||||
"object-visit": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz",
|
||||
@ -6869,6 +6908,44 @@
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"dev": true
|
||||
},
|
||||
"raw-body": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz",
|
||||
"integrity": "sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==",
|
||||
"requires": {
|
||||
"bytes": "3.1.0",
|
||||
"http-errors": "1.7.3",
|
||||
"iconv-lite": "0.4.24",
|
||||
"unpipe": "1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"http-errors": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz",
|
||||
"integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==",
|
||||
"requires": {
|
||||
"depd": "~1.1.2",
|
||||
"inherits": "2.0.4",
|
||||
"setprototypeof": "1.1.1",
|
||||
"statuses": ">= 1.5.0 < 2",
|
||||
"toidentifier": "1.0.0"
|
||||
}
|
||||
},
|
||||
"iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"requires": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
}
|
||||
},
|
||||
"setprototypeof": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
|
||||
"integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
@ -7261,6 +7338,16 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"side-channel": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
|
||||
"integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
|
||||
"requires": {
|
||||
"call-bind": "^1.0.0",
|
||||
"get-intrinsic": "^1.0.2",
|
||||
"object-inspect": "^1.9.0"
|
||||
}
|
||||
},
|
||||
"signal-exit": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
|
||||
@ -7647,6 +7734,25 @@
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo="
|
||||
},
|
||||
"stripe": {
|
||||
"version": "8.150.0",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-8.150.0.tgz",
|
||||
"integrity": "sha512-48YMLupzvDyVZUs37xUBd1SF0E3B77ahOTLhL7ycVwZqwjlQ30K7iHTejIAUdtEnWaNkaOz0LX6jHeR49IulRQ==",
|
||||
"requires": {
|
||||
"@types/node": ">=8.1.0",
|
||||
"qs": "^6.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"qs": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz",
|
||||
"integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==",
|
||||
"requires": {
|
||||
"side-channel": "^1.0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
@ -7956,6 +8062,11 @@
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="
|
||||
},
|
||||
"unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw="
|
||||
},
|
||||
"unset-value": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz",
|
||||
|
@ -17,6 +17,7 @@
|
||||
"@fortawesome/fontawesome-free": "^5.15.1",
|
||||
"@joplin/lib": "^1.0.9",
|
||||
"@joplin/renderer": "^1.7.4",
|
||||
"@koa/cors": "^3.1.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bulma": "^0.9.1",
|
||||
"bulma-prefers-dark": "^0.1.0-beta.0",
|
||||
@ -29,14 +30,16 @@
|
||||
"markdown-it": "^12.0.4",
|
||||
"mustache": "^3.1.0",
|
||||
"nanoid": "^2.1.1",
|
||||
"node-cron": "^3.0.0",
|
||||
"node-env-file": "^0.1.8",
|
||||
"nodemailer": "^6.6.0",
|
||||
"nodemon": "^2.0.6",
|
||||
"pg": "^8.5.1",
|
||||
"pretty-bytes": "^5.6.0",
|
||||
"query-string": "^6.8.3",
|
||||
"raw-body": "^2.4.1",
|
||||
"sqlite3": "^4.1.0",
|
||||
"node-cron": "^3.0.0",
|
||||
"stripe": "^8.150.0",
|
||||
"yargs": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
Binary file not shown.
@ -16,7 +16,9 @@ import ownerHandler from './middleware/ownerHandler';
|
||||
import setupAppContext from './utils/setupAppContext';
|
||||
import { initializeJoplinUtils } from './utils/joplinUtils';
|
||||
import startServices from './utils/startServices';
|
||||
import { credentialFile } from './utils/testing/testUtils';
|
||||
|
||||
const cors = require('@koa/cors');
|
||||
const nodeEnvFile = require('node-env-file');
|
||||
const { shimInit } = require('@joplin/lib/shim-init-node.js');
|
||||
shimInit();
|
||||
@ -50,6 +52,18 @@ const app = new Koa();
|
||||
// loads the user, which is then used by notificationHandler. And finally
|
||||
// routeHandler uses data from both previous middlewares. It would be good to
|
||||
// layout these dependencies in code but not clear how to do this.
|
||||
const corsAllowedDomains = ['https://joplinapp.org'];
|
||||
|
||||
app.use(cors({
|
||||
// https://github.com/koajs/cors/issues/52#issuecomment-413887382
|
||||
origin: (ctx: AppContext) => {
|
||||
if (corsAllowedDomains.indexOf(ctx.request.header.origin) !== -1) {
|
||||
return ctx.request.header.origin;
|
||||
}
|
||||
// we can't return void, so let's return one of the valid domains
|
||||
return corsAllowedDomains[0];
|
||||
},
|
||||
}));
|
||||
app.use(ownerHandler);
|
||||
app.use(notificationHandler);
|
||||
app.use(routeHandler);
|
||||
@ -72,8 +86,7 @@ async function getEnvFilePath(env: Env, argv: any): Promise<string> {
|
||||
if (argv.envFile) return argv.envFile;
|
||||
|
||||
if (env === Env.Dev) {
|
||||
const envFilePath = `${require('os').homedir()}/joplin-credentials/server.env`;
|
||||
if (await fs.pathExists(envFilePath)) return envFilePath;
|
||||
return credentialFile('server.env');
|
||||
}
|
||||
|
||||
return '';
|
||||
@ -86,7 +99,7 @@ async function main() {
|
||||
|
||||
if (!envVariables[env]) throw new Error(`Invalid env: ${env}`);
|
||||
|
||||
await initConfig({
|
||||
await initConfig(env, {
|
||||
...envVariables[env],
|
||||
...process.env,
|
||||
});
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { rtrimSlashes } from '@joplin/lib/path-utils';
|
||||
import { Config, DatabaseConfig, DatabaseConfigClient, MailerConfig, RouteType } from './utils/types';
|
||||
import { Config, DatabaseConfig, DatabaseConfigClient, Env, MailerConfig, RouteType, StripeConfig } from './utils/types';
|
||||
import * as pathUtils from 'path';
|
||||
import { readFile } from 'fs-extra';
|
||||
|
||||
export interface EnvVariables {
|
||||
APP_NAME?: string;
|
||||
|
||||
APP_BASE_URL?: string;
|
||||
USER_CONTENT_BASE_URL?: string;
|
||||
API_BASE_URL?: string;
|
||||
@ -29,6 +31,10 @@ export interface EnvVariables {
|
||||
|
||||
// This must be the full path to the database file
|
||||
SQLITE_DATABASE?: string;
|
||||
|
||||
STRIPE_SECRET_KEY?: string;
|
||||
STRIPE_PUBLISHABLE_KEY?: string;
|
||||
STRIPE_WEBHOOK_SECRET?: string;
|
||||
}
|
||||
|
||||
let runningInDocker_: boolean = false;
|
||||
@ -84,6 +90,14 @@ function mailerConfigFromEnv(env: EnvVariables): MailerConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function stripeConfigFromEnv(env: EnvVariables): StripeConfig {
|
||||
return {
|
||||
secretKey: env.STRIPE_SECRET_KEY || '',
|
||||
publishableKey: env.STRIPE_PUBLISHABLE_KEY || '',
|
||||
webhookSecret: env.STRIPE_WEBHOOK_SECRET || '',
|
||||
};
|
||||
}
|
||||
|
||||
function baseUrlFromEnv(env: any, appPort: number): string {
|
||||
if (env.APP_BASE_URL) {
|
||||
return rtrimSlashes(env.APP_BASE_URL);
|
||||
@ -103,7 +117,7 @@ async function readPackageJson(filePath: string): Promise<PackageJson> {
|
||||
|
||||
let config_: Config = null;
|
||||
|
||||
export async function initConfig(env: EnvVariables, overrides: any = null) {
|
||||
export async function initConfig(envType: Env, env: EnvVariables, overrides: any = null) {
|
||||
runningInDocker_ = !!env.RUNNING_IN_DOCKER;
|
||||
|
||||
const rootDir = pathUtils.dirname(__dirname);
|
||||
@ -116,7 +130,8 @@ export async function initConfig(env: EnvVariables, overrides: any = null) {
|
||||
|
||||
config_ = {
|
||||
appVersion: packageJson.version,
|
||||
appName: 'Joplin Server',
|
||||
appName: env.APP_NAME || 'Joplin Server',
|
||||
env: envType,
|
||||
rootDir: rootDir,
|
||||
viewDir: viewDir,
|
||||
layoutDir: `${viewDir}/layouts`,
|
||||
@ -124,6 +139,7 @@ export async function initConfig(env: EnvVariables, overrides: any = null) {
|
||||
logDir: `${rootDir}/logs`,
|
||||
database: databaseConfigFromEnv(runningInDocker_, env),
|
||||
mailer: mailerConfigFromEnv(env),
|
||||
stripe: stripeConfigFromEnv(env),
|
||||
port: appPort,
|
||||
baseUrl,
|
||||
apiBaseUrl: env.API_BASE_URL ? env.API_BASE_URL : baseUrl,
|
||||
|
@ -394,6 +394,17 @@ export interface Token extends WithDates {
|
||||
user_id?: Uuid;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id?: number;
|
||||
user_id?: Uuid;
|
||||
stripe_user_id?: Uuid;
|
||||
stripe_subscription_id?: Uuid;
|
||||
last_payment_time?: number;
|
||||
last_payment_failed_time?: number;
|
||||
updated_time?: string;
|
||||
created_time?: string;
|
||||
}
|
||||
|
||||
export const databaseSchema: DatabaseTables = {
|
||||
users: {
|
||||
id: { type: 'string' },
|
||||
@ -534,5 +545,15 @@ export const databaseSchema: DatabaseTables = {
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
},
|
||||
subscriptions: {
|
||||
id: { type: 'number' },
|
||||
user_id: { type: 'string' },
|
||||
stripe_user_id: { type: 'string' },
|
||||
stripe_subscription_id: { type: 'string' },
|
||||
last_payment_time: { type: 'string' },
|
||||
last_payment_failed_time: { type: 'string' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
},
|
||||
};
|
||||
// AUTO-GENERATED-TYPES
|
||||
|
19
packages/server/src/migrations/20210531150817_stripe.ts
Normal file
19
packages/server/src/migrations/20210531150817_stripe.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Knex } from 'knex';
|
||||
import { DbConnection } from '../db';
|
||||
|
||||
export async function up(db: DbConnection): Promise<any> {
|
||||
await db.schema.createTable('subscriptions', function(table: Knex.CreateTableBuilder) {
|
||||
table.increments('id').unique().primary().notNullable();
|
||||
table.string('user_id', 32).notNullable();
|
||||
table.string('stripe_user_id', 64).notNullable();
|
||||
table.string('stripe_subscription_id', 64).notNullable();
|
||||
table.bigInteger('last_payment_time').notNullable();
|
||||
table.bigInteger('last_payment_failed_time').defaultTo(0).notNullable();
|
||||
table.bigInteger('updated_time').notNullable();
|
||||
table.bigInteger('created_time').notNullable();
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(_db: DbConnection): Promise<any> {
|
||||
|
||||
}
|
@ -4,6 +4,7 @@ import uuidgen from '../utils/uuidgen';
|
||||
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
|
||||
import { Models } from './factory';
|
||||
import * as EventEmitter from 'events';
|
||||
import { Config } from '../utils/types';
|
||||
|
||||
export interface SaveOptions {
|
||||
isNew?: boolean;
|
||||
@ -41,13 +42,13 @@ export default abstract class BaseModel<T> {
|
||||
private db_: DbConnection;
|
||||
private transactionHandler_: TransactionHandler;
|
||||
private modelFactory_: Function;
|
||||
private baseUrl_: string;
|
||||
private static eventEmitter_: EventEmitter = null;
|
||||
private config_: Config;
|
||||
|
||||
public constructor(db: DbConnection, modelFactory: Function, baseUrl: string) {
|
||||
public constructor(db: DbConnection, modelFactory: Function, config: Config) {
|
||||
this.db_ = db;
|
||||
this.modelFactory_ = modelFactory;
|
||||
this.baseUrl_ = baseUrl;
|
||||
this.config_ = config;
|
||||
|
||||
this.transactionHandler_ = new TransactionHandler(db);
|
||||
}
|
||||
@ -56,11 +57,15 @@ export default abstract class BaseModel<T> {
|
||||
// connection is passed to it. That connection can be the regular db
|
||||
// connection, or the active transaction.
|
||||
protected models(db: DbConnection = null): Models {
|
||||
return this.modelFactory_(db || this.db);
|
||||
return this.modelFactory_(db || this.db, this.config_);
|
||||
}
|
||||
|
||||
protected get baseUrl(): string {
|
||||
return this.baseUrl_;
|
||||
return this.config_.baseUrl;
|
||||
}
|
||||
|
||||
protected get appName(): string {
|
||||
return this.config_.appName;
|
||||
}
|
||||
|
||||
protected get db(): DbConnection {
|
||||
|
@ -15,25 +15,6 @@ interface NotificationType {
|
||||
message: string;
|
||||
}
|
||||
|
||||
const notificationTypes: Record<string, NotificationType> = {
|
||||
[NotificationKey.ConfirmEmail]: {
|
||||
level: NotificationLevel.Normal,
|
||||
message: 'Welcome to Joplin Server! An email has been sent to you containing an activation link to complete your registration.',
|
||||
},
|
||||
[NotificationKey.EmailConfirmed]: {
|
||||
level: NotificationLevel.Normal,
|
||||
message: 'You email has been confirmed',
|
||||
},
|
||||
[NotificationKey.PasswordSet]: {
|
||||
level: NotificationLevel.Normal,
|
||||
message: 'Welcome to Joplin Server! Your password has been set successfully.',
|
||||
},
|
||||
[NotificationKey.UsingSqliteInProd]: {
|
||||
level: NotificationLevel.Important,
|
||||
message: 'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.',
|
||||
},
|
||||
};
|
||||
|
||||
export default class NotificationModel extends BaseModel<Notification> {
|
||||
|
||||
protected get tableName(): string {
|
||||
@ -49,6 +30,25 @@ export default class NotificationModel extends BaseModel<Notification> {
|
||||
const n: Notification = await this.loadByKey(userId, key);
|
||||
if (n) return n;
|
||||
|
||||
const notificationTypes: Record<string, NotificationType> = {
|
||||
[NotificationKey.ConfirmEmail]: {
|
||||
level: NotificationLevel.Normal,
|
||||
message: `Welcome to ${this.appName}! An email has been sent to you containing an activation link to complete your registration.`,
|
||||
},
|
||||
[NotificationKey.EmailConfirmed]: {
|
||||
level: NotificationLevel.Normal,
|
||||
message: 'Your email has been confirmed',
|
||||
},
|
||||
[NotificationKey.PasswordSet]: {
|
||||
level: NotificationLevel.Normal,
|
||||
message: `Welcome to ${this.appName}! Your password has been set successfully.`,
|
||||
},
|
||||
[NotificationKey.UsingSqliteInProd]: {
|
||||
level: NotificationLevel.Important,
|
||||
message: 'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.',
|
||||
},
|
||||
};
|
||||
|
||||
const type = notificationTypes[key];
|
||||
|
||||
if (level === null) {
|
||||
|
40
packages/server/src/models/SubscriptionModel.test.ts
Normal file
40
packages/server/src/models/SubscriptionModel.test.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../utils/testing/testUtils';
|
||||
import { AccountType } from './UserModel';
|
||||
import { MB } from '../utils/bytes';
|
||||
|
||||
describe('SubscriptionModel', function() {
|
||||
|
||||
beforeAll(async () => {
|
||||
await beforeAllDb('SubscriptionModel');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllTests();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
test('should create a user and subscription', async function() {
|
||||
await models().subscription().saveUserAndSubscription(
|
||||
'toto@example.com',
|
||||
AccountType.Pro,
|
||||
'STRIPE_USER_ID',
|
||||
'STRIPE_SUB_ID'
|
||||
);
|
||||
|
||||
const user = await models().user().loadByEmail('toto@example.com');
|
||||
const sub = await models().subscription().byStripeSubscriptionId('STRIPE_SUB_ID');
|
||||
|
||||
expect(user.account_type).toBe(AccountType.Pro);
|
||||
expect(user.email).toBe('toto@example.com');
|
||||
expect(user.can_share).toBe(1);
|
||||
expect(user.max_item_size).toBe(200 * MB);
|
||||
|
||||
expect(sub.stripe_subscription_id).toBe('STRIPE_SUB_ID');
|
||||
expect(sub.stripe_user_id).toBe('STRIPE_USER_ID');
|
||||
expect(sub.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
});
|
72
packages/server/src/models/SubscriptionModel.ts
Normal file
72
packages/server/src/models/SubscriptionModel.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { EmailSender, Subscription, Uuid } from '../db';
|
||||
import { ErrorNotFound } from '../utils/errors';
|
||||
import uuidgen from '../utils/uuidgen';
|
||||
import BaseModel from './BaseModel';
|
||||
import { AccountType, accountTypeProperties } from './UserModel';
|
||||
|
||||
export default class SubscriptionModel extends BaseModel<Subscription> {
|
||||
|
||||
public get tableName(): string {
|
||||
return 'subscriptions';
|
||||
}
|
||||
|
||||
protected hasUuid(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
public async handlePayment(subscriptionId: string, success: boolean) {
|
||||
const sub = await this.byStripeSubscriptionId(subscriptionId);
|
||||
if (!sub) throw new ErrorNotFound(`No such subscription: ${subscriptionId}`);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const toSave: Subscription = { id: sub.id };
|
||||
|
||||
if (success) {
|
||||
toSave.last_payment_time = now;
|
||||
} else {
|
||||
toSave.last_payment_failed_time = now;
|
||||
|
||||
const user = await this.models().user().load(sub.user_id, { fields: ['email'] });
|
||||
|
||||
await this.models().email().push({
|
||||
subject: `${this.appName} subscription payment failed`,
|
||||
body: `Your invoice payment has failed. Please follow this URL to update your payment details: \n\n[Manage your subscription](${this.baseUrl}/portal)`,
|
||||
recipient_email: user.email,
|
||||
sender_id: EmailSender.Support,
|
||||
});
|
||||
}
|
||||
|
||||
await this.save(toSave);
|
||||
}
|
||||
|
||||
public async byStripeSubscriptionId(id: string): Promise<Subscription> {
|
||||
return this.db(this.tableName).select(this.defaultFields).where('stripe_subscription_id', '=', id).first();
|
||||
}
|
||||
|
||||
public async byUserId(userId: Uuid): Promise<Subscription> {
|
||||
return this.db(this.tableName).select(this.defaultFields).where('user_id', '=', userId).first();
|
||||
}
|
||||
|
||||
public async saveUserAndSubscription(email: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string) {
|
||||
return this.withTransaction(async () => {
|
||||
const user = await this.models().user().save({
|
||||
...accountTypeProperties(accountType),
|
||||
email,
|
||||
email_confirmed: 1,
|
||||
password: uuidgen(),
|
||||
must_set_password: 1,
|
||||
});
|
||||
|
||||
const subscription = await this.save({
|
||||
user_id: user.id,
|
||||
stripe_user_id: stripeUserId,
|
||||
stripe_subscription_id: stripeSubscriptionId,
|
||||
last_payment_time: Date.now(),
|
||||
});
|
||||
|
||||
return { user, subscription };
|
||||
});
|
||||
}
|
||||
|
||||
}
|
@ -4,7 +4,41 @@ import * as auth from '../utils/auth';
|
||||
import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge, ErrorNotFound } from '../utils/errors';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import prettyBytes = require('pretty-bytes');
|
||||
import { formatBytes, MB } from '../utils/bytes';
|
||||
|
||||
export enum AccountType {
|
||||
Default = 0,
|
||||
Free = 1,
|
||||
Pro = 2,
|
||||
}
|
||||
|
||||
interface AccountTypeProperties {
|
||||
account_type: number;
|
||||
can_share: number;
|
||||
max_item_size: number;
|
||||
}
|
||||
|
||||
export function accountTypeProperties(accountType: AccountType): AccountTypeProperties {
|
||||
const types: AccountTypeProperties[] = [
|
||||
{
|
||||
account_type: AccountType.Default,
|
||||
can_share: 1,
|
||||
max_item_size: 0,
|
||||
},
|
||||
{
|
||||
account_type: AccountType.Free,
|
||||
can_share: 0,
|
||||
max_item_size: 10 * MB,
|
||||
},
|
||||
{
|
||||
account_type: AccountType.Pro,
|
||||
can_share: 1,
|
||||
max_item_size: 200 * MB,
|
||||
},
|
||||
];
|
||||
|
||||
return types.find(a => a.account_type === accountType);
|
||||
}
|
||||
|
||||
export default class UserModel extends BaseModel<User> {
|
||||
|
||||
@ -85,7 +119,7 @@ export default class UserModel extends BaseModel<User> {
|
||||
throw new ErrorPayloadTooLarge(_('Cannot save %s "%s" because it is larger than than the allowed limit (%s)',
|
||||
isNote ? _('note') : _('attachment'),
|
||||
itemTitle ? itemTitle : name,
|
||||
prettyBytes(user.max_item_size)
|
||||
formatBytes(user.max_item_size)
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -188,11 +222,13 @@ export default class UserModel extends BaseModel<User> {
|
||||
recipient_id: savedUser.id,
|
||||
recipient_email: savedUser.email,
|
||||
recipient_name: savedUser.full_name || '',
|
||||
subject: 'Please setup your Joplin account',
|
||||
body: `Your new Joplin account has been created!\n\nPlease click on the following link to complete the creation of your account:\n\n${confirmUrl}`,
|
||||
subject: `Please setup your ${this.appName} account`,
|
||||
body: `Your new ${this.appName} account has been created!\n\nPlease click on the following link to complete the creation of your account:\n\n[Complete your account](${confirmUrl})`,
|
||||
});
|
||||
}
|
||||
|
||||
UserModel.eventEmitter.emit('created');
|
||||
|
||||
return savedUser;
|
||||
});
|
||||
}
|
||||
|
@ -68,71 +68,77 @@ import ItemResourceModel from './ItemResourceModel';
|
||||
import ShareUserModel from './ShareUserModel';
|
||||
import KeyValueModel from './KeyValueModel';
|
||||
import TokenModel from './TokenModel';
|
||||
import SubscriptionModel from './SubscriptionModel';
|
||||
import { Config } from '../utils/types';
|
||||
|
||||
export class Models {
|
||||
|
||||
private db_: DbConnection;
|
||||
private baseUrl_: string;
|
||||
private config_: Config;
|
||||
|
||||
public constructor(db: DbConnection, baseUrl: string) {
|
||||
public constructor(db: DbConnection, config: Config) {
|
||||
this.db_ = db;
|
||||
this.baseUrl_ = baseUrl;
|
||||
this.config_ = config;
|
||||
}
|
||||
|
||||
public item() {
|
||||
return new ItemModel(this.db_, newModelFactory, this.baseUrl_);
|
||||
return new ItemModel(this.db_, newModelFactory, this.config_);
|
||||
}
|
||||
|
||||
public user() {
|
||||
return new UserModel(this.db_, newModelFactory, this.baseUrl_);
|
||||
return new UserModel(this.db_, newModelFactory, this.config_);
|
||||
}
|
||||
|
||||
public email() {
|
||||
return new EmailModel(this.db_, newModelFactory, this.baseUrl_);
|
||||
return new EmailModel(this.db_, newModelFactory, this.config_);
|
||||
}
|
||||
|
||||
public userItem() {
|
||||
return new UserItemModel(this.db_, newModelFactory, this.baseUrl_);
|
||||
return new UserItemModel(this.db_, newModelFactory, this.config_);
|
||||
}
|
||||
|
||||
public token() {
|
||||
return new TokenModel(this.db_, newModelFactory, this.baseUrl_);
|
||||
return new TokenModel(this.db_, newModelFactory, this.config_);
|
||||
}
|
||||
|
||||
public itemResource() {
|
||||
return new ItemResourceModel(this.db_, newModelFactory, this.baseUrl_);
|
||||
return new ItemResourceModel(this.db_, newModelFactory, this.config_);
|
||||
}
|
||||
|
||||
public apiClient() {
|
||||
return new ApiClientModel(this.db_, newModelFactory, this.baseUrl_);
|
||||
return new ApiClientModel(this.db_, newModelFactory, this.config_);
|
||||
}
|
||||
|
||||
public session() {
|
||||
return new SessionModel(this.db_, newModelFactory, this.baseUrl_);
|
||||
return new SessionModel(this.db_, newModelFactory, this.config_);
|
||||
}
|
||||
|
||||
public change() {
|
||||
return new ChangeModel(this.db_, newModelFactory, this.baseUrl_);
|
||||
return new ChangeModel(this.db_, newModelFactory, this.config_);
|
||||
}
|
||||
|
||||
public notification() {
|
||||
return new NotificationModel(this.db_, newModelFactory, this.baseUrl_);
|
||||
return new NotificationModel(this.db_, newModelFactory, this.config_);
|
||||
}
|
||||
|
||||
public share() {
|
||||
return new ShareModel(this.db_, newModelFactory, this.baseUrl_);
|
||||
return new ShareModel(this.db_, newModelFactory, this.config_);
|
||||
}
|
||||
|
||||
public shareUser() {
|
||||
return new ShareUserModel(this.db_, newModelFactory, this.baseUrl_);
|
||||
return new ShareUserModel(this.db_, newModelFactory, this.config_);
|
||||
}
|
||||
|
||||
public keyValue() {
|
||||
return new KeyValueModel(this.db_, newModelFactory, this.baseUrl_);
|
||||
return new KeyValueModel(this.db_, newModelFactory, this.config_);
|
||||
}
|
||||
|
||||
public subscription() {
|
||||
return new SubscriptionModel(this.db_, newModelFactory, this.config_);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default function newModelFactory(db: DbConnection, baseUrl: string): Models {
|
||||
return new Models(db, baseUrl);
|
||||
export default function newModelFactory(db: DbConnection, config: Config): Models {
|
||||
return new Models(db, config);
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import config from '../../config';
|
||||
import Router from '../../utils/Router';
|
||||
import { RouteType } from '../../utils/types';
|
||||
|
||||
@ -6,7 +7,7 @@ const router = new Router(RouteType.Api);
|
||||
router.public = true;
|
||||
|
||||
router.get('api/ping', async () => {
|
||||
return { status: 'ok', message: 'Joplin Server is running' };
|
||||
return { status: 'ok', message: `${config().appName} is running` };
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
@ -10,7 +10,7 @@ import defaultView from '../../utils/defaultView';
|
||||
import { View } from '../../services/MustacheService';
|
||||
import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
|
||||
import { PaginationOrderDir } from '../../models/utils/pagination';
|
||||
const prettyBytes = require('pretty-bytes');
|
||||
import { formatBytes } from '../../utils/bytes';
|
||||
|
||||
const router = new Router(RouteType.Web);
|
||||
|
||||
@ -50,7 +50,7 @@ router.get('items', async (_path: SubPath, ctx: AppContext) => {
|
||||
url: showItemUrls(config()) ? `${config().userContentBaseUrl}/items/${item.id}/content` : null,
|
||||
},
|
||||
{
|
||||
value: prettyBytes(item.content_size),
|
||||
value: formatBytes(item.content_size),
|
||||
},
|
||||
{
|
||||
value: item.mime_type || 'binary',
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { NotificationKey } from '../../models/NotificationModel';
|
||||
import { AccountType } from '../../models/UserModel';
|
||||
import { MB } from '../../utils/bytes';
|
||||
import { execRequestC } from '../../utils/testing/apiUtils';
|
||||
import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../../utils/testing/testUtils';
|
||||
|
||||
@ -27,7 +29,10 @@ describe('index_signup', function() {
|
||||
// Check that the user has been created
|
||||
const user = await models().user().loadByEmail('toto@example.com');
|
||||
expect(user).toBeTruthy();
|
||||
expect(user.account_type).toBe(AccountType.Free);
|
||||
expect(user.email_confirmed).toBe(0);
|
||||
expect(user.can_share).toBe(0);
|
||||
expect(user.max_item_size).toBe(10 * MB);
|
||||
|
||||
// Check that the user is logged in
|
||||
const session = await models().session().load(context.cookies.get('sessionId'));
|
||||
|
@ -8,6 +8,7 @@ import defaultView from '../../utils/defaultView';
|
||||
import { View } from '../../services/MustacheService';
|
||||
import { checkPassword } from './users';
|
||||
import { NotificationKey } from '../../models/NotificationModel';
|
||||
import { AccountType, accountTypeProperties } from '../../models/UserModel';
|
||||
|
||||
function makeView(error: Error = null): View {
|
||||
const view = defaultView('signup');
|
||||
@ -38,6 +39,7 @@ router.post('signup', async (_path: SubPath, ctx: AppContext) => {
|
||||
const password = checkPassword(formUser, true);
|
||||
|
||||
const user = await ctx.models.user().save({
|
||||
...accountTypeProperties(AccountType.Free),
|
||||
email: formUser.email,
|
||||
full_name: formUser.full_name,
|
||||
password,
|
||||
|
289
packages/server/src/routes/index/stripe.ts
Normal file
289
packages/server/src/routes/index/stripe.ts
Normal file
@ -0,0 +1,289 @@
|
||||
import { SubPath } from '../../utils/routeUtils';
|
||||
import Router from '../../utils/Router';
|
||||
import { RouteType, StripeConfig } from '../../utils/types';
|
||||
import { AppContext } from '../../utils/types';
|
||||
import { bodyFields } from '../../utils/requestUtils';
|
||||
import globalConfig from '../../config';
|
||||
import { ErrorForbidden, ErrorNotFound } from '../../utils/errors';
|
||||
import { Stripe } from 'stripe';
|
||||
import Logger from '@joplin/lib/Logger';
|
||||
import getRawBody = require('raw-body');
|
||||
import { AccountType } from '../../models/UserModel';
|
||||
const stripeLib = require('stripe');
|
||||
|
||||
const logger = Logger.create('/stripe');
|
||||
|
||||
const router: Router = new Router(RouteType.Web);
|
||||
|
||||
router.public = true;
|
||||
|
||||
function stripeConfig(): StripeConfig {
|
||||
return globalConfig().stripe;
|
||||
}
|
||||
|
||||
function initStripe(): Stripe {
|
||||
return stripeLib(stripeConfig().secretKey);
|
||||
}
|
||||
|
||||
async function stripeEvent(stripe: Stripe, req: any): Promise<Stripe.Event> {
|
||||
if (!stripeConfig().webhookSecret) throw new Error('webhookSecret is required');
|
||||
|
||||
const body = await getRawBody(req);
|
||||
|
||||
return stripe.webhooks.constructEvent(
|
||||
body,
|
||||
req.headers['stripe-signature'],
|
||||
stripeConfig().webhookSecret
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateCheckoutSessionFields {
|
||||
priceId: string;
|
||||
}
|
||||
|
||||
type StripeRouteHandler = (stripe: Stripe, path: SubPath, ctx: AppContext)=> Promise<any>;
|
||||
|
||||
const postHandlers: Record<string, StripeRouteHandler> = {
|
||||
|
||||
createCheckoutSession: async (stripe: Stripe, __path: SubPath, ctx: AppContext) => {
|
||||
const fields = await bodyFields<CreateCheckoutSessionFields>(ctx.req);
|
||||
const priceId = fields.priceId;
|
||||
|
||||
// See https://stripe.com/docs/api/checkout/sessions/create
|
||||
// for additional parameters to pass.
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
// For metered billing, do not pass quantity
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
// {CHECKOUT_SESSION_ID} is a string literal; do not change it!
|
||||
// the actual Session ID is returned in the query parameter when your customer
|
||||
// is redirected to the success page.
|
||||
success_url: `${globalConfig().baseUrl}/stripe/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${globalConfig().baseUrl}/stripe/cancel`,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
};
|
||||
},
|
||||
|
||||
webhook: async (stripe: Stripe, _path: SubPath, ctx: AppContext) => {
|
||||
const event = await stripeEvent(stripe, ctx.req);
|
||||
|
||||
const hooks: any = {
|
||||
|
||||
'checkout.session.completed': async () => {
|
||||
// Payment is successful and the subscription is created.
|
||||
//
|
||||
// For testing: `stripe trigger checkout.session.completed`
|
||||
// Or use /checkoutTest URL.
|
||||
|
||||
// {
|
||||
// "object": {
|
||||
// "id": "cs_test_xxxxxxxxxxxxxxxxxx",
|
||||
// "object": "checkout.session",
|
||||
// "allow_promotion_codes": null,
|
||||
// "amount_subtotal": 499,
|
||||
// "amount_total": 499,
|
||||
// "billing_address_collection": null,
|
||||
// "cancel_url": "http://joplincloud.local:22300/stripe/cancel",
|
||||
// "client_reference_id": null,
|
||||
// "currency": "gbp",
|
||||
// "customer": "cus_xxxxxxxxxxxx",
|
||||
// "customer_details": {
|
||||
// "email": "toto@example.com",
|
||||
// "tax_exempt": "none",
|
||||
// "tax_ids": [
|
||||
// ]
|
||||
// },
|
||||
// "customer_email": null,
|
||||
// "livemode": false,
|
||||
// "locale": null,
|
||||
// "metadata": {
|
||||
// },
|
||||
// "mode": "subscription",
|
||||
// "payment_intent": null,
|
||||
// "payment_method_options": {
|
||||
// },
|
||||
// "payment_method_types": [
|
||||
// "card"
|
||||
// ],
|
||||
// "payment_status": "paid",
|
||||
// "setup_intent": null,
|
||||
// "shipping": null,
|
||||
// "shipping_address_collection": null,
|
||||
// "submit_type": null,
|
||||
// "subscription": "sub_xxxxxxxxxxxxxxxx",
|
||||
// "success_url": "http://joplincloud.local:22300/stripe/success?session_id={CHECKOUT_SESSION_ID}",
|
||||
// "total_details": {
|
||||
// "amount_discount": 0,
|
||||
// "amount_shipping": 0,
|
||||
// "amount_tax": 0
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
const checkoutSession: Stripe.Checkout.Session = event.data.object as Stripe.Checkout.Session;
|
||||
|
||||
// The Stripe TypeScript object defines "customer" and
|
||||
// "subscription" as various types but they are actually
|
||||
// string according to the documentation.
|
||||
const stripeUserId = checkoutSession.customer as string;
|
||||
const stripeSubscriptionId = checkoutSession.subscription as string;
|
||||
|
||||
await ctx.models.subscription().saveUserAndSubscription(
|
||||
checkoutSession.customer_details.email || checkoutSession.customer_email,
|
||||
AccountType.Pro,
|
||||
stripeUserId,
|
||||
stripeSubscriptionId
|
||||
);
|
||||
},
|
||||
|
||||
'invoice.paid': async () => {
|
||||
// Continue to provision the subscription as payments continue
|
||||
// to be made. Store the status in your database and check when
|
||||
// a user accesses your service. This approach helps you avoid
|
||||
// hitting rate limits.
|
||||
//
|
||||
// Note that when the subscription is created, this event is
|
||||
// going to be triggered before "checkout.session.completed" (at
|
||||
// least in tests), which means it won't find the subscription
|
||||
// at this point, but this is fine because the required data is
|
||||
// saved in checkout.session.completed.
|
||||
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
await ctx.models.subscription().handlePayment(invoice.subscription as string, true);
|
||||
},
|
||||
|
||||
'invoice.payment_failed': async () => {
|
||||
// The payment failed or the customer does not have a valid payment method.
|
||||
// The subscription becomes past_due. Notify your customer and send them to the
|
||||
// customer portal to update their payment information.
|
||||
//
|
||||
// For testing: `stripe trigger invoice.payment_failed`
|
||||
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const subId = invoice.subscription as string;
|
||||
await ctx.models.subscription().handlePayment(subId, false);
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
if (hooks[event.type]) {
|
||||
logger.info(`Got Stripe event: ${event.type} [Handled]`);
|
||||
await hooks[event.type]();
|
||||
} else {
|
||||
logger.info(`Got Stripe event: ${event.type} [Unhandled]`);
|
||||
}
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
const getHandlers: Record<string, StripeRouteHandler> = {
|
||||
|
||||
success: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => {
|
||||
return `
|
||||
<p>Thank you for signing up for ${globalConfig().appName} Pro! You should receive an email shortly with instructions on how to connect to your account.</p>
|
||||
<p><a href="https://joplinapp.org">Go back to JoplinApp.org</a></p>
|
||||
`;
|
||||
},
|
||||
|
||||
cancel: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => {
|
||||
return `
|
||||
<p>Your payment has been cancelled.</p>
|
||||
<p><a href="https://joplinapp.org">Go back to JoplinApp.org</a></p>
|
||||
`;
|
||||
},
|
||||
|
||||
portal: async (stripe: Stripe, _path: SubPath, ctx: AppContext) => {
|
||||
if (!ctx.owner) throw new ErrorForbidden('Please login to access the subscription portal');
|
||||
|
||||
const sub = await ctx.models.subscription().byUserId(ctx.owner.id);
|
||||
if (!sub) throw new ErrorNotFound('Could not find subscription');
|
||||
|
||||
const billingPortalSession = await stripe.billingPortal.sessions.create({ customer: sub.stripe_user_id as string });
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv = "refresh" content = "1; url = ${billingPortalSession.url};" />
|
||||
<script>setTimeout(() => { window.location.href = ${JSON.stringify(billingPortalSession.url)}; }, 2000)</script>
|
||||
</head>
|
||||
<body>
|
||||
Redirecting to subscription portal...
|
||||
</body>
|
||||
</html>`;
|
||||
},
|
||||
|
||||
checkoutTest: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => {
|
||||
return `
|
||||
<head>
|
||||
<title>Checkout</title>
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
|
||||
<script>
|
||||
var stripe = Stripe(${JSON.stringify(stripeConfig().publishableKey)});
|
||||
|
||||
var createCheckoutSession = function(priceId) {
|
||||
return fetch("/stripe/createCheckoutSession", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
priceId: priceId
|
||||
})
|
||||
}).then(function(result) {
|
||||
return result.json();
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<button id="checkout">Subscribe</button>
|
||||
<script>
|
||||
var PRICE_ID = 'price_1IvlmiLx4fybOTqJMKNZhLh2';
|
||||
|
||||
function handleResult() {
|
||||
console.info('Redirected to checkout');
|
||||
}
|
||||
|
||||
document
|
||||
.getElementById("checkout")
|
||||
.addEventListener("click", function(evt) {
|
||||
evt.preventDefault();
|
||||
|
||||
// You'll have to define PRICE_ID as a price ID before this code block
|
||||
createCheckoutSession(PRICE_ID).then(function(data) {
|
||||
// Call Stripe.js method to redirect to the new Checkout page
|
||||
stripe
|
||||
.redirectToCheckout({
|
||||
sessionId: data.sessionId
|
||||
})
|
||||
.then(handleResult);
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
</body>
|
||||
`;
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
router.post('stripe/:id', async (path: SubPath, ctx: AppContext) => {
|
||||
if (!postHandlers[path.id]) throw new ErrorNotFound(`No such action: ${path.id}`);
|
||||
return postHandlers[path.id](initStripe(), path, ctx);
|
||||
});
|
||||
|
||||
router.get('stripe/:id', async (path: SubPath, ctx: AppContext) => {
|
||||
if (!getHandlers[path.id]) throw new ErrorNotFound(`No such action: ${path.id}`);
|
||||
return getHandlers[path.id](initStripe(), path, ctx);
|
||||
});
|
||||
|
||||
export default router;
|
@ -10,7 +10,7 @@ import { View } from '../../services/MustacheService';
|
||||
import defaultView from '../../utils/defaultView';
|
||||
import { AclAction } from '../../models/BaseModel';
|
||||
import { NotificationKey } from '../../models/NotificationModel';
|
||||
const prettyBytes = require('pretty-bytes');
|
||||
import { formatBytes } from '../../utils/bytes';
|
||||
|
||||
interface CheckPasswordInput {
|
||||
password: string;
|
||||
@ -71,7 +71,7 @@ router.get('users', async (_path: SubPath, ctx: AppContext) => {
|
||||
view.content.users = users.map(user => {
|
||||
return {
|
||||
...user,
|
||||
formattedItemMaxSize: user.max_item_size ? prettyBytes(user.max_item_size) : '∞',
|
||||
formattedItemMaxSize: user.max_item_size ? formatBytes(user.max_item_size) : '∞',
|
||||
};
|
||||
});
|
||||
return view;
|
||||
|
@ -18,6 +18,7 @@ import indexNotifications from './index/notifications';
|
||||
import indexSignup from './index/signup';
|
||||
import indexShares from './index/shares';
|
||||
import indexUsers from './index/users';
|
||||
import indexStripe from './index/stripe';
|
||||
|
||||
import defaultRoute from './default';
|
||||
|
||||
@ -40,6 +41,7 @@ const routes: Routers = {
|
||||
'signup': indexSignup,
|
||||
'shares': indexShares,
|
||||
'users': indexUsers,
|
||||
'stripe': indexStripe,
|
||||
|
||||
'': defaultRoute,
|
||||
};
|
||||
|
@ -5,6 +5,7 @@ import Mail = require('nodemailer/lib/mailer');
|
||||
import { createTransport } from 'nodemailer';
|
||||
import { Email, EmailSender } from '../db';
|
||||
import { errorToString } from '../utils/errors';
|
||||
import MarkdownIt = require('markdown-it');
|
||||
|
||||
const logger = Logger.create('EmailService');
|
||||
|
||||
@ -31,6 +32,7 @@ export default class EmailService extends BaseService {
|
||||
|
||||
try {
|
||||
await this.transport_.verify();
|
||||
logger.info('Transporter is operational - service will be enabled');
|
||||
} catch (error) {
|
||||
this.enabled_ = false;
|
||||
this.transport_ = null;
|
||||
@ -53,6 +55,16 @@ export default class EmailService extends BaseService {
|
||||
throw new Error(`Invalid sender ID: ${senderId}`);
|
||||
}
|
||||
|
||||
private markdownBodyToPlainText(md: string): string {
|
||||
// Just convert the links to plain URLs
|
||||
return md.replace(/\[.*\]\((.*)\)/g, '$1');
|
||||
}
|
||||
|
||||
private markdownBodyToHtml(md: string): string {
|
||||
const markdownIt = new MarkdownIt();
|
||||
return markdownIt.render(md);
|
||||
}
|
||||
|
||||
private escapeEmailField(f: string): string {
|
||||
return f.replace(/[\n\r"<>]/g, '');
|
||||
}
|
||||
@ -82,7 +94,8 @@ export default class EmailService extends BaseService {
|
||||
from: this.formatNameAndEmail(sender.email, sender.name),
|
||||
to: this.formatNameAndEmail(email.recipient_email, email.recipient_name),
|
||||
subject: email.subject,
|
||||
text: email.body,
|
||||
text: this.markdownBodyToPlainText(email.body),
|
||||
html: this.markdownBodyToHtml(email.body),
|
||||
};
|
||||
|
||||
const emailToSave: Email = {
|
||||
@ -115,7 +128,11 @@ export default class EmailService extends BaseService {
|
||||
return;
|
||||
}
|
||||
|
||||
UserModel.eventEmitter.on('created', this.scheduleMaintenance);
|
||||
UserModel.eventEmitter.on('created', () => {
|
||||
logger.info('User was created - scheduling maintenance');
|
||||
void this.scheduleMaintenance();
|
||||
});
|
||||
|
||||
await super.runInBackground();
|
||||
}
|
||||
|
||||
|
@ -16,7 +16,7 @@ export async function createTestUsers(db: DbConnection, config: Config) {
|
||||
await dropTables(db);
|
||||
await migrateDb(db);
|
||||
|
||||
const models = newModelFactory(db, config.baseUrl);
|
||||
const models = newModelFactory(db, config);
|
||||
|
||||
for (let userNum = 1; userNum <= 2; userNum++) {
|
||||
await models.user().save({
|
||||
|
@ -45,6 +45,8 @@ const propertyTypes: Record<string, string> = {
|
||||
'share_users.status': 'ShareUserStatus',
|
||||
'emails.sender_id': 'EmailSender',
|
||||
'emails.sent_time': 'number',
|
||||
'subscriptions.last_payment_time': 'number',
|
||||
'subscriptions.last_payment_failed_time': 'number',
|
||||
};
|
||||
|
||||
function insertContentIntoFile(filePath: string, markerOpen: string, markerClose: string, contentToInsert: string): void {
|
||||
|
17
packages/server/src/utils/bytes.test.ts
Normal file
17
packages/server/src/utils/bytes.test.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { GB, KB, MB, formatBytes } from './bytes';
|
||||
|
||||
describe('bytes', function() {
|
||||
|
||||
it('should convert bytes', async function() {
|
||||
expect(1 * KB).toBe(1024);
|
||||
expect(1 * MB).toBe(1048576);
|
||||
expect(1 * GB).toBe(1073741824);
|
||||
});
|
||||
|
||||
it('should display pretty bytes', async function() {
|
||||
expect(formatBytes(100 * KB)).toBe('100 kB');
|
||||
expect(formatBytes(200 * MB)).toBe('200 MB');
|
||||
expect(formatBytes(3 * GB)).toBe('3 GB');
|
||||
});
|
||||
|
||||
});
|
11
packages/server/src/utils/bytes.ts
Normal file
11
packages/server/src/utils/bytes.ts
Normal file
@ -0,0 +1,11 @@
|
||||
const prettyBytes = require('pretty-bytes');
|
||||
|
||||
export const KB = 1024;
|
||||
export const MB = KB * KB;
|
||||
export const GB = KB * MB;
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
// To simplify we display the result with SI prefix, but removes the "i".
|
||||
// So 1024 bytes = 1 kB (and not 1 kiB)
|
||||
return prettyBytes(bytes, { binary: true }).replace(/i/g, '');
|
||||
}
|
@ -26,7 +26,7 @@ async function setupServices(env: Env, models: Models, config: Config): Promise<
|
||||
export default async function(appContext: AppContext, env: Env, dbConnection: DbConnection, appLogger: ()=> LoggerWrapper) {
|
||||
appContext.env = env;
|
||||
appContext.db = dbConnection;
|
||||
appContext.models = newModelFactory(appContext.db, config().baseUrl);
|
||||
appContext.models = newModelFactory(appContext.db, config());
|
||||
appContext.services = await setupServices(env, appContext.models, config());
|
||||
appContext.appLogger = appLogger;
|
||||
appContext.routes = { ...routes };
|
||||
|
@ -66,7 +66,7 @@ export async function beforeAllDb(unitName: string) {
|
||||
// Uncomment the code below to run the test units with Postgres. Run first
|
||||
// `docker-compose -f docker-compose.db-dev.yml` to get a dev db.
|
||||
|
||||
// await initConfig({
|
||||
// await initConfig(Env.Dev, {
|
||||
// DB_CLIENT: 'pg',
|
||||
// POSTGRES_DATABASE: unitName,
|
||||
// POSTGRES_USER: 'joplin',
|
||||
@ -75,7 +75,7 @@ export async function beforeAllDb(unitName: string) {
|
||||
// tempDir: tempDir,
|
||||
// });
|
||||
|
||||
await initConfig({
|
||||
await initConfig(Env.Dev, {
|
||||
SQLITE_DATABASE: createdDbPath_,
|
||||
}, {
|
||||
tempDir: tempDir,
|
||||
@ -217,12 +217,12 @@ export function db() {
|
||||
return db_;
|
||||
}
|
||||
|
||||
function baseUrl() {
|
||||
return 'http://localhost:22300';
|
||||
}
|
||||
// function baseUrl() {
|
||||
// return 'http://localhost:22300';
|
||||
// }
|
||||
|
||||
export function models() {
|
||||
return modelFactory(db(), baseUrl());
|
||||
return modelFactory(db(), config());
|
||||
}
|
||||
|
||||
export function parseHtml(html: string): Document {
|
||||
@ -367,6 +367,23 @@ export function checkContextError(context: AppContext) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function credentialFile(filename: string): Promise<string> {
|
||||
const filePath = `${require('os').homedir()}/joplin-credentials/${filename}`;
|
||||
if (await fs.pathExists(filePath)) return filePath;
|
||||
return '';
|
||||
}
|
||||
|
||||
export async function readCredentialFile(filename: string, defaultValue: string = null) {
|
||||
const filePath = await credentialFile(filename);
|
||||
if (!filePath) {
|
||||
if (defaultValue === null) throw new Error(`File not found: ${filename}`);
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const r = await fs.readFile(filePath);
|
||||
return r.toString();
|
||||
}
|
||||
|
||||
export async function checkThrowAsync(asyncFn: Function): Promise<any> {
|
||||
try {
|
||||
await asyncFn();
|
||||
|
@ -57,9 +57,16 @@ export interface MailerConfig {
|
||||
noReplyEmail: string;
|
||||
}
|
||||
|
||||
export interface StripeConfig {
|
||||
secretKey: string;
|
||||
publishableKey: string;
|
||||
webhookSecret: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
appVersion: string;
|
||||
appName: string;
|
||||
env: Env;
|
||||
port: number;
|
||||
rootDir: string;
|
||||
viewDir: string;
|
||||
@ -73,6 +80,7 @@ export interface Config {
|
||||
userContentBaseUrl: string;
|
||||
database: DatabaseConfig;
|
||||
mailer: MailerConfig;
|
||||
stripe: StripeConfig;
|
||||
}
|
||||
|
||||
export enum HttpMethod {
|
||||
|
Loading…
Reference in New Issue
Block a user