mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-26 04:08:01 +03:00
refs https://github.com/TryGhost/Product/issues/3674 refs https://github.com/TryGhost/Product/issues/3675 - this reverts commits8a32941ae8
andb587429008
- the reverted commits added some logic to create offers based on a Stripe coupon. However, the logic bypassed the Offer entity, and therefore skipped any validations/constraints — causing invalid data in the database and some sites to crash.
This commit is contained in:
parent
a19883ac75
commit
96d9099195
@ -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 \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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});
|
||||
|
@ -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]
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user