mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-20 22:42:53 -05:00
0c72e78e6a
refs https://github.com/TryGhost/Team/issues/1519 **Tests:** - Tests whether the metadata from an offer is read correctly and stored in the database - Test that invalid offer ids are ignored **Changes in members repository:** - Compare changes here: https://github.com/TryGhost/Members/compare/%40tryghost/members-api%405.9.2...%40tryghost/members-api%405.11.1 - The `offer_id` column of subscriptions is set based on the coupon id from Stripe - `getByStripeCouponId` method added in the offers repository (required to look up an offer from a stripe_coupon_id) - the `members-payments` package was bumped twice (once for changes, once for undoing those changes, my bad). Nothing else has changed in that package.
1870 lines
76 KiB
JavaScript
1870 lines
76 KiB
JavaScript
const crypto = require('crypto');
|
|
const assert = require('assert');
|
|
const nock = require('nock');
|
|
const should = require('should');
|
|
const stripe = require('stripe');
|
|
const {Product} = require('../../../core/server/models/product');
|
|
const {agentProvider, mockManager, fixtureManager} = require('../../utils/e2e-framework');
|
|
const models = require('../../../core/server/models');
|
|
const offers = require('../../../core/server/services/offers');
|
|
|
|
let membersAgent;
|
|
let adminAgent;
|
|
|
|
function createStripeID(prefix) {
|
|
return `${prefix}_${crypto.randomBytes(16).toString('hex')}`;
|
|
}
|
|
|
|
async function getPaidProduct() {
|
|
return await Product.findOne({type: 'paid'});
|
|
}
|
|
|
|
async function assertMemberEvents({eventType, memberId, asserts}) {
|
|
const events = (await models[eventType].where('member_id', memberId).fetchAll()).toJSON();
|
|
events.should.match(asserts);
|
|
assert.equal(events.length, asserts.length, `Only ${asserts.length} ${eventType} should have been added.`);
|
|
}
|
|
|
|
async function assertSubscription(subscriptionId, asserts) {
|
|
// eslint-disable-next-line dot-notation
|
|
const subscription = await models['StripeCustomerSubscription'].where('subscription_id', subscriptionId).fetch({require: true});
|
|
|
|
// We use the native toJSON to prevent calling the overriden serialize method
|
|
models.Base.Model.prototype.serialize.call(subscription).should.match(asserts);
|
|
}
|
|
|
|
describe('Members API', function () {
|
|
before(async function () {
|
|
const agents = await agentProvider.getAgentsForMembers();
|
|
membersAgent = agents.membersAgent;
|
|
adminAgent = agents.adminAgent;
|
|
|
|
await fixtureManager.init('members');
|
|
await adminAgent.loginAsOwner();
|
|
});
|
|
|
|
beforeEach(function () {
|
|
mockManager.mockLabsEnabled('multipleProducts');
|
|
mockManager.mockLabsEnabled('multipleNewsletters');
|
|
mockManager.mockMail();
|
|
mockManager.mockStripe();
|
|
});
|
|
|
|
afterEach(function () {
|
|
mockManager.restore();
|
|
});
|
|
|
|
it('Can communicate with the frontend Members API', async function () {
|
|
await membersAgent.get('/api/site/')
|
|
.expectStatus(200);
|
|
});
|
|
|
|
// @todo: Test what happens when a complementary subscription ends (should create comped -> free event)
|
|
// @todo: Test what happens when a complementary subscription starts a paid subscription
|
|
|
|
describe('/webhooks/stripe/', function () {
|
|
// We create some shared stripe resources, so we don't have to have nocks in every test case
|
|
const subscription = {};
|
|
const customer = {};
|
|
const paymentMethod = {};
|
|
const setupIntent = {};
|
|
const coupon = {};
|
|
|
|
beforeEach(function () {
|
|
nock('https://api.stripe.com')
|
|
.persist()
|
|
.get(/v1\/.*/)
|
|
.reply((uri, body) => {
|
|
const [match, resource, id] = uri.match(/\/?v1\/(\w+)\/?(\w+)/) || [null];
|
|
|
|
if (!match) {
|
|
return [500];
|
|
}
|
|
|
|
if (resource === 'setup_intents') {
|
|
return [200, setupIntent];
|
|
}
|
|
|
|
if (resource === 'customers') {
|
|
if (customer.id !== id) {
|
|
return [404];
|
|
}
|
|
return [200, customer];
|
|
}
|
|
|
|
if (resource === 'subscriptions') {
|
|
if (subscription.id !== id) {
|
|
return [404];
|
|
}
|
|
return [200, subscription];
|
|
}
|
|
|
|
if (resource === 'coupons') {
|
|
if (coupon.id !== id) {
|
|
return [404];
|
|
}
|
|
return [200, coupon];
|
|
}
|
|
});
|
|
|
|
nock('https://api.stripe.com')
|
|
.persist()
|
|
.post(/v1\/.*/)
|
|
.reply((uri, body) => {
|
|
const [match, resource, id, action] = uri.match(/\/?v1\/(\w+)(?:\/?(\w+)){0,2}/) || [null];
|
|
|
|
if (!match) {
|
|
return [500];
|
|
}
|
|
|
|
if (resource === 'payment_methods') {
|
|
return [200, paymentMethod];
|
|
}
|
|
|
|
if (resource === 'subscriptions') {
|
|
return [200, subscription];
|
|
}
|
|
|
|
if (resource === 'customers') {
|
|
return [200, customer];
|
|
}
|
|
|
|
if (resource === 'coupons') {
|
|
return [200, coupon];
|
|
}
|
|
|
|
return [500];
|
|
});
|
|
});
|
|
|
|
afterEach(function () {
|
|
nock.cleanAll();
|
|
});
|
|
|
|
// Helper methods to update the customer and subscription
|
|
function set(object, newValues) {
|
|
for (const key of Object.keys(object)) {
|
|
delete object[key];
|
|
}
|
|
Object.assign(object, newValues);
|
|
}
|
|
|
|
/**
|
|
* Helper method to create an existing member based on a customer in stripe (= current customer)
|
|
*/
|
|
async function createMemberFromStripe() {
|
|
const initialMember = {
|
|
name: customer.name,
|
|
email: customer.email,
|
|
subscribed: true,
|
|
stripe_customer_id: customer.id
|
|
};
|
|
|
|
const {body} = await adminAgent
|
|
.post(`/members/`)
|
|
.body({members: [initialMember]})
|
|
.expectStatus(201);
|
|
assert.equal(body.members.length, 1, 'The member was not created');
|
|
const member = body.members[0];
|
|
return member;
|
|
}
|
|
|
|
it('Responds with a 401 when the signature is invalid', async function () {
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body({
|
|
fake: 'data'
|
|
})
|
|
.header('stripe-signature', 'dodgy')
|
|
.expectStatus(401);
|
|
});
|
|
|
|
it('Responds with a 200 to unknown events with valid signature', async function () {
|
|
const webhookPayload = JSON.stringify({
|
|
type: 'unknown',
|
|
data: {
|
|
id: 'id_123'
|
|
}
|
|
});
|
|
const webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature)
|
|
.expectStatus(200);
|
|
});
|
|
|
|
describe('Handling cancelled subscriptions', function () {
|
|
describe('With the dashboardV5 flag', function () {
|
|
beforeEach(function () {
|
|
mockManager.mockLabsEnabled('dashboardV5');
|
|
});
|
|
it('Handles cancellation of paid subscriptions correctly', async function () {
|
|
const customer_id = createStripeID('cust');
|
|
const subscription_id = createStripeID('sub');
|
|
|
|
// Create a new subscription in Stripe
|
|
set(subscription, {
|
|
id: subscription_id,
|
|
customer: customer_id,
|
|
status: 'active',
|
|
items: {
|
|
type: 'list',
|
|
data: [{
|
|
id: 'item_123',
|
|
price: {
|
|
id: 'price_123',
|
|
product: 'product_123',
|
|
active: true,
|
|
nickname: 'Monthly',
|
|
currency: 'USD',
|
|
recurring: {
|
|
interval: 'month'
|
|
},
|
|
unit_amount: 500,
|
|
type: 'recurring'
|
|
}
|
|
}]
|
|
},
|
|
start_date: Date.now() / 1000,
|
|
current_period_end: Date.now() / 1000 + (60 * 60 * 24 * 31),
|
|
cancel_at_period_end: false
|
|
});
|
|
|
|
// Create a new customer in Stripe
|
|
set(customer, {
|
|
id: customer_id,
|
|
name: 'Test Member',
|
|
email: 'expired-paid-test@email.com',
|
|
subscriptions: {
|
|
type: 'list',
|
|
data: [subscription]
|
|
}
|
|
});
|
|
|
|
// Make sure this customer has a corresponding member in the database
|
|
// And all the subscriptions are setup correctly
|
|
const initialMember = await createMemberFromStripe();
|
|
assert.equal(initialMember.status, 'paid', 'The member initial status should be paid');
|
|
assert.equal(initialMember.products.length, 1, 'The member should have one product');
|
|
should(initialMember.subscriptions).match([
|
|
{
|
|
status: 'active'
|
|
}
|
|
]);
|
|
|
|
// Cancel the previously created subscription in Stripe
|
|
set(subscription, {
|
|
...subscription,
|
|
cancel_at_period_end: true
|
|
});
|
|
|
|
// Send the webhook call to anounce the cancelation
|
|
const webhookPayload = JSON.stringify({
|
|
type: 'customer.subscription.updated',
|
|
data: {
|
|
object: subscription
|
|
}
|
|
});
|
|
const webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature)
|
|
.expectStatus(200);
|
|
|
|
// Check status has been updated to 'free' after cancelling
|
|
const {body: body2} = await adminAgent.get('/members/' + initialMember.id + '/');
|
|
assert.equal(body2.members.length, 1, 'The member does not exist');
|
|
const updatedMember = body2.members[0];
|
|
assert.equal(updatedMember.status, 'paid');
|
|
assert.equal(updatedMember.products.length, 1, 'The member should have products');
|
|
should(updatedMember.subscriptions).match([
|
|
{
|
|
cancel_at_period_end: true
|
|
}
|
|
]);
|
|
|
|
// Check the status events for this newly created member (should be NULL -> paid only)
|
|
await assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: [
|
|
{
|
|
from_status: null,
|
|
to_status: 'free'
|
|
},
|
|
{
|
|
from_status: 'free',
|
|
to_status: 'paid'
|
|
}
|
|
]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: [
|
|
{
|
|
type: 'created',
|
|
mrr_delta: 500
|
|
},
|
|
{
|
|
type: 'canceled',
|
|
mrr_delta: -500
|
|
}
|
|
]
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Without the dashboardV5 flag', function () {
|
|
it('Handles cancellation of paid subscriptions correctly', async function () {
|
|
const customer_id = createStripeID('cust');
|
|
const subscription_id = createStripeID('sub');
|
|
|
|
// Create a new subscription in Stripe
|
|
set(subscription, {
|
|
id: subscription_id,
|
|
customer: customer_id,
|
|
status: 'active',
|
|
items: {
|
|
type: 'list',
|
|
data: [{
|
|
id: 'item_123',
|
|
price: {
|
|
id: 'price_123',
|
|
product: 'product_123',
|
|
active: true,
|
|
nickname: 'Monthly',
|
|
currency: 'USD',
|
|
recurring: {
|
|
interval: 'month'
|
|
},
|
|
unit_amount: 500,
|
|
type: 'recurring'
|
|
}
|
|
}]
|
|
},
|
|
start_date: Date.now() / 1000,
|
|
current_period_end: Date.now() / 1000 + (60 * 60 * 24 * 31),
|
|
cancel_at_period_end: false
|
|
});
|
|
|
|
// Create a new customer in Stripe
|
|
set(customer, {
|
|
id: customer_id,
|
|
name: 'Test Member',
|
|
email: 'cancelled-paid-test-no-flag@email.com',
|
|
subscriptions: {
|
|
type: 'list',
|
|
data: [subscription]
|
|
}
|
|
});
|
|
|
|
// Make sure this customer has a corresponding member in the database
|
|
// And all the subscriptions are setup correctly
|
|
const initialMember = await createMemberFromStripe();
|
|
assert.equal(initialMember.status, 'paid', 'The member initial status should be paid');
|
|
assert.equal(initialMember.products.length, 1, 'The member should have one product');
|
|
should(initialMember.subscriptions).match([
|
|
{
|
|
status: 'active'
|
|
}
|
|
]);
|
|
|
|
// Cancel the previously created subscription in Stripe
|
|
set(subscription, {
|
|
...subscription,
|
|
cancel_at_period_end: true
|
|
});
|
|
|
|
// Send the webhook call to anounce the cancelation
|
|
const webhookPayload = JSON.stringify({
|
|
type: 'customer.subscription.updated',
|
|
data: {
|
|
object: subscription
|
|
}
|
|
});
|
|
const webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature)
|
|
.expectStatus(200);
|
|
|
|
// Check status has been updated to 'free' after cancelling
|
|
const {body: body2} = await adminAgent.get('/members/' + initialMember.id + '/');
|
|
assert.equal(body2.members.length, 1, 'The member does not exist');
|
|
const updatedMember = body2.members[0];
|
|
assert.equal(updatedMember.status, 'paid');
|
|
assert.equal(updatedMember.products.length, 1, 'The member should have products');
|
|
should(updatedMember.subscriptions).match([
|
|
{
|
|
cancel_at_period_end: true
|
|
}
|
|
]);
|
|
|
|
// Check the status events for this newly created member (should be NULL -> paid only)
|
|
await assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: [
|
|
{
|
|
from_status: null,
|
|
to_status: 'free'
|
|
},
|
|
{
|
|
from_status: 'free',
|
|
to_status: 'paid'
|
|
}
|
|
]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: [
|
|
{
|
|
type: 'created',
|
|
mrr_delta: 500
|
|
},
|
|
{
|
|
type: 'canceled',
|
|
mrr_delta: 0
|
|
}
|
|
]
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Handling the end of subscriptions', function () {
|
|
let canceledPaidMember;
|
|
|
|
it('Handles cancellation of paid subscriptions correctly', async function () {
|
|
const customer_id = createStripeID('cust');
|
|
const subscription_id = createStripeID('sub');
|
|
|
|
// Create a new subscription in Stripe
|
|
set(subscription, {
|
|
id: subscription_id,
|
|
customer: customer_id,
|
|
status: 'active',
|
|
items: {
|
|
type: 'list',
|
|
data: [{
|
|
id: 'item_123',
|
|
price: {
|
|
id: 'price_123',
|
|
product: 'product_123',
|
|
active: true,
|
|
nickname: 'Monthly',
|
|
currency: 'usd',
|
|
recurring: {
|
|
interval: 'month'
|
|
},
|
|
unit_amount: 500,
|
|
type: 'recurring'
|
|
}
|
|
}]
|
|
},
|
|
start_date: Date.now() / 1000,
|
|
current_period_end: Date.now() / 1000 + (60 * 60 * 24 * 31),
|
|
cancel_at_period_end: false
|
|
});
|
|
|
|
// Create a new customer in Stripe
|
|
set(customer, {
|
|
id: customer_id,
|
|
name: 'Test Member',
|
|
email: 'cancel-paid-test@email.com',
|
|
subscriptions: {
|
|
type: 'list',
|
|
data: [subscription]
|
|
}
|
|
});
|
|
|
|
// Make sure this customer has a corresponding member in the database
|
|
// And all the subscriptions are setup correctly
|
|
const initialMember = await createMemberFromStripe();
|
|
assert.equal(initialMember.status, 'paid', 'The member initial status should be paid');
|
|
assert.equal(initialMember.products.length, 1, 'The member should have one product');
|
|
should(initialMember.subscriptions).match([
|
|
{
|
|
status: 'active'
|
|
}
|
|
]);
|
|
|
|
// Check whether MRR and status has been set
|
|
await assertSubscription(initialMember.subscriptions[0].id, {
|
|
subscription_id: subscription.id,
|
|
status: 'active',
|
|
cancel_at_period_end: false,
|
|
plan_amount: 500,
|
|
plan_interval: 'month',
|
|
plan_currency: 'usd',
|
|
mrr: 500
|
|
});
|
|
|
|
// Cancel the previously created subscription in Stripe
|
|
set(subscription, {
|
|
...subscription,
|
|
status: 'canceled'
|
|
});
|
|
|
|
// Send the webhook call to anounce the cancelation
|
|
const webhookPayload = JSON.stringify({
|
|
type: 'customer.subscription.updated',
|
|
data: {
|
|
object: subscription
|
|
}
|
|
});
|
|
const webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature)
|
|
.expectStatus(200);
|
|
|
|
// Check status has been updated to 'free' after cancelling
|
|
const {body: body2} = await adminAgent.get('/members/' + initialMember.id + '/');
|
|
assert.equal(body2.members.length, 1, 'The member does not exist');
|
|
const updatedMember = body2.members[0];
|
|
assert.equal(updatedMember.status, 'free');
|
|
assert.equal(updatedMember.products.length, 0, 'The member should have no products');
|
|
should(updatedMember.subscriptions).match([
|
|
{
|
|
status: 'canceled'
|
|
}
|
|
]);
|
|
|
|
// Check whether MRR and status has been set
|
|
await assertSubscription(initialMember.subscriptions[0].id, {
|
|
subscription_id: subscription.id,
|
|
status: 'canceled',
|
|
cancel_at_period_end: false,
|
|
plan_amount: 500,
|
|
plan_interval: 'month',
|
|
plan_currency: 'usd',
|
|
mrr: 0
|
|
});
|
|
|
|
// Check the status events for this newly created member (should be NULL -> paid only)
|
|
await assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: [
|
|
{
|
|
from_status: null,
|
|
to_status: 'free'
|
|
},
|
|
{
|
|
from_status: 'free',
|
|
to_status: 'paid'
|
|
},
|
|
{
|
|
from_status: 'paid',
|
|
to_status: 'free'
|
|
}
|
|
]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: [
|
|
{
|
|
type: 'created',
|
|
mrr_delta: 500
|
|
},
|
|
{
|
|
type: 'expired',
|
|
mrr_delta: -500
|
|
}
|
|
]
|
|
});
|
|
|
|
canceledPaidMember = updatedMember;
|
|
});
|
|
|
|
it('Can create a comlimentary subscription after canceling a paid subscription', async function () {
|
|
const product = await getPaidProduct();
|
|
|
|
const compedPayload = {
|
|
id: canceledPaidMember.id,
|
|
products: [
|
|
{
|
|
id: product.id
|
|
}
|
|
]
|
|
};
|
|
|
|
const {body} = await adminAgent
|
|
.put(`/members/${canceledPaidMember.id}/`)
|
|
.body({members: [compedPayload]})
|
|
.expectStatus(200);
|
|
|
|
const updatedMember = body.members[0];
|
|
assert.equal(updatedMember.status, 'comped', 'A comped member should have the comped status');
|
|
assert.equal(updatedMember.products.length, 1, 'The member should have one product');
|
|
should(updatedMember.subscriptions).match([
|
|
{
|
|
status: 'canceled'
|
|
},
|
|
{
|
|
status: 'active'
|
|
}
|
|
]);
|
|
assert.equal(updatedMember.subscriptions.length, 2, 'The member should have two subscriptions');
|
|
|
|
// Check the status events for this newly created member (should be NULL -> paid only)
|
|
await assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: [
|
|
{
|
|
from_status: null,
|
|
to_status: 'free'
|
|
},
|
|
{
|
|
from_status: 'free',
|
|
to_status: 'paid'
|
|
},
|
|
{
|
|
from_status: 'paid',
|
|
to_status: 'free'
|
|
},
|
|
{
|
|
from_status: 'free',
|
|
to_status: 'comped'
|
|
}
|
|
]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: [
|
|
{
|
|
mrr_delta: 500
|
|
},
|
|
{
|
|
mrr_delta: -500
|
|
}
|
|
]
|
|
});
|
|
});
|
|
|
|
it('Handles cancellation of old fashioned comped subscriptions correctly', async function () {
|
|
const customer_id = createStripeID('cust');
|
|
const subscription_id = createStripeID('sub');
|
|
|
|
const price = {
|
|
id: 'price_123',
|
|
product: 'product_123',
|
|
active: true,
|
|
nickname: 'Complimentary',
|
|
currency: 'usd',
|
|
recurring: {
|
|
interval: 'month'
|
|
},
|
|
unit_amount: 0,
|
|
type: 'recurring'
|
|
};
|
|
|
|
// Create a new subscription in Stripe
|
|
set(subscription, {
|
|
id: subscription_id,
|
|
customer: customer_id,
|
|
status: 'active',
|
|
items: {
|
|
type: 'list',
|
|
data: [{
|
|
id: 'item_123',
|
|
price
|
|
}]
|
|
},
|
|
plan: price, // Old stripe thing
|
|
start_date: Date.now() / 1000,
|
|
current_period_end: Date.now() / 1000 + (60 * 60 * 24 * 31),
|
|
cancel_at_period_end: false
|
|
});
|
|
|
|
// Create a new customer in Stripe
|
|
set(customer, {
|
|
id: customer_id,
|
|
name: 'Test Member',
|
|
email: 'cancel-complementary-test@email.com',
|
|
subscriptions: {
|
|
type: 'list',
|
|
data: [subscription]
|
|
}
|
|
});
|
|
|
|
// Make sure this customer has a corresponding member in the database
|
|
// And all the subscriptions are setup correctly
|
|
const initialMember = await createMemberFromStripe();
|
|
assert.equal(initialMember.status, 'comped', 'The member initial status should be comped');
|
|
assert.equal(initialMember.products.length, 1, 'The member should have one product');
|
|
should(initialMember.subscriptions).match([
|
|
{
|
|
status: 'active'
|
|
}
|
|
]);
|
|
|
|
// Cancel the previously created subscription in Stripe
|
|
set(subscription, {
|
|
...subscription,
|
|
status: 'canceled'
|
|
});
|
|
|
|
// Send the webhook call to anounce the cancelation
|
|
const webhookPayload = JSON.stringify({
|
|
type: 'customer.subscription.updated',
|
|
data: {
|
|
object: subscription
|
|
}
|
|
});
|
|
const webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature)
|
|
.expectStatus(200);
|
|
|
|
// Check status has been updated to 'free' after cancelling
|
|
const {body: body2} = await adminAgent.get('/members/' + initialMember.id + '/');
|
|
assert.equal(body2.members.length, 1, 'The member does not exist');
|
|
const updatedMember = body2.members[0];
|
|
assert.equal(updatedMember.status, 'free');
|
|
assert.equal(updatedMember.products.length, 0, 'The member should have no products');
|
|
should(updatedMember.subscriptions).match([
|
|
{
|
|
status: 'canceled'
|
|
}
|
|
]);
|
|
|
|
// Check the status events for this newly created member (should be NULL -> paid only)
|
|
await assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: [
|
|
{
|
|
from_status: null,
|
|
to_status: 'free'
|
|
},
|
|
{
|
|
from_status: 'free',
|
|
to_status: 'comped'
|
|
},
|
|
{
|
|
from_status: 'comped',
|
|
to_status: 'free'
|
|
}
|
|
]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: [{
|
|
type: 'created',
|
|
mrr_delta: 0
|
|
}, {
|
|
type: 'expired',
|
|
mrr_delta: 0
|
|
}]
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('checkout.session.completed', function () {
|
|
// The subscription that we got from Stripe was created 2 seconds earlier (used for testing events)
|
|
const beforeNow = Math.floor((Date.now() - 2000) / 1000) * 1000;
|
|
before(function () {
|
|
set(subscription, {
|
|
id: 'sub_123',
|
|
customer: 'cus_123',
|
|
status: 'active',
|
|
items: {
|
|
type: 'list',
|
|
data: [{
|
|
id: 'item_123',
|
|
price: {
|
|
id: 'price_123',
|
|
product: 'product_123',
|
|
active: true,
|
|
nickname: 'Monthly',
|
|
currency: 'usd',
|
|
recurring: {
|
|
interval: 'month'
|
|
},
|
|
unit_amount: 500,
|
|
type: 'recurring'
|
|
}
|
|
}]
|
|
},
|
|
start_date: beforeNow / 1000,
|
|
current_period_end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31),
|
|
cancel_at_period_end: false
|
|
});
|
|
});
|
|
|
|
it('Will create a member if one does not exist', async function () {
|
|
set(customer, {
|
|
id: 'cus_123',
|
|
name: 'Test Member',
|
|
email: 'checkout-webhook-test@email.com',
|
|
subscriptions: {
|
|
type: 'list',
|
|
data: [subscription]
|
|
}
|
|
});
|
|
|
|
{ // ensure member didn't already exist
|
|
const {body} = await adminAgent.get('/members/?search=checkout-webhook-test@email.com');
|
|
assert.equal(body.members.length, 0, 'A member already existed');
|
|
}
|
|
|
|
const webhookPayload = JSON.stringify({
|
|
type: 'checkout.session.completed',
|
|
data: {
|
|
object: {
|
|
mode: 'subscription',
|
|
customer: customer.id,
|
|
subscription: subscription.id,
|
|
metadata: {}
|
|
}
|
|
}
|
|
});
|
|
|
|
const webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature);
|
|
|
|
const {body} = await adminAgent.get('/members/?search=checkout-webhook-test@email.com');
|
|
assert.equal(body.members.length, 1, 'The member was not created');
|
|
const member = body.members[0];
|
|
|
|
assert.equal(member.status, 'paid', 'The member should be "paid"');
|
|
assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription');
|
|
|
|
mockManager.assert.sentEmail({
|
|
subject: '🙌 Thank you for signing up to Ghost!',
|
|
to: 'checkout-webhook-test@email.com'
|
|
});
|
|
|
|
// Check whether MRR and status has been set
|
|
await assertSubscription(member.subscriptions[0].id, {
|
|
subscription_id: subscription.id,
|
|
status: 'active',
|
|
cancel_at_period_end: false,
|
|
plan_amount: 500,
|
|
plan_interval: 'month',
|
|
plan_currency: 'usd',
|
|
current_period_end: new Date(Math.floor(beforeNow / 1000) * 1000 + (60 * 60 * 24 * 31 * 1000)),
|
|
mrr: 500
|
|
});
|
|
|
|
// Check the status events for this newly created member (should be NULL -> paid only)
|
|
await assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: member.id,
|
|
asserts: [
|
|
{
|
|
from_status: null,
|
|
to_status: 'free',
|
|
created_at: new Date(member.created_at)
|
|
},
|
|
{
|
|
from_status: 'free',
|
|
to_status: 'paid',
|
|
created_at: new Date(beforeNow)
|
|
}
|
|
]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: member.id,
|
|
asserts: [
|
|
{
|
|
mrr_delta: 500
|
|
}
|
|
]
|
|
});
|
|
});
|
|
|
|
it('Will create a member with default newsletter subscriptions', async function () {
|
|
set(customer, {
|
|
id: 'cus_123',
|
|
name: 'Test Member',
|
|
email: 'checkout-newsletter-default-test@email.com',
|
|
subscriptions: {
|
|
type: 'list',
|
|
data: [subscription]
|
|
}
|
|
});
|
|
|
|
{ // ensure member didn't already exist
|
|
const {body} = await adminAgent.get('/members/?search=checkout-newsletter-default-test@email.com');
|
|
assert.equal(body.members.length, 0, 'A member already existed');
|
|
}
|
|
|
|
const webhookPayload = JSON.stringify({
|
|
type: 'checkout.session.completed',
|
|
data: {
|
|
object: {
|
|
mode: 'subscription',
|
|
customer: customer.id,
|
|
subscription: subscription.id,
|
|
metadata: {}
|
|
}
|
|
}
|
|
});
|
|
|
|
const webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature);
|
|
|
|
const {body} = await adminAgent.get('/members/?search=checkout-newsletter-default-test@email.com');
|
|
assert.equal(body.members.length, 1, 'The member was not created');
|
|
const member = body.members[0];
|
|
|
|
assert.equal(member.status, 'paid', 'The member should be "paid"');
|
|
assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription');
|
|
assert.equal(member.newsletters.length, 1, 'The member should have a single newsletter');
|
|
});
|
|
|
|
it('Will create a member with signup newsletter preference', async function () {
|
|
set(customer, {
|
|
id: 'cus_123',
|
|
name: 'Test Member',
|
|
email: 'checkout-newsletter-test@email.com',
|
|
subscriptions: {
|
|
type: 'list',
|
|
data: [subscription]
|
|
}
|
|
});
|
|
|
|
{ // ensure member didn't already exist
|
|
const {body} = await adminAgent.get('/members/?search=checkout-newsletter-test@email.com');
|
|
assert.equal(body.members.length, 0, 'A member already existed');
|
|
}
|
|
|
|
const webhookPayload = JSON.stringify({
|
|
type: 'checkout.session.completed',
|
|
data: {
|
|
object: {
|
|
mode: 'subscription',
|
|
customer: customer.id,
|
|
subscription: subscription.id,
|
|
metadata: {
|
|
newsletters: JSON.stringify([])
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
const webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature);
|
|
|
|
const {body} = await adminAgent.get('/members/?search=checkout-newsletter-test@email.com');
|
|
assert.equal(body.members.length, 1, 'The member was not created');
|
|
const member = body.members[0];
|
|
|
|
assert.equal(member.status, 'paid', 'The member should be "paid"');
|
|
assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription');
|
|
assert.equal(member.newsletters.length, 0, 'The member should not have any newsletter subscription');
|
|
});
|
|
|
|
it('Does not 500 if the member is unknown', async function () {
|
|
set(paymentMethod, {
|
|
id: 'card_456'
|
|
});
|
|
|
|
set(subscription, {
|
|
id: 'sub_456',
|
|
customer: 'cus_456',
|
|
status: 'active',
|
|
items: {
|
|
type: 'list',
|
|
data: [{
|
|
id: 'item_456',
|
|
price: {
|
|
id: 'price_456',
|
|
product: 'product_456',
|
|
active: true,
|
|
nickname: 'Monthly',
|
|
currency: 'usd',
|
|
recurring: {
|
|
interval: 'month'
|
|
},
|
|
unit_amount: 500,
|
|
type: 'recurring'
|
|
}
|
|
}]
|
|
},
|
|
start_date: Date.now() / 1000,
|
|
current_period_end: Date.now() / 1000 + (60 * 60 * 24 * 31),
|
|
cancel_at_period_end: false
|
|
});
|
|
|
|
set(setupIntent, {
|
|
id: 'setup_intent_456',
|
|
payment_method: paymentMethod.id,
|
|
metadata: {
|
|
customer_id: 'cus_456', // invalid customer id
|
|
subscription_id: subscription.id
|
|
}
|
|
});
|
|
|
|
const webhookPayload = JSON.stringify({
|
|
type: 'checkout.session.completed',
|
|
data: {
|
|
object: {
|
|
mode: 'setup',
|
|
customer: 'cus_456',
|
|
setup_intent: setupIntent.id
|
|
}
|
|
}
|
|
});
|
|
|
|
const webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature)
|
|
.expectStatus(200);
|
|
});
|
|
});
|
|
|
|
describe('Discounts', function () {
|
|
const beforeNow = Math.floor((Date.now() - 2000) / 1000) * 1000;
|
|
let offer;
|
|
let couponId = 'testCoupon123';
|
|
|
|
before(async function () {
|
|
// Create a random offer_id that we'll use
|
|
// The actual amounts don't matter as we'll only take the ones from Stripe ATM
|
|
const newOffer = {
|
|
name: 'Black Friday',
|
|
code: 'black-friday',
|
|
display_title: 'Black Friday Sale!',
|
|
display_description: '10% off on yearly plan',
|
|
type: 'percent',
|
|
cadence: 'year',
|
|
amount: 12,
|
|
duration: 'once',
|
|
duration_in_months: null,
|
|
currency_restriction: false,
|
|
currency: null,
|
|
status: 'active',
|
|
redemption_count: 0,
|
|
tier: {
|
|
id: (await getPaidProduct()).id
|
|
}
|
|
};
|
|
|
|
// Make sure we link this to the right coupon in Stripe
|
|
// This will store the offer with stripe_coupon_id = couponId
|
|
set(coupon, {
|
|
id: couponId
|
|
});
|
|
|
|
const {body} = await adminAgent
|
|
.post(`offers/`)
|
|
.body({offers: [newOffer]})
|
|
.expectStatus(200);
|
|
offer = body.offers[0];
|
|
});
|
|
|
|
/**
|
|
* Helper for repetitive tests. It tests the MRR and MRR delta given a discount + a price
|
|
*/
|
|
async function testDiscount({discount, interval, unit_amount, assert_mrr, offer_id}) {
|
|
const customer_id = createStripeID('cust');
|
|
const subscription_id = createStripeID('sub');
|
|
|
|
discount.customer = customer_id;
|
|
|
|
set(subscription, {
|
|
id: subscription_id,
|
|
customer: customer_id,
|
|
status: 'active',
|
|
discount,
|
|
items: {
|
|
type: 'list',
|
|
data: [{
|
|
id: 'item_123',
|
|
price: {
|
|
id: 'price_123',
|
|
product: 'product_123',
|
|
active: true,
|
|
nickname: interval,
|
|
currency: 'usd',
|
|
recurring: {
|
|
interval
|
|
},
|
|
unit_amount,
|
|
type: 'recurring'
|
|
}
|
|
}]
|
|
},
|
|
start_date: beforeNow / 1000,
|
|
current_period_end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31),
|
|
cancel_at_period_end: false,
|
|
metadata: {}
|
|
});
|
|
|
|
set(customer, {
|
|
id: customer_id,
|
|
name: 'Test Member',
|
|
email: `${customer_id}@email.com`,
|
|
subscriptions: {
|
|
type: 'list',
|
|
data: [subscription]
|
|
}
|
|
});
|
|
|
|
let webhookPayload = JSON.stringify({
|
|
type: 'checkout.session.completed',
|
|
data: {
|
|
object: {
|
|
mode: 'subscription',
|
|
customer: customer.id,
|
|
subscription: subscription.id,
|
|
metadata: {}
|
|
}
|
|
}
|
|
});
|
|
|
|
let webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature)
|
|
.expectStatus(200);
|
|
|
|
const {body} = await adminAgent.get(`/members/?search=${customer_id}@email.com`);
|
|
assert.equal(body.members.length, 1, 'The member was not created');
|
|
const member = body.members[0];
|
|
|
|
assert.equal(member.status, 'paid', 'The member should be "paid"');
|
|
assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription');
|
|
|
|
// Check whether MRR and status has been set
|
|
await assertSubscription(member.subscriptions[0].id, {
|
|
subscription_id: subscription.id,
|
|
status: 'active',
|
|
cancel_at_period_end: false,
|
|
plan_amount: unit_amount,
|
|
plan_interval: interval,
|
|
plan_currency: 'usd',
|
|
current_period_end: new Date(Math.floor(beforeNow / 1000) * 1000 + (60 * 60 * 24 * 31 * 1000)),
|
|
mrr: assert_mrr,
|
|
offer_id: offer_id
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: member.id,
|
|
asserts: [
|
|
{
|
|
mrr_delta: assert_mrr
|
|
}
|
|
]
|
|
});
|
|
|
|
// Now cancel, and check if the discount is also applied for the cancellation
|
|
set(subscription, {
|
|
...subscription,
|
|
status: 'canceled'
|
|
});
|
|
|
|
// Send the webhook call to anounce the cancelation
|
|
webhookPayload = JSON.stringify({
|
|
type: 'customer.subscription.updated',
|
|
data: {
|
|
object: subscription
|
|
}
|
|
});
|
|
|
|
webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature)
|
|
.expectStatus(200);
|
|
|
|
// Check status has been updated to 'free' after cancelling
|
|
const {body: body2} = await adminAgent.get('/members/' + member.id + '/');
|
|
assert.equal(body2.members.length, 1, 'The member does not exist');
|
|
const updatedMember = body2.members[0];
|
|
assert.equal(updatedMember.status, 'free');
|
|
assert.equal(updatedMember.products.length, 0, 'The member should have no products');
|
|
should(updatedMember.subscriptions).match([
|
|
{
|
|
status: 'canceled'
|
|
}
|
|
]);
|
|
|
|
// Check whether MRR and status has been set
|
|
await assertSubscription(member.subscriptions[0].id, {
|
|
subscription_id: subscription.id,
|
|
status: 'canceled',
|
|
cancel_at_period_end: false,
|
|
plan_amount: unit_amount,
|
|
plan_interval: interval,
|
|
plan_currency: 'usd',
|
|
mrr: 0,
|
|
offer_id: offer_id
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: [
|
|
{
|
|
type: 'created',
|
|
mrr_delta: assert_mrr
|
|
},
|
|
{
|
|
type: 'expired',
|
|
mrr_delta: -assert_mrr
|
|
}
|
|
]
|
|
});
|
|
}
|
|
|
|
describe('With the dashboardV5 flag', function () {
|
|
beforeEach(function () {
|
|
mockManager.mockLabsEnabled('dashboardV5');
|
|
});
|
|
|
|
it('Correctly includes monthly forever percentage discounts in MRR', async function () {
|
|
const discount = {
|
|
id: 'di_1Knkn7HUEDadPGIBPOQgmzIX',
|
|
object: 'discount',
|
|
checkout_session: null,
|
|
coupon: {
|
|
id: couponId, // This coupon id maps to the created offer above
|
|
object: 'coupon',
|
|
amount_off: null,
|
|
created: 1649774041,
|
|
currency: 'eur',
|
|
duration: 'forever',
|
|
duration_in_months: null,
|
|
livemode: false,
|
|
max_redemptions: null,
|
|
metadata: {},
|
|
name: '50% off',
|
|
percent_off: 50,
|
|
redeem_by: null,
|
|
times_redeemed: 0,
|
|
valid: true
|
|
},
|
|
end: null,
|
|
invoice: null,
|
|
invoice_item: null,
|
|
promotion_code: null,
|
|
start: beforeNow / 1000,
|
|
subscription: null
|
|
};
|
|
await testDiscount({
|
|
discount,
|
|
unit_amount: 500,
|
|
interval: 'month',
|
|
assert_mrr: 250,
|
|
offer_id: offer.id
|
|
});
|
|
});
|
|
|
|
it('Correctly includes yearly forever percentage discounts in MRR', async function () {
|
|
const discount = {
|
|
id: 'di_1Knkn7HUEDadPGIBPOQgmzIX',
|
|
object: 'discount',
|
|
checkout_session: null,
|
|
coupon: {
|
|
id: couponId,
|
|
object: 'coupon',
|
|
amount_off: null,
|
|
created: 1649774041,
|
|
currency: 'eur',
|
|
duration: 'forever',
|
|
duration_in_months: null,
|
|
livemode: false,
|
|
max_redemptions: null,
|
|
metadata: {},
|
|
name: '50% off',
|
|
percent_off: 50,
|
|
redeem_by: null,
|
|
times_redeemed: 0,
|
|
valid: true
|
|
},
|
|
end: null,
|
|
invoice: null,
|
|
invoice_item: null,
|
|
promotion_code: null,
|
|
start: beforeNow / 1000,
|
|
subscription: null
|
|
};
|
|
await testDiscount({
|
|
discount,
|
|
unit_amount: 1200,
|
|
interval: 'year',
|
|
assert_mrr: 50,
|
|
offer_id: offer.id
|
|
});
|
|
});
|
|
|
|
it('Correctly includes monthly forever amount off discounts in MRR', async function () {
|
|
const discount = {
|
|
id: 'di_1Knkn7HUEDadPGIBPOQgmzIX',
|
|
object: 'discount',
|
|
checkout_session: null,
|
|
coupon: {
|
|
id: couponId,
|
|
object: 'coupon',
|
|
amount_off: 1,
|
|
created: 1649774041,
|
|
currency: 'eur',
|
|
duration: 'forever',
|
|
duration_in_months: null,
|
|
livemode: false,
|
|
max_redemptions: null,
|
|
metadata: {},
|
|
name: '1 cent off',
|
|
percent_off: null,
|
|
redeem_by: null,
|
|
times_redeemed: 0,
|
|
valid: true
|
|
},
|
|
end: null,
|
|
invoice: null,
|
|
invoice_item: null,
|
|
promotion_code: null,
|
|
start: beforeNow / 1000,
|
|
subscription: null
|
|
};
|
|
await testDiscount({
|
|
discount,
|
|
unit_amount: 500,
|
|
interval: 'month',
|
|
assert_mrr: 499,
|
|
offer_id: offer.id
|
|
});
|
|
});
|
|
|
|
it('Correctly includes yearly forever amount off discounts in MRR', async function () {
|
|
const discount = {
|
|
id: 'di_1Knkn7HUEDadPGIBPOQgmzIX',
|
|
object: 'discount',
|
|
checkout_session: null,
|
|
coupon: {
|
|
id: couponId,
|
|
object: 'coupon',
|
|
amount_off: 60,
|
|
created: 1649774041,
|
|
currency: 'eur',
|
|
duration: 'forever',
|
|
duration_in_months: null,
|
|
livemode: false,
|
|
max_redemptions: null,
|
|
metadata: {},
|
|
name: '60 cent off, yearly',
|
|
percent_off: null,
|
|
redeem_by: null,
|
|
times_redeemed: 0,
|
|
valid: true
|
|
},
|
|
end: null,
|
|
invoice: null,
|
|
invoice_item: null,
|
|
promotion_code: null,
|
|
start: beforeNow / 1000,
|
|
subscription: null
|
|
};
|
|
await testDiscount({
|
|
discount,
|
|
unit_amount: 1200,
|
|
interval: 'year',
|
|
assert_mrr: 95,
|
|
offer_id: offer.id
|
|
});
|
|
});
|
|
|
|
it('Does not include repeating discounts in MRR', async function () {
|
|
const discount = {
|
|
id: 'di_1Knkn7HUEDadPGIBPOQgmzIX',
|
|
object: 'discount',
|
|
checkout_session: null,
|
|
coupon: {
|
|
id: couponId,
|
|
object: 'coupon',
|
|
amount_off: null,
|
|
created: 1649774041,
|
|
currency: 'eur',
|
|
duration: 'repeating',
|
|
duration_in_months: 3,
|
|
livemode: false,
|
|
max_redemptions: null,
|
|
metadata: {},
|
|
name: '50% off',
|
|
percent_off: 50,
|
|
redeem_by: null,
|
|
times_redeemed: 0,
|
|
valid: true
|
|
},
|
|
end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31 * 3),
|
|
invoice: null,
|
|
invoice_item: null,
|
|
promotion_code: null,
|
|
start: beforeNow / 1000,
|
|
subscription: null
|
|
};
|
|
await testDiscount({
|
|
discount,
|
|
unit_amount: 500,
|
|
interval: 'month',
|
|
assert_mrr: 500,
|
|
offer_id: offer.id
|
|
});
|
|
});
|
|
|
|
it('Also supports adding a discount to an existing subscription', async function () {
|
|
const interval = 'month';
|
|
const unit_amount = 500;
|
|
const mrr_without = 500;
|
|
const mrr_with = 400;
|
|
const mrr_difference = 100;
|
|
|
|
const discount = {
|
|
id: 'di_1Knkn7HUEDadPGIBPOQgmzIX',
|
|
object: 'discount',
|
|
checkout_session: null,
|
|
coupon: {
|
|
id: couponId,
|
|
object: 'coupon',
|
|
amount_off: null,
|
|
created: 1649774041,
|
|
currency: 'eur',
|
|
duration: 'forever',
|
|
duration_in_months: null,
|
|
livemode: false,
|
|
max_redemptions: null,
|
|
metadata: {},
|
|
name: '20% off',
|
|
percent_off: 20,
|
|
redeem_by: null,
|
|
times_redeemed: 0,
|
|
valid: true
|
|
},
|
|
end: null,
|
|
invoice: null,
|
|
invoice_item: null,
|
|
promotion_code: null,
|
|
start: beforeNow / 1000,
|
|
subscription: null
|
|
};
|
|
|
|
const customer_id = createStripeID('cust');
|
|
const subscription_id = createStripeID('sub');
|
|
|
|
discount.customer = customer_id;
|
|
|
|
set(subscription, {
|
|
id: subscription_id,
|
|
customer: customer_id,
|
|
status: 'active',
|
|
discount: null,
|
|
items: {
|
|
type: 'list',
|
|
data: [{
|
|
id: 'item_123',
|
|
price: {
|
|
id: 'price_123',
|
|
product: 'product_123',
|
|
active: true,
|
|
nickname: interval,
|
|
currency: 'usd',
|
|
recurring: {
|
|
interval
|
|
},
|
|
unit_amount,
|
|
type: 'recurring'
|
|
}
|
|
}]
|
|
},
|
|
start_date: beforeNow / 1000,
|
|
current_period_end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31),
|
|
cancel_at_period_end: false
|
|
});
|
|
|
|
set(customer, {
|
|
id: customer_id,
|
|
name: 'Test Member',
|
|
email: `${customer_id}@email.com`,
|
|
subscriptions: {
|
|
type: 'list',
|
|
data: [subscription]
|
|
}
|
|
});
|
|
|
|
let webhookPayload = JSON.stringify({
|
|
type: 'checkout.session.completed',
|
|
data: {
|
|
object: {
|
|
mode: 'subscription',
|
|
customer: customer.id,
|
|
subscription: subscription.id,
|
|
metadata: {}
|
|
}
|
|
}
|
|
});
|
|
|
|
let webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature);
|
|
|
|
const {body} = await adminAgent.get(`/members/?search=${customer_id}@email.com`);
|
|
assert.equal(body.members.length, 1, 'The member was not created');
|
|
const member = body.members[0];
|
|
|
|
assert.equal(member.status, 'paid', 'The member should be "paid"');
|
|
assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription');
|
|
|
|
// Check whether MRR and status has been set
|
|
await assertSubscription(member.subscriptions[0].id, {
|
|
subscription_id: subscription.id,
|
|
status: 'active',
|
|
cancel_at_period_end: false,
|
|
plan_amount: unit_amount,
|
|
plan_interval: interval,
|
|
plan_currency: 'usd',
|
|
current_period_end: new Date(Math.floor(beforeNow / 1000) * 1000 + (60 * 60 * 24 * 31 * 1000)),
|
|
mrr: mrr_without,
|
|
offer_id: null
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: member.id,
|
|
asserts: [
|
|
{
|
|
mrr_delta: mrr_without
|
|
}
|
|
]
|
|
});
|
|
|
|
// Now add the discount
|
|
set(subscription, {
|
|
...subscription,
|
|
discount
|
|
});
|
|
|
|
// Send the webhook call to anounce the cancelation
|
|
webhookPayload = JSON.stringify({
|
|
type: 'customer.subscription.updated',
|
|
data: {
|
|
object: subscription
|
|
}
|
|
});
|
|
|
|
webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature)
|
|
.expectStatus(200);
|
|
|
|
// Check status has been updated to 'free' after cancelling
|
|
const {body: body2} = await adminAgent.get('/members/' + member.id + '/');
|
|
const updatedMember = body2.members[0];
|
|
|
|
// Check whether MRR and status has been set
|
|
await assertSubscription(updatedMember.subscriptions[0].id, {
|
|
subscription_id: subscription.id,
|
|
status: 'active',
|
|
cancel_at_period_end: false,
|
|
plan_amount: unit_amount,
|
|
plan_interval: interval,
|
|
plan_currency: 'usd',
|
|
mrr: mrr_with,
|
|
offer_id: offer.id
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: [
|
|
{
|
|
type: 'created',
|
|
mrr_delta: mrr_without
|
|
},
|
|
{
|
|
type: 'updated',
|
|
mrr_delta: -mrr_difference
|
|
}
|
|
]
|
|
});
|
|
});
|
|
|
|
it('Silently ignores an invalid offer id in metadata', async function () {
|
|
const interval = 'month';
|
|
const unit_amount = 500;
|
|
const mrr_with = 400;
|
|
|
|
const discount = {
|
|
id: 'di_1Knkn7HUEDadPGIBPOQgmzIX',
|
|
object: 'discount',
|
|
checkout_session: null,
|
|
coupon: {
|
|
id: 'unknownCoupon', // this one is unknown in Ghost
|
|
object: 'coupon',
|
|
amount_off: null,
|
|
created: 1649774041,
|
|
currency: 'eur',
|
|
duration: 'forever',
|
|
duration_in_months: null,
|
|
livemode: false,
|
|
max_redemptions: null,
|
|
metadata: {},
|
|
name: '20% off',
|
|
percent_off: 20,
|
|
redeem_by: null,
|
|
times_redeemed: 0,
|
|
valid: true
|
|
},
|
|
end: null,
|
|
invoice: null,
|
|
invoice_item: null,
|
|
promotion_code: null,
|
|
start: beforeNow / 1000,
|
|
subscription: null
|
|
};
|
|
|
|
const customer_id = createStripeID('cust');
|
|
const subscription_id = createStripeID('sub');
|
|
|
|
discount.customer = customer_id;
|
|
|
|
set(subscription, {
|
|
id: subscription_id,
|
|
customer: customer_id,
|
|
status: 'active',
|
|
discount,
|
|
items: {
|
|
type: 'list',
|
|
data: [{
|
|
id: 'item_123',
|
|
price: {
|
|
id: 'price_123',
|
|
product: 'product_123',
|
|
active: true,
|
|
nickname: interval,
|
|
currency: 'usd',
|
|
recurring: {
|
|
interval
|
|
},
|
|
unit_amount,
|
|
type: 'recurring'
|
|
}
|
|
}]
|
|
},
|
|
start_date: beforeNow / 1000,
|
|
current_period_end: Math.floor(beforeNow / 1000) + (60 * 60 * 24 * 31),
|
|
cancel_at_period_end: false
|
|
});
|
|
|
|
set(customer, {
|
|
id: customer_id,
|
|
name: 'Test Member',
|
|
email: `${customer_id}@email.com`,
|
|
subscriptions: {
|
|
type: 'list',
|
|
data: [subscription]
|
|
}
|
|
});
|
|
|
|
let webhookPayload = JSON.stringify({
|
|
type: 'checkout.session.completed',
|
|
data: {
|
|
object: {
|
|
mode: 'subscription',
|
|
customer: customer.id,
|
|
subscription: subscription.id,
|
|
metadata: {}
|
|
}
|
|
}
|
|
});
|
|
|
|
let webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature);
|
|
|
|
const {body} = await adminAgent.get(`/members/?search=${customer_id}@email.com`);
|
|
assert.equal(body.members.length, 1, 'The member was not created');
|
|
const member = body.members[0];
|
|
|
|
assert.equal(member.status, 'paid', 'The member should be "paid"');
|
|
assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription');
|
|
|
|
// Check whether MRR and status has been set
|
|
await assertSubscription(member.subscriptions[0].id, {
|
|
subscription_id: subscription.id,
|
|
status: 'active',
|
|
cancel_at_period_end: false,
|
|
plan_amount: unit_amount,
|
|
plan_interval: interval,
|
|
plan_currency: 'usd',
|
|
current_period_end: new Date(Math.floor(beforeNow / 1000) * 1000 + (60 * 60 * 24 * 31 * 1000)),
|
|
mrr: mrr_with,
|
|
offer_id: null
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: member.id,
|
|
asserts: [
|
|
{
|
|
mrr_delta: mrr_with
|
|
}
|
|
]
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Without the dashboardV5 flag', function () {
|
|
it('Does not include forever percentage discounts in MRR', async function () {
|
|
const discount = {
|
|
id: 'di_1Knkn7HUEDadPGIBPOQgmzIX',
|
|
object: 'discount',
|
|
checkout_session: null,
|
|
coupon: {
|
|
id: couponId,
|
|
object: 'coupon',
|
|
amount_off: null,
|
|
created: 1649774041,
|
|
currency: 'eur',
|
|
duration: 'forever',
|
|
duration_in_months: null,
|
|
livemode: false,
|
|
max_redemptions: null,
|
|
metadata: {},
|
|
name: '50% off',
|
|
percent_off: 50,
|
|
redeem_by: null,
|
|
times_redeemed: 0,
|
|
valid: true
|
|
},
|
|
end: null,
|
|
invoice: null,
|
|
invoice_item: null,
|
|
promotion_code: null,
|
|
start: beforeNow / 1000,
|
|
subscription: null
|
|
};
|
|
await testDiscount({
|
|
discount,
|
|
unit_amount: 500,
|
|
interval: 'month',
|
|
assert_mrr: 500,
|
|
offer_id: offer.id
|
|
});
|
|
});
|
|
|
|
it('Does not include forever amount off discounts in MRR', async function () {
|
|
const discount = {
|
|
id: 'di_1Knkn7HUEDadPGIBPOQgmzIX',
|
|
object: 'discount',
|
|
checkout_session: null,
|
|
coupon: {
|
|
id: couponId,
|
|
object: 'coupon',
|
|
amount_off: 1,
|
|
created: 1649774041,
|
|
currency: 'eur',
|
|
duration: 'forever',
|
|
duration_in_months: null,
|
|
livemode: false,
|
|
max_redemptions: null,
|
|
metadata: {},
|
|
name: '1 cent off',
|
|
percent_off: null,
|
|
redeem_by: null,
|
|
times_redeemed: 0,
|
|
valid: true
|
|
},
|
|
end: null,
|
|
invoice: null,
|
|
invoice_item: null,
|
|
promotion_code: null,
|
|
start: beforeNow / 1000,
|
|
subscription: null
|
|
};
|
|
await testDiscount({
|
|
discount,
|
|
unit_amount: 500,
|
|
interval: 'month',
|
|
assert_mrr: 500,
|
|
offer_id: offer.id
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|