Skip to content

Commit 3d68789

Browse files
authored
Added price details for gift subscriptions in Members API (#27647)
closes https://linear.app/ghost/issue/BER-3576 - attachSubscriptionsToMember was hardcoding amount: 0, currency: 'USD', and interval: 'year' on the synthetic subscription returned for gift-status members, even though the gift row already stores the real cadence/currency/amount - now hydrates the synthetic subscription with the values from the gift, via a new bulk getActiveByMembers lookup on the gift repository to keep browse() to a single query - complimentary subscriptions keep the existing hardcoded shape; gift-status members with no active redeemed gift fall back to the defaults with a warning so the read/browse paths stay robust API shape For a gift member, `subscriptions[0].plan` and `subscriptions[0].price` previously returned: ```json { "amount": 0, "currency": "USD", "interval": "year" } ``` After this PR they return the actual gift values, e.g.: ``` { "amount": 5000, "currency": "usd", "interval": "year" } ```
1 parent 54ba385 commit 3d68789

10 files changed

Lines changed: 426 additions & 51 deletions

File tree

ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,29 @@ export class GiftBookshelfRepository implements GiftRepository {
9393
return model ? this.toGift(model) : null;
9494
}
9595

96+
async getActiveByMembers(memberIds: string[], options: RepositoryTransactionOptions = {}): Promise<Map<string, Gift>> {
97+
const map = new Map<string, Gift>();
98+
99+
if (memberIds.length === 0) {
100+
return map;
101+
}
102+
103+
const idList = memberIds.map(id => `'${id}'`).join(',');
104+
const collection = await this.model.findAll({
105+
filter: `redeemer_member_id:[${idList}]+status:redeemed`,
106+
...options
107+
});
108+
109+
for (const model of collection.models) {
110+
const gift = this.toGift(model);
111+
if (gift.redeemerMemberId) {
112+
map.set(gift.redeemerMemberId, gift);
113+
}
114+
}
115+
116+
return map;
117+
}
118+
96119
async findPendingConsumption(): Promise<Gift[]> {
97120
const now = new Date();
98121

ghost/core/core/server/services/gifts/gift-repository.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface GiftRepository {
2121
findPendingExpiration(): Promise<Gift[]>;
2222
findPendingReminder(options: FindPendingReminderOptions): Promise<Gift[]>;
2323
getActiveByMember(memberId: string, options?: RepositoryTransactionOptions): Promise<Gift | null>;
24+
getActiveByMembers(memberIds: string[], options?: RepositoryTransactionOptions): Promise<Map<string, Gift>>;
2425
create(gift: Gift, options?: RepositoryTransactionOptions): Promise<void>;
2526
update(gift: Gift, options?: RepositoryTransactionOptions): Promise<void>;
2627
transaction<T>(callback: (transacting: unknown) => Promise<T>): Promise<T>;

ghost/core/core/server/services/gifts/gift-service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,13 @@ export class GiftService {
373373
return this.deps.giftRepository.getActiveByMember(memberId, options);
374374
}
375375

376+
async getActiveByMembers(memberIds: string[], options: {transacting?: unknown} = {}): Promise<Map<string, Gift>> {
377+
if (!memberIds || memberIds.length === 0) {
378+
return new Map();
379+
}
380+
return this.deps.giftRepository.getActiveByMembers(memberIds, options);
381+
}
382+
376383
getRemainingActiveDays(gift: Gift, now: Date = new Date()): number {
377384
if (!gift.isRedeemed() || !gift.consumesAt || gift.isConsumed()) {
378385
return 0;

ghost/core/core/server/services/members/members-api/members-api.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,8 @@ module.exports = function MembersAPI({
164164
emailSuppressionList,
165165
settingsHelpers,
166166
nextPaymentCalculator,
167-
commentsService
167+
commentsService,
168+
giftService
168169
});
169170

170171
const geolocationService = new GeolocationService();

ghost/core/core/server/services/members/members-api/services/member-bread-service.js

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ const messages = {
2828
* @typedef {import('@tryghost/members-offers/lib/application/OfferMapper').OfferDTO} OfferDTO
2929
*/
3030

31+
/**
32+
* @typedef {object} IGiftServiceWrapper
33+
* @prop {{getActiveByMembers: (memberIds: string[]) => Promise<Map<string, {cadence: 'month' | 'year', currency: string, amount: number}>>}} service
34+
*/
35+
3136
module.exports = class MemberBREADService {
3237
/**
3338
* @param {object} deps
@@ -40,8 +45,9 @@ module.exports = class MemberBREADService {
4045
* @param {import('@tryghost/email-suppression-list/lib/email-suppression-list').IEmailSuppressionList} deps.emailSuppressionList
4146
* @param {import('@tryghost/settings-helpers')} deps.settingsHelpers
4247
* @param {import('./next-payment-calculator')} deps.nextPaymentCalculator
48+
* @param {IGiftServiceWrapper} deps.giftService
4349
*/
44-
constructor({memberRepository, labsService, emailService, stripeService, offersAPI, memberAttributionService, emailSuppressionList, settingsHelpers, nextPaymentCalculator, commentsService}) {
50+
constructor({memberRepository, labsService, emailService, stripeService, offersAPI, memberAttributionService, emailSuppressionList, settingsHelpers, nextPaymentCalculator, commentsService, giftService}) {
4551
this.offersAPI = offersAPI;
4652
/** @private */
4753
this.memberRepository = memberRepository;
@@ -61,13 +67,17 @@ module.exports = class MemberBREADService {
6167
this.nextPaymentCalculator = nextPaymentCalculator;
6268
/** @private */
6369
this.commentsService = commentsService;
70+
/** @private */
71+
this.giftService = giftService;
6472
}
6573

6674
/**
6775
* @private
6876
* Adds missing complimentary subscriptions to a member and makes sure the tier of all subscriptions is set correctly.
77+
* @param {Object} member JSON serialized member
78+
* @param {Map<string, {cadence: 'month' | 'year', currency: string, amount: number}>} [giftMap] Map of memberId → active redeemed gift, used to populate real price details on synthetic gift subscriptions
6979
*/
70-
attachSubscriptionsToMember(member) {
80+
attachSubscriptionsToMember(member, giftMap = new Map()) {
7181
if (!member.products || !Array.isArray(member.products)) {
7282
return member;
7383
}
@@ -87,6 +97,22 @@ module.exports = class MemberBREADService {
8797
// Note: a complimentary or gift subscription should always be the current member subscription and match its status,
8898
// as non-Stripe subscriptions are removed when a member continues with a Stripe paid subscription
8999
if (member.status === 'comped' || member.status === 'gift') {
100+
let interval = 'year';
101+
let currency = 'USD';
102+
let amount = 0;
103+
const nickname = member.status === 'gift' ? 'Gift subscription' : 'Complimentary';
104+
105+
if (member.status === 'gift') {
106+
const gift = giftMap.get(member.id);
107+
if (gift) {
108+
interval = gift.cadence;
109+
currency = gift.currency;
110+
amount = gift.amount;
111+
} else {
112+
logging.warn(`No active gift found for gift member ${member.id} — falling back to default subscription price details.`);
113+
}
114+
}
115+
90116
for (const product of member.products) {
91117
if (!subscriptionProducts.includes(product.id)) {
92118
const productAddEvent = member.productEvents.find(event => event.product_id === product.id && event.action === 'added');
@@ -97,8 +123,6 @@ module.exports = class MemberBREADService {
97123
startDate = moment(productAddEvent.created_at);
98124
}
99125

100-
const nickname = member.status === 'gift' ? 'Gift subscription' : 'Complimentary';
101-
102126
member.subscriptions.push({
103127
id: '',
104128
tier: product,
@@ -110,9 +134,9 @@ module.exports = class MemberBREADService {
110134
plan: {
111135
id: '',
112136
nickname,
113-
interval: 'year',
114-
currency: 'USD',
115-
amount: 0
137+
interval,
138+
currency,
139+
amount
116140
},
117141
status: 'active',
118142
start_date: startDate,
@@ -124,10 +148,10 @@ module.exports = class MemberBREADService {
124148
id: '',
125149
price_id: '',
126150
nickname,
127-
amount: 0,
128-
interval: 'year',
151+
amount,
152+
interval,
129153
type: 'recurring',
130-
currency: 'USD',
154+
currency,
131155
product: {
132156
id: '',
133157
product_id: product.id
@@ -283,6 +307,30 @@ module.exports = class MemberBREADService {
283307
}
284308
}
285309

310+
/**
311+
* @private
312+
* Fetches active redeemed gifts for any gift-status members in the input list.
313+
* @param {import('bookshelf').Model[]} members - Bookshelf member models
314+
* @returns {Promise<Map<string, {cadence: 'month' | 'year', currency: string, amount: number}>>} keyed by member.id → active Gift
315+
*/
316+
async fetchActiveGiftsForMembers(members) {
317+
const giftMemberIds = members
318+
.filter(m => m.get('status') === 'gift')
319+
.map(m => m.id);
320+
321+
if (giftMemberIds.length === 0) {
322+
return new Map();
323+
}
324+
325+
try {
326+
return await this.giftService.service.getActiveByMembers(giftMemberIds);
327+
} catch (e) {
328+
logging.error(`Failed to load active gifts for members - ${giftMemberIds.join(', ')}.`);
329+
logging.error(e);
330+
return new Map();
331+
}
332+
}
333+
286334
async read(data, options = {}) {
287335
const defaultWithRelated = [
288336
'labels',
@@ -324,12 +372,13 @@ module.exports = class MemberBREADService {
324372
const stripeSubscriptions = model.related('stripeSubscriptions');
325373

326374
member.subscriptions = member.subscriptions.filter(sub => !!sub.price);
327-
this.attachSubscriptionsToMember(member);
328375

329-
const [offerMap, offerRedemptionsMap] = await Promise.all([
376+
const [offerMap, offerRedemptionsMap, giftMap] = await Promise.all([
330377
this.fetchSubscriptionOffers(stripeSubscriptions),
331-
this.fetchSubscriptionOfferRedemptions(stripeSubscriptions)
378+
this.fetchSubscriptionOfferRedemptions(stripeSubscriptions),
379+
this.fetchActiveGiftsForMembers([model])
332380
]);
381+
this.attachSubscriptionsToMember(member, giftMap);
333382
this.attachOffersToSubscriptions(member, offerMap, offerRedemptionsMap);
334383
this.attachNextPaymentToSubscriptions(member);
335384
await this.attachAttributionsToMember(member, subscriptionIdMap);
@@ -578,17 +627,18 @@ module.exports = class MemberBREADService {
578627
}
579628

580629
const subscriptions = page.data.flatMap(model => model.related('stripeSubscriptions').slice());
581-
const [offerMap, offerRedemptionsMap] = await Promise.all([
630+
const [offerMap, offerRedemptionsMap, giftMap] = await Promise.all([
582631
this.fetchSubscriptionOffers(subscriptions),
583-
this.fetchSubscriptionOfferRedemptions(subscriptions)
632+
this.fetchSubscriptionOfferRedemptions(subscriptions),
633+
this.fetchActiveGiftsForMembers(page.data)
584634
]);
585635

586636
const bulkSuppressionData = await this.emailSuppressionList.getBulkSuppressionData(page.data.map(member => member.get('email')));
587637

588638
const data = page.data.map((model, index) => {
589639
const member = model.toJSON(options);
590640
member.subscriptions = member.subscriptions.filter(sub => !!sub.price);
591-
this.attachSubscriptionsToMember(member);
641+
this.attachSubscriptionsToMember(member, giftMap);
592642
this.attachOffersToSubscriptions(member, offerMap, offerRedemptionsMap);
593643
this.attachNextPaymentToSubscriptions(member);
594644
if (!originalWithRelated.includes('products')) {

ghost/core/test/e2e-api/admin/members.test.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const testUtils = require('../../utils');
1414
const Papa = require('papaparse');
1515

1616
const models = require('../../../core/server/models');
17+
const {knex} = require('../../../core/server/data/db');
1718
const membersService = require('../../../core/server/services/members');
1819
const memberAttributionService = require('../../../core/server/services/member-attribution');
1920
const urlService = require('../../../core/server/services/url');
@@ -906,6 +907,85 @@ describe('Members API', function () {
906907
});
907908
});
908909

910+
it('Can read a member with an active gift subscription', async function () {
911+
const member = await createGiftMember({email: 'gift-member-api-shape@test.com'});
912+
const paidProduct = await getPaidProduct();
913+
let gift;
914+
915+
try {
916+
await models.Member.edit({
917+
products: [{id: paidProduct.id}]
918+
}, {id: member.id});
919+
920+
const giftAmount = 1500;
921+
const giftCurrency = 'eur';
922+
const giftCadence = 'month';
923+
924+
gift = await models.Gift.add({
925+
token: `gift-admin-shape-${member.id}`,
926+
buyer_email: 'gift-buyer@test.com',
927+
buyer_member_id: null,
928+
redeemer_member_id: member.id,
929+
tier_id: paidProduct.id,
930+
cadence: giftCadence,
931+
duration: 1,
932+
currency: giftCurrency,
933+
amount: giftAmount,
934+
stripe_checkout_session_id: `cs_admin_shape_${member.id}`,
935+
stripe_payment_intent_id: `pi_admin_shape_${member.id}`,
936+
consumes_at: new Date('2099-01-01T00:00:00.000Z'),
937+
expires_at: new Date('2099-01-01T00:00:00.000Z'),
938+
status: 'redeemed',
939+
purchased_at: new Date(),
940+
redeemed_at: new Date(),
941+
consumed_at: null,
942+
expired_at: null,
943+
refunded_at: null
944+
});
945+
946+
const {body: readBody} = await agent
947+
.get(`/members/${member.id}/`)
948+
.expectStatus(200);
949+
950+
assert.equal(readBody.members[0].status, 'gift');
951+
assert.equal(readBody.members[0].subscriptions.length, 1, 'Gift member should expose a single synthetic subscription');
952+
953+
const readSub = readBody.members[0].subscriptions[0];
954+
assert.equal(readSub.plan.nickname, 'Gift subscription');
955+
assert.equal(readSub.price.nickname, 'Gift subscription');
956+
assert.equal(readSub.plan.amount, giftAmount);
957+
assert.equal(readSub.plan.currency, giftCurrency);
958+
assert.equal(readSub.plan.interval, giftCadence);
959+
assert.equal(readSub.price.amount, giftAmount);
960+
assert.equal(readSub.price.currency, giftCurrency);
961+
assert.equal(readSub.price.interval, giftCadence);
962+
963+
const {body: browseBody} = await agent
964+
.get(`/members/?filter=${encodeURIComponent(`id:${member.id}`)}`)
965+
.expectStatus(200);
966+
967+
const browsedMember = browseBody.members.find(m => m.id === member.id);
968+
assert.ok(browsedMember, 'Gift member should appear in the browse response');
969+
970+
const browseSub = browsedMember.subscriptions[0];
971+
assert.equal(browseSub.plan.nickname, 'Gift subscription');
972+
assert.equal(browseSub.price.nickname, 'Gift subscription');
973+
assert.equal(browseSub.plan.amount, giftAmount);
974+
assert.equal(browseSub.plan.currency, giftCurrency);
975+
assert.equal(browseSub.plan.interval, giftCadence);
976+
assert.equal(browseSub.price.amount, giftAmount);
977+
assert.equal(browseSub.price.currency, giftCurrency);
978+
assert.equal(browseSub.price.interval, giftCadence);
979+
} finally {
980+
// Avoid leaking this fixture into later tests that count members.
981+
// Gift.destroy is blocked by the model — fall back to a raw delete.
982+
if (gift) {
983+
await knex('gifts').where({id: gift.id}).del();
984+
}
985+
await models.Member.destroy({id: member.id});
986+
}
987+
});
988+
909989
// Create a member
910990

911991
it('Can add', async function () {

0 commit comments

Comments
 (0)