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.
- package/components/{tryghost-api-framework-5.116.2.tgz → tryghost-api-framework-5.118.0.tgz} +0 -0
- package/components/tryghost-constants-5.118.0.tgz +0 -0
- package/components/tryghost-custom-fonts-5.118.0.tgz +0 -0
- package/components/tryghost-custom-theme-settings-service-5.118.0.tgz +0 -0
- package/components/tryghost-domain-events-5.118.0.tgz +0 -0
- package/components/tryghost-donations-5.118.0.tgz +0 -0
- package/components/tryghost-email-addresses-5.118.0.tgz +0 -0
- package/components/{tryghost-email-service-5.116.2.tgz → tryghost-email-service-5.118.0.tgz} +0 -0
- package/components/tryghost-email-suppression-list-5.118.0.tgz +0 -0
- package/components/tryghost-html-to-plaintext-5.118.0.tgz +0 -0
- package/components/tryghost-i18n-5.118.0.tgz +0 -0
- package/components/{tryghost-job-manager-5.116.2.tgz → tryghost-job-manager-5.118.0.tgz} +0 -0
- package/components/tryghost-link-replacer-5.118.0.tgz +0 -0
- package/components/{tryghost-magic-link-5.116.2.tgz → tryghost-magic-link-5.118.0.tgz} +0 -0
- package/components/{tryghost-member-attribution-5.116.2.tgz → tryghost-member-attribution-5.118.0.tgz} +0 -0
- package/components/tryghost-member-events-5.118.0.tgz +0 -0
- package/components/{tryghost-members-csv-5.116.2.tgz → tryghost-members-csv-5.118.0.tgz} +0 -0
- package/components/{tryghost-members-offers-5.116.2.tgz → tryghost-members-offers-5.118.0.tgz} +0 -0
- package/components/tryghost-mw-error-handler-5.118.0.tgz +0 -0
- package/components/tryghost-mw-vhost-5.118.0.tgz +0 -0
- package/components/{tryghost-post-events-5.116.2.tgz → tryghost-post-events-5.118.0.tgz} +0 -0
- package/components/tryghost-post-revisions-5.118.0.tgz +0 -0
- package/components/tryghost-posts-service-5.118.0.tgz +0 -0
- package/components/tryghost-prometheus-metrics-5.118.0.tgz +0 -0
- package/components/tryghost-security-5.118.0.tgz +0 -0
- package/components/tryghost-tiers-5.118.0.tgz +0 -0
- package/components/tryghost-webmentions-5.118.0.tgz +0 -0
- package/content/themes/casper/LICENSE +1 -1
- package/content/themes/casper/README.md +1 -1
- package/content/themes/casper/assets/built/screen.css +1 -1
- package/content/themes/casper/assets/built/screen.css.map +1 -1
- package/content/themes/casper/assets/css/screen.css +1 -1
- package/content/themes/casper/author.hbs +23 -2
- package/content/themes/casper/package.json +2 -2
- package/content/themes/casper/partials/icons/bluesky.hbs +3 -0
- package/content/themes/casper/partials/icons/instagram.hbs +5 -0
- package/content/themes/casper/partials/icons/linkedin.hbs +3 -0
- package/content/themes/casper/partials/icons/mastodon.hbs +3 -0
- package/content/themes/casper/partials/icons/threads.hbs +3 -0
- package/content/themes/casper/partials/icons/tiktok.hbs +3 -0
- package/content/themes/casper/partials/icons/twitter.hbs +3 -1
- package/content/themes/casper/partials/icons/youtube.hbs +3 -0
- package/content/themes/source/LICENSE +1 -1
- package/content/themes/source/README.md +1 -1
- package/content/themes/source/assets/built/screen.css +1 -1
- package/content/themes/source/assets/built/screen.css.map +1 -1
- package/content/themes/source/assets/css/screen.css +7 -12
- package/content/themes/source/author.hbs +24 -3
- package/content/themes/source/package.json +2 -2
- package/content/themes/source/partials/feature-image.hbs +2 -2
- package/content/themes/source/partials/icons/bluesky.hbs +3 -0
- package/content/themes/source/partials/icons/instagram.hbs +5 -0
- package/content/themes/source/partials/icons/linkedin.hbs +3 -0
- package/content/themes/source/partials/icons/mastodon.hbs +3 -0
- package/content/themes/source/partials/icons/threads.hbs +3 -0
- package/content/themes/source/partials/icons/tiktok.hbs +3 -0
- package/content/themes/source/partials/icons/youtube.hbs +3 -0
- package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +31793 -26588
- package/core/built/admin/assets/admin-x-settings/{CodeEditorView-550846e0.mjs → CodeEditorView-1143c509.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-f3cb3f4d.mjs → index-19ebc8ad.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-4ce2fcd1.mjs → index-ac104f42.mjs} +2635 -2607
- package/core/built/admin/assets/admin-x-settings/{modals-6bc20529.mjs → modals-994901ee.mjs} +6680 -6165
- package/core/built/admin/assets/{chunk.524.578de86e5014b911b05a.js → chunk.524.5710919eb507b9a81166.js} +8 -8
- package/core/built/admin/assets/{chunk.582.21bf3e37b5d84ac4b58a.js → chunk.582.c8cb99b85cfa13fc7df1.js} +10 -10
- package/core/built/admin/assets/{chunk.713.761d11035fe0bf3e557c.js → chunk.713.48f120c377bcaffdfddf.js} +6 -9
- package/core/built/admin/assets/{ghost-868c537d5c02ca65323d0122596a67ec.js → ghost-cd90a28b214ee800a007bb62cd45e6e6.js} +780 -775
- package/core/built/admin/assets/posts/posts.js +11561 -11302
- package/core/built/admin/assets/stats/stats.js +76076 -59355
- package/core/built/admin/index.html +4 -4
- package/core/frontend/helpers/social_url.js +31 -0
- package/core/server/api/endpoints/users.js +7 -0
- package/core/server/api/endpoints/utils/serializers/output/config.js +2 -1
- 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
- package/core/server/data/schema/schema.js +7 -0
- package/core/server/services/auth/session/index.js +5 -2
- package/core/server/services/auth/session/middleware.js +2 -1
- package/core/server/services/auth/session/session-service.js +7 -6
- package/core/server/services/members/api.js +2 -2
- package/core/server/services/members/members-api/controllers/MemberController.js +214 -0
- package/core/server/services/members/members-api/controllers/RouterController.js +667 -0
- package/core/server/services/members/members-api/controllers/WellKnownController.js +46 -0
- package/core/server/services/members/members-api/members-api.js +404 -0
- package/core/server/services/members/members-api/repositories/EventRepository.js +984 -0
- package/core/server/services/members/members-api/repositories/MemberRepository.js +1739 -0
- package/core/server/services/members/members-api/repositories/ProductRepository.js +662 -0
- package/core/server/services/members/members-api/services/GeolocationService.js +23 -0
- package/core/server/services/members/members-api/services/MemberBREADService.js +444 -0
- package/core/server/services/members/members-api/services/PaymentsService.js +522 -0
- package/core/server/services/members/members-api/services/TokenService.js +54 -0
- package/core/server/services/milestones/BookshelfMilestoneRepository.js +8 -9
- package/core/server/services/milestones/InMemoryMilestoneRepository.js +119 -0
- package/core/server/services/milestones/Milestone.js +231 -0
- package/core/server/services/milestones/MilestoneCreatedEvent.js +22 -0
- package/core/server/services/milestones/MilestonesService.js +327 -0
- package/core/server/services/milestones/service.js +2 -2
- package/core/server/services/newsletters/index.js +1 -1
- package/core/server/services/public-config/config.js +2 -1
- package/core/server/services/settings/settings-service.js +1 -1
- package/core/server/services/slack-notifications/SlackNotifications.js +1 -1
- package/core/server/services/slack-notifications/SlackNotificationsService.js +2 -2
- package/core/server/services/staff/StaffService.js +1 -1
- package/core/shared/config/defaults.json +3 -0
- package/core/shared/config/env/config.testing-mysql.json +3 -0
- package/core/shared/config/env/config.testing.json +3 -0
- package/core/shared/labs.js +2 -2
- package/package.json +63 -63
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +306 -70
- package/components/tryghost-constants-5.116.2.tgz +0 -0
- package/components/tryghost-custom-fonts-5.116.2.tgz +0 -0
- package/components/tryghost-custom-theme-settings-service-5.116.2.tgz +0 -0
- package/components/tryghost-domain-events-5.116.2.tgz +0 -0
- package/components/tryghost-donations-5.116.2.tgz +0 -0
- package/components/tryghost-email-addresses-5.116.2.tgz +0 -0
- package/components/tryghost-email-suppression-list-5.116.2.tgz +0 -0
- package/components/tryghost-html-to-plaintext-5.116.2.tgz +0 -0
- package/components/tryghost-i18n-5.116.2.tgz +0 -0
- package/components/tryghost-link-replacer-5.116.2.tgz +0 -0
- package/components/tryghost-member-events-5.116.2.tgz +0 -0
- package/components/tryghost-members-api-5.116.2.tgz +0 -0
- package/components/tryghost-milestones-5.116.2.tgz +0 -0
- package/components/tryghost-mw-error-handler-5.116.2.tgz +0 -0
- package/components/tryghost-mw-vhost-5.116.2.tgz +0 -0
- package/components/tryghost-post-revisions-5.116.2.tgz +0 -0
- package/components/tryghost-posts-service-5.116.2.tgz +0 -0
- package/components/tryghost-prometheus-metrics-5.116.2.tgz +0 -0
- package/components/tryghost-security-5.116.2.tgz +0 -0
- package/components/tryghost-tiers-5.116.2.tgz +0 -0
- package/components/tryghost-webmentions-5.116.2.tgz +0 -0
- /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
|
+
};
|