0
Fork 0
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:
Fabien O'Carroll 2019-09-06 13:13:35 +08:00
parent 7376a333c2
commit 61561a5af6
9 changed files with 356 additions and 0 deletions

View 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;

View 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);
}

View 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
);

View file

@ -0,0 +1,6 @@
module.exports = {
customers: require('./customers'),
products: require('./products'),
plans: require('./plans'),
subscriptions: require('./subscriptions')
};

View 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
);

View 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
);

View 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
};

View 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
};

View 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);
}
};