96d9099195
refs https://github.com/TryGhost/Product/issues/3674 refs https://github.com/TryGhost/Product/issues/3675 - this reverts commits8a32941ae8
andb587429008
- the reverted commits added some logic to create offers based on a Stripe coupon. However, the logic bypassed the Offer entity, and therefore skipped any validations/constraints — causing invalid data in the database and some sites to crash.
2013 lines
72 KiB
JavaScript
2013 lines
72 KiB
JavaScript
const crypto = require('crypto');
|
|
const assert = require('assert/strict');
|
|
const nock = require('nock');
|
|
const should = require('should');
|
|
const stripe = require('stripe');
|
|
const {Product} = require('../../../core/server/models/product');
|
|
const {agentProvider, mockManager, fixtureManager, matchers} = require('../../utils/e2e-framework');
|
|
const models = require('../../../core/server/models');
|
|
const urlService = require('../../../core/server/services/url');
|
|
const urlUtils = require('../../../core/shared/url-utils');
|
|
const DomainEvents = require('@tryghost/domain-events');
|
|
const {anyContentVersion, anyEtag, anyObjectId, anyUuid, anyISODateTime, anyString, anyArray, anyObject} = matchers;
|
|
|
|
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 getSubscription(subscriptionId) {
|
|
// eslint-disable-next-line dot-notation
|
|
return await models['StripeCustomerSubscription'].where('subscription_id', subscriptionId).fetch({require: true});
|
|
}
|
|
async function getMember(memberId) {
|
|
// eslint-disable-next-line dot-notation
|
|
return await models['Member'].where('id', memberId).fetch({require: true});
|
|
}
|
|
|
|
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) {
|
|
const subscription = await getSubscription(subscriptionId);
|
|
|
|
// 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 () {
|
|
// @todo: Test what happens when a complimentary subscription ends (should create comped -> free event)
|
|
// @todo: Test what happens when a complimentary subscription starts a paid subscription
|
|
|
|
// 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) => {
|
|
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) => {
|
|
const [match, resource] = 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 () {
|
|
mockManager.restore();
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
|
|
describe('/webhooks/stripe/', 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.mockMail();
|
|
});
|
|
|
|
afterEach(function () {
|
|
mockManager.restore();
|
|
});
|
|
|
|
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('content-type', 'application/json')
|
|
.header('stripe-signature', webhookSignature)
|
|
.expectStatus(200);
|
|
});
|
|
});
|
|
|
|
describe('Handling the end of subscriptions', 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.mockMail();
|
|
});
|
|
|
|
afterEach(function () {
|
|
mockManager.restore();
|
|
});
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
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.tiers.length, 1, 'The member should have one tier');
|
|
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 announce 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('content-type', 'application/json')
|
|
.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.tiers.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,
|
|
tiers: [
|
|
{
|
|
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.tiers.length, 1, 'The member should have one tier');
|
|
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-complimentary-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.tiers.length, 1, 'The member should have one tier');
|
|
should(initialMember.subscriptions).match([
|
|
{
|
|
status: 'active'
|
|
}
|
|
]);
|
|
|
|
// Cancel the previously created subscription in Stripe
|
|
set(subscription, {
|
|
...subscription,
|
|
status: 'canceled'
|
|
});
|
|
|
|
// Send the webhook call to announce 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('content-type', 'application/json')
|
|
.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.tiers.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(async function () {
|
|
const agents = await agentProvider.getAgentsForMembers();
|
|
membersAgent = agents.membersAgent;
|
|
adminAgent = agents.adminAgent;
|
|
|
|
await fixtureManager.init('members');
|
|
await adminAgent.loginAsOwner();
|
|
|
|
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
|
|
});
|
|
});
|
|
|
|
beforeEach(function () {
|
|
mockManager.mockMail();
|
|
});
|
|
|
|
afterEach(function () {
|
|
mockManager.restore();
|
|
});
|
|
|
|
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('content-type', 'application/json')
|
|
.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
|
|
}
|
|
]
|
|
});
|
|
|
|
// Wait for the dispatched events (because this happens async)
|
|
await DomainEvents.allSettled();
|
|
|
|
mockManager.assert.sentEmail({
|
|
subject: '💸 Paid subscription started: checkout-webhook-test@email.com',
|
|
to: 'jbloggs@example.com'
|
|
});
|
|
});
|
|
|
|
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('content-type', 'application/json')
|
|
.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('content-type', 'application/json')
|
|
.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('content-type', 'application/json')
|
|
.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 () {
|
|
const agents = await agentProvider.getAgentsForMembers();
|
|
membersAgent = agents.membersAgent;
|
|
adminAgent = agents.adminAgent;
|
|
|
|
await fixtureManager.init('members');
|
|
await adminAgent.loginAsOwner();
|
|
|
|
// 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];
|
|
});
|
|
|
|
beforeEach(function () {
|
|
mockManager.mockMail();
|
|
});
|
|
|
|
afterEach(function () {
|
|
mockManager.restore();
|
|
});
|
|
|
|
/**
|
|
* 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('content-type', 'application/json')
|
|
.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
|
|
});
|
|
|
|
// Check whether the offer attribute is passed correctly in the response when fetching a single member
|
|
member.subscriptions[0].should.match({
|
|
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 announce 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('content-type', 'application/json')
|
|
.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.tiers.length, 0, 'The member should have no products');
|
|
should(updatedMember.subscriptions).match([
|
|
{
|
|
status: 'canceled',
|
|
offer: {
|
|
id: offer_id
|
|
}
|
|
}
|
|
]);
|
|
|
|
// 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
|
|
}
|
|
]
|
|
});
|
|
}
|
|
|
|
it('Correctly includes monthly forever percentage discounts in MRR', async function () {
|
|
// Do you get a offer_id is null failed test here
|
|
// -> check if members-api and members-offers package versions are in sync in yarn.lock or if both are linked in dev
|
|
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('content-type', 'application/json')
|
|
.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
|
|
});
|
|
|
|
// Check whether the offer attribute is passed correctly in the response when fetching a single member
|
|
member.subscriptions[0].should.match({
|
|
offer: 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 announce 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('content-type', 'application/json')
|
|
.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
|
|
});
|
|
|
|
// Check whether the offer attribute is passed correctly in the response when fetching a single member
|
|
updatedMember.subscriptions[0].should.match({
|
|
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('content-type', 'application/json')
|
|
.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
|
|
});
|
|
|
|
// Check whether the offer attribute is passed correctly in the response when fetching a single member
|
|
member.subscriptions[0].should.match({
|
|
offer: null
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: member.id,
|
|
asserts: [
|
|
{
|
|
mrr_delta: mrr_with
|
|
}
|
|
]
|
|
});
|
|
});
|
|
});
|
|
|
|
// Test if the session metadata is processed correctly
|
|
describe('Member attribution', function () {
|
|
before(async function () {
|
|
const agents = await agentProvider.getAgentsForMembers();
|
|
membersAgent = agents.membersAgent;
|
|
adminAgent = agents.adminAgent;
|
|
|
|
await fixtureManager.init('posts', 'products');
|
|
await adminAgent.loginAsOwner();
|
|
});
|
|
|
|
beforeEach(function () {
|
|
mockManager.mockMail();
|
|
});
|
|
|
|
afterEach(function () {
|
|
mockManager.restore();
|
|
});
|
|
|
|
// 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;
|
|
|
|
const memberMatcherShallowIncludes = {
|
|
id: anyObjectId,
|
|
uuid: anyUuid,
|
|
email: anyString,
|
|
created_at: anyISODateTime,
|
|
updated_at: anyISODateTime,
|
|
subscriptions: anyArray,
|
|
labels: anyArray,
|
|
tiers: anyArray,
|
|
attribution: anyObject,
|
|
newsletters: anyArray
|
|
};
|
|
|
|
// Activity feed
|
|
// This test is here because creating subscriptions is a PITA now, and we would need to essentially duplicate all above tests elsewhere
|
|
it('empty initial activity feed', async function () {
|
|
// Check activity feed
|
|
await adminAgent
|
|
.get(`/members/events/?filter=type:subscription_event`)
|
|
.expectStatus(200)
|
|
.matchHeaderSnapshot({
|
|
'content-version': anyContentVersion,
|
|
etag: anyEtag
|
|
})
|
|
.matchBodySnapshot({
|
|
events: new Array(0).fill({
|
|
type: anyString,
|
|
data: anyObject
|
|
})
|
|
});
|
|
});
|
|
|
|
async function testWithAttribution(attribution, attributionResource) {
|
|
const customer_id = createStripeID('cust');
|
|
const subscription_id = createStripeID('sub');
|
|
|
|
const interval = 'month';
|
|
const unit_amount = 150;
|
|
|
|
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: 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: attribution ? {
|
|
attribution_id: attribution.id,
|
|
attribution_url: attribution.url,
|
|
attribution_type: attribution.type
|
|
} : {}
|
|
}
|
|
}
|
|
});
|
|
|
|
let webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('content-type', 'application/json')
|
|
.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');
|
|
|
|
// Convert Stripe ID to internal model ID
|
|
const subscriptionModel = await getSubscription(member.subscriptions[0].id);
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'SubscriptionCreatedEvent',
|
|
memberId: member.id,
|
|
asserts: [
|
|
{
|
|
member_id: member.id,
|
|
subscription_id: subscriptionModel.id,
|
|
|
|
// Defaults if attribution is not set
|
|
attribution_id: attribution?.id ?? null,
|
|
attribution_url: attribution?.url ?? null,
|
|
attribution_type: attribution?.type ?? null
|
|
}
|
|
]
|
|
});
|
|
|
|
const memberModel = await getMember(member.id);
|
|
|
|
// It also should have created a new member, and a MemberCreatedEvent
|
|
// With the same attributions
|
|
await assertMemberEvents({
|
|
eventType: 'MemberCreatedEvent',
|
|
memberId: member.id,
|
|
asserts: [
|
|
{
|
|
member_id: member.id,
|
|
created_at: memberModel.get('created_at'),
|
|
|
|
// Defaults if attribution is not set
|
|
attribution_id: attribution?.id ?? null,
|
|
attribution_url: attribution?.url ?? null,
|
|
attribution_type: attribution?.type ?? null,
|
|
source: 'member'
|
|
}
|
|
]
|
|
});
|
|
|
|
await adminAgent
|
|
.get(`/members/${member.id}/`)
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill(memberMatcherShallowIncludes)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
'content-version': anyContentVersion,
|
|
etag: anyEtag
|
|
})
|
|
.expect(({body: body3}) => {
|
|
should(body3.members[0].attribution).eql(attributionResource);
|
|
should(body3.members[0].subscriptions[0].attribution).eql(attributionResource);
|
|
subscriptionAttributions.push(body3.members[0].subscriptions[0].attribution);
|
|
});
|
|
|
|
return memberModel;
|
|
}
|
|
|
|
const subscriptionAttributions = [];
|
|
|
|
it('Creates a SubscriptionCreatedEvent with url attribution', async function () {
|
|
// This mainly tests for nullable fields being set to null and handled correctly
|
|
const attribution = {
|
|
id: null,
|
|
url: '/',
|
|
type: 'url'
|
|
};
|
|
|
|
const absoluteUrl = urlUtils.createUrl('/', true);
|
|
|
|
await testWithAttribution(attribution, {
|
|
id: null,
|
|
url: absoluteUrl,
|
|
type: 'url',
|
|
title: 'homepage',
|
|
referrer_source: null,
|
|
referrer_medium: null,
|
|
referrer_url: null
|
|
});
|
|
});
|
|
|
|
it('Creates a SubscriptionCreatedEvent with post attribution', async function () {
|
|
const id = fixtureManager.get('posts', 0).id;
|
|
const post = await models.Post.where('id', id).fetch({require: true});
|
|
|
|
const attribution = {
|
|
id: post.id,
|
|
url: '/out-of-date-url/',
|
|
type: 'post'
|
|
};
|
|
|
|
const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true});
|
|
|
|
await testWithAttribution(attribution, {
|
|
id: post.id,
|
|
url: absoluteUrl,
|
|
type: 'post',
|
|
title: post.get('title'),
|
|
referrer_source: null,
|
|
referrer_medium: null,
|
|
referrer_url: null
|
|
});
|
|
});
|
|
|
|
it('Creates a SubscriptionCreatedEvent with deleted post attribution', async function () {
|
|
const attribution = {
|
|
id: 'doesnt-exist-anylonger',
|
|
url: '/removed-blog-post/',
|
|
type: 'post'
|
|
};
|
|
|
|
const absoluteUrl = urlUtils.createUrl('/removed-blog-post/', true);
|
|
|
|
await testWithAttribution(attribution, {
|
|
id: null,
|
|
url: absoluteUrl,
|
|
type: 'url',
|
|
title: '/removed-blog-post/',
|
|
referrer_source: null,
|
|
referrer_medium: null,
|
|
referrer_url: null
|
|
});
|
|
});
|
|
|
|
it('Creates a SubscriptionCreatedEvent with page attribution', async function () {
|
|
const id = fixtureManager.get('posts', 5).id;
|
|
const post = await models.Post.where('id', id).fetch({require: true});
|
|
|
|
const attribution = {
|
|
id: post.id,
|
|
url: '/out-of-date-url/',
|
|
type: 'page'
|
|
};
|
|
|
|
const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true});
|
|
|
|
await testWithAttribution(attribution, {
|
|
id: post.id,
|
|
url: absoluteUrl,
|
|
type: 'page',
|
|
title: post.get('title'),
|
|
referrer_source: null,
|
|
referrer_medium: null,
|
|
referrer_url: null
|
|
});
|
|
});
|
|
|
|
it('Creates a SubscriptionCreatedEvent with tag attribution', async function () {
|
|
const id = fixtureManager.get('tags', 0).id;
|
|
const tag = await models.Tag.where('id', id).fetch({require: true});
|
|
|
|
const attribution = {
|
|
id: tag.id,
|
|
url: '/out-of-date-url/',
|
|
type: 'tag'
|
|
};
|
|
|
|
const absoluteUrl = urlService.getUrlByResourceId(tag.id, {absolute: true});
|
|
|
|
await testWithAttribution(attribution, {
|
|
id: tag.id,
|
|
url: absoluteUrl,
|
|
type: 'tag',
|
|
title: tag.get('name'),
|
|
referrer_source: null,
|
|
referrer_medium: null,
|
|
referrer_url: null
|
|
});
|
|
});
|
|
|
|
it('Creates a SubscriptionCreatedEvent with author attribution', async function () {
|
|
const id = fixtureManager.get('users', 0).id;
|
|
const author = await models.User.where('id', id).fetch({require: true});
|
|
|
|
const attribution = {
|
|
id: author.id,
|
|
url: '/out-of-date-url/',
|
|
type: 'author'
|
|
};
|
|
|
|
const absoluteUrl = urlService.getUrlByResourceId(author.id, {absolute: true});
|
|
|
|
await testWithAttribution(attribution, {
|
|
id: author.id,
|
|
url: absoluteUrl,
|
|
type: 'author',
|
|
title: author.get('name'),
|
|
referrer_source: null,
|
|
referrer_medium: null,
|
|
referrer_url: null
|
|
});
|
|
});
|
|
|
|
it('Creates a SubscriptionCreatedEvent without attribution', async function () {
|
|
const attribution = undefined;
|
|
await testWithAttribution(attribution, {
|
|
id: null,
|
|
url: null,
|
|
type: null,
|
|
title: null,
|
|
referrer_source: null,
|
|
referrer_medium: null,
|
|
referrer_url: null
|
|
});
|
|
});
|
|
|
|
it('Creates a SubscriptionCreatedEvent with empty attribution object', async function () {
|
|
// Shouldn't happen, but to make sure we handle it
|
|
const attribution = {};
|
|
await testWithAttribution(attribution, {
|
|
id: null,
|
|
url: null,
|
|
type: null,
|
|
title: null,
|
|
referrer_source: null,
|
|
referrer_medium: null,
|
|
referrer_url: null
|
|
});
|
|
});
|
|
|
|
// Activity feed
|
|
// This test is here because creating subscriptions is a PITA now, and we would need to essentially duplicate all above tests elsewhere
|
|
it('Returns subscription created attributions in activity feed', async function () {
|
|
// Check activity feed
|
|
await adminAgent
|
|
.get(`/members/events/?filter=type:subscription_event`)
|
|
.expectStatus(200)
|
|
.matchHeaderSnapshot({
|
|
'content-version': anyContentVersion,
|
|
etag: anyEtag
|
|
})
|
|
.matchBodySnapshot({
|
|
events: new Array(subscriptionAttributions.length).fill({
|
|
data: anyObject
|
|
})
|
|
})
|
|
.expect(({body}) => {
|
|
should(body.events.find(e => e.type !== 'subscription_event')).be.undefined();
|
|
should(body.events.map(e => e.data.attribution)).containDeep(subscriptionAttributions);
|
|
});
|
|
});
|
|
});
|
|
});
|