ghost 5.116.2 → 5.118.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/components/{tryghost-api-framework-5.116.2.tgz → tryghost-api-framework-5.118.0.tgz} +0 -0
  2. package/components/tryghost-constants-5.118.0.tgz +0 -0
  3. package/components/tryghost-custom-fonts-5.118.0.tgz +0 -0
  4. package/components/tryghost-custom-theme-settings-service-5.118.0.tgz +0 -0
  5. package/components/tryghost-domain-events-5.118.0.tgz +0 -0
  6. package/components/tryghost-donations-5.118.0.tgz +0 -0
  7. package/components/tryghost-email-addresses-5.118.0.tgz +0 -0
  8. package/components/{tryghost-email-service-5.116.2.tgz → tryghost-email-service-5.118.0.tgz} +0 -0
  9. package/components/tryghost-email-suppression-list-5.118.0.tgz +0 -0
  10. package/components/tryghost-html-to-plaintext-5.118.0.tgz +0 -0
  11. package/components/tryghost-i18n-5.118.0.tgz +0 -0
  12. package/components/{tryghost-job-manager-5.116.2.tgz → tryghost-job-manager-5.118.0.tgz} +0 -0
  13. package/components/tryghost-link-replacer-5.118.0.tgz +0 -0
  14. package/components/{tryghost-magic-link-5.116.2.tgz → tryghost-magic-link-5.118.0.tgz} +0 -0
  15. package/components/{tryghost-member-attribution-5.116.2.tgz → tryghost-member-attribution-5.118.0.tgz} +0 -0
  16. package/components/tryghost-member-events-5.118.0.tgz +0 -0
  17. package/components/{tryghost-members-csv-5.116.2.tgz → tryghost-members-csv-5.118.0.tgz} +0 -0
  18. package/components/{tryghost-members-offers-5.116.2.tgz → tryghost-members-offers-5.118.0.tgz} +0 -0
  19. package/components/tryghost-mw-error-handler-5.118.0.tgz +0 -0
  20. package/components/tryghost-mw-vhost-5.118.0.tgz +0 -0
  21. package/components/{tryghost-post-events-5.116.2.tgz → tryghost-post-events-5.118.0.tgz} +0 -0
  22. package/components/tryghost-post-revisions-5.118.0.tgz +0 -0
  23. package/components/tryghost-posts-service-5.118.0.tgz +0 -0
  24. package/components/tryghost-prometheus-metrics-5.118.0.tgz +0 -0
  25. package/components/tryghost-security-5.118.0.tgz +0 -0
  26. package/components/tryghost-tiers-5.118.0.tgz +0 -0
  27. package/components/tryghost-webmentions-5.118.0.tgz +0 -0
  28. package/content/themes/casper/LICENSE +1 -1
  29. package/content/themes/casper/README.md +1 -1
  30. package/content/themes/casper/assets/built/screen.css +1 -1
  31. package/content/themes/casper/assets/built/screen.css.map +1 -1
  32. package/content/themes/casper/assets/css/screen.css +1 -1
  33. package/content/themes/casper/author.hbs +23 -2
  34. package/content/themes/casper/package.json +2 -2
  35. package/content/themes/casper/partials/icons/bluesky.hbs +3 -0
  36. package/content/themes/casper/partials/icons/instagram.hbs +5 -0
  37. package/content/themes/casper/partials/icons/linkedin.hbs +3 -0
  38. package/content/themes/casper/partials/icons/mastodon.hbs +3 -0
  39. package/content/themes/casper/partials/icons/threads.hbs +3 -0
  40. package/content/themes/casper/partials/icons/tiktok.hbs +3 -0
  41. package/content/themes/casper/partials/icons/twitter.hbs +3 -1
  42. package/content/themes/casper/partials/icons/youtube.hbs +3 -0
  43. package/content/themes/source/LICENSE +1 -1
  44. package/content/themes/source/README.md +1 -1
  45. package/content/themes/source/assets/built/screen.css +1 -1
  46. package/content/themes/source/assets/built/screen.css.map +1 -1
  47. package/content/themes/source/assets/css/screen.css +7 -12
  48. package/content/themes/source/author.hbs +24 -3
  49. package/content/themes/source/package.json +2 -2
  50. package/content/themes/source/partials/feature-image.hbs +2 -2
  51. package/content/themes/source/partials/icons/bluesky.hbs +3 -0
  52. package/content/themes/source/partials/icons/instagram.hbs +5 -0
  53. package/content/themes/source/partials/icons/linkedin.hbs +3 -0
  54. package/content/themes/source/partials/icons/mastodon.hbs +3 -0
  55. package/content/themes/source/partials/icons/threads.hbs +3 -0
  56. package/content/themes/source/partials/icons/tiktok.hbs +3 -0
  57. package/content/themes/source/partials/icons/youtube.hbs +3 -0
  58. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +31793 -26588
  59. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-550846e0.mjs → CodeEditorView-1143c509.mjs} +2 -2
  60. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
  61. package/core/built/admin/assets/admin-x-settings/{index-f3cb3f4d.mjs → index-19ebc8ad.mjs} +2 -2
  62. package/core/built/admin/assets/admin-x-settings/{index-4ce2fcd1.mjs → index-ac104f42.mjs} +2635 -2607
  63. package/core/built/admin/assets/admin-x-settings/{modals-6bc20529.mjs → modals-994901ee.mjs} +6680 -6165
  64. package/core/built/admin/assets/{chunk.524.578de86e5014b911b05a.js → chunk.524.5710919eb507b9a81166.js} +8 -8
  65. package/core/built/admin/assets/{chunk.582.21bf3e37b5d84ac4b58a.js → chunk.582.c8cb99b85cfa13fc7df1.js} +10 -10
  66. package/core/built/admin/assets/{chunk.713.761d11035fe0bf3e557c.js → chunk.713.48f120c377bcaffdfddf.js} +6 -9
  67. package/core/built/admin/assets/{ghost-868c537d5c02ca65323d0122596a67ec.js → ghost-cd90a28b214ee800a007bb62cd45e6e6.js} +780 -775
  68. package/core/built/admin/assets/posts/posts.js +11561 -11302
  69. package/core/built/admin/assets/stats/stats.js +76076 -59355
  70. package/core/built/admin/index.html +4 -4
  71. package/core/frontend/helpers/social_url.js +31 -0
  72. package/core/server/api/endpoints/users.js +7 -0
  73. package/core/server/api/endpoints/utils/serializers/output/config.js +2 -1
  74. package/core/server/data/migrations/versions/5.117/2025-04-14-02-36-30-add-additional-social-accounts-columns-to-user-table.js +38 -0
  75. package/core/server/data/schema/schema.js +7 -0
  76. package/core/server/services/auth/session/index.js +5 -2
  77. package/core/server/services/auth/session/middleware.js +2 -1
  78. package/core/server/services/auth/session/session-service.js +7 -6
  79. package/core/server/services/members/api.js +2 -2
  80. package/core/server/services/members/members-api/controllers/MemberController.js +214 -0
  81. package/core/server/services/members/members-api/controllers/RouterController.js +667 -0
  82. package/core/server/services/members/members-api/controllers/WellKnownController.js +46 -0
  83. package/core/server/services/members/members-api/members-api.js +404 -0
  84. package/core/server/services/members/members-api/repositories/EventRepository.js +984 -0
  85. package/core/server/services/members/members-api/repositories/MemberRepository.js +1739 -0
  86. package/core/server/services/members/members-api/repositories/ProductRepository.js +662 -0
  87. package/core/server/services/members/members-api/services/GeolocationService.js +23 -0
  88. package/core/server/services/members/members-api/services/MemberBREADService.js +444 -0
  89. package/core/server/services/members/members-api/services/PaymentsService.js +522 -0
  90. package/core/server/services/members/members-api/services/TokenService.js +54 -0
  91. package/core/server/services/milestones/BookshelfMilestoneRepository.js +8 -9
  92. package/core/server/services/milestones/InMemoryMilestoneRepository.js +119 -0
  93. package/core/server/services/milestones/Milestone.js +231 -0
  94. package/core/server/services/milestones/MilestoneCreatedEvent.js +22 -0
  95. package/core/server/services/milestones/MilestonesService.js +327 -0
  96. package/core/server/services/milestones/service.js +2 -2
  97. package/core/server/services/newsletters/index.js +1 -1
  98. package/core/server/services/public-config/config.js +2 -1
  99. package/core/server/services/settings/settings-service.js +1 -1
  100. package/core/server/services/slack-notifications/SlackNotifications.js +1 -1
  101. package/core/server/services/slack-notifications/SlackNotificationsService.js +2 -2
  102. package/core/server/services/staff/StaffService.js +1 -1
  103. package/core/shared/config/defaults.json +3 -0
  104. package/core/shared/config/env/config.testing-mysql.json +3 -0
  105. package/core/shared/config/env/config.testing.json +3 -0
  106. package/core/shared/labs.js +2 -2
  107. package/package.json +63 -63
  108. package/tsconfig.tsbuildinfo +1 -1
  109. package/yarn.lock +306 -70
  110. package/components/tryghost-constants-5.116.2.tgz +0 -0
  111. package/components/tryghost-custom-fonts-5.116.2.tgz +0 -0
  112. package/components/tryghost-custom-theme-settings-service-5.116.2.tgz +0 -0
  113. package/components/tryghost-domain-events-5.116.2.tgz +0 -0
  114. package/components/tryghost-donations-5.116.2.tgz +0 -0
  115. package/components/tryghost-email-addresses-5.116.2.tgz +0 -0
  116. package/components/tryghost-email-suppression-list-5.116.2.tgz +0 -0
  117. package/components/tryghost-html-to-plaintext-5.116.2.tgz +0 -0
  118. package/components/tryghost-i18n-5.116.2.tgz +0 -0
  119. package/components/tryghost-link-replacer-5.116.2.tgz +0 -0
  120. package/components/tryghost-member-events-5.116.2.tgz +0 -0
  121. package/components/tryghost-members-api-5.116.2.tgz +0 -0
  122. package/components/tryghost-milestones-5.116.2.tgz +0 -0
  123. package/components/tryghost-mw-error-handler-5.116.2.tgz +0 -0
  124. package/components/tryghost-mw-vhost-5.116.2.tgz +0 -0
  125. package/components/tryghost-post-revisions-5.116.2.tgz +0 -0
  126. package/components/tryghost-posts-service-5.116.2.tgz +0 -0
  127. package/components/tryghost-prometheus-metrics-5.116.2.tgz +0 -0
  128. package/components/tryghost-security-5.116.2.tgz +0 -0
  129. package/components/tryghost-tiers-5.116.2.tgz +0 -0
  130. package/components/tryghost-webmentions-5.116.2.tgz +0 -0
  131. /package/core/built/admin/assets/{chunk.713.761d11035fe0bf3e557c.js.LICENSE.txt → chunk.713.48f120c377bcaffdfddf.js.LICENSE.txt} +0 -0
