mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-03 23:00:14 -05:00
Added stripe payments module
This commit is contained in:
parent
7376a333c2
commit
61561a5af6
9 changed files with 356 additions and 0 deletions
75
ghost/members-api/lib/stripe/api/createDeterministicApi.js
Normal file
75
ghost/members-api/lib/stripe/api/createDeterministicApi.js
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
const hash = data => require('crypto').createHash('sha256').update(data).digest('hex');
|
||||||
|
const {
|
||||||
|
del: stripeDel,
|
||||||
|
create: stripeCreate,
|
||||||
|
retrieve: stripeRetrieve
|
||||||
|
} = require('./stripeRequests');
|
||||||
|
|
||||||
|
function createDeterministicApi(resource, validResult, getAttrs, generateHashSeed) {
|
||||||
|
const get = createGetter(resource, validResult);
|
||||||
|
const create = createCreator(resource, getAttrs);
|
||||||
|
const remove = createRemover(resource, get, generateHashSeed);
|
||||||
|
const ensure = createEnsurer(get, create, generateHashSeed);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get, create, remove, ensure
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefixHashSeed(stripe, seed) {
|
||||||
|
const prefix = stripe.__TEST_MODE__ ? 'test_' : 'prod_';
|
||||||
|
return prefix + seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGetter(resource, validResult) {
|
||||||
|
return function get(stripe, object, idSeed) {
|
||||||
|
const id = hash(prefixHashSeed(stripe, idSeed));
|
||||||
|
return stripeRetrieve(stripe, resource, id)
|
||||||
|
.then((result) => {
|
||||||
|
if (validResult(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return get(stripe, object, id);
|
||||||
|
}, (err) => {
|
||||||
|
err.id_requested = id;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCreator(resource, getAttrs) {
|
||||||
|
return function create(stripe, id, object, ...rest) {
|
||||||
|
return stripeCreate(
|
||||||
|
stripe,
|
||||||
|
resource,
|
||||||
|
Object.assign(getAttrs(object, ...rest), {id})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRemover(resource, get, generateHashSeed) {
|
||||||
|
return function remove(stripe, object, ...rest) {
|
||||||
|
return get(stripe, object, generateHashSeed(object, ...rest)).then((res) => {
|
||||||
|
return stripeDel(stripe, resource, res.id);
|
||||||
|
}).catch((err) => {
|
||||||
|
if (err.code !== 'resource_missing') {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEnsurer(get, create, generateHashSeed) {
|
||||||
|
return function ensure(stripe, object, ...rest) {
|
||||||
|
return get(stripe, object, generateHashSeed(object, ...rest))
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.code !== 'resource_missing') {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const id = err.id_requested;
|
||||||
|
return create(stripe, id, object, ...rest);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createDeterministicApi;
|
56
ghost/members-api/lib/stripe/api/createStripeRequest.js
Normal file
56
ghost/members-api/lib/stripe/api/createStripeRequest.js
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
module.exports = function createStripeRequest(makeRequest) {
|
||||||
|
return function stripeRequest(...args) {
|
||||||
|
const errorHandler = (err) => {
|
||||||
|
switch (err.type) {
|
||||||
|
case 'StripeCardError':
|
||||||
|
// Card declined
|
||||||
|
throw err;
|
||||||
|
case 'RateLimitError':
|
||||||
|
// Ronseal
|
||||||
|
return exponentiallyBackoff(makeRequest, ...args).catch((err) => {
|
||||||
|
// We do not want to recurse further if we get RateLimitError
|
||||||
|
// after running the exponential backoff
|
||||||
|
if (err.type === 'RateLimitError') {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return errorHandler(err);
|
||||||
|
});
|
||||||
|
case 'StripeInvalidRequestError':
|
||||||
|
// Invalid params to the request
|
||||||
|
throw err;
|
||||||
|
case 'StripeAPIError':
|
||||||
|
// Rare internal server error from stripe
|
||||||
|
throw err;
|
||||||
|
case 'StripeConnectionError':
|
||||||
|
// Weird network/https issue
|
||||||
|
throw err;
|
||||||
|
case 'StripeAuthenticationError':
|
||||||
|
// Invalid API Key (probably)
|
||||||
|
throw err;
|
||||||
|
default:
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return makeRequest(...args).catch(errorHandler);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function exponentiallyBackoff(makeRequest, ...args) {
|
||||||
|
function backoffRequest(timeout, ...args) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, timeout)).then(() => {
|
||||||
|
return makeRequest(...args).catch((err) => {
|
||||||
|
if (err.type !== 'RateLimitError') {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeout > 30000) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return backoffRequest(timeout * 2, ...args);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return backoffRequest(1000, ...args);
|
||||||
|
}
|
12
ghost/members-api/lib/stripe/api/customers.js
Normal file
12
ghost/members-api/lib/stripe/api/customers.js
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
const createDeterministicApi = require('./createDeterministicApi');
|
||||||
|
|
||||||
|
const isNotDeleted = x => !x.deleted;
|
||||||
|
const getCustomerAttr = ({email}) => ({email});
|
||||||
|
const getCustomerHashSeed = member => member.email;
|
||||||
|
|
||||||
|
module.exports = createDeterministicApi(
|
||||||
|
'customers',
|
||||||
|
isNotDeleted,
|
||||||
|
getCustomerAttr,
|
||||||
|
getCustomerHashSeed
|
||||||
|
);
|
6
ghost/members-api/lib/stripe/api/index.js
Normal file
6
ghost/members-api/lib/stripe/api/index.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
customers: require('./customers'),
|
||||||
|
products: require('./products'),
|
||||||
|
plans: require('./plans'),
|
||||||
|
subscriptions: require('./subscriptions')
|
||||||
|
};
|
21
ghost/members-api/lib/stripe/api/plans.js
Normal file
21
ghost/members-api/lib/stripe/api/plans.js
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
const createDeterministicApi = require('./createDeterministicApi');
|
||||||
|
|
||||||
|
const isActive = x => x.active;
|
||||||
|
const getPlanAttr = ({name, amount, interval, currency}, product) => ({
|
||||||
|
nickname: name,
|
||||||
|
amount,
|
||||||
|
interval,
|
||||||
|
currency,
|
||||||
|
product: product.id,
|
||||||
|
billing_scheme: 'per_unit'
|
||||||
|
});
|
||||||
|
const getPlanHashSeed = (plan, product) => {
|
||||||
|
return product.id + plan.interval + plan.currency + plan.amount;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = createDeterministicApi(
|
||||||
|
'plans',
|
||||||
|
isActive,
|
||||||
|
getPlanAttr,
|
||||||
|
getPlanHashSeed
|
||||||
|
);
|
12
ghost/members-api/lib/stripe/api/products.js
Normal file
12
ghost/members-api/lib/stripe/api/products.js
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
const createDeterministicApi = require('./createDeterministicApi');
|
||||||
|
|
||||||
|
const isActive = x => x.active;
|
||||||
|
const getProductAttr = ({name}) => ({name, type: 'service'});
|
||||||
|
const getProductHashSeed = () => 'Ghost Subscription';
|
||||||
|
|
||||||
|
module.exports = createDeterministicApi(
|
||||||
|
'products',
|
||||||
|
isActive,
|
||||||
|
getProductAttr,
|
||||||
|
getProductHashSeed
|
||||||
|
);
|
26
ghost/members-api/lib/stripe/api/stripeRequests.js
Normal file
26
ghost/members-api/lib/stripe/api/stripeRequests.js
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
const createStripeRequest = require('./createStripeRequest');
|
||||||
|
|
||||||
|
const createSource = createStripeRequest(function (stripe, customerId, stripeToken) {
|
||||||
|
return stripe.customers.createSource(customerId, {
|
||||||
|
source: stripeToken
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const retrieve = createStripeRequest(function (stripe, resource, id) {
|
||||||
|
return stripe[resource].retrieve(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const create = createStripeRequest(function (stripe, resource, object) {
|
||||||
|
return stripe[resource].create(object);
|
||||||
|
});
|
||||||
|
|
||||||
|
const del = createStripeRequest(function (stripe, resource, id) {
|
||||||
|
return stripe[resource].del(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createSource,
|
||||||
|
retrieve,
|
||||||
|
create,
|
||||||
|
del
|
||||||
|
};
|
64
ghost/members-api/lib/stripe/api/subscriptions.js
Normal file
64
ghost/members-api/lib/stripe/api/subscriptions.js
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
const customers = require('./customers');
|
||||||
|
const {del, create, createSource} = require('./stripeRequests');
|
||||||
|
|
||||||
|
function removeSubscription(stripe, member) {
|
||||||
|
return customers.get(stripe, member, member.email).then((customer) => {
|
||||||
|
// CASE customer has no subscriptions
|
||||||
|
if (!customer.subscriptions || customer.subscriptions.total_count === 0) {
|
||||||
|
throw new Error('Cannot remove subscription');
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = customer.subscriptions.data[0];
|
||||||
|
|
||||||
|
return del(stripe, 'subscriptions', subscription.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubscription(stripe, member) {
|
||||||
|
return customers.get(stripe, member, member.email).then((customer) => {
|
||||||
|
// CASE customer has either none or multiple subscriptions
|
||||||
|
if (!customer.subscriptions || customer.subscriptions.total_count !== 1) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = customer.subscriptions.data[0];
|
||||||
|
|
||||||
|
// CASE subscription has multiple plans
|
||||||
|
if (subscription.items.total_count !== 1) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = subscription.plan;
|
||||||
|
|
||||||
|
return {
|
||||||
|
validUntil: subscription.current_period_end,
|
||||||
|
plan: plan.nickname,
|
||||||
|
amount: plan.amount,
|
||||||
|
status: subscription.status
|
||||||
|
};
|
||||||
|
}).catch(() => {
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSubscription(stripe, member, metadata) {
|
||||||
|
return customers.ensure(stripe, member, member.email).then((customer) => {
|
||||||
|
if (customer.subscriptions && customer.subscriptions.total_count !== 0) {
|
||||||
|
throw new Error('Customer already has a subscription');
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSource(stripe, customer.id, metadata.stripeToken).then(() => {
|
||||||
|
return create(stripe, 'subscriptions', {
|
||||||
|
customer: customer.id,
|
||||||
|
items: [{plan: metadata.plan.id}],
|
||||||
|
coupon: metadata.coupon
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
create: createSubscription,
|
||||||
|
get: getSubscription,
|
||||||
|
remove: removeSubscription
|
||||||
|
};
|
84
ghost/members-api/lib/stripe/index.js
Normal file
84
ghost/members-api/lib/stripe/index.js
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
const api = require('./api');
|
||||||
|
|
||||||
|
module.exports = class StripePaymentProcessor {
|
||||||
|
constructor(config) {
|
||||||
|
this._ready = new Promise((resolve, reject) => {
|
||||||
|
this._resolveReady = resolve;
|
||||||
|
this._rejectReady = reject;
|
||||||
|
});
|
||||||
|
this._configure(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ready() {
|
||||||
|
return this._ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _configure(config) {
|
||||||
|
this._stripe = require('stripe')(config.secretKey);
|
||||||
|
this._stripe.__TEST_MODE__ = config.secretKey.startsWith('sk_test_');
|
||||||
|
this._public_token = config.publicKey;
|
||||||
|
this._checkoutSuccessUrl = config.checkoutSuccessUrl;
|
||||||
|
this._checkoutCancelUrl = config.checkoutCancelUrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._product = await api.products.ensure(this._stripe, config.product);
|
||||||
|
} catch (err) {
|
||||||
|
return this._rejectReady(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._plans = [];
|
||||||
|
for (const planSpec of config.plans) {
|
||||||
|
try {
|
||||||
|
const plan = await api.plans.ensure(this._stripe, planSpec, this._product);
|
||||||
|
this._plans.push(plan);
|
||||||
|
} catch (err) {
|
||||||
|
return this._rejectReady(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._resolveReady({
|
||||||
|
product: this._product,
|
||||||
|
plans: this._plans
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getPublicConfig() {
|
||||||
|
return {
|
||||||
|
publicKey: this._public_token,
|
||||||
|
plans: this._plans.map(({id, currency, amount, interval, nickname}) => ({
|
||||||
|
id, currency, amount, interval,
|
||||||
|
name: nickname
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCheckoutSession(member, planName) {
|
||||||
|
const customer = await api.customers.ensure(this._stripe, member, member.email);
|
||||||
|
const plan = this._plans.find(plan => plan.nickname === planName);
|
||||||
|
const session = await this._stripe.checkout.sessions.create({
|
||||||
|
payment_method_types: ['card'],
|
||||||
|
success_url: this._checkoutSuccessUrl,
|
||||||
|
cancel_url: this._checkoutCancelUrl,
|
||||||
|
customer: customer.id,
|
||||||
|
subscription_data: {
|
||||||
|
items: [{
|
||||||
|
plan: plan.id
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSubscription(member) {
|
||||||
|
return api.subscriptions.get(this._stripe, member);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSubscription(member) {
|
||||||
|
return api.subscriptions.remove(this._stripe, member);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeCustomer(member) {
|
||||||
|
return api.customers.remove(this._stripe, member);
|
||||||
|
}
|
||||||
|
};
|
Loading…
Add table
Reference in a new issue