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