@@ -0,0 +1,1739 @@
1
+ const _ = require('lodash');
2
+ const errors = require('@tryghost/errors');
3
+ const logging = require('@tryghost/logging');
4
+ const tpl = require('@tryghost/tpl');
5
+ const DomainEvents = require('@tryghost/domain-events');
6
+ const {SubscriptionActivatedEvent, MemberCreatedEvent, SubscriptionCreatedEvent, MemberSubscribeEvent, SubscriptionCancelledEvent, OfferRedemptionEvent} = require('@tryghost/member-events');
7
+ const ObjectId = require('bson-objectid').default;
8
+ const {NotFoundError} = require('@tryghost/errors');
9
+ const validator = require('@tryghost/validator');
10
+ const crypto = require('crypto');
11
+
12
+ const messages = {
13
+ noStripeConnection: 'Cannot {action} without a Stripe Connection',
14
+ moreThanOneProduct: 'A member cannot have more than one Product',
15
+ addProductWithActiveSubscription: 'Cannot add comped Products to a Member with active Subscriptions',
16
+ deleteProductWithActiveSubscription: 'Cannot delete a non-comped Product from a Member, because it has an active Subscription for the same product',
17
+ memberNotFound: 'Could not find Member {id}',
18
+ subscriptionNotFound: 'Could not find Subscription {id}',
19
+ productNotFound: 'Could not find Product {id}',
20
+ bulkActionRequiresFilter: 'Cannot perform {action} without a filter or all=true',
21
+ tierArchived: 'Cannot use archived Tiers',
22
+ invalidEmail: 'Invalid Email'
23
+ };
24
+
25
+ const SUBSCRIPTION_STATUS_TRIALING = 'trialing';
26
+
27
+ /**
28
+ * @typedef {object} ITokenService
29
+ * @prop {(token: string) => Promise<import('jsonwebtoken').JwtPayload>} decodeToken
30
+ */
31
+
32
+ module.exports = class MemberRepository {
33
+ /**
34
+ * @param {object} deps
35
+ * @param {any} deps.Member
36
+ * @param {any} deps.MemberNewsletter
37
+ * @param {any} deps.MemberCancelEvent
38
+ * @param {any} deps.MemberSubscribeEventModel
39
+ * @param {any} deps.MemberEmailChangeEvent
40
+ * @param {any} deps.MemberPaidSubscriptionEvent
41
+ * @param {any} deps.MemberStatusEvent
42
+ * @param {any} deps.MemberProductEvent
43
+ * @param {any} deps.StripeCustomer
44
+ * @param {any} deps.StripeCustomerSubscription
45
+ * @param {any} deps.OfferRedemption
46
+ * @param {import('../../services/stripe-api')} deps.stripeAPIService
47
+ * @param {any} deps.labsService
48
+ * @param {any} deps.productRepository
49
+ * @param {any} deps.offerRepository
50
+ * @param {ITokenService} deps.tokenService
51
+ * @param {any} deps.newslettersService
52
+ */
53
+ constructor({
54
+ Member,
55
+ MemberNewsletter,
56
+ MemberCancelEvent,
57
+ MemberSubscribeEventModel,
58
+ MemberEmailChangeEvent,
59
+ MemberPaidSubscriptionEvent,
60
+ MemberStatusEvent,
61
+ MemberProductEvent,
62
+ StripeCustomer,
63
+ StripeCustomerSubscription,
64
+ OfferRedemption,
65
+ stripeAPIService,
66
+ labsService,
67
+ productRepository,
68
+ offerRepository,
69
+ tokenService,
70
+ newslettersService
71
+ }) {
72
+ this._Member = Member;
73
+ this._MemberNewsletter = MemberNewsletter;
74
+ this._MemberCancelEvent = MemberCancelEvent;
75
+ this._MemberSubscribeEvent = MemberSubscribeEventModel;
76
+ this._MemberEmailChangeEvent = MemberEmailChangeEvent;
77
+ this._MemberPaidSubscriptionEvent = MemberPaidSubscriptionEvent;
78
+ this._MemberStatusEvent = MemberStatusEvent;
79
+ this._MemberProductEvent = MemberProductEvent;
80
+ this._OfferRedemption = OfferRedemption;
81
+ this._StripeCustomer = StripeCustomer;
82
+ this._StripeCustomerSubscription = StripeCustomerSubscription;
83
+ this._stripeAPIService = stripeAPIService;
84
+ this._productRepository = productRepository;
85
+ this._offerRepository = offerRepository;
86
+ this.tokenService = tokenService;
87
+ this._newslettersService = newslettersService;
88
+ this._labsService = labsService;
89
+
90
+ DomainEvents.subscribe(OfferRedemptionEvent, async function (event) {
91
+ if (!event.data.offerId) {
92
+ return;
93
+ }
94
+
95
+ // To be extra safe, check if the redemption already exists before adding it
96
+ const existingRedemption = await OfferRedemption.findOne({
97
+ member_id: event.data.memberId,
98
+ subscription_id: event.data.subscriptionId,
99
+ offer_id: event.data.offerId
100
+ });
101
+
102
+ if (!existingRedemption) {
103
+ await OfferRedemption.add({
104
+ member_id: event.data.memberId,
105
+ subscription_id: event.data.subscriptionId,
106
+ offer_id: event.data.offerId,
107
+ created_at: event.timestamp || Date.now()
108
+ });
109
+ }
110
+ });
111
+ }
112
+
113
+ dispatchEvent(event, options) {
114
+ if (options?.transacting) {
115
+ // Only dispatch the event after the transaction has finished
116
+ options.transacting.executionPromise.then(async () => {
117
+ DomainEvents.dispatch(event);
118
+ }).catch((err) => {
119
+ // catches transaction errors/rollback to not dispatch event
120
+ logging.error({
121
+ err,
122
+ message: `Error dispatching event ${event.constructor.name} for member ${event.data.memberId} after transaction finished`
123
+ });
124
+ });
125
+ } else {
126
+ DomainEvents.dispatch(event);
127
+ }
128
+ }
129
+
130
+ isActiveSubscriptionStatus(status) {
131
+ return ['active', 'trialing', 'unpaid', 'past_due'].includes(status);
132
+ }
133
+
134
+ isComplimentarySubscription(subscription) {
135
+ return subscription.plan && subscription.plan.nickname && subscription.plan.nickname.toLowerCase() === 'complimentary';
136
+ }
137
+
138
+ /**
139
+ * Maps the framework context to members_*.source table record value
140
+ * @param {Object} context instance of ghost framework context object
141
+ * @returns {'import' | 'system' | 'api' | 'admin' | 'member'}
142
+ */
143
+ _resolveContextSource(context) {
144
+ let source;
145
+
146
+ if (context.import || context.importer) {
147
+ source = 'import';
148
+ } else if (context.internal) {
149
+ source = 'system';
150
+ } else if (context.api_key) {
151
+ source = 'api';
152
+ } else if (context.user) {
153
+ source = 'admin';
154
+ } else {
155
+ source = 'member';
156
+ }
157
+
158
+ return source;
159
+ }
160
+
161
+ getMRR({interval, amount, status = null, canceled = false, discount = null}) {
162
+ if (status === 'trialing') {
163
+ return 0;
164
+ }
165
+ if (status === 'incomplete') {
166
+ return 0;
167
+ }
168
+ if (status === 'incomplete_expired') {
169
+ return 0;
170
+ }
171
+ if (status === 'canceled') {
172
+ return 0;
173
+ }
174
+
175
+ if (canceled) {
176
+ return 0;
177
+ }
178
+
179
+ let amountWithDiscount = amount;
180
+
181
+ if (discount && discount.end === null && discount.coupon && discount.coupon.duration === 'forever') {
182
+ // Discounts should only get applied when they are 'forever' discounts / they don't have an end date
183
+ if (discount.coupon.amount_off !== null) {
184
+ amountWithDiscount = Math.max(0, amountWithDiscount - discount.coupon.amount_off);
185
+ } else {
186
+ amountWithDiscount = Math.round((amountWithDiscount * (100 - discount.coupon.percent_off)) / 100);
187
+ }
188
+ }
189
+
190
+ if (interval === 'year') {
191
+ return Math.floor(amountWithDiscount / 12);
192
+ }
193
+
194
+ if (interval === 'month') {
195
+ return amountWithDiscount;
196
+ }
197
+
198
+ if (interval === 'week') {
199
+ return amountWithDiscount * 4;
200
+ }
201
+
202
+ if (interval === 'day') {
203
+ return amountWithDiscount * 30;
204
+ }
205
+ }
206
+
207
+ async get(data, options) {
208
+ if (data.customer_id) {
209
+ const customer = await this._StripeCustomer.findOne({
210
+ customer_id: data.customer_id
211
+ }, {
212
+ withRelated: ['member']
213
+ });
214
+ if (customer) {
215
+ return customer.related('member');
216
+ }
217
+ return null;
218
+ }
219
+ return await this._Member.findOne(data, options);
220
+ }
221
+
222
+ async getByToken(token, options) {
223
+ const data = await this.tokenService.decodeToken(token);
224
+
225
+ return this.get({
226
+ email: data.sub
227
+ }, options);
228
+ }
229
+
230
+ _generateTransientId() {
231
+ return crypto.randomUUID();
232
+ }
233
+
234
+ async cycleTransientId({id, email}) {
235
+ await this.update({
236
+ transient_id: this._generateTransientId()
237
+ }, {id, email});
238
+ }
239
+
240
+ /**
241
+ * Create a member
242
+ * @param {Object} data
243
+ * @param {string} data.email
244
+ * @param {string} [data.name]
245
+ * @param {string} [data.note]
246
+ * @param {(string|Object)[]} [data.labels]
247
+ * @param {boolean} [data.subscribed] (deprecated)
248
+ * @param {string} [data.geolocation]
249
+ * @param {Date} [data.created_at]
250
+ * @param {Object[]} [data.products]
251
+ * @param {Object[]} [data.newsletters]
252
+ * @param {Object} [data.stripeCustomer]
253
+ * @param {string} [data.offerId]
254
+ * @param {import('@tryghost/member-attribution/lib/Attribution').AttributionResource} [data.attribution]
255
+ * @param {boolean} [data.email_disabled]
256
+ * @param {*} options
257
+ * @returns
258
+ */
259
+ async create(data, options) {
260
+ if (!options) {
261
+ options = {};
262
+ }
263
+
264
+ if (!options.batch_id) {
265
+ // We'll use this to link related events
266
+ options.batch_id = ObjectId().toHexString();
267
+ }
268
+
269
+ const {labels, stripeCustomer, offerId, attribution} = data;
270
+
271
+ if (labels) {
272
+ labels.forEach((label, index) => {
273
+ if (typeof label === 'string') {
274
+ labels[index] = {name: label};
275
+ }
276
+ });
277
+ }
278
+
279
+ const memberData = _.pick(data, ['email', 'name', 'note', 'subscribed', 'geolocation', 'created_at', 'products', 'newsletters', 'email_disabled']);
280
+
281
+ // Generate a random transient_id
282
+ memberData.transient_id = await this._generateTransientId();
283
+
284
+ // Throw error if email is invalid using latest validator
285
+ if (!validator.isEmail(memberData.email, {legacy: false})) {
286
+ throw new errors.ValidationError({
287
+ message: tpl(messages.invalidEmail),
288
+ property: 'email'
289
+ });
290
+ }
291
+
292
+ memberData.email_disabled = !!memberData.email_disabled;
293
+
294
+ if (memberData.products && memberData.products.length > 1) {
295
+ throw new errors.BadRequestError({message: tpl(messages.moreThanOneProduct)});
296
+ }
297
+
298
+ if (memberData.products) {
299
+ for (const productData of memberData.products) {
300
+ const product = await this._productRepository.get(productData);
301
+ if (product.get('active') !== true) {
302
+ throw new errors.BadRequestError({message: tpl(messages.tierArchived)});
303
+ }
304
+ }
305
+ }
306
+
307
+ const memberStatusData = {
308
+ status: 'free'
309
+ };
310
+
311
+ if (memberData.products && memberData.products.length === 1) {
312
+ memberStatusData.status = 'comped';
313
+ }
314
+
315
+ // Subscribe members to default newsletters
316
+ if (memberData.subscribed !== false && !memberData.newsletters) {
317
+ const browseOptions = _.pick(options, 'transacting');
318
+ memberData.newsletters = await this.getSubscribeOnSignupNewsletters(browseOptions);
319
+ }
320
+
321
+ const withRelated = options.withRelated ? options.withRelated : [];
322
+ if (!withRelated.includes('labels')) {
323
+ withRelated.push('labels');
324
+ }
325
+ if (!withRelated.includes('newsletters')) {
326
+ withRelated.push('newsletters');
327
+ }
328
+
329
+ const member = await this._Member.add({
330
+ ...memberData,
331
+ ...memberStatusData,
332
+ labels
333
+ }, {...options, withRelated});
334
+
335
+ for (const product of member.related('products').models) {
336
+ await this._MemberProductEvent.add({
337
+ member_id: member.id,
338
+ product_id: product.id,
339
+ action: 'added'
340
+ }, options);
341
+ }
342
+
343
+ const context = options && options.context || {};
344
+ const source = this._resolveContextSource(context);
345
+
346
+ const eventData = _.pick(data, ['created_at']);
347
+
348
+ if (!eventData.created_at) {
349
+ eventData.created_at = member.get('created_at');
350
+ }
351
+
352
+ await this._MemberStatusEvent.add({
353
+ member_id: member.id,
354
+ from_status: null,
355
+ to_status: member.get('status'),
356
+ ...eventData
357
+ }, options);
358
+
359
+ const newsletters = member.related('newsletters').models;
360
+
361
+ for (const newsletter of newsletters) {
362
+ await this._MemberSubscribeEvent.add({
363
+ member_id: member.id,
364
+ newsletter_id: newsletter.id,
365
+ subscribed: true,
366
+ source,
367
+ ...eventData
368
+ }, options);
369
+ }
370
+
371
+ if (newsletters && newsletters.length > 0) {
372
+ this.dispatchEvent(MemberSubscribeEvent.create({
373
+ memberId: member.id,
374
+ source: source
375
+ }, eventData.created_at), options);
376
+ }
377
+
378
+ // For paid members created via stripe checkout webhook event, link subscription
379
+ if (stripeCustomer) {
380
+ await this.upsertCustomer({
381
+ member_id: member.id,
382
+ customer_id: stripeCustomer.id,
383
+ name: stripeCustomer.name,
384
+ email: stripeCustomer.email
385
+ });
386
+
387
+ for (const subscription of stripeCustomer.subscriptions.data) {
388
+ try {
389
+ await this.linkSubscription({
390
+ id: member.id,
391
+ subscription,
392
+ offerId,
393
+ attribution
394
+ }, {batch_id: options.batch_id});
395
+ } catch (err) {
396
+ if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') {
397
+ throw err;
398
+ }
399
+ throw new errors.ConflictError({
400
+ err
401
+ });
402
+ }
403
+ }
404
+ }
405
+ this.dispatchEvent(MemberCreatedEvent.create({
406
+ memberId: member.id,
407
+ batchId: options.batch_id,
408
+ attribution: data.attribution,
409
+ source
410
+ }, eventData.created_at), options);
411
+
412
+ return member;
413
+ }
414
+
415
+ async getSubscribeOnSignupNewsletters(browseOptions) {
416
+ // By default subscribe to all active auto opt-in newsletters with members visibility
417
+ //TODO: Will mostly need to be updated later for paid-only newsletters
418
+ browseOptions.filter = 'status:active+subscribe_on_signup:true+visibility:members';
419
+ const newsletters = await this._newslettersService.getAll(browseOptions);
420
+ return newsletters || [];
421
+ }
422
+
423
+ async update(data, options) {
424
+ const sharedOptions = {
425
+ transacting: options.transacting
426
+ };
427
+
428
+ if (!options) {
429
+ options = {};
430
+ }
431
+
432
+ const withRelated = options.withRelated ? options.withRelated : [];
433
+ if (!withRelated.includes('labels')) {
434
+ withRelated.push('labels');
435
+ }
436
+ if (!withRelated.includes('newsletters')) {
437
+ withRelated.push('newsletters');
438
+ }
439
+
440
+ const memberData = _.pick(data, [
441
+ 'email',
442
+ 'name',
443
+ 'note',
444
+ 'subscribed',
445
+ 'labels',
446
+ 'geolocation',
447
+ 'products',
448
+ 'newsletters',
449
+ 'enable_comment_notifications',
450
+ 'last_seen_at',
451
+ 'last_commented_at',
452
+ 'expertise',
453
+ 'email_disabled',
454
+ 'transient_id'
455
+ ]);
456
+
457
+ // Trim whitespaces from expertise
458
+ if (memberData.expertise) {
459
+ memberData.expertise = memberData.expertise.trim();
460
+ }
461
+
462
+ // Determine if we need to fetch the initial member with relations
463
+ const needsProducts = this._stripeAPIService.configured && data.products;
464
+
465
+ // only update newsletters if we are receiving newsletter data
466
+ const needsNewsletters = memberData.newsletters || typeof memberData.subscribed === 'boolean';
467
+
468
+ // Build list for withRelated
469
+ const requiredRelations = [];
470
+ if (needsNewsletters) {
471
+ requiredRelations.push('newsletters');
472
+ }
473
+ if (needsProducts) {
474
+ requiredRelations.push('products');
475
+ }
476
+
477
+ // Fetch the member
478
+ let initialMember = await this._Member.findOne({
479
+ id: options.id
480
+ }, {...sharedOptions, withRelated: requiredRelations, require: false});
481
+
482
+ // Make sure we throw the right error if it doesn't exist
483
+ if (!initialMember) {
484
+ throw new NotFoundError({message: tpl(messages.memberNotFound, {id: options.id})});
485
+ }
486
+
487
+ // Throw error if email is invalid and it's been changed
488
+ if (
489
+ initialMember?.get('email') && memberData.email
490
+ && initialMember.get('email') !== memberData.email
491
+ && !validator.isEmail(memberData.email, {legacy: false})
492
+ ) {
493
+ throw new errors.ValidationError({
494
+ message: tpl(messages.invalidEmail),
495
+ property: 'email'
496
+ });
497
+ }
498
+
499
+ const memberStatusData = {};
500
+
501
+ let productsToAdd = [];
502
+ let productsToRemove = [];
503
+ if (needsProducts) {
504
+ const existingProducts = initialMember.related('products').models;
505
+ const existingProductIds = existingProducts.map(product => product.id);
506
+ const incomingProductIds = data.products.map(product => product.id);
507
+
508
+ if (incomingProductIds.length > 1 && incomingProductIds.length > existingProductIds.length) {
509
+ throw new errors.BadRequestError({message: tpl(messages.moreThanOneProduct)});
510
+ }
511
+
512
+ productsToAdd = _.differenceWith(incomingProductIds, existingProductIds);
513
+ productsToRemove = _.differenceWith(existingProductIds, incomingProductIds);
514
+ const productsToModify = productsToAdd.concat(productsToRemove);
515
+
516
+ if (productsToModify.length !== 0) {
517
+ // Load active subscriptions information
518
+ await initialMember.load(
519
+ [
520
+ 'stripeSubscriptions',
521
+ 'stripeSubscriptions.stripePrice',
522
+ 'stripeSubscriptions.stripePrice.stripeProduct',
523
+ 'stripeSubscriptions.stripePrice.stripeProduct.product'
524
+ ], sharedOptions);
525
+
526
+ const exisitingSubscriptions = initialMember.related('stripeSubscriptions')?.models ?? [];
527
+
528
+ if (productsToRemove.length > 0) {
529
+ // Only allow to delete comped products without a subscription attached to them
530
+ // Other products should be removed by canceling them via the related stripe subscription
531
+ const dontAllowToRemoveProductsIds = exisitingSubscriptions
532
+ .filter(sub => this.isActiveSubscriptionStatus(sub.get('status')))
533
+ .map(sub => sub.related('stripePrice')?.related('stripeProduct')?.get('product_id'));
534
+
535
+ for (const deleteId of productsToRemove) {
536
+ if (dontAllowToRemoveProductsIds.includes(deleteId)) {
537
+ throw new errors.BadRequestError({message: tpl(messages.deleteProductWithActiveSubscription)});
538
+ }
539
+ }
540
+
541
+ if (incomingProductIds.length === 0) {
542
+ // CASE: We are removing all (comped) products from a member & there were no active subscriptions - the member is "free"
543
+ memberStatusData.status = 'free';
544
+ }
545
+ }
546
+
547
+ if (productsToAdd.length > 0) {
548
+ // Don't allow to add complimentary subscriptions (= creating a new product) when the member already has an active
549
+ // subscription
550
+ const existingActiveSubscriptions = exisitingSubscriptions.filter((subscription) => {
551
+ return this.isActiveSubscriptionStatus(subscription.get('status'));
552
+ });
553
+
554
+ if (existingActiveSubscriptions.length) {
555
+ throw new errors.BadRequestError({message: tpl(messages.addProductWithActiveSubscription)});
556
+ }
557
+
558
+ // CASE: We are changing products & there were not active stripe subscriptions - the member is "comped"
559
+ memberStatusData.status = 'comped';
560
+ }
561
+ }
562
+ }
563
+
564
+ for (const productId of productsToAdd) {
565
+ const product = await this._productRepository.get({id: productId}, sharedOptions);
566
+ if (!product) {
567
+ throw new errors.BadRequestError({
568
+ message: tpl(messages.productNotFound, {
569
+ id: productId
570
+ })
571
+ });
572
+ }
573
+
574
+ if (product.get('active') !== true) {
575
+ throw new errors.BadRequestError({message: tpl(messages.tierArchived)});
576
+ }
577
+ }
578
+
579
+ // Keep track of the newsletters that were added and removed of a member so we can generate the corresponding events
580
+ let newslettersToAdd = [];
581
+ let newslettersToRemove = [];
582
+
583
+ if (needsNewsletters) {
584
+ const existingNewsletters = initialMember.related('newsletters').models;
585
+ // This maps the old subscribed property to the new newsletters field and is only used to keep backward compatibility
586
+ if (!memberData.newsletters) {
587
+ if (memberData.subscribed === false) {
588
+ memberData.newsletters = [];
589
+ } else if (memberData.subscribed === true && !existingNewsletters.find(n => n.get('status') === 'active')) {
590
+ const browseOptions = _.pick(options, 'transacting');
591
+ memberData.newsletters = await this.getSubscribeOnSignupNewsletters(browseOptions);
592
+ }
593
+ }
594
+
595
+ // only ever populated with active newsletters - never archived ones
596
+ if (memberData.newsletters) {
597
+ const archivedNewsletters = existingNewsletters.filter(n => n.get('status') === 'archived').map(n => n.id);
598
+ const existingNewsletterIds = existingNewsletters
599
+ .filter(newsletter => newsletter.attributes.status !== 'archived')
600
+ .map(newsletter => newsletter.id);
601
+ const incomingNewsletterIds = memberData.newsletters.map(newsletter => newsletter.id);
602
+ // make sure newslettersToAdd does not contain archived newsletters (since that creates false events)
603
+ newslettersToAdd = _.differenceWith(_.differenceWith(incomingNewsletterIds, existingNewsletterIds), archivedNewsletters);
604
+ newslettersToRemove = _.differenceWith(existingNewsletterIds, incomingNewsletterIds);
605
+ }
606
+
607
+ // need to maintain archived newsletters; these are not exposed by the members api
608
+ const archivedNewsletters = existingNewsletters.filter(n => n.attributes.status === 'archived');
609
+
610
+ if (archivedNewsletters.length > 0) {
611
+ // if (!memberData.newsletters) {
612
+ // memberData.newsletters = [];
613
+ // }
614
+ archivedNewsletters.forEach(n => memberData.newsletters.push(n));
615
+ }
616
+ }
617
+
618
+ const member = await this._Member.edit({
619
+ ...memberData,
620
+ ...memberStatusData
621
+ }, {...options, withRelated});
622
+
623
+ for (const productToAdd of productsToAdd) {
624
+ await this._MemberProductEvent.add({
625
+ member_id: member.id,
626
+ product_id: productToAdd,
627
+ action: 'added'
628
+ }, options);
629
+ }
630
+
631
+ for (const productToRemove of productsToRemove) {
632
+ await this._MemberProductEvent.add({
633
+ member_id: member.id,
634
+ product_id: productToRemove,
635
+ action: 'removed'
636
+ }, options);
637
+ }
638
+
639
+ // Add subscribe events for all (un)subscribed newsletters
640
+ const context = options && options.context || {};
641
+ const source = this._resolveContextSource(context);
642
+
643
+ for (const newsletterToAdd of newslettersToAdd) {
644
+ await this._MemberSubscribeEvent.add({
645
+ member_id: member.id,
646
+ newsletter_id: newsletterToAdd,
647
+ subscribed: true,
648
+ source
649
+ }, sharedOptions);
650
+ }
651
+
652
+ for (const newsletterToRemove of newslettersToRemove) {
653
+ await this._MemberSubscribeEvent.add({
654
+ member_id: member.id,
655
+ newsletter_id: newsletterToRemove,
656
+ subscribed: false,
657
+ source
658
+ }, sharedOptions);
659
+ }
660
+
661
+ if (newslettersToAdd.length > 0 || newslettersToRemove.length > 0) {
662
+ this.dispatchEvent(MemberSubscribeEvent.create({
663
+ memberId: member.id,
664
+ source: source
665
+ }, member.updated_at), sharedOptions);
666
+ }
667
+
668
+ if (member.attributes.email !== member._previousAttributes.email) {
669
+ await this._MemberEmailChangeEvent.add({
670
+ member_id: member.id,
671
+ from_email: member._previousAttributes.email,
672
+ to_email: member.get('email')
673
+ }, sharedOptions);
674
+ }
675
+
676
+ if (member.attributes.status !== member._previousAttributes.status) {
677
+ await this._MemberStatusEvent.add({
678
+ member_id: member.id,
679
+ from_status: member._previousAttributes.status,
680
+ to_status: member.get('status')
681
+ }, sharedOptions);
682
+ }
683
+
684
+ if (this._stripeAPIService.configured && member._changed.email) {
685
+ await member.related('stripeCustomers').fetch();
686
+ const customers = member.related('stripeCustomers');
687
+ for (const customer of customers.models) {
688
+ await this._stripeAPIService.updateCustomerEmail(
689
+ customer.get('customer_id'),
690
+ member.get('email')
691
+ );
692
+ }
693
+ }
694
+
695
+ return member;
696
+ }
697
+
698
+ async list(options) {
699
+ return this._Member.findPage(options);
700
+ }
701
+
702
+ async destroy(data, options) {
703
+ const member = await this._Member.findOne(data, options);
704
+
705
+ if (!member) {
706
+ // throw error?
707
+ return;
708
+ }
709
+
710
+ if (this._stripeAPIService.configured && options.cancelStripeSubscriptions) {
711
+ await member.related('stripeSubscriptions').fetch();
712
+ const subscriptions = member.related('stripeSubscriptions');
713
+ for (const subscription of subscriptions.models) {
714
+ if (subscription.get('status') !== 'canceled') {
715
+ const updatedSubscription = await this._stripeAPIService.cancelSubscription(
716
+ subscription.get('subscription_id')
717
+ );
718
+
719
+ await this._StripeCustomerSubscription.upsert({
720
+ status: updatedSubscription.status,
721
+ mrr: 0
722
+ }, {
723
+ subscription_id: updatedSubscription.id
724
+ });
725
+
726
+ await this._MemberPaidSubscriptionEvent.add({
727
+ member_id: member.id,
728
+ source: 'stripe',
729
+ subscription_id: subscription.id,
730
+ from_plan: subscription.get('plan_id'),
731
+ to_plan: null,
732
+ currency: subscription.get('plan_currency'),
733
+ mrr_delta: -1 * subscription.get('mrr')
734
+ }, options);
735
+ }
736
+ }
737
+ }
738
+
739
+ return this._Member.destroy({
740
+ id: data.id
741
+ }, options);
742
+ }
743
+
744
+ async bulkDestroy(options) {
745
+ const {all, filter, search} = options;
746
+
747
+ if (!filter && !search && (!all || all !== true)) {
748
+ throw new errors.IncorrectUsageError({
749
+ message: tpl(messages.bulkActionRequiresFilter, {action: 'bulk delete'})
750
+ });
751
+ }
752
+
753
+ const filterOptions = _.pick(options, ['transacting', 'context']);
754
+
755
+ if (all !== true) {
756
+ // Include mongoTransformer to apply subscribed:{true|false} => newsletter relation mapping
757
+ Object.assign(filterOptions, _.pick(options, ['filter', 'search', 'mongoTransformer']));
758
+ }
759
+
760
+ const memberRows = await this._Member.getFilteredCollectionQuery(filterOptions)
761
+ .select('members.id')
762
+ .distinct();
763
+
764
+ const memberIds = memberRows.map(row => row.id);
765
+
766
+ const bulkDestroyResult = await this._Member.bulkDestroy(memberIds);
767
+
768
+ bulkDestroyResult.unsuccessfulIds = bulkDestroyResult.unsuccessfulData;
769
+
770
+ delete bulkDestroyResult.unsuccessfulData;
771
+
772
+ return bulkDestroyResult;
773
+ }
774
+
775
+ async bulkEdit(data, options) {
776
+ const {all, filter, search} = options;
777
+
778
+ if (!['unsubscribe', 'addLabel', 'removeLabel'].includes(data.action)) {
779
+ throw new errors.IncorrectUsageError({
780
+ message: 'Unsupported bulk action'
781
+ });
782
+ }
783
+
784
+ if (!filter && !search && (!all || all !== true)) {
785
+ throw new errors.IncorrectUsageError({
786
+ message: tpl(messages.bulkActionRequiresFilter, {action: 'bulk edit'})
787
+ });
788
+ }
789
+
790
+ const filterOptions = _.pick(options, ['transacting', 'context']);
791
+
792
+ if (all !== true) {
793
+ // Include mongoTransformer to apply subscribed:{true|false} => newsletter relation mapping
794
+ Object.assign(filterOptions, _.pick(options, ['filter', 'search', 'mongoTransformer']));
795
+ }
796
+ const memberRows = await this._Member.getFilteredCollectionQuery(filterOptions)
797
+ .select('members.id')
798
+ .distinct();
799
+
800
+ const memberIds = memberRows.map(row => row.id);
801
+
802
+ if (data.action === 'unsubscribe') {
803
+ const hasNewsletterSelected = (Object.prototype.hasOwnProperty.call(data, 'newsletter') && data.newsletter !== null);
804
+ if (hasNewsletterSelected) {
805
+ const membersArr = memberIds.map(i => `'${i}'`).join(',');
806
+ const unsubscribeRows = await this._MemberNewsletter.getFilteredCollectionQuery({
807
+ filter: `newsletter_id:'${data.newsletter}'+member_id:[${membersArr}]`
808
+ });
809
+ const toUnsubscribe = unsubscribeRows.map(row => row.id);
810
+
811
+ return await this._MemberNewsletter.bulkDestroy(toUnsubscribe);
812
+ }
813
+ if (!hasNewsletterSelected) {
814
+ return await this._Member.bulkDestroy(memberIds, 'members_newsletters', {column: 'member_id'});
815
+ }
816
+ }
817
+ if (data.action === 'removeLabel') {
818
+ const membersLabelsRows = await this._Member.getLabelRelations({
819
+ labelId: data.meta.label.id,
820
+ memberIds
821
+ });
822
+
823
+ const membersLabelsIds = membersLabelsRows.map(row => row.id);
824
+
825
+ return this._Member.bulkDestroy(membersLabelsIds, 'members_labels');
826
+ }
827
+
828
+ if (data.action === 'addLabel') {
829
+ const relations = memberIds.map((id) => {
830
+ return {
831
+ member_id: id,
832
+ label_id: data.meta.label.id,
833
+ id: ObjectId().toHexString()
834
+ };
835
+ });
836
+
837
+ return this._Member.bulkAdd(relations, 'members_labels');
838
+ }
839
+ }
840
+
841
+ async upsertCustomer(data) {
842
+ return await this._StripeCustomer.upsert({
843
+ customer_id: data.customer_id,
844
+ member_id: data.member_id,
845
+ name: data.name,
846
+ email: data.email
847
+ });
848
+ }
849
+
850
+ async linkStripeCustomer(data, options) {
851
+ if (!this._stripeAPIService.configured) {
852
+ throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'link Stripe Customer'})});
853
+ }
854
+ const customer = await this._stripeAPIService.getCustomer(data.customer_id);
855
+
856
+ if (!customer) {
857
+ return;
858
+ }
859
+
860
+ // Add instead of upsert ensures that we do not link existing customer
861
+ await this._StripeCustomer.add({
862
+ customer_id: data.customer_id,
863
+ member_id: data.member_id,
864
+ name: customer.name,
865
+ email: customer.email
866
+ }, options);
867
+
868
+ for (const subscription of customer.subscriptions.data) {
869
+ await this.linkSubscription({
870
+ id: data.member_id,
871
+ subscription
872
+ }, options);
873
+ }
874
+ }
875
+
876
+ async getCustomerIdByEmail(email) {
877
+ return this._stripeAPIService.getCustomerIdByEmail(email);
878
+ }
879
+
880
+ async getSubscriptionByStripeID(id, options) {
881
+ const subscription = await this._StripeCustomerSubscription.findOne({
882
+ subscription_id: id
883
+ }, options);
884
+
885
+ return subscription;
886
+ }
887
+
888
+ /**
889
+ *
890
+ * @param {Object} data
891
+ * @param {String} data.id - member ID
892
+ * @param {Object} data.subscription
893
+ * @param {String} data.offerId
894
+ * @param {import('@tryghost/member-attribution/lib/Attribution').AttributionResource} [data.attribution]
895
+ * @param {*} options
896
+ * @returns
897
+ */
898
+ async linkSubscription(data, options = {}) {
899
+ if (!this._stripeAPIService.configured) {
900
+ throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'link Stripe Subscription'})});
901
+ }
902
+
903
+ if (!options.transacting) {
904
+ return this._Member.transaction((transacting) => {
905
+ return this.linkSubscription(data, {
906
+ ...options,
907
+ transacting
908
+ });
909
+ });
910
+ }
911
+
912
+ if (!options.batch_id) {
913
+ options.batch_id = ObjectId().toHexString();
914
+ }
915
+
916
+ const member = await this._Member.findOne({
917
+ id: data.id
918
+ }, {...options, forUpdate: true});
919
+
920
+ const customer = await member.related('stripeCustomers').query({
921
+ where: {
922
+ customer_id: data.subscription.customer
923
+ }
924
+ }).fetchOne(options);
925
+
926
+ if (!customer) {
927
+ // Maybe just link the customer?
928
+ throw new errors.NotFoundError({message: tpl(messages.subscriptionNotFound)});
929
+ }
930
+
931
+ const subscription = await this._stripeAPIService.getSubscription(data.subscription.id);
932
+ let paymentMethodId;
933
+ if (!subscription.default_payment_method) {
934
+ paymentMethodId = null;
935
+ } else if (typeof subscription.default_payment_method === 'string') {
936
+ paymentMethodId = subscription.default_payment_method;
937
+ } else {
938
+ paymentMethodId = subscription.default_payment_method.id;
939
+ }
940
+ const paymentMethod = paymentMethodId ? await this._stripeAPIService.getCardPaymentMethod(paymentMethodId) : null;
941
+
942
+ const model = await this.getSubscriptionByStripeID(subscription.id, {...options, forUpdate: true});
943
+
944
+ const subscriptionPriceData = _.get(subscription, 'items.data[0].price');
945
+ let ghostProduct;
946
+ try {
947
+ ghostProduct = await this._productRepository.get({stripe_product_id: subscriptionPriceData.product}, options);
948
+ // Use first Ghost product as default product in case of missing link
949
+ if (!ghostProduct) {
950
+ ghostProduct = await this._productRepository.getDefaultProduct({
951
+ forUpdate: true,
952
+ ...options
953
+ });
954
+ }
955
+
956
+ // Link Stripe Product & Price to Ghost Product
957
+ if (ghostProduct) {
958
+ await this._productRepository.update({
959
+ id: ghostProduct.get('id'),
960
+ name: ghostProduct.get('name'),
961
+ stripe_prices: [
962
+ {
963
+ stripe_price_id: subscriptionPriceData.id,
964
+ stripe_product_id: subscriptionPriceData.product,
965
+ active: subscriptionPriceData.active,
966
+ nickname: subscriptionPriceData.nickname,
967
+ currency: subscriptionPriceData.currency,
968
+ amount: subscriptionPriceData.unit_amount,
969
+ type: subscriptionPriceData.type,
970
+ interval: (subscriptionPriceData.recurring && subscriptionPriceData.recurring.interval) || null
971
+ }
972
+ ]
973
+ }, options);
974
+ } else {
975
+ // Log error if no Ghost products found
976
+ logging.error(`There was an error linking subscription - ${subscription.id}, no Products exist.`);
977
+ }
978
+ } catch (e) {
979
+ logging.error(`Failed to handle prices and product for - ${subscription.id}.`);
980
+ logging.error(e);
981
+ }
982
+
983
+ let stripeCouponId = subscription.discount && subscription.discount.coupon ? subscription.discount.coupon.id : null;
984
+
985
+ // For trial offers, offer id is passed from metadata as there is no stripe coupon
986
+ let offerId = data.offerId || null;
987
+ let offer = null;
988
+
989
+ if (stripeCouponId) {
990
+ // Get the offer from our database
991
+ offer = await this._offerRepository.getByStripeCouponId(stripeCouponId, {transacting: options.transacting});
992
+ if (offer) {
993
+ offerId = offer.id;
994
+ } else {
995
+ logging.error(`Received an unknown stripe coupon id (${stripeCouponId}) for subscription - ${subscription.id}.`);
996
+ }
997
+ } else if (offerId) {
998
+ offer = await this._offerRepository.getById(offerId, {transacting: options.transacting});
999
+ }
1000
+
1001
+ const subscriptionData = {
1002
+ customer_id: subscription.customer,
1003
+ subscription_id: subscription.id,
1004
+ status: subscription.status,
1005
+ cancel_at_period_end: subscription.cancel_at_period_end,
1006
+ cancellation_reason: this.getCancellationReason(subscription),
1007
+ current_period_end: new Date(subscription.current_period_end * 1000),
1008
+ start_date: new Date(subscription.start_date * 1000),
1009
+ default_payment_card_last4: paymentMethod && paymentMethod.card && paymentMethod.card.last4 || null,
1010
+ stripe_price_id: subscriptionPriceData.id,
1011
+ plan_id: subscriptionPriceData.id,
1012
+ // trial start and end are returned as Stripe timestamps and need coversion
1013
+ trial_start_at: subscription.trial_start ? new Date(subscription.trial_start * 1000) : null,
1014
+ trial_end_at: subscription.trial_end ? new Date(subscription.trial_end * 1000) : null,
1015
+ // NOTE: Defaulting to interval as migration to nullable field
1016
+ // turned out to be much bigger problem.
1017
+ // Ideally, would need nickname field to be nullable on the DB level
1018
+ // condition can be simplified once this is done
1019
+ plan_nickname: subscriptionPriceData.nickname || _.get(subscriptionPriceData, 'recurring.interval'),
1020
+ plan_interval: _.get(subscriptionPriceData, 'recurring.interval', ''),
1021
+ plan_amount: subscriptionPriceData.unit_amount,
1022
+ plan_currency: subscriptionPriceData.currency,
1023
+ mrr: this.getMRR({
1024
+ interval: _.get(subscriptionPriceData, 'recurring.interval', ''),
1025
+ amount: subscriptionPriceData.unit_amount,
1026
+ status: subscription.status,
1027
+ canceled: subscription.cancel_at_period_end,
1028
+ discount: subscription.discount
1029
+ }),
1030
+ offer_id: offerId
1031
+ };
1032
+
1033
+ const getStatus = (modelToCheck) => {
1034
+ const status = modelToCheck.get('status');
1035
+ const canceled = modelToCheck.get('cancel_at_period_end');
1036
+
1037
+ if (status === 'canceled') {
1038
+ return 'expired';
1039
+ }
1040
+
1041
+ if (canceled) {
1042
+ return 'canceled';
1043
+ }
1044
+
1045
+ if (this.isActiveSubscriptionStatus(status)) {
1046
+ return 'active';
1047
+ }
1048
+
1049
+ return 'inactive';
1050
+ };
1051
+ let eventData = {};
1052
+
1053
+ const shouldBeDeleted = subscription.metadata && !!subscription.metadata.ghost_migrated_to && subscription.status === 'canceled';
1054
+ if (shouldBeDeleted) {
1055
+ logging.warn(`Subscription ${subscriptionData.subscription_id} is marked for deletion, skipping linking.`);
1056
+
1057
+ if (model) {
1058
+ // Delete all paid subscription events manually for this subscription
1059
+ // This is the only related event without a foreign key constraint
1060
+ await this._MemberPaidSubscriptionEvent.query().where('subscription_id', model.id).delete().transacting(options.transacting);
1061
+
1062
+ // Delete the subscription in the database because we don't want to show it in the UI or in our data calculations
1063
+ await model.destroy(options);
1064
+ }
1065
+ } else if (model) {
1066
+ // CASE: Offer is already mapped against sub, don't overwrite it with NULL
1067
+ // Needed for trial offers, which don't have a stripe coupon/discount attached to sub
1068
+ if (!subscriptionData.offer_id) {
1069
+ delete subscriptionData.offer_id;
1070
+ }
1071
+ const updated = await this._StripeCustomerSubscription.edit(subscriptionData, {
1072
+ ...options,
1073
+ id: model.id
1074
+ });
1075
+
1076
+ // CASE: Existing free member subscribes to a paid tier with an offer
1077
+ // Stripe doesn't send the discount/offer info in the subscription.created event
1078
+ // So we need to record the offer redemption event upon updating the subscription here
1079
+ if (model.get('offer_id') === null && subscriptionData.offer_id) {
1080
+ const event = OfferRedemptionEvent.create({
1081
+ memberId: member.id,
1082
+ offerId: subscriptionData.offer_id,
1083
+ subscriptionId: updated.id
1084
+ }, updated.get('created_at'));
1085
+ this.dispatchEvent(event, options);
1086
+ }
1087
+
1088
+ if (model.get('mrr') !== updated.get('mrr') || model.get('plan_id') !== updated.get('plan_id') || model.get('status') !== updated.get('status') || model.get('cancel_at_period_end') !== updated.get('cancel_at_period_end')) {
1089
+ const originalMrrDelta = model.get('mrr');
1090
+ const updatedMrrDelta = updated.get('mrr');
1091
+
1092
+ const getEventType = (originalStatus, updatedStatus) => {
1093
+ if (originalStatus === updatedStatus) {
1094
+ return 'updated';
1095
+ }
1096
+
1097
+ if (originalStatus === 'canceled' && updatedStatus === 'active') {
1098
+ return 'reactivated';
1099
+ }
1100
+
1101
+ return updatedStatus;
1102
+ };
1103
+
1104
+ const originalStatus = getStatus(model);
1105
+ const updatedStatus = getStatus(updated);
1106
+ const eventType = getEventType(originalStatus, updatedStatus);
1107
+
1108
+ const mrrDelta = updatedMrrDelta - originalMrrDelta;
1109
+
1110
+ await this._MemberPaidSubscriptionEvent.add({
1111
+ member_id: member.id,
1112
+ source: 'stripe',
1113
+ type: eventType,
1114
+ subscription_id: updated.id,
1115
+ from_plan: model.get('plan_id'),
1116
+ to_plan: updated.get('status') === 'canceled' ? null : updated.get('plan_id'),
1117
+ currency: subscriptionPriceData.currency,
1118
+ mrr_delta: mrrDelta
1119
+ }, options);
1120
+
1121
+ // Did we activate this subscription?
1122
+ // This happens when an incomplete subscription is completed
1123
+ // This always happens during the 3D secure flow, so it is important to catch
1124
+ if (originalStatus !== 'active' && updatedStatus === 'active') {
1125
+ const context = options?.context || {};
1126
+ const source = this._resolveContextSource(context);
1127
+
1128
+ const event = SubscriptionActivatedEvent.create({
1129
+ source,
1130
+ tierId: ghostProduct?.get('id'),
1131
+ memberId: member.id,
1132
+ subscriptionId: updated.get('id'),
1133
+ offerId: offerId,
1134
+ batchId: options.batch_id
1135
+ });
1136
+ this.dispatchEvent(event, options);
1137
+ }
1138
+
1139
+ // Dispatch cancellation event, i.e. send paid cancellation staff notification, if:
1140
+ // 1. The subscription has been set to cancel at period end, by the member in Portal, status 'canceled'
1141
+ // 2. The subscription has been immediately canceled (e.g. due to multiple failed payments), status 'expired'
1142
+ if (this.isActiveSubscriptionStatus(originalStatus) && (updatedStatus === 'canceled' || updatedStatus === 'expired')) {
1143
+ const context = options?.context || {};
1144
+ const source = this._resolveContextSource(context);
1145
+ const cancelNow = updatedStatus === 'expired';
1146
+ const canceledAt = new Date(subscription.canceled_at * 1000);
1147
+ const expiryAt = cancelNow ? canceledAt : updated.get('current_period_end');
1148
+
1149
+ const event = SubscriptionCancelledEvent.create({
1150
+ source,
1151
+ tierId: ghostProduct?.get('id'),
1152
+ memberId: member.id,
1153
+ subscriptionId: updated.get('id'),
1154
+ cancelNow,
1155
+ canceledAt,
1156
+ expiryAt
1157
+ });
1158
+
1159
+ this.dispatchEvent(event, options);
1160
+ }
1161
+ }
1162
+ } else {
1163
+ eventData.created_at = new Date(subscription.start_date * 1000);
1164
+ const subscriptionModel = await this._StripeCustomerSubscription.add(subscriptionData, options);
1165
+ await this._MemberPaidSubscriptionEvent.add({
1166
+ member_id: member.id,
1167
+ subscription_id: subscriptionModel.id,
1168
+ type: 'created',
1169
+ source: 'stripe',
1170
+ from_plan: null,
1171
+ to_plan: subscriptionPriceData.id,
1172
+ currency: subscriptionPriceData.currency,
1173
+ mrr_delta: subscriptionModel.get('mrr'),
1174
+ ...eventData
1175
+ }, options);
1176
+
1177
+ const context = options?.context || {};
1178
+ const source = this._resolveContextSource(context);
1179
+ const attribution = {
1180
+ id: data.attribution?.id ?? subscription.metadata?.attribution_id ?? null,
1181
+ url: data.attribution?.url ?? subscription.metadata?.attribution_url ?? null,
1182
+ type: data.attribution?.type ?? subscription.metadata?.attribution_type ?? null,
1183
+ referrerSource: data.attribution?.referrerSource ?? subscription.metadata?.referrer_source ?? null,
1184
+ referrerMedium: data.attribution?.referrerMedium ?? subscription.metadata?.referrer_medium ?? null,
1185
+ referrerUrl: data.attribution?.referrerUrl ?? subscription.metadata?.referrer_url ?? null
1186
+ };
1187
+
1188
+ const subscriptionCreatedEvent = SubscriptionCreatedEvent.create({
1189
+ source,
1190
+ tierId: ghostProduct?.get('id'),
1191
+ memberId: member.id,
1192
+ subscriptionId: subscriptionModel.get('id'),
1193
+ offerId: offerId,
1194
+ attribution: attribution,
1195
+ batchId: options.batch_id
1196
+ });
1197
+
1198
+ this.dispatchEvent(subscriptionCreatedEvent, options);
1199
+
1200
+ if (offerId) {
1201
+ const offerRedemptionEvent = OfferRedemptionEvent.create({
1202
+ memberId: member.id,
1203
+ offerId: offerId,
1204
+ subscriptionId: subscriptionModel.get('id')
1205
+ });
1206
+ this.dispatchEvent(offerRedemptionEvent, options);
1207
+ }
1208
+
1209
+ if (getStatus(subscriptionModel) === 'active') {
1210
+ const activatedEvent = SubscriptionActivatedEvent.create({
1211
+ source,
1212
+ tierId: ghostProduct?.get('id'),
1213
+ memberId: member.id,
1214
+ subscriptionId: subscriptionModel.get('id'),
1215
+ offerId: offerId,
1216
+ attribution: attribution,
1217
+ batchId: options.batch_id
1218
+ });
1219
+ this.dispatchEvent(activatedEvent, options);
1220
+ }
1221
+ }
1222
+
1223
+ let memberProducts = (await member.related('products').fetch(options)).toJSON();
1224
+ const oldMemberProducts = member.related('products').toJSON();
1225
+ let status = memberProducts.length === 0 ? 'free' : 'comped';
1226
+ if (!shouldBeDeleted && this.isActiveSubscriptionStatus(subscription.status)) {
1227
+ if (this.isComplimentarySubscription(subscription)) {
1228
+ status = 'comped';
1229
+ } else {
1230
+ status = 'paid';
1231
+ }
1232
+
1233
+ if (model) {
1234
+ // We might need to...
1235
+ // 1. delete the previous product from the linked member products (in case an existing subscription changed product/price)
1236
+ // 2. fix the list of products linked to a member (an existing subscription doesn't have a linked product to this member)
1237
+
1238
+ const subscriptions = await member.related('stripeSubscriptions').fetch(options);
1239
+
1240
+ const previousProduct = await this._productRepository.get({
1241
+ stripe_price_id: model.get('stripe_price_id')
1242
+ }, options);
1243
+
1244
+ if (previousProduct) {
1245
+ let activeSubscriptionForPreviousProduct = false;
1246
+
1247
+ for (const subscriptionModel of subscriptions.models) {
1248
+ if (this.isActiveSubscriptionStatus(subscriptionModel.get('status')) && subscriptionModel.id !== model.id) {
1249
+ try {
1250
+ const subscriptionProduct = await this._productRepository.get({stripe_price_id: subscriptionModel.get('stripe_price_id')}, options);
1251
+ if (subscriptionProduct && previousProduct && subscriptionProduct.id === previousProduct.id) {
1252
+ activeSubscriptionForPreviousProduct = true;
1253
+ }
1254
+
1255
+ if (subscriptionProduct && !memberProducts.find(p => p.id === subscriptionProduct.id)) {
1256
+ // Due to a bug in the past it is possible that this subscription's product wasn't added to the member products
1257
+ // So we need to add it again
1258
+ memberProducts.push(subscriptionProduct.toJSON());
1259
+ }
1260
+ } catch (e) {
1261
+ logging.error(`Failed to attach products to member - ${data.id}`);
1262
+ logging.error(e);
1263
+ }
1264
+ }
1265
+ }
1266
+
1267
+ if (!activeSubscriptionForPreviousProduct) {
1268
+ // We can safely remove the product from this member because it doesn't have any other remaining active subscription for it
1269
+ memberProducts = memberProducts.filter((product) => {
1270
+ return product.id !== previousProduct.id;
1271
+ });
1272
+ }
1273
+ }
1274
+ }
1275
+
1276
+ if (ghostProduct) {
1277
+ // Note: we add the product here
1278
+ // We don't override the products because in an edge case a member can have multiple subscriptions
1279
+ // We'll need to keep all the products related to those subscriptions to avoid creating other issues
1280
+ memberProducts.push(ghostProduct.toJSON());
1281
+ }
1282
+ } else {
1283
+ const subscriptions = await member.related('stripeSubscriptions').fetch(options);
1284
+ let activeSubscriptionForGhostProduct = false;
1285
+ for (const subscriptionModel of subscriptions.models) {
1286
+ if (this.isActiveSubscriptionStatus(subscriptionModel.get('status'))) {
1287
+ status = 'paid';
1288
+ try {
1289
+ const subscriptionProduct = await this._productRepository.get({stripe_price_id: subscriptionModel.get('stripe_price_id')}, options);
1290
+ if (subscriptionProduct && ghostProduct && subscriptionProduct.id === ghostProduct.id) {
1291
+ activeSubscriptionForGhostProduct = true;
1292
+ }
1293
+
1294
+ if (subscriptionProduct && !memberProducts.find(p => p.id === subscriptionProduct.id)) {
1295
+ // Due to a bug in the past it is possible that this subscription's product wasn't added to the member products
1296
+ // So we need to add it again
1297
+ memberProducts.push(subscriptionProduct.toJSON());
1298
+ }
1299
+ } catch (e) {
1300
+ logging.error(`Failed to attach products to member - ${data.id}`);
1301
+ logging.error(e);
1302
+ }
1303
+ }
1304
+ }
1305
+
1306
+ if (!activeSubscriptionForGhostProduct) {
1307
+ // We don't have an active subscription for this product anymore, so we can safely unlink it from the member
1308
+ memberProducts = memberProducts.filter((product) => {
1309
+ return product.id !== ghostProduct.id;
1310
+ });
1311
+ }
1312
+
1313
+ if (memberProducts.length === 0) {
1314
+ // If all products were removed, set the status back to 'free'
1315
+ status = 'free';
1316
+ }
1317
+ }
1318
+
1319
+ let updatedMember;
1320
+ try {
1321
+ // Remove duplicate products from the list
1322
+ memberProducts = _.uniqBy(memberProducts, function (e) {
1323
+ return e.id;
1324
+ });
1325
+ // Edit member with updated products assoicated
1326
+ updatedMember = await this._Member.edit({status: status, products: memberProducts}, {...options, id: data.id});
1327
+ } catch (e) {
1328
+ logging.error(`Failed to update member - ${data.id} - with related products`);
1329
+ logging.error(e);
1330
+ updatedMember = await this._Member.edit({status: status}, {...options, id: data.id});
1331
+ }
1332
+
1333
+ const newMemberProductIds = memberProducts.map(product => product.id);
1334
+ const oldMemberProductIds = oldMemberProducts.map(product => product.id);
1335
+
1336
+ const productsToAdd = _.differenceWith(newMemberProductIds, oldMemberProductIds);
1337
+ const productsToRemove = _.differenceWith(oldMemberProductIds, newMemberProductIds);
1338
+
1339
+ for (const productToAdd of productsToAdd) {
1340
+ await this._MemberProductEvent.add({
1341
+ member_id: member.id,
1342
+ product_id: productToAdd,
1343
+ action: 'added'
1344
+ }, options);
1345
+ }
1346
+
1347
+ for (const productToRemove of productsToRemove) {
1348
+ await this._MemberProductEvent.add({
1349
+ member_id: member.id,
1350
+ product_id: productToRemove,
1351
+ action: 'removed'
1352
+ }, options);
1353
+ }
1354
+
1355
+ if (updatedMember.attributes.status !== updatedMember._previousAttributes.status) {
1356
+ await this._MemberStatusEvent.add({
1357
+ member_id: data.id,
1358
+ from_status: updatedMember._previousAttributes.status,
1359
+ to_status: updatedMember.get('status'),
1360
+ ...eventData
1361
+ }, options);
1362
+ }
1363
+ }
1364
+
1365
+ getCancellationReason(subscription) {
1366
+ // Case: manual cancellation in Portal
1367
+ if (subscription.metadata && subscription.metadata.cancellation_reason) {
1368
+ return subscription.metadata.cancellation_reason;
1369
+
1370
+ // Case: Automatic cancellation due to several payment failures
1371
+ } else if (subscription.cancellation_details && subscription.cancellation_details.reason && subscription.cancellation_details.reason === 'payment_failed') {
1372
+ return 'Payment failed';
1373
+ }
1374
+
1375
+ return null;
1376
+ }
1377
+
1378
+ async getSubscription(data, options) {
1379
+ if (!this._stripeAPIService.configured) {
1380
+ throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'get Stripe Subscription'})});
1381
+ }
1382
+
1383
+ const member = await this._Member.findOne({
1384
+ email: data.email
1385
+ });
1386
+
1387
+ const subscription = await member.related('stripeSubscriptions').query({
1388
+ where: {
1389
+ subscription_id: data.subscription.subscription_id
1390
+ }
1391
+ }).fetchOne(options);
1392
+
1393
+ if (!subscription) {
1394
+ throw new errors.NotFoundError({message: tpl(messages.subscriptionNotFound, {id: data.subscription.subscription_id})});
1395
+ }
1396
+
1397
+ return subscription.toJSON();
1398
+ }
1399
+
1400
+ async cancelSubscription(data, options) {
1401
+ const sharedOptions = {
1402
+ transacting: options ? options.transacting : null
1403
+ };
1404
+ if (!this._stripeAPIService.configured) {
1405
+ throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'update Stripe Subscription'})});
1406
+ }
1407
+
1408
+ let findQuery = null;
1409
+ if (data.id) {
1410
+ findQuery = {id: data.id};
1411
+ } else if (data.email) {
1412
+ findQuery = {email: data.email};
1413
+ }
1414
+
1415
+ if (!findQuery) {
1416
+ throw new errors.NotFoundError({message: tpl(messages.subscriptionNotFound)});
1417
+ }
1418
+
1419
+ const member = await this._Member.findOne(findQuery);
1420
+
1421
+ const subscription = await member.related('stripeSubscriptions').query({
1422
+ where: {
1423
+ subscription_id: data.subscription.subscription_id
1424
+ }
1425
+ }).fetchOne(options);
1426
+
1427
+ if (!subscription) {
1428
+ throw new errors.NotFoundError({message: tpl(messages.subscriptionNotFound, {id: data.subscription.subscription_id})});
1429
+ }
1430
+
1431
+ const updatedSubscription = await this._stripeAPIService.cancelSubscription(data.subscription.subscription_id);
1432
+
1433
+ await this.linkSubscription({
1434
+ id: member.id,
1435
+ subscription: updatedSubscription
1436
+ }, options);
1437
+
1438
+ await this._MemberCancelEvent.add({
1439
+ member_id: member.id,
1440
+ from_plan: subscription.get('plan_id')
1441
+ }, sharedOptions);
1442
+ }
1443
+
1444
+ async updateSubscription(data, options) {
1445
+ const sharedOptions = {
1446
+ transacting: options ? options.transacting : null
1447
+ };
1448
+ if (!this._stripeAPIService.configured) {
1449
+ throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'update Stripe Subscription'})});
1450
+ }
1451
+
1452
+ let findQuery = null;
1453
+ if (data.id) {
1454
+ findQuery = {id: data.id};
1455
+ } else if (data.email) {
1456
+ findQuery = {email: data.email};
1457
+ }
1458
+
1459
+ if (!findQuery) {
1460
+ throw new errors.NotFoundError({message: tpl(messages.subscriptionNotFound)});
1461
+ }
1462
+
1463
+ const member = await this._Member.findOne(findQuery);
1464
+
1465
+ const subscriptionModel = await member.related('stripeSubscriptions').query({
1466
+ where: {
1467
+ subscription_id: data.subscription.subscription_id
1468
+ }
1469
+ }).fetchOne(options);
1470
+
1471
+ if (!subscriptionModel) {
1472
+ throw new errors.NotFoundError({message: tpl(messages.subscriptionNotFound, {id: data.subscription.subscription_id})});
1473
+ }
1474
+
1475
+ let updatedSubscription;
1476
+ if (data.subscription.price) {
1477
+ const subscription = await this._stripeAPIService.getSubscription(
1478
+ data.subscription.subscription_id
1479
+ );
1480
+
1481
+ const subscriptionItem = subscription.items.data[0];
1482
+
1483
+ if (data.subscription.price !== subscription.price) {
1484
+ updatedSubscription = await this._stripeAPIService.updateSubscriptionItemPrice(
1485
+ subscription.id,
1486
+ subscriptionItem.id,
1487
+ data.subscription.price
1488
+ );
1489
+ updatedSubscription = await this._stripeAPIService.removeCouponFromSubscription(subscription.id);
1490
+
1491
+ if (subscriptionModel.get('status') === SUBSCRIPTION_STATUS_TRIALING) {
1492
+ updatedSubscription = await this._stripeAPIService.cancelSubscriptionTrial(subscription.id);
1493
+ }
1494
+ }
1495
+ }
1496
+
1497
+ if (data.subscription.cancel_at_period_end !== undefined) {
1498
+ if (data.subscription.cancel_at_period_end) {
1499
+ updatedSubscription = await this._stripeAPIService.cancelSubscriptionAtPeriodEnd(
1500
+ data.subscription.subscription_id,
1501
+ data.subscription.cancellationReason
1502
+ );
1503
+
1504
+ await this._MemberCancelEvent.add({
1505
+ member_id: member.id,
1506
+ from_plan: subscriptionModel.get('plan_id')
1507
+ }, sharedOptions);
1508
+ } else {
1509
+ updatedSubscription = await this._stripeAPIService.continueSubscriptionAtPeriodEnd(
1510
+ data.subscription.subscription_id
1511
+ );
1512
+ }
1513
+ }
1514
+
1515
+ if (updatedSubscription) {
1516
+ await this.linkSubscription({
1517
+ id: member.id,
1518
+ subscription: updatedSubscription
1519
+ }, options);
1520
+ }
1521
+ }
1522
+
1523
+ async createSubscription(data, options) {
1524
+ if (!this._stripeAPIService.configured) {
1525
+ throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'create Stripe Subscription'})});
1526
+ }
1527
+ const member = await this._Member.findOne({
1528
+ id: data.id
1529
+ }, options);
1530
+
1531
+ let stripeCustomer;
1532
+
1533
+ await member.related('stripeCustomers').fetch(options);
1534
+
1535
+ for (const customer of member.related('stripeCustomers').models) {
1536
+ try {
1537
+ const fetchedCustomer = await this._stripeAPIService.getCustomer(customer.get('customer_id'));
1538
+ stripeCustomer = fetchedCustomer;
1539
+ } catch (err) {
1540
+ logging.info('Ignoring error for fetching customer for checkout');
1541
+ }
1542
+ }
1543
+
1544
+ if (!stripeCustomer) {
1545
+ stripeCustomer = await this._stripeAPIService.createCustomer({
1546
+ email: member.get('email')
1547
+ });
1548
+
1549
+ await this._StripeCustomer.add({
1550
+ customer_id: stripeCustomer.id,
1551
+ member_id: data.id,
1552
+ email: stripeCustomer.email,
1553
+ name: stripeCustomer.name
1554
+ }, options);
1555
+ }
1556
+
1557
+ const subscription = await this._stripeAPIService.createSubscription(stripeCustomer.id, data.subscription.stripe_price_id);
1558
+
1559
+ await this.linkSubscription({
1560
+ id: member.id,
1561
+ subscription
1562
+ }, options);
1563
+ }
1564
+
1565
+ /**
1566
+ *
1567
+ * @param {Object} data
1568
+ * @param {String} data.id - member ID
1569
+ * @param {Object} options
1570
+ * @param {Object} [options.transacting]
1571
+ */
1572
+ async setComplimentarySubscription(data, options = {}) {
1573
+ if (!options.transacting) {
1574
+ return this._Member.transaction((transacting) => {
1575
+ return this.setComplimentarySubscription(data, {
1576
+ ...options,
1577
+ transacting
1578
+ });
1579
+ });
1580
+ }
1581
+
1582
+ if (!this._stripeAPIService.configured) {
1583
+ throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'create Complimentary Subscription'})});
1584
+ }
1585
+ const member = await this._Member.findOne({
1586
+ id: data.id
1587
+ }, options);
1588
+
1589
+ const subscriptions = await member.related('stripeSubscriptions').fetch(options);
1590
+
1591
+ const activeSubscriptions = subscriptions.models.filter((subscription) => {
1592
+ return this.isActiveSubscriptionStatus(subscription.get('status'));
1593
+ });
1594
+ const sharedOptions = _.pick(options, ['context', 'transacting']);
1595
+
1596
+ const ghostProductModel = await this._productRepository.getDefaultProduct({
1597
+ withRelated: ['stripePrices'],
1598
+ ...sharedOptions
1599
+ });
1600
+
1601
+ const defaultProduct = ghostProductModel?.toJSON();
1602
+
1603
+ if (!defaultProduct) {
1604
+ throw new errors.NotFoundError({
1605
+ message: tpl(messages.productNotFound, {id: '"default"'})
1606
+ });
1607
+ }
1608
+
1609
+ const zeroValuePrices = defaultProduct.stripePrices.filter((price) => {
1610
+ return price.amount === 0;
1611
+ });
1612
+
1613
+ if (activeSubscriptions.length) {
1614
+ for (const subscription of activeSubscriptions) {
1615
+ const price = await subscription.related('stripePrice').fetch(options);
1616
+
1617
+ let zeroValuePrice = zeroValuePrices.find((p) => {
1618
+ return p.currency.toLowerCase() === price.get('currency').toLowerCase();
1619
+ });
1620
+
1621
+ if (!zeroValuePrice) {
1622
+ const product = (await this._productRepository.update({
1623
+ id: defaultProduct.id,
1624
+ name: defaultProduct.name,
1625
+ description: defaultProduct.description,
1626
+ stripe_prices: [{
1627
+ nickname: 'Complimentary',
1628
+ currency: price.get('currency'),
1629
+ type: 'recurring',
1630
+ interval: 'year',
1631
+ amount: 0
1632
+ }]
1633
+ }, options)).toJSON();
1634
+ zeroValuePrice = product.stripePrices.find((p) => {
1635
+ return p.currency.toLowerCase() === price.get('currency').toLowerCase() && p.amount === 0;
1636
+ });
1637
+ zeroValuePrices.push(zeroValuePrice);
1638
+ }
1639
+
1640
+ const stripeSubscription = await this._stripeAPIService.getSubscription(
1641
+ subscription.get('subscription_id')
1642
+ );
1643
+
1644
+ const subscriptionItem = stripeSubscription.items.data[0];
1645
+
1646
+ const updatedSubscription = await this._stripeAPIService.updateSubscriptionItemPrice(
1647
+ stripeSubscription.id,
1648
+ subscriptionItem.id,
1649
+ zeroValuePrice.stripe_price_id
1650
+ );
1651
+
1652
+ await this.linkSubscription({
1653
+ id: member.id,
1654
+ subscription: updatedSubscription
1655
+ }, sharedOptions);
1656
+ }
1657
+ } else {
1658
+ const stripeCustomer = await this._stripeAPIService.createCustomer({
1659
+ email: member.get('email')
1660
+ });
1661
+
1662
+ await this._StripeCustomer.upsert({
1663
+ customer_id: stripeCustomer.id,
1664
+ member_id: data.id,
1665
+ email: stripeCustomer.email,
1666
+ name: stripeCustomer.name
1667
+ }, sharedOptions);
1668
+
1669
+ let zeroValuePrice = zeroValuePrices[0];
1670
+
1671
+ if (!zeroValuePrice) {
1672
+ const product = (await this._productRepository.update({
1673
+ id: defaultProduct.id,
1674
+ name: defaultProduct.name,
1675
+ description: defaultProduct.description,
1676
+ stripe_prices: [{
1677
+ nickname: 'Complimentary',
1678
+ currency: 'USD',
1679
+ type: 'recurring',
1680
+ interval: 'year',
1681
+ amount: 0
1682
+ }]
1683
+ }, sharedOptions)).toJSON();
1684
+ zeroValuePrice = product.stripePrices.find((price) => {
1685
+ return price.currency.toLowerCase() === 'usd' && price.amount === 0;
1686
+ });
1687
+ zeroValuePrices.push(zeroValuePrice);
1688
+ }
1689
+
1690
+ const subscription = await this._stripeAPIService.createSubscription(
1691
+ stripeCustomer.id,
1692
+ zeroValuePrice.stripe_price_id
1693
+ );
1694
+
1695
+ await this.linkSubscription({
1696
+ id: member.id,
1697
+ subscription
1698
+ }, sharedOptions);
1699
+ }
1700
+ }
1701
+
1702
+ /**
1703
+ *
1704
+ * @param {Object} data
1705
+ * @param {String} data.id - member ID
1706
+ * @param {Object} options
1707
+ * @param {Object} [options.transacting]
1708
+ */
1709
+ async cancelComplimentarySubscription({id}, options) {
1710
+ if (!this._stripeAPIService.configured) {
1711
+ throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'cancel Complimentary Subscription'})});
1712
+ }
1713
+
1714
+ const member = await this._Member.findOne({
1715
+ id: id
1716
+ });
1717
+
1718
+ const subscriptions = await member.related('stripeSubscriptions').fetch();
1719
+
1720
+ for (const subscription of subscriptions.models) {
1721
+ if (subscription.get('status') !== 'canceled') {
1722
+ try {
1723
+ const updatedSubscription = await this._stripeAPIService.cancelSubscription(
1724
+ subscription.get('subscription_id')
1725
+ );
1726
+ // Only needs to update `status`
1727
+ await this.linkSubscription({
1728
+ id: id,
1729
+ subscription: updatedSubscription
1730
+ }, options);
1731
+ } catch (err) {
1732
+ logging.error(`There was an error cancelling subscription ${subscription.get('subscription_id')}`);
1733
+ logging.error(err);
1734
+ }
1735
+ }
1736
+ }
1737
+ return true;
1738
+ }
1739
+ };