Skip to content

Commit

Permalink
Added subscription update middleware (#107)
Browse files Browse the repository at this point in the history
refs ##11434

- Added method to allow updating single subscription. Only `cancel_at_period_end` field can be updated. 
- Middleware is needed to allow Ghost Core to cancel/uncancel member's subscription. 
- Relies on the request containing identity information to be able to verify if subscription belongs to the user
- When member could not be identified by the identity information present in the request we should throw instead of continuing processing
- Handling and messaging inspired by https://github.com/TryGhost/Ghost/blob/3.1.1/core/server/services/mega/mega.js#L132
- When the user initiates subscription cancellation we can safely mark the subscription as canceled so that it's not shown in the interface on subsequent request. Otherwise, we end up in a situation where we still return the subscription in the period until Stripe triggers the webhook.
- Added boolean coercion for cancel_at_period_end parameter. If anything but boolean is passed to Stripe API it throws an error.  Coercing the value on our side is a gives a better dev experience
  • Loading branch information
naz committed Dec 12, 2019
1 parent 94ef530 commit ff5fcea
Show file tree
Hide file tree
Showing 3 changed files with 69 additions and 2 deletions.
54 changes: 53 additions & 1 deletion ghost/members-api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ module.exports = function MembersApi({
const middleware = {
sendMagicLink: Router(),
createCheckoutSession: Router(),
handleStripeWebhook: Router()
handleStripeWebhook: Router(),
updateSubscription: Router({mergeParams: true})
};

middleware.sendMagicLink.use(body.json(), async function (req, res) {
Expand Down Expand Up @@ -231,6 +232,57 @@ module.exports = function MembersApi({
}
});

middleware.updateSubscription.use(ensureStripe, body.json(), async function (req, res) {
const identity = req.body.identity;
const cancelAtPeriodEnd = req.body.cancel_at_period_end;
const subscriptionId = req.params.id;

let member;

try {
if (!identity) {
throw new common.errors.BadRequestError({
message: 'Cancel membership failed! Could not find member'
});
}

const claims = await decodeToken(identity);
const email = claims.sub;
member = email ? await users.get({email}) : null;

if (!member) {
throw new common.errors.BadRequestError({
message: 'Cancel membership failed! Could not find member'
});
}
} catch (err) {
res.writeHead(401);
return res.end('Unauthorized');
}

// Don't allow removing subscriptions that don't belong to the member
const subscription = member.stripe.subscriptions.find(sub => sub.id === subscriptionId);

if (!subscription) {
res.writeHead(403);
return res.end('No permission');
}

if (cancelAtPeriodEnd === undefined) {
throw new common.errors.BadRequestError({
message: 'Canceling membership failed!',
help: 'Request should contain boolean "cancel" field.'
});
}

subscription.cancel_at_period_end = !!(cancelAtPeriodEnd);

await stripe.updateSubscriptionFromClient(subscription);

res.writeHead(204);
res.end();
});

const getPublicConfig = function () {
return Promise.resolve({
publicKey,
Expand Down
4 changes: 4 additions & 0 deletions ghost/members-api/lib/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,9 @@ module.exports = {
Object.assign(loggerInterface, Object.create(newLogger));
}
});
},

get errors() {
return require('ghost-ignition').errors;
}
};
13 changes: 12 additions & 1 deletion ghost/members-api/lib/stripe/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const debug = require('ghost-ignition').debug('stripe');
const {retrieve, list, create, del} = require('./api/stripeRequests');
const {retrieve, list, create, update, del} = require('./api/stripeRequests');
const api = require('./api');

const STRIPE_API_VERSION = '2019-09-09';
Expand Down Expand Up @@ -135,6 +135,15 @@ module.exports = class StripePaymentProcessor {
return true;
}

async updateSubscriptionFromClient(subscription) {
const updatedSubscription = await update(this._stripe, 'subscriptions', subscription.id, {
cancel_at_period_end: subscription.cancel_at_period_end
});
await this._updateSubscription(updatedSubscription);

return updatedSubscription;
}

async getSubscriptions(member) {
const metadata = await this.storage.get(member);

Expand Down Expand Up @@ -162,6 +171,7 @@ module.exports = class StripePaymentProcessor {
status: subscription.status,
start_date: subscription.start_date,
default_payment_card_last4: subscription.default_payment_card_last4,
cancel_at_period_end: subscription.cancel_at_period_end,
current_period_end: subscription.current_period_end
};
});
Expand Down Expand Up @@ -235,6 +245,7 @@ module.exports = class StripePaymentProcessor {

subscription_id: subscription.id,
status: subscription.status,
cancel_at_period_end: subscription.cancel_at_period_end,
current_period_end: new Date(subscription.current_period_end * 1000),
start_date: new Date(subscription.start_date * 1000),
default_payment_card_last4: payment && payment.card && payment.card.last4 || null,
Expand Down

0 comments on commit ff5fcea

Please sign in to comment.