a011151e24
fixes https://github.com/TryGhost/Product/issues/3752 - Added some extra tests for edge cases - Updated handling of multiple subscriptions so they are handled better - Canceling a subscription when the member still has other subscriptions will now get handled correctly where the status and products of the member stay intact
438 lines
13 KiB
JavaScript
438 lines
13 KiB
JavaScript
const DomainEvents = require('@tryghost/domain-events');
|
|
const nock = require('nock');
|
|
let members = {};
|
|
let stripeService = {};
|
|
let tiers = {};
|
|
let models = {};
|
|
const crypto = require('crypto');
|
|
|
|
/**
|
|
* The Stripe Mocker mimics an in memory version of the Stripe API. We can use it to quickly create new subscriptions and get a close to real world test environment with working webhooks etc.
|
|
* If you create a new subscription, it will automatically send the customer.subscription.created webhook. So you can test what happens.
|
|
*/
|
|
class StripeMocker {
|
|
customers = [];
|
|
subscriptions = [];
|
|
paymentMethods = [];
|
|
setupIntents = [];
|
|
coupons = [];
|
|
prices = [];
|
|
products = [];
|
|
checkoutSessions = [];
|
|
|
|
nockInterceptors = [];
|
|
|
|
constructor(data = {}) {
|
|
this.customers = data.customers ?? [];
|
|
this.subscriptions = data.subscriptions ?? [];
|
|
this.paymentMethods = data.paymentMethods ?? [];
|
|
this.setupIntents = data.setupIntents ?? [];
|
|
this.coupons = data.coupons ?? [];
|
|
this.prices = data.prices ?? [];
|
|
this.products = data.products ?? [];
|
|
}
|
|
|
|
reset() {
|
|
this.customers = [];
|
|
this.subscriptions = [];
|
|
this.paymentMethods = [];
|
|
this.setupIntents = [];
|
|
this.coupons = [];
|
|
this.prices = [];
|
|
this.products = [];
|
|
this.checkoutSessions = [];
|
|
|
|
// Fix for now, because of importing order breaking some things when they are not initialized
|
|
members = require('../../core/server/services/members');
|
|
stripeService = require('../../core/server/services/stripe');
|
|
tiers = require('../../core/server/services/tiers');
|
|
models = require('../../core/server/models');
|
|
}
|
|
|
|
#generateRandomId() {
|
|
return crypto.randomBytes(8).toString('hex');
|
|
}
|
|
|
|
createCustomer(overrides = {}) {
|
|
const customerId = `cus_${this.#generateRandomId()}`;
|
|
const stripeCustomer = {
|
|
id: customerId,
|
|
object: 'customer',
|
|
name: 'Test User',
|
|
email: customerId + '@example.com',
|
|
subscriptions: {
|
|
type: 'list',
|
|
data: []
|
|
},
|
|
...overrides
|
|
};
|
|
this.customers.push(stripeCustomer);
|
|
return stripeCustomer;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {*} tierSlug
|
|
* @param {'month' | 'year'} cadence
|
|
* @returns
|
|
*/
|
|
async getPriceForTier(tierSlug, cadence) {
|
|
const product = await models.Product.findOne({slug: tierSlug});
|
|
|
|
if (!product) {
|
|
throw new Error('Product not found with slug ' + tierSlug);
|
|
}
|
|
const tier = await tiers.api.read(product.id);
|
|
const payments = members.api.paymentsService;
|
|
const {id} = await payments.createPriceForTierCadence(tier, cadence);
|
|
return this.#getData(this.prices, id)[1];
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {object} data
|
|
* @param {string} [data.name]
|
|
* @param {string} data.currency
|
|
* @param {number} data.monthly_price
|
|
* @param {number} data.yearly_price
|
|
* @returns
|
|
*/
|
|
async createTier({name, currency, monthly_price, yearly_price}) {
|
|
const result = await tiers.api.add({
|
|
name: name ?? ('Tier ' + this.#generateRandomId()),
|
|
type: 'paid',
|
|
currency: currency.toUpperCase(),
|
|
monthlyPrice: monthly_price,
|
|
yearlyPrice: yearly_price
|
|
});
|
|
return await models.Product.findOne({
|
|
id: result.id.toHexString()
|
|
});
|
|
}
|
|
|
|
async createTrialSubscription({customer, price, ...overrides}) {
|
|
return await this.createSubscription({
|
|
customer,
|
|
price,
|
|
status: 'trialing',
|
|
trial_start: Date.now() / 1000,
|
|
trial_end: (Date.now() + 1000 * 60 * 60 * 24 * 7) / 1000,
|
|
...overrides
|
|
});
|
|
}
|
|
|
|
async createIncompleteSubscription({customer, price, ...overrides}) {
|
|
return await this.createSubscription({
|
|
customer,
|
|
price,
|
|
status: 'incomplete',
|
|
...overrides
|
|
});
|
|
}
|
|
|
|
async updateSubscription({id, ...overrides}) {
|
|
const subscription = this.#postData(this.subscriptions, id, overrides, 'subscriptions')[1];
|
|
|
|
// Send update webhook
|
|
await this.sendWebhook({
|
|
type: 'customer.subscription.updated',
|
|
data: {
|
|
object: subscription
|
|
}
|
|
});
|
|
await DomainEvents.allSettled();
|
|
}
|
|
|
|
async createSubscription({customer, price, ...overrides}, options = {sendWebhook: true}) {
|
|
const subscriptionId = `sub_${this.#generateRandomId()}`;
|
|
|
|
const subscription = {
|
|
id: subscriptionId,
|
|
object: 'subscription',
|
|
cancel_at_period_end: false,
|
|
canceled_at: null,
|
|
current_period_end: (Date.now() + 1000 * 60 * 60 * 24 * 31) / 1000,
|
|
start_date: Math.floor(Date.now() / 1000),
|
|
|
|
status: 'active',
|
|
items: {
|
|
type: 'list',
|
|
data: [
|
|
{
|
|
price
|
|
}
|
|
]
|
|
},
|
|
customer: customer.id,
|
|
...overrides
|
|
};
|
|
this.subscriptions.push(subscription);
|
|
customer.subscriptions.data.push(subscription);
|
|
|
|
// Announce
|
|
if (options.sendWebhook) {
|
|
await this.sendWebhook({
|
|
type: 'checkout.session.completed',
|
|
data: {
|
|
object: {
|
|
mode: 'subscription',
|
|
customer: customer.id,
|
|
metadata: {
|
|
checkoutType: 'signup'
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return subscription;
|
|
}
|
|
|
|
#getData(arr, id) {
|
|
const setupIntent = arr.find(c => c.id === id);
|
|
if (!setupIntent) {
|
|
return [404];
|
|
}
|
|
return [200, setupIntent];
|
|
}
|
|
|
|
#postData(arr, id, body, resource) {
|
|
const qs = require('qs');
|
|
let decoded = qs.parse(body, {
|
|
allowPrototypes: true,
|
|
decoder(value) {
|
|
// Convert numbers to numbers and bools to bools
|
|
if (/^(\d+|\d*\.\d+)$/.test(value)) {
|
|
return parseFloat(value);
|
|
}
|
|
|
|
let keywords = {
|
|
true: true,
|
|
false: false,
|
|
null: null
|
|
};
|
|
if (value in keywords) {
|
|
return keywords[value];
|
|
}
|
|
|
|
return decodeURIComponent(value);
|
|
}
|
|
});
|
|
|
|
if (resource === 'customers') {
|
|
if (!id) {
|
|
// Add default fields
|
|
decoded = {
|
|
object: 'customer',
|
|
subscriptions: {
|
|
type: 'list',
|
|
data: []
|
|
},
|
|
...decoded
|
|
};
|
|
}
|
|
}
|
|
|
|
if (resource === 'checkout') {
|
|
if (!id) {
|
|
// Add default fields
|
|
decoded = {
|
|
object: 'checkout.session',
|
|
...decoded,
|
|
url: 'https://checkout.stripe.com/c/pay/fake-data'
|
|
};
|
|
}
|
|
}
|
|
|
|
if (resource === 'subscriptions') {
|
|
// Convert price to price object
|
|
if (Array.isArray(decoded.items)) {
|
|
const first = decoded.items[0];
|
|
if (first && typeof first.price === 'string') {
|
|
const price = this.#getData(this.prices, first.price)[1];
|
|
if (!price) {
|
|
return [400, {error: 'Invalid price ' + first.price}];
|
|
}
|
|
|
|
decoded.items = {
|
|
data: [
|
|
{
|
|
...first,
|
|
price
|
|
}
|
|
]
|
|
};
|
|
}
|
|
}
|
|
|
|
// Add default fields
|
|
if (!id) {
|
|
decoded = {
|
|
object: 'subscription',
|
|
cancel_at_period_end: false,
|
|
canceled_at: null,
|
|
current_period_end: (Date.now() + 1000 * 60 * 60 * 24 * 31) / 1000,
|
|
start_date: Math.floor(Date.now() / 1000),
|
|
|
|
status: 'active',
|
|
items: {
|
|
type: 'list',
|
|
data: []
|
|
},
|
|
...decoded
|
|
};
|
|
}
|
|
|
|
if (typeof decoded.customer === 'string') {
|
|
// Add customer to customer list
|
|
const customer = this.#getData(this.customers, decoded.customer)[1];
|
|
if (!customer) {
|
|
return [400, {error: 'Invalid customer ' + decoded.customer}];
|
|
}
|
|
customer.subscriptions.data.push(decoded);
|
|
}
|
|
}
|
|
|
|
if (!id) {
|
|
// create
|
|
decoded.id = `${resource.substr(0, 4)}_${this.#generateRandomId()}`;
|
|
arr.push(decoded);
|
|
return [200, decoded];
|
|
}
|
|
|
|
// Patch
|
|
const subscription = arr.find(c => c.id === id);
|
|
if (!subscription) {
|
|
return [404];
|
|
}
|
|
Object.assign(subscription, decoded);
|
|
return [200, subscription];
|
|
}
|
|
|
|
remove() {
|
|
for (const interceptor of this.nockInterceptors) {
|
|
nock.removeInterceptor(interceptor);
|
|
}
|
|
this.nockInterceptors = [];
|
|
}
|
|
|
|
stub() {
|
|
this.remove();
|
|
|
|
let interceptor = nock('https://api.stripe.com')
|
|
.persist()
|
|
.get(/v1\/.*/);
|
|
this.nockInterceptors.push(interceptor);
|
|
interceptor
|
|
.reply((uri) => {
|
|
const [match, resource, id] = uri.match(/\/?v1\/(\w+)\/?(\w+)/) || [null];
|
|
|
|
if (!match) {
|
|
return [500];
|
|
}
|
|
|
|
if (resource === 'setup_intents') {
|
|
return this.#getData(this.setupIntents, id);
|
|
}
|
|
|
|
if (resource === 'customers') {
|
|
return this.#getData(this.customers, id);
|
|
}
|
|
|
|
if (resource === 'subscriptions') {
|
|
return this.#getData(this.subscriptions, id);
|
|
}
|
|
|
|
if (resource === 'coupons') {
|
|
return this.#getData(this.coupons, id);
|
|
}
|
|
|
|
if (resource === 'payment_methods') {
|
|
return this.#getData(this.paymentMethods, id);
|
|
}
|
|
|
|
if (resource === 'prices') {
|
|
return this.#getData(this.prices, id);
|
|
}
|
|
|
|
if (resource === 'products') {
|
|
return this.#getData(this.products, id);
|
|
}
|
|
|
|
return [500];
|
|
});
|
|
|
|
interceptor = nock('https://api.stripe.com')
|
|
.persist()
|
|
.post(/v1\/.*/);
|
|
this.nockInterceptors.push(interceptor);
|
|
interceptor
|
|
.reply((uri, body) => {
|
|
const [match, resource, id] = uri.match(/\/?v1\/(\w+)(?:\/?(\w+)){0,2}/) || [null];
|
|
|
|
if (!match) {
|
|
return [500];
|
|
}
|
|
|
|
if (resource === 'payment_methods') {
|
|
return this.#postData(this.paymentMethods, id, body, resource);
|
|
}
|
|
|
|
if (resource === 'subscriptions') {
|
|
return this.#postData(this.subscriptions, id, body, resource);
|
|
}
|
|
|
|
if (resource === 'customers') {
|
|
return this.#postData(this.customers, id, body, resource);
|
|
}
|
|
|
|
if (resource === 'coupons') {
|
|
return this.#postData(this.coupons, id, body, resource);
|
|
}
|
|
|
|
if (resource === 'prices') {
|
|
return this.#postData(this.prices, id, body, resource);
|
|
}
|
|
|
|
if (resource === 'products') {
|
|
return this.#postData(this.products, id, body, resource);
|
|
}
|
|
|
|
if (resource === 'checkout' && id === 'sessions') {
|
|
return this.#postData(this.checkoutSessions, null, body, resource);
|
|
}
|
|
|
|
return [500];
|
|
});
|
|
|
|
interceptor = nock('https://api.stripe.com')
|
|
.persist()
|
|
.delete(/v1\/.*/);
|
|
this.nockInterceptors.push(interceptor);
|
|
interceptor
|
|
.reply((uri) => {
|
|
const [match, resource, id] = uri.match(/\/?v1\/(\w+)(?:\/?(\w+)){0,2}/) || [null];
|
|
|
|
if (!match) {
|
|
return [500];
|
|
}
|
|
|
|
if (resource === 'subscriptions') {
|
|
return this.#postData(this.subscriptions, id, 'status=canceled', resource);
|
|
}
|
|
|
|
return [500];
|
|
});
|
|
}
|
|
|
|
async sendWebhook(event) {
|
|
/**
|
|
* @type {any}
|
|
*/
|
|
const webhookController = stripeService.webhookController;
|
|
await webhookController.handleEvent(event);
|
|
}
|
|
}
|
|
|
|
module.exports = StripeMocker;
|