Revert "Added support for importing Stripe Coupons as Offers (#17415)" (#17915)

refs https://github.com/TryGhost/Product/issues/3674
refs https://github.com/TryGhost/Product/issues/3675

- this reverts commits 8a32941ae8 and
b587429008
- 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.
This commit is contained in:
Sag 2023-09-01 09:49:29 +02:00 committed by GitHub
parent a19883ac75
commit 96d9099195
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 142 additions and 909 deletions

View File

@ -2723,228 +2723,6 @@ Object {
}
`;
exports[`Members API Can create an offer in Ghost when Stripe subscription has an unknown offer attached 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": null,
"referrer_medium": "Ghost Admin",
"referrer_source": "Created manually",
"referrer_url": null,
"title": null,
"type": null,
"url": null,
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": "create-member-offer-test@email.com",
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"email_suppression": Object {
"info": null,
"suppressed": false,
},
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": "Test Member",
"newsletters": Array [
Object {
"background_color": "light",
"body_font_category": "sans_serif",
"border_color": null,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"description": null,
"feedback_enabled": false,
"footer_content": null,
"header_image": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Default Newsletter",
"sender_email": null,
"sender_name": null,
"sender_reply_to": "newsletter",
"show_badge": true,
"show_comment_cta": true,
"show_feature_image": true,
"show_header_icon": true,
"show_header_name": true,
"show_header_title": true,
"show_latest_posts": false,
"show_post_title_section": true,
"show_subscription_details": false,
"slug": "default-newsletter",
"sort_order": 0,
"status": "active",
"subscribe_on_signup": true,
"title_alignment": "center",
"title_color": null,
"title_font_category": "sans_serif",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
"visibility": "members",
},
],
"note": null,
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Array [
Object {
"active": true,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"currency": "usd",
"description": null,
"expiry_at": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"monthly_price": 500,
"monthly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Default Product",
"slug": "default-product",
"trial_days": 0,
"type": "paid",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"visibility": "public",
"welcome_page_url": "/welcome-paid",
"yearly_price": 5000,
"yearly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
],
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Can create an offer in Ghost when Stripe subscription has an unknown offer attached 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "3700",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/members\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Can create an offer in Ghost when Stripe subscription has an unknown offer attached with a duplicate name 1: [body] 1`] = `
Object {
"members": Array [
Object {
"attribution": Object {
"id": null,
"referrer_medium": "Ghost Admin",
"referrer_source": "Created manually",
"referrer_url": null,
"title": null,
"type": null,
"url": null,
},
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": "create-member-offer-test2@email.com",
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"email_suppression": Object {
"info": null,
"suppressed": false,
},
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": "Test Member",
"newsletters": Array [
Object {
"background_color": "light",
"body_font_category": "sans_serif",
"border_color": null,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"description": null,
"feedback_enabled": false,
"footer_content": null,
"header_image": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Default Newsletter",
"sender_email": null,
"sender_name": null,
"sender_reply_to": "newsletter",
"show_badge": true,
"show_comment_cta": true,
"show_feature_image": true,
"show_header_icon": true,
"show_header_name": true,
"show_header_title": true,
"show_latest_posts": false,
"show_post_title_section": true,
"show_subscription_details": false,
"slug": "default-newsletter",
"sort_order": 0,
"status": "active",
"subscribe_on_signup": true,
"title_alignment": "center",
"title_color": null,
"title_font_category": "sans_serif",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
"visibility": "members",
},
],
"note": null,
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Array [
Object {
"active": true,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"currency": "usd",
"description": null,
"expiry_at": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"monthly_price": 500,
"monthly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Default Product",
"slug": "default-product",
"trial_days": 0,
"type": "paid",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"visibility": "public",
"welcome_page_url": "/welcome-paid",
"yearly_price": 5000,
"yearly_price_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
],
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
],
}
`;
exports[`Members API Can create an offer in Ghost when Stripe subscription has an unknown offer attached with a duplicate name 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "3727",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/members\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API Can delete a member while cancelling Stripe Subscription 1: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
@ -3456,11 +3234,11 @@ Object {
"comped": 4,
"date": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/,
"free": 4,
"paid": 4,
"paid": 2,
},
],
"resource": "members",
"total": 12,
"total": 10,
}
`;
@ -4056,58 +3834,6 @@ Object {
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
Object {
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": "create-member-offer-test2@email.com",
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"email_suppression": Object {
"info": null,
"suppressed": false,
},
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": "Test Member",
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
Object {
"avatar_image": null,
"comped": false,
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"email": "create-member-offer-test@email.com",
"email_count": 0,
"email_open_rate": null,
"email_opened_count": 0,
"email_suppression": Object {
"info": null,
"suppressed": false,
},
"geolocation": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"labels": Any<Array>,
"last_seen_at": null,
"name": "Test Member",
"newsletters": Any<Array>,
"note": null,
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
Object {
"avatar_image": null,
"comped": true,
@ -4298,7 +4024,7 @@ Object {
"page": 1,
"pages": 1,
"prev": null,
"total": 10,
"total": 8,
},
},
}
@ -4308,7 +4034,7 @@ exports[`Members API Can filter on tier slug 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "30800",
"content-length": "23956",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

View File

@ -1395,229 +1395,6 @@ describe('Members API', function () {
});
});
it('Can create an offer in Ghost when Stripe subscription has an unknown offer attached', async function () {
const fakePrice = {
id: 'price_test_offer_123',
product: 'product_test_offer_123',
active: true,
nickname: 'Monthly',
unit_amount: 5000,
currency: 'usd',
type: 'recurring',
recurring: {
interval: 'month'
}
};
const fakeSubscription = {
id: 'sub_offer_test_123',
customer: 'cus_offer_test_123',
status: 'active',
cancel_at_period_end: false,
metadata: {},
current_period_end: Date.now() / 1000 + 50000,
start_date: Date.now() / 1000,
plan: fakePrice,
items: {
data: [{
price: fakePrice
}]
},
discount: {
coupon: {
id: 'coupon_1',
name: 'Stripe Special',
duration: 'repeating',
duration_in_months: 5,
amount_off: 1000
}
}
};
const fakeCustomer = {
id: 'cus_offer_test_123',
name: 'Test Member',
email: 'create-member-offer-test@email.com',
subscriptions: {
type: 'list',
data: [fakeSubscription]
}
};
stripeMocker.customers.push(fakeCustomer);
stripeMocker.subscriptions.push(fakeSubscription);
stripeMocker.prices.push(fakePrice);
const initialMember = {
name: fakeCustomer.name,
email: fakeCustomer.email,
subscribed: true,
newsletters: [newsletters[0]],
stripe_customer_id: fakeCustomer.id
};
const {body} = await agent
.post(`/members/`)
.body({members: [initialMember]})
.expectStatus(201)
.matchBodySnapshot({
members: new Array(1).fill({
id: anyObjectId,
uuid: anyUuid,
created_at: anyISODateTime,
updated_at: anyISODateTime,
labels: anyArray,
subscriptions: anyArray,
tiers: new Array(1).fill(tierMatcher),
newsletters: new Array(1).fill(newsletterSnapshot)
})
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('members')
});
const newMember = body.members[0];
assert.equal(newMember.status, 'paid', 'The created member should have the paid status');
// Check offer is created in the database
const offers = (await models.Offer.findAll({filter: 'name:\'Stripe Special\''})).models;
assert.equal(offers.length, 1);
assert.equal(offers[0].get('name'), 'Stripe Special');
assert.equal(offers[0].get('code'), 'stripe-special');
assert.equal(offers[0].get('stripe_coupon_id'), 'coupon_1');
assert.equal(offers[0].get('discount_type'), 'amount');
assert.equal(offers[0].get('discount_amount'), 1000);
assert.equal(offers[0].get('duration'), 'repeating');
assert.equal(offers[0].get('duration_in_months'), 5);
// Check subscription is linked to offer
assert.equal(newMember.subscriptions[0].offer.id, offers[0].id);
await assertSubscription('sub_offer_test_123', {
subscription_id: 'sub_offer_test_123',
status: 'active',
cancel_at_period_end: false,
plan_amount: 5000,
plan_interval: 'month',
plan_currency: 'usd',
mrr: 5000,
offer_id: offers[0].get('id')
});
});
it('Can create an offer in Ghost when Stripe subscription has an unknown offer attached with a duplicate name', async function () {
const existingOffers = (await models.Offer.findAll({filter: 'name:\'Stripe Special\''})).models;
assert.equal(existingOffers.length, 1, 'This test expects an offer with the name Stripe Special to already exist');
const fakePrice = {
id: 'price_test_offer_1234',
product: 'product_test_offer_1234',
active: true,
nickname: 'Monthly',
unit_amount: 5000,
currency: 'usd',
type: 'recurring',
recurring: {
interval: 'month'
}
};
const fakeSubscription = {
id: 'sub_offer_test_1234',
customer: 'cus_offer_test_1234',
status: 'active',
cancel_at_period_end: false,
metadata: {},
current_period_end: Date.now() / 1000 + 50000,
start_date: Date.now() / 1000,
plan: fakePrice,
items: {
data: [{
price: fakePrice
}]
},
discount: {
coupon: {
id: 'coupon_2',
name: 'Stripe Special', // Duplicate name
duration: 'repeating',
duration_in_months: 5,
amount_off: 1000
}
}
};
const fakeCustomer = {
id: 'cus_offer_test_1234',
name: 'Test Member',
email: 'create-member-offer-test2@email.com',
subscriptions: {
type: 'list',
data: [fakeSubscription]
}
};
stripeMocker.customers.push(fakeCustomer);
stripeMocker.subscriptions.push(fakeSubscription);
stripeMocker.prices.push(fakePrice);
const initialMember = {
name: fakeCustomer.name,
email: fakeCustomer.email,
subscribed: true,
newsletters: [newsletters[0]],
stripe_customer_id: fakeCustomer.id
};
const {body} = await agent
.post(`/members/`)
.body({members: [initialMember]})
.expectStatus(201)
.matchBodySnapshot({
members: new Array(1).fill({
id: anyObjectId,
uuid: anyUuid,
created_at: anyISODateTime,
updated_at: anyISODateTime,
labels: anyArray,
subscriptions: anyArray,
tiers: new Array(1).fill(tierMatcher),
newsletters: new Array(1).fill(newsletterSnapshot)
})
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('members')
});
const newMember = body.members[0];
assert.equal(newMember.status, 'paid', 'The created member should have the paid status');
// Check offer is created in the database
const offers = (await models.Offer.findAll({filter: 'name:\'Stripe Special (coupon_2)\''})).models;
assert.equal(offers.length, 1);
assert.equal(offers[0].get('name'), 'Stripe Special (coupon_2)');
assert.equal(offers[0].get('code'), 'stripe-special-coupon_2');
assert.equal(offers[0].get('stripe_coupon_id'), 'coupon_2');
assert.equal(offers[0].get('discount_type'), 'amount');
assert.equal(offers[0].get('discount_amount'), 1000);
assert.equal(offers[0].get('duration'), 'repeating');
assert.equal(offers[0].get('duration_in_months'), 5);
// Check subscription is linked to offer
assert.equal(newMember.subscriptions[0].offer.id, offers[0].id);
await assertSubscription('sub_offer_test_1234', {
subscription_id: 'sub_offer_test_1234',
status: 'active',
cancel_at_period_end: false,
plan_amount: 5000,
plan_interval: 'month',
plan_currency: 'usd',
mrr: 5000,
offer_id: offers[0].get('id')
});
});
let memberWithPaidSubscription;
it('Can create a member with an existing paid subscription', async function () {
@ -2494,7 +2271,7 @@ describe('Members API', function () {
.get('/members/?include=tiers&filter=tier:default-product')
.expectStatus(200)
.matchBodySnapshot({
members: new Array(10).fill(buildMemberMatcherShallowIncludesWithTiers())
members: new Array(8).fill(buildMemberMatcherShallowIncludesWithTiers())
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,

View File

@ -1496,6 +1496,141 @@ describe('Members API', function () {
]
});
});
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

View File

@ -940,8 +940,7 @@ module.exports = class MemberRepository {
logging.error(e);
}
const stripeCoupon = subscription.discount?.coupon;
const stripeCouponId = stripeCoupon ? subscription.discount.coupon.id : null;
let stripeCouponId = subscription.discount && subscription.discount.coupon ? subscription.discount.coupon.id : null;
// For trial offers, offer id is passed from metadata as there is no stripe coupon
let offerId = data.offerId || null;
@ -953,21 +952,7 @@ module.exports = class MemberRepository {
if (offer) {
offerId = offer.id;
} else {
try {
// Create an offer in our database
const productId = ghostProduct.get('id');
const currency = subscriptionPriceData.currency;
const interval = _.get(subscriptionPriceData, 'recurring.interval', '');
offer = await this._offerRepository.createFromCoupon(
stripeCoupon,
{productId, currency, interval, active: false},
{transacting: options.transacting}
);
offerId = offer?.id;
} catch (e) {
logging.error(`Error when creating an offer from stripe coupon id (${stripeCouponId}) for subscription - ${subscription.id}.`);
logging.error(e);
}
logging.error(`Received an unknown stripe coupon id (${stripeCouponId}) for subscription - ${subscription.id}.`);
}
} else if (offerId) {
offer = await this._offerRepository.getById(offerId, {transacting: options.transacting});

View File

@ -43,14 +43,6 @@ const mongoTransformer = flowRight(statusTransformer, rejectNonStatusTransformer
* @prop {string} filter
*/
/**
* @typedef {object} OfferAdditionalParams
* @prop {string} productId the Ghost Product ID
* @prop {string} currency the currency of the plan
* @prop {string} interval the billing interval of the plan (month, year)
* @prop {boolean} active whether the offer is active upoon creation
*/
class OfferRepository {
/**
* @param {{forge: (data: object) => import('bookshelf').Model<Offer.OfferProps>}} OfferModel
@ -187,49 +179,6 @@ class OfferRepository {
return Promise.all(offers);
}
/**
* @param {import('stripe').Stripe.CouponCreateParams} coupon
* @param {OfferAdditionalParams} params
* @param {BaseOptions} [options]
*/
async createFromCoupon(coupon, params, options) {
const {productId, currency, interval, active} = params;
let code = coupon.name && coupon.name.split(' ').map(word => word.toLowerCase()).join('-');
let name = coupon.name;
// If name or coupon already exists, we'll append the Stripe id to the name and code
if (await this.existsByName(name, options)) {
name = `${name} (${coupon.id})`;
}
if (await this.existsByCode(code, options)) {
code = `${code}-${coupon.id}`;
}
const data = {
active,
name,
code,
product_id: productId,
stripe_coupon_id: coupon.id,
interval,
currency,
duration: coupon.duration,
duration_in_months: coupon.duration === 'repeating' ? coupon.duration_in_months : null,
portal_title: coupon.name
};
if (coupon.percent_off) {
data.discount_type = 'percent';
data.discount_amount = coupon.percent_off;
} else {
data.discount_type = 'amount';
data.discount_amount = coupon.amount_off;
}
return await this.OfferModel.add(data, options);
}
/**
* @param {Offer} offer
* @param {BaseOptions} [options]

View File

@ -1,339 +0,0 @@
const sinon = require('sinon');
const OfferRepository = require('../../../lib/application/OfferRepository');
const Offer = {
add: sinon.stub(),
findOne: sinon.stub()
};
describe('OfferRepository', function () {
describe('#createFromCoupon', function () {
it('creates a 50% off for 3 months offer', async function () {
const coupon = {
id: 'coupon-id',
name: 'Coupon Name',
percent_off: 50,
duration: 'repeating',
duration_in_months: 3
};
const params = {
productId: 'product-id',
currency: 'usd',
interval: 'month',
active: true
};
const options = {
transacting: true
};
const expectedData = {
active: true,
name: 'Coupon Name',
code: 'coupon-name',
product_id: 'product-id',
stripe_coupon_id: 'coupon-id',
interval: 'month',
currency: 'usd',
duration: 'repeating',
duration_in_months: 3,
portal_title: 'Coupon Name',
discount_type: 'percent',
discount_amount: 50
};
const offerRepository = new OfferRepository(Offer);
await offerRepository.createFromCoupon(coupon, params, options);
Offer.add.calledWith(expectedData, options).should.be.true();
});
it('creates a 1 USD off for 3 months offer', async function () {
const coupon = {
id: 'coupon-id',
name: 'Coupon Name',
amount_off: 1,
duration: 'repeating',
duration_in_months: 3
};
const params = {
productId: 'product-id',
currency: 'usd',
interval: 'month',
active: true
};
const options = {
transacting: true
};
const expectedData = {
active: true,
name: 'Coupon Name',
code: 'coupon-name',
product_id: 'product-id',
stripe_coupon_id: 'coupon-id',
interval: 'month',
currency: 'usd',
duration: 'repeating',
duration_in_months: 3,
portal_title: 'Coupon Name',
discount_type: 'amount',
discount_amount: 1
};
const offerRepository = new OfferRepository(Offer);
await offerRepository.createFromCoupon(coupon, params, options);
Offer.add.calledWith(expectedData, options).should.be.true();
});
it('creates a 50% off forever offer', async function () {
const coupon = {
id: 'coupon-id',
name: 'Coupon Name',
percent_off: 50,
duration: 'forever',
duration_in_months: null
};
const params = {
productId: 'product-id',
currency: 'usd',
interval: 'month',
active: true
};
const options = {
transacting: true
};
const expectedData = {
active: true,
name: 'Coupon Name',
code: 'coupon-name',
product_id: 'product-id',
stripe_coupon_id: 'coupon-id',
interval: 'month',
currency: 'usd',
duration: 'forever',
duration_in_months: null,
portal_title: 'Coupon Name',
discount_type: 'percent',
discount_amount: 50
};
const offerRepository = new OfferRepository(Offer);
await offerRepository.createFromCoupon(coupon, params, options);
Offer.add.calledWith(expectedData, options).should.be.true();
});
it('creates a 1 USD off forever offer', async function () {
const coupon = {
id: 'coupon-id',
name: 'Coupon Name',
amount_off: 1,
duration: 'forever',
duration_in_months: null
};
const params = {
productId: 'product-id',
currency: 'usd',
interval: 'month',
active: true
};
const options = {
transacting: true
};
const expectedData = {
active: true,
name: 'Coupon Name',
code: 'coupon-name',
product_id: 'product-id',
stripe_coupon_id: 'coupon-id',
interval: 'month',
currency: 'usd',
duration: 'forever',
duration_in_months: null,
portal_title: 'Coupon Name',
discount_type: 'amount',
discount_amount: 1
};
const offerRepository = new OfferRepository(Offer);
await offerRepository.createFromCoupon(coupon, params, options);
Offer.add.calledWith(expectedData, options).should.be.true();
});
it('creates a 50% USD off once yearly offer', async function () {
const coupon = {
id: 'coupon-id',
name: 'Coupon Name',
percent_off: 50,
duration: 'once',
duration_in_months: null
};
const params = {
productId: 'product-id',
currency: 'usd',
interval: 'yearly',
active: true
};
const options = {
transacting: true
};
const expectedData = {
active: true,
name: 'Coupon Name',
code: 'coupon-name',
product_id: 'product-id',
stripe_coupon_id: 'coupon-id',
interval: 'yearly',
currency: 'usd',
duration: 'once',
duration_in_months: null,
portal_title: 'Coupon Name',
discount_type: 'percent',
discount_amount: 50
};
const offerRepository = new OfferRepository(Offer);
await offerRepository.createFromCoupon(coupon, params, options);
Offer.add.calledWith(expectedData, options).should.be.true();
});
it('creates a 1 USD off once yearly offer', async function () {
const coupon = {
id: 'coupon-id',
name: 'Coupon Name',
amount_off: 1,
duration: 'once',
duration_in_months: null
};
const params = {
productId: 'product-id',
currency: 'usd',
interval: 'yearly',
active: true
};
const options = {
transacting: true
};
const expectedData = {
active: true,
name: 'Coupon Name',
code: 'coupon-name',
product_id: 'product-id',
stripe_coupon_id: 'coupon-id',
interval: 'yearly',
currency: 'usd',
duration: 'once',
duration_in_months: null,
portal_title: 'Coupon Name',
discount_type: 'amount',
discount_amount: 1
};
const offerRepository = new OfferRepository(Offer);
await offerRepository.createFromCoupon(coupon, params, options);
Offer.add.calledWith(expectedData, options).should.be.true();
});
it('creates a 50% off during one month offer', async function () {
const coupon = {
id: 'coupon-id',
name: 'Coupon Name',
percent_off: 50,
duration: 'repeating',
duration_in_months: 1
};
const params = {
productId: 'product-id',
currency: 'usd',
interval: 'month',
active: true
};
const options = {
transacting: true
};
const expectedData = {
active: true,
name: 'Coupon Name',
code: 'coupon-name',
product_id: 'product-id',
stripe_coupon_id: 'coupon-id',
interval: 'month',
currency: 'usd',
duration: 'repeating',
duration_in_months: 1,
portal_title: 'Coupon Name',
discount_type: 'percent',
discount_amount: 50
};
const offerRepository = new OfferRepository(Offer);
await offerRepository.createFromCoupon(coupon, params, options);
Offer.add.calledWith(expectedData, options).should.be.true();
});
it('creates a 1 USD off during one month offer', async function () {
const coupon = {
id: 'coupon-id',
name: 'Coupon Name',
amount_off: 1,
duration: 'repeating',
duration_in_months: 1
};
const params = {
productId: 'product-id',
currency: 'usd',
interval: 'month',
active: true
};
const options = {
transacting: true
};
const expectedData = {
active: true,
name: 'Coupon Name',
code: 'coupon-name',
product_id: 'product-id',
stripe_coupon_id: 'coupon-id',
interval: 'month',
currency: 'usd',
duration: 'repeating',
duration_in_months: 1,
portal_title: 'Coupon Name',
discount_type: 'amount',
discount_amount: 1
};
const offerRepository = new OfferRepository(Offer);
await offerRepository.createFromCoupon(coupon, params, options);
Offer.add.calledWith(expectedData, options).should.be.true();
});
});
});