mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-30 14:22:07 +03:00
6566903df5
no issue This commit removes the `memberAttribution` feature flag from the codebase. Some CSS classes are not removed as removing them and updating the associated CSS files have side effects sadly.
2014 lines
72 KiB
JavaScript
2014 lines
72 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, 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: 'url',
|
|
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: 'url',
|
|
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({
|
|
type: anyString,
|
|
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);
|
|
});
|
|
});
|
|
});
|
|
});
|