Handled stripe setup for free trial offers

refs https://github.com/TryGhost/Team/issues/1726

- free trial offers don't need a stripe coupon created for them
- checkout sessions for free trial offers ignore stripe coupon and directly pass the trial days value
- trial days of an offer take precedence over trial days added as default to a tier
This commit is contained in:
Rishabh 2022-08-09 14:43:31 +05:30 committed by Rishabh Garg
parent 66970e5002
commit 843bbfa55d
4 changed files with 194 additions and 18 deletions

View File

@ -120,6 +120,46 @@ Object {
}
`;
exports[`Offers API Can add a trial offer 1: [body] 1`] = `
Object {
"offers": Array [
Object {
"amount": 20,
"cadence": "year",
"code": "4th-trial",
"currency": null,
"currency_restriction": false,
"display_description": "",
"display_title": "",
"duration": "trial",
"duration_in_months": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Fourth of July Sales trial",
"redemption_count": 0,
"status": "active",
"tier": Object {
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
},
"type": "trial",
},
],
}
`;
exports[`Offers API Can add a trial offer 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": "359",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/offers\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
"vary": "Origin, Accept-Encoding",
"x-cache-invalidate": "/*",
"x-powered-by": "Express",
}
`;
exports[`Offers API Can archive an offer 1: [body] 1`] = `
Object {
"offers": Array [
@ -243,6 +283,26 @@ Object {
},
"type": "fixed",
},
Object {
"amount": 20,
"cadence": "year",
"code": "4th-trial",
"currency": null,
"currency_restriction": false,
"display_description": "",
"display_title": "",
"duration": "trial",
"duration_in_months": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Fourth of July Sales trial",
"redemption_count": 0,
"status": "active",
"tier": Object {
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Default Product",
},
"type": "trial",
},
],
}
`;
@ -251,7 +311,7 @@ exports[`Offers API Can browse 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": "1491",
"content-length": "1863",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
@ -322,6 +382,26 @@ Object {
},
"type": "fixed",
},
Object {
"amount": 20,
"cadence": "year",
"code": "4th-trial",
"currency": null,
"currency_restriction": false,
"display_description": "",
"display_title": "",
"duration": "trial",
"duration_in_months": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Fourth of July Sales trial",
"redemption_count": 0,
"status": "active",
"tier": Object {
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Default Product",
},
"type": "trial",
},
],
}
`;
@ -330,7 +410,7 @@ exports[`Offers API Can browse active 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": "1089",
"content-length": "1461",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
@ -456,6 +536,45 @@ Object {
}
`;
exports[`Offers API Can get a trial offer 1: [body] 1`] = `
Object {
"offers": Array [
Object {
"amount": 20,
"cadence": "year",
"code": "4th-trial",
"currency": null,
"currency_restriction": false,
"display_description": "",
"display_title": "",
"duration": "trial",
"duration_in_months": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Fourth of July Sales trial",
"redemption_count": 0,
"status": "active",
"tier": Object {
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Default Product",
},
"type": "trial",
},
],
}
`;
exports[`Offers API Can get a trial offer 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": "384",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Offers API Cannot create offer with same code 1: [body] 1`] = `
Object {
"errors": Array [

View File

@ -16,6 +16,7 @@ async function getFreeProduct() {
describe('Offers API', function () {
let defaultTier;
let savedOffer;
let trialOffer;
before(async function () {
agent = await agentProvider.getAdminAPIAgent();
@ -53,7 +54,7 @@ describe('Offers API', function () {
id: defaultTier.id
}
};
const {body} = await agent
.post(`offers/`)
.body({offers: [newOffer]})
@ -85,7 +86,7 @@ describe('Offers API', function () {
id: defaultTier.id
}
};
await agent
.post(`offers/`)
.body({offers: [newOffer]})
@ -116,7 +117,7 @@ describe('Offers API', function () {
id: defaultTier.id
}
};
await agent
.post(`offers/`)
.body({offers: [newOffer]})
@ -151,7 +152,7 @@ describe('Offers API', function () {
id: defaultTier.id
}
};
await agent
.post(`offers/`)
.body({offers: [newOffer]})
@ -170,6 +171,39 @@ describe('Offers API', function () {
});
});
it('Can add a trial offer', async function () {
const newOffer = {
name: 'Fourth of July Sales trial',
code: '4th-trial',
cadence: 'year',
amount: 20,
duration: 'trial',
type: 'trial',
currency: 'USD',
tier: {
id: defaultTier.id
}
};
const {body} = await agent
.post(`offers/`)
.body({offers: [newOffer]})
.expectStatus(200)
.matchHeaderSnapshot({
etag: anyEtag,
location: anyLocationFor('offers')
})
.matchBodySnapshot({
offers: [{
id: anyObjectId,
tier: {
id: anyObjectId
}
}]
});
trialOffer = body.offers[0];
});
it('Cannot create offer with same code', async function () {
const newOffer = {
name: 'Fourth of July',
@ -183,7 +217,7 @@ describe('Offers API', function () {
id: defaultTier.id
}
};
await agent
.post(`offers/`)
.body({offers: [newOffer]})
@ -211,7 +245,7 @@ describe('Offers API', function () {
id: defaultTier.id
}
};
await agent
.post(`offers/`)
.body({offers: [newOffer]})
@ -239,7 +273,7 @@ describe('Offers API', function () {
id: defaultTier.id
}
};
await agent
.post(`offers/`)
.body({offers: [newOffer]})
@ -262,7 +296,7 @@ describe('Offers API', function () {
etag: anyEtag
})
.matchBodySnapshot({
offers: new Array(4).fill({
offers: new Array(5).fill({
id: anyObjectId,
tier: {
id: anyObjectId
@ -288,6 +322,25 @@ describe('Offers API', function () {
});
});
it('Can get a trial offer', async function () {
await agent
.get(`offers/${trialOffer.id}/`)
.expectStatus(200)
.matchHeaderSnapshot({
etag: anyEtag
})
.matchBodySnapshot({
offers: new Array(1).fill({
id: anyObjectId,
type: 'trial',
duration: 'trial',
tier: {
id: anyObjectId
}
})
});
});
it('Can edit an offer', async function () {
// We can change all fields except discount related fields
let updatedOffer = {
@ -431,7 +484,7 @@ describe('Offers API', function () {
etag: anyEtag
})
.matchBodySnapshot({
offers: new Array(3).fill({
offers: new Array(4).fill({
id: anyObjectId,
tier: {
id: anyObjectId

View File

@ -168,6 +168,7 @@ module.exports = class RouterController {
}
let couponId = null;
let trialDays;
if (offerId) {
const offer = await this._offersAPI.getOffer({id: offerId});
const tier = (await this._productRepository.get(offer.tier)).toJSON();
@ -183,9 +184,13 @@ module.exports = class RouterController {
} else {
ghostPriceId = tier.yearly_price_id;
}
const coupon = await this._paymentsService.getCouponForOffer(offerId);
couponId = coupon.id;
// Free trial offers don't have a stripe coupon
if (offer.type === 'trial') {
trialDays = offer.amount;
} else {
const coupon = await this._paymentsService.getCouponForOffer(offerId);
couponId = coupon.id;
}
metadata.offer = offer.id;
}
@ -214,9 +219,8 @@ module.exports = class RouterController {
const priceId = price.get('stripe_price_id');
const product = await this._productRepository.get({stripe_price_id: priceId});
let trialDays;
if (this.labsService.isSet('freeTrial')) {
if (this.labsService.isSet('freeTrial') && !trialDays) {
trialDays = product.get('trial_days');
}

View File

@ -27,8 +27,8 @@ class PaymentsService {
* @returns {Promise<{id: string}>}
*/
async getCouponForOffer(offerId) {
const row = await this.OfferModel.where({id: offerId}).query().select('stripe_coupon_id').first();
if (!row) {
const row = await this.OfferModel.where({id: offerId}).query().select('stripe_coupon_id', 'discount_type').first();
if (!row || row.discount_type === 'trial') {
return null;
}
if (!row.stripe_coupon_id) {