Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions ghost/core/core/server/services/offers/application/offer-mapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,29 @@
* @prop {string|null} last_redeemed
*/

/**
* @typedef {object} PublicOfferDTO
* @prop {string} id
*
* @prop {string} display_title
* @prop {string} display_description
*
* @prop {'percent'|'fixed'|'trial'} type
*
* @prop {'month'|'year'} cadence
* @prop {number} amount
*
* @prop {'once'|'repeating'|'forever'|'trial'} duration
* @prop {null|number} duration_in_months
*
* @prop {string|null} currency
*
* @prop {'active'|'archived'} status
* @prop {'signup'|'retention'} redemption_type
*
* @prop {{id: string}|null} tier
*/

class OfferMapper {
/**
* @param {Offer} offer
Expand Down Expand Up @@ -62,6 +85,31 @@ class OfferMapper {
last_redeemed: offer.lastRedeemed
};
}

/**
* Returns a DTO for a public facing offer (e.g. Portal's retention offer UI)
*
* @param {Offer} offer
* @returns {PublicOfferDTO}
*/
static toPublicDTO(offer) {
return {
id: offer.id,
display_title: offer.displayTitle.value,
display_description: offer.displayDescription.value,
type: offer.type.value,
cadence: offer.cadence.value,
amount: offer.amount.value,
duration: offer.duration.value.type,
duration_in_months: offer.duration.value.type === 'repeating' ? offer.duration.value.months : null,
currency: offer.type.value === 'fixed' ? offer.currency.value : null,
status: offer.status.value,
redemption_type: offer.redemptionType.value,
tier: offer.tier
? {id: offer.tier.id}
: null
};
}
}

module.exports = OfferMapper;
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ class OffersAPI {
* @param {string} options.tierId
* @param {'month'|'year'} options.cadence
* @param {'signup'|'retention'} [options.redemptionType]
* @returns {Promise<OfferMapper.OfferDTO[]>}
* @returns {Promise<OfferMapper.PublicOfferDTO[]>}
*/
async listOffersAvailableToSubscription({subscriptionId, tierId, cadence, redemptionType}) {
debug(`listOffersAvailableToSubscription: subscriptionId=${subscriptionId}, tierId=${tierId}, cadence=${cadence}, redemptionType=${redemptionType}`);
Expand Down Expand Up @@ -246,7 +246,7 @@ class OffersAPI {
}

debug(`listOffersAvailableToSubscription: returning ${available.length} available offers`);
return available.map(OfferMapper.toDTO);
return available.map(OfferMapper.toPublicDTO);
});
}

Expand Down
10 changes: 8 additions & 2 deletions ghost/core/test/e2e-api/members/member-offers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,6 @@ describe('Members API - Member Offers', function () {

assert.equal(body.offers.length, 1);
assert.equal(body.offers[0].id, offer.id);
assert.equal(body.offers[0].name, 'Test Retention Offer');
assert.equal(body.offers[0].code, 'test-retention');
assert.equal(body.offers[0].display_title, '20% off for 3 months');
assert.equal(body.offers[0].display_description, 'Stay with us!');
assert.equal(body.offers[0].type, 'percent');
Expand All @@ -145,6 +143,14 @@ describe('Members API - Member Offers', function () {
assert.equal(body.offers[0].duration_in_months, 3);
assert.equal(body.offers[0].cadence, cadence);
assert.equal(body.offers[0].redemption_type, 'retention');

// Ensure only public facing fields are returned
assert.equal(body.offers[0].name, undefined);
assert.equal(body.offers[0].code, undefined);
assert.equal(body.offers[0].currency_restriction, undefined);
assert.equal(body.offers[0].redemption_count, undefined);
assert.equal(body.offers[0].created_at, undefined);
assert.equal(body.offers[0].last_redeemed, undefined);
} finally {
// Clean up
await models.Offer.destroy({id: offer.id});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1620,7 +1620,7 @@ describe('RouterController', function () {
});

it('returns offers when subscription has an expired discount', async function () {
const mockOffer = {id: 'retention_offer', name: 'Stay with us'};
const mockOffer = {id: 'retention_offer'};
mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]);

const pastDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
Expand All @@ -1641,7 +1641,7 @@ describe('RouterController', function () {
});

it('returns offers when subscription has expired once offer (legacy data, no discount_start)', async function () {
const mockOffer = {id: 'retention_offer', name: 'Stay with us'};
const mockOffer = {id: 'retention_offer'};
mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]);
mockOffersAPI.getOffer.resolves({duration: 'once'});

Expand Down Expand Up @@ -1693,7 +1693,7 @@ describe('RouterController', function () {
});

it('returns offers when subscription trial has ended', async function () {
const mockOffer = {id: 'offer_123', name: 'Test Offer'};
const mockOffer = {id: 'offer_123'};
mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]);

const pastDate = new Date();
Expand All @@ -1712,7 +1712,7 @@ describe('RouterController', function () {
});

it('returns offers when subscription has no trial period', async function () {
const mockOffer = {id: 'offer_123', name: 'Test Offer'};
const mockOffer = {id: 'offer_123'};
mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]);

const routerController = createRouterController({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,56 @@ describe('OffersAPI', function () {
assert.equal(result[0].id, 'offer-1');
});

it('returns public facing offer DTO', async function () {
const tierId = new ObjectID().toHexString();
const offers = [
createMockOffer('offer-1', {tierId, cadence: 'month', redemptionType: 'retention'})
];

const repository = createMockRepository({
getAll: sinon.stub().resolves(offers),
getRedeemedOfferIdsForSubscription: sinon.stub().resolves([])
});

const api = new OffersAPI(/** @type {OfferBookshelfRepository} */ (/** @type {unknown} */ (repository)));

const result = await api.listOffersAvailableToSubscription({
subscriptionId: 'sub_123',
tierId,
cadence: 'month',
redemptionType: 'retention'
});

assert.equal(result.length, 1);

const keys = Object.keys(result[0]);

assert.deepEqual(keys.sort(), [
'amount',
'cadence',
'currency',
'display_description',
'display_title',
'duration',
'duration_in_months',
'id',
'redemption_type',
'status',
'tier',
'type'
]);

assert.equal(result[0].name, undefined);
assert.equal(result[0].code, undefined);
assert.equal(result[0].currency_restriction, undefined);
assert.equal(result[0].redemption_count, undefined);
assert.equal(result[0].created_at, undefined);
assert.equal(result[0].last_redeemed, undefined);

assert.deepEqual(Object.keys(result[0].tier).sort(), ['id']);
assert.equal(result[0].tier.name, undefined);
});

it('excludes trial offers', async function () {
const tierId = new ObjectID().toHexString();
const offers = [
Expand Down