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,522 @@
1
+ const logging = require('@tryghost/logging');
2
+ const DomainEvents = require('@tryghost/domain-events');
3
+ const {TierCreatedEvent, TierPriceChangeEvent, TierNameChangeEvent} = require('@tryghost/tiers');
4
+ const OfferCreatedEvent = require('@tryghost/members-offers').events.OfferCreatedEvent;
5
+ const {BadRequestError} = require('@tryghost/errors');
6
+
7
+ class PaymentsService {
8
+ /**
9
+ * @param {object} deps
10
+ * @param {import('bookshelf').Model} deps.Offer
11
+ * @param {import('@tryghost/members-offers/lib/application/OffersAPI')} deps.offersAPI
12
+ * @param {import('@tryghost/members-stripe-service/lib/StripeAPI')} deps.stripeAPIService
13
+ * @param {{get(key: string): any}} deps.settingsCache
14
+ */
15
+ constructor(deps) {
16
+ /** @private */
17
+ this.OfferModel = deps.Offer;
18
+ /** @private */
19
+ this.StripeProductModel = deps.StripeProduct;
20
+ /** @private */
21
+ this.StripePriceModel = deps.StripePrice;
22
+ /** @private */
23
+ this.StripeCustomerModel = deps.StripeCustomer;
24
+ /** @private */
25
+ this.offersAPI = deps.offersAPI;
26
+ /** @private */
27
+ this.stripeAPIService = deps.stripeAPIService;
28
+ /** @private */
29
+ this.settingsCache = deps.settingsCache;
30
+
31
+ DomainEvents.subscribe(OfferCreatedEvent, async (event) => {
32
+ await this.getCouponForOffer(event.data.offer.id);
33
+ });
34
+
35
+ DomainEvents.subscribe(TierCreatedEvent, async (event) => {
36
+ if (event.data.tier.type === 'paid') {
37
+ await this.getPriceForTierCadence(event.data.tier, 'month');
38
+ await this.getPriceForTierCadence(event.data.tier, 'year');
39
+ }
40
+ });
41
+
42
+ DomainEvents.subscribe(TierPriceChangeEvent, async (event) => {
43
+ if (event.data.tier.type === 'paid') {
44
+ await this.getPriceForTierCadence(event.data.tier, 'month');
45
+ await this.getPriceForTierCadence(event.data.tier, 'year');
46
+ }
47
+ });
48
+
49
+ DomainEvents.subscribe(TierNameChangeEvent, async (event) => {
50
+ if (event.data.tier.type === 'paid') {
51
+ await this.updateNameForTierProducts(event.data.tier);
52
+ }
53
+ });
54
+ }
55
+
56
+ /**
57
+ * @param {object} params
58
+ * @param {Tier} params.tier
59
+ * @param {Tier.Cadence} params.cadence
60
+ * @param {Offer} [params.offer]
61
+ * @param {Member} [params.member]
62
+ * @param {Object.<string, any>} [params.metadata]
63
+ * @param {string} params.successUrl
64
+ * @param {string} params.cancelUrl
65
+ * @param {string} [params.email]
66
+ *
67
+ * @returns {Promise<URL>}
68
+ */
69
+ async getPaymentLink({tier, cadence, offer, member, metadata, successUrl, cancelUrl, email}) {
70
+ let coupon = null;
71
+ let trialDays = null;
72
+ if (offer) {
73
+ if (!tier.id.equals(offer.tier.id)) {
74
+ throw new BadRequestError({
75
+ message: 'This Offer is not valid for the Tier'
76
+ });
77
+ }
78
+ if (offer.type === 'trial') {
79
+ trialDays = offer.amount;
80
+ } else {
81
+ coupon = await this.getCouponForOffer(offer.id);
82
+ }
83
+ }
84
+
85
+ let customer = null;
86
+ if (member) {
87
+ customer = await this.getCustomerForMember(member);
88
+ }
89
+
90
+ const price = await this.getPriceForTierCadence(tier, cadence);
91
+
92
+ const data = {
93
+ metadata,
94
+ successUrl: successUrl,
95
+ cancelUrl: cancelUrl,
96
+ trialDays: trialDays ?? tier.trialDays,
97
+ coupon: coupon?.id
98
+ };
99
+
100
+ // If we already have a coupon, we don't want to give trial days over it
101
+ if (data.coupon) {
102
+ delete data.trialDays;
103
+ }
104
+
105
+ if (!customer && email) {
106
+ data.customerEmail = email;
107
+ }
108
+
109
+ const session = await this.stripeAPIService.createCheckoutSession(price.id, customer, data);
110
+
111
+ return session.url;
112
+ }
113
+
114
+ /**
115
+ * @param {object} params
116
+ * @param {Member} [params.member]
117
+ * @param {Object.<string, any>} [params.metadata]
118
+ * @param {string} params.successUrl
119
+ * @param {string} params.cancelUrl
120
+ * @param {boolean} [params.isAuthenticated]
121
+ * @param {string} [params.email]
122
+ *
123
+ * @returns {Promise<URL>}
124
+ */
125
+ async getDonationPaymentLink({member, metadata, successUrl, cancelUrl, email, isAuthenticated, personalNote}) {
126
+ let customer = null;
127
+ if (member && isAuthenticated) {
128
+ customer = await this.getCustomerForMember(member);
129
+ }
130
+
131
+ const data = {
132
+ priceId: (await this.getPriceForDonations()).id,
133
+ metadata,
134
+ successUrl: successUrl,
135
+ cancelUrl: cancelUrl,
136
+ customer,
137
+ customerEmail: !customer && email ? email : null,
138
+ personalNote: personalNote
139
+
140
+ };
141
+
142
+ const session = await this.stripeAPIService.createDonationCheckoutSession(data);
143
+ return session.url;
144
+ }
145
+
146
+ async getCustomerForMember(member) {
147
+ const rows = await this.StripeCustomerModel.where({
148
+ member_id: member.id
149
+ }).query().select('customer_id');
150
+
151
+ for (const row of rows) {
152
+ try {
153
+ const customer = await this.stripeAPIService.getCustomer(row.customer_id);
154
+ if (!customer.deleted) {
155
+ return customer;
156
+ }
157
+ } catch (err) {
158
+ logging.warn(err);
159
+ }
160
+ }
161
+
162
+ const customer = await this.createCustomerForMember(member);
163
+
164
+ return customer;
165
+ }
166
+
167
+ async createCustomerForMember(member) {
168
+ const customer = await this.stripeAPIService.createCustomer({
169
+ email: member.get('email'),
170
+ name: member.get('name')
171
+ });
172
+
173
+ await this.StripeCustomerModel.add({
174
+ member_id: member.id,
175
+ customer_id: customer.id,
176
+ email: customer.email,
177
+ name: customer.name
178
+ });
179
+
180
+ return customer;
181
+ }
182
+
183
+ /**
184
+ * @param {import('@tryghost/tiers').Tier} tier
185
+ * @returns {Promise<{id: string}>}
186
+ */
187
+ async getProductForTier(tier) {
188
+ const rows = await this.StripeProductModel
189
+ .where({product_id: tier.id.toHexString()})
190
+ .query()
191
+ .select('stripe_product_id');
192
+
193
+ for (const row of rows) {
194
+ try {
195
+ const product = await this.stripeAPIService.getProduct(row.stripe_product_id);
196
+ if (product.active) {
197
+ return {id: product.id};
198
+ }
199
+ } catch (err) {
200
+ logging.warn(err);
201
+ }
202
+ }
203
+
204
+ const product = await this.createProductForTier(tier);
205
+
206
+ return {
207
+ id: product.id
208
+ };
209
+ }
210
+
211
+ /**
212
+ * @param {import('@tryghost/tiers').Tier} tier
213
+ * @returns {Promise<import('stripe').default.Product>}
214
+ */
215
+ async createProductForTier(tier) {
216
+ const product = await this.stripeAPIService.createProduct({name: tier.name});
217
+ await this.StripeProductModel.add({
218
+ product_id: tier.id.toHexString(),
219
+ stripe_product_id: product.id
220
+ });
221
+ return product;
222
+ }
223
+
224
+ /**
225
+ * @param {import('@tryghost/tiers').Tier} tier
226
+ * @returns {Promise<void>}
227
+ */
228
+ async updateNameForTierProducts(tier) {
229
+ const rows = await this.StripeProductModel
230
+ .where({product_id: tier.id.toHexString()})
231
+ .query()
232
+ .select('stripe_product_id');
233
+
234
+ for (const row of rows) {
235
+ await this.stripeAPIService.updateProduct(row.stripe_product_id, {
236
+ name: tier.name
237
+ });
238
+ }
239
+ }
240
+
241
+ /**
242
+ * @returns {Promise<{id: string}>}
243
+ */
244
+ async getProductForDonations({name}) {
245
+ const existingDonationPrices = await this.StripePriceModel
246
+ .where({
247
+ type: 'donation'
248
+ })
249
+ .query()
250
+ .select('stripe_product_id');
251
+
252
+ for (const row of existingDonationPrices) {
253
+ const product = await this.StripeProductModel
254
+ .where({
255
+ stripe_product_id: row.stripe_product_id
256
+ })
257
+ .query()
258
+ .select('stripe_product_id')
259
+ .first();
260
+
261
+ if (product) {
262
+ // Check active in Stripe
263
+ try {
264
+ const stripeProduct = await this.stripeAPIService.getProduct(row.stripe_product_id);
265
+ if (stripeProduct.active) {
266
+ return {id: stripeProduct.id};
267
+ }
268
+ } catch (err) {
269
+ logging.warn(err);
270
+ }
271
+ }
272
+ }
273
+
274
+ const product = await this.createProductForDonations({name});
275
+
276
+ return {
277
+ id: product.id
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Stripe's nickname field is limited to 250 characters
283
+ * @returns {string}
284
+ */
285
+ getDonationPriceNickname() {
286
+ const nickname = 'Support ' + this.settingsCache.get('title');
287
+ return nickname.substring(0, 250);
288
+ }
289
+
290
+ /**
291
+ * @returns {Promise<{id: string}>}
292
+ */
293
+ async getPriceForDonations() {
294
+ const nickname = this.getDonationPriceNickname();
295
+ const currency = this.settingsCache.get('donations_currency');
296
+ const suggestedAmount = this.settingsCache.get('donations_suggested_amount');
297
+
298
+ // Stripe requires a minimum charge amount
299
+ // @see https://stripe.com/docs/currencies#minimum-and-maximum-charge-amounts
300
+ const amount = suggestedAmount && suggestedAmount >= 100 ? suggestedAmount : 0;
301
+
302
+ const price = await this.StripePriceModel
303
+ .where({
304
+ type: 'donation',
305
+ active: true,
306
+ amount,
307
+ currency
308
+ })
309
+ .query()
310
+ .select('stripe_price_id', 'stripe_product_id', 'id', 'nickname')
311
+ .first();
312
+
313
+ if (price) {
314
+ if (price.nickname !== nickname) {
315
+ // Rename it in Stripe (in case the publication name changed)
316
+ try {
317
+ await this.stripeAPIService.updatePrice(price.stripe_price_id, {
318
+ nickname
319
+ });
320
+
321
+ // Update product too
322
+ await this.stripeAPIService.updateProduct(price.stripe_product_id, {
323
+ name: nickname
324
+ });
325
+
326
+ await this.StripePriceModel.edit({
327
+ nickname
328
+ }, {id: price.id});
329
+ } catch (err) {
330
+ logging.warn(err);
331
+ }
332
+ }
333
+ return {
334
+ id: price.stripe_price_id
335
+ };
336
+ }
337
+
338
+ const newPrice = await this.createPriceForDonations({
339
+ nickname,
340
+ currency,
341
+ amount
342
+ });
343
+ return {
344
+ id: newPrice.id
345
+ };
346
+ }
347
+
348
+ /**
349
+ * @returns {Promise<import('stripe').default.Price>}
350
+ */
351
+ async createPriceForDonations({currency, amount, nickname}) {
352
+ const product = await this.getProductForDonations({name: nickname});
353
+
354
+ const preset = amount ? amount : undefined;
355
+
356
+ // Create the price in Stripe
357
+ const price = await this.stripeAPIService.createPrice({
358
+ currency,
359
+ product: product.id,
360
+ custom_unit_amount: {
361
+ enabled: true,
362
+ preset
363
+ },
364
+ nickname,
365
+ type: 'one-time',
366
+ active: true
367
+ });
368
+
369
+ // Save it to the database
370
+ await this.StripePriceModel.add({
371
+ stripe_price_id: price.id,
372
+ stripe_product_id: product.id,
373
+ active: price.active,
374
+ nickname: price.nickname,
375
+ currency: price.currency,
376
+ amount,
377
+ type: 'donation',
378
+ interval: null
379
+ });
380
+ return price;
381
+ }
382
+
383
+ /**
384
+ * @returns {Promise<import('stripe').default.Product>}
385
+ */
386
+ async createProductForDonations({name}) {
387
+ const product = await this.stripeAPIService.createProduct({
388
+ name
389
+ });
390
+
391
+ await this.StripeProductModel.add({
392
+ product_id: null,
393
+ stripe_product_id: product.id
394
+ });
395
+ return product;
396
+ }
397
+
398
+ /**
399
+ * @param {import('@tryghost/tiers').Tier} tier
400
+ * @param {'month'|'year'} cadence
401
+ * @returns {Promise<{id: string}>}
402
+ */
403
+ async getPriceForTierCadence(tier, cadence) {
404
+ const product = await this.getProductForTier(tier);
405
+ const currency = tier.currency.toLowerCase();
406
+ const amount = tier.getPrice(cadence);
407
+ const rows = await this.StripePriceModel.where({
408
+ stripe_product_id: product.id,
409
+ currency,
410
+ interval: cadence,
411
+ amount,
412
+ active: true,
413
+ type: 'recurring'
414
+ }).query().select('id', 'stripe_price_id');
415
+
416
+ for (const row of rows) {
417
+ try {
418
+ const price = await this.stripeAPIService.getPrice(row.stripe_price_id);
419
+ if (price.active && price.currency.toLowerCase() === currency && price.unit_amount === amount && price.recurring?.interval === cadence) {
420
+ return {
421
+ id: price.id
422
+ };
423
+ } else {
424
+ // Update the database model to prevent future Stripe fetches when it is not needed
425
+ await this.StripePriceModel.edit({
426
+ active: !!price.active
427
+ }, {id: row.id});
428
+ }
429
+ } catch (err) {
430
+ logging.error(`Failed to lookup Stripe Price ${row.stripe_price_id}`);
431
+ logging.error(err);
432
+ }
433
+ }
434
+
435
+ const price = await this.createPriceForTierCadence(tier, cadence);
436
+
437
+ return {
438
+ id: price.id
439
+ };
440
+ }
441
+
442
+ /**
443
+ * @param {import('@tryghost/tiers').Tier} tier
444
+ * @param {'month'|'year'} cadence
445
+ * @returns {Promise<import('stripe').default.Price>}
446
+ */
447
+ async createPriceForTierCadence(tier, cadence) {
448
+ const product = await this.getProductForTier(tier);
449
+ const price = await this.stripeAPIService.createPrice({
450
+ product: product.id,
451
+ interval: cadence,
452
+ currency: tier.currency,
453
+ amount: tier.getPrice(cadence),
454
+ nickname: cadence === 'month' ? 'Monthly' : 'Yearly',
455
+ type: 'recurring',
456
+ active: true
457
+ });
458
+ await this.StripePriceModel.add({
459
+ stripe_price_id: price.id,
460
+ stripe_product_id: product.id,
461
+ active: price.active,
462
+ nickname: price.nickname,
463
+ currency: price.currency,
464
+ amount: price.unit_amount,
465
+ type: 'recurring',
466
+ interval: cadence
467
+ });
468
+ return price;
469
+ }
470
+
471
+ /**
472
+ * @param {string} offerId
473
+ *
474
+ * @returns {Promise<{id: string}>}
475
+ */
476
+ async getCouponForOffer(offerId) {
477
+ const row = await this.OfferModel.where({id: offerId}).query().select('stripe_coupon_id', 'discount_type').first();
478
+ if (!row || row.discount_type === 'trial') {
479
+ return null;
480
+ }
481
+ if (!row.stripe_coupon_id) {
482
+ const offer = await this.offersAPI.getOffer({id: offerId});
483
+ await this.createCouponForOffer(offer);
484
+ return this.getCouponForOffer(offerId);
485
+ }
486
+ return {
487
+ id: row.stripe_coupon_id
488
+ };
489
+ }
490
+
491
+ /**
492
+ * @param {import('@tryghost/members-offers/lib/application/OfferMapper').OfferDTO} offer
493
+ */
494
+ async createCouponForOffer(offer) {
495
+ /** @type {import('stripe').Stripe.CouponCreateParams} */
496
+ const couponData = {
497
+ name: offer.name,
498
+ duration: offer.duration
499
+ };
500
+
501
+ if (offer.duration === 'repeating') {
502
+ couponData.duration_in_months = offer.duration_in_months;
503
+ }
504
+
505
+ if (offer.type === 'percent') {
506
+ couponData.percent_off = offer.amount;
507
+ } else {
508
+ couponData.amount_off = offer.amount;
509
+ couponData.currency = offer.currency;
510
+ }
511
+
512
+ const coupon = await this.stripeAPIService.createCoupon(couponData);
513
+
514
+ await this.OfferModel.edit({
515
+ stripe_coupon_id: coupon.id
516
+ }, {
517
+ id: offer.id
518
+ });
519
+ }
520
+ }
521
+
522
+ module.exports = PaymentsService;
@@ -0,0 +1,54 @@
1
+ const jose = require('node-jose');
2
+ const jwt = require('jsonwebtoken');
3
+
4
+ module.exports = class TokenService {
5
+ constructor({
6
+ privateKey,
7
+ publicKey,
8
+ issuer
9
+ }) {
10
+ this._keyStore = jose.JWK.createKeyStore();
11
+ this._keyStoreReady = this._keyStore.add(privateKey, 'pem');
12
+ this._privateKey = privateKey;
13
+ this._publicKey = publicKey;
14
+ this._issuer = issuer;
15
+ }
16
+
17
+ async encodeIdentityToken({sub}) {
18
+ const jwk = await this._keyStoreReady;
19
+ return jwt.sign({
20
+ sub,
21
+ kid: jwk.kid
22
+ }, this._privateKey, {
23
+ keyid: jwk.kid,
24
+ algorithm: 'RS512',
25
+ audience: this._issuer,
26
+ expiresIn: '10m',
27
+ issuer: this._issuer
28
+ });
29
+ }
30
+
31
+ /**
32
+ * @param {string} token
33
+ * @returns {Promise<jwt.JwtPayload>}
34
+ */
35
+ async decodeToken(token) {
36
+ await this._keyStoreReady;
37
+
38
+ const result = jwt.verify(token, this._publicKey, {
39
+ algorithms: ['RS512'],
40
+ issuer: this._issuer
41
+ });
42
+
43
+ if (typeof result === 'string') {
44
+ return {sub: result};
45
+ }
46
+
47
+ return result;
48
+ }
49
+
50
+ async getPublicKeys() {
51
+ await this._keyStoreReady;
52
+ return this._keyStore.toJSON();
53
+ }
54
+ };
@@ -1,8 +1,7 @@
1
- const {Milestone} = require('@tryghost/milestones');
1
+ const Milestone = require('./Milestone');
2
2
 
3
3
  /**
4
- * @typedef {import('@tryghost/milestones/lib/MilestonesService').IMilestoneRepository} IMilestoneRepository
5
- * @typedef {import('@tryghost/milestones/lib/MilestonesService')} Milestone
4
+ * @typedef {import('./MilestonesService').IMilestoneRepository} IMilestoneRepository
6
5
  */
7
6
 
8
7
  /**
@@ -37,7 +36,7 @@ module.exports = class BookshelfMilestoneRepository {
37
36
  }
38
37
 
39
38
  /**
40
- * @param {import('@tryghost/milestones/lib/Milestone')} milestone
39
+ * @param {Milestone} milestone
41
40
  * @returns {Promise<void>}
42
41
  */
43
42
  async save(milestone) {
@@ -68,7 +67,7 @@ module.exports = class BookshelfMilestoneRepository {
68
67
  * @param {'arr'|'members'} type
69
68
  * @param {string} [currency]
70
69
  *
71
- * @returns {Promise<import('@tryghost/milestones/lib/Milestone')[]>}
70
+ * @returns {Promise<Milestone[]>}
72
71
  */
73
72
  async getAllByType(type, currency = 'usd') {
74
73
  let milestone = null;
@@ -93,7 +92,7 @@ module.exports = class BookshelfMilestoneRepository {
93
92
  * @param {'arr'|'members'} type
94
93
  * @param {string} [currency]
95
94
  *
96
- * @returns {Promise<import('@tryghost/milestones/lib/Milestone')|null>}
95
+ * @returns {Promise<Milestone|null>}
97
96
  */
98
97
  async getLatestByType(type, currency = 'usd') {
99
98
  const allMilestonesForType = await this.getAllByType(type, currency);
@@ -101,7 +100,7 @@ module.exports = class BookshelfMilestoneRepository {
101
100
  }
102
101
 
103
102
  /**
104
- * @returns {Promise<import('@tryghost/milestones/lib/Milestone')|null>}
103
+ * @returns {Promise<Milestone|null>}
105
104
  */
106
105
  async getLastEmailSent() {
107
106
  let milestone = await this.#MilestoneModel.findAll({filter: 'email_sent_at:-null', order: 'email_sent_at ASC'}, {require: false});
@@ -119,7 +118,7 @@ module.exports = class BookshelfMilestoneRepository {
119
118
  * @param {number} value
120
119
  * @param {string} [currency]
121
120
  *
122
- * @returns {Promise<import('@tryghost/milestones/lib/Milestone')|null>}
121
+ * @returns {Promise<Milestone|null>}
123
122
  */
124
123
  async getByARR(value, currency = 'usd') {
125
124
  // find a milestone of the ARR type by a given value
@@ -134,7 +133,7 @@ module.exports = class BookshelfMilestoneRepository {
134
133
  /**
135
134
  * @param {number} value
136
135
  *
137
- * @returns {Promise<import('@tryghost/milestones/lib/Milestone')|null>}
136
+ * @returns {Promise<Milestone|null>}
138
137
  */
139
138
  async getByCount(value) {
140
139
  // find a milestone of the members type by a given value