mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-29 22:01:49 +03:00
375 lines
11 KiB
JavaScript
375 lines
11 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 = [];
|
||
|
|
||
|
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 = [];
|
||
|
|
||
|
// 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});
|
||
|
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];
|
||
|
}
|
||
|
|
||
|
async createTrialSubscription({customer, price, ...overrides}) {
|
||
|
return await this.createSubscription({
|
||
|
customer,
|
||
|
price,
|
||
|
status: 'trialing',
|
||
|
trial_end_at: (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}) {
|
||
|
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
|
||
|
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 === '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];
|
||
|
}
|
||
|
|
||
|
stub() {
|
||
|
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 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];
|
||
|
});
|
||
|
|
||
|
nock('https://api.stripe.com')
|
||
|
.persist()
|
||
|
.post(/v1\/.*/)
|
||
|
.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);
|
||
|
}
|
||
|
|
||
|
return [500];
|
||
|
});
|
||
|
|
||
|
nock('https://api.stripe.com')
|
||
|
.persist()
|
||
|
.delete(/v1\/.*/)
|
||
|
.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;
|