diff --git a/habitica-images b/habitica-images index 351ca72bc4..ee59cc377a 160000 --- a/habitica-images +++ b/habitica-images @@ -1 +1 @@ -Subproject commit 351ca72bc4cecce515aa94a1951e4b7803f5d3f3 +Subproject commit ee59cc377a7c02b02f1c6697132b167ac1ce6d81 diff --git a/migrations/archive/2023/20230123_habit_birthday.js b/migrations/archive/2023/20230123_habit_birthday.js new file mode 100644 index 0000000000..618664e0b8 --- /dev/null +++ b/migrations/archive/2023/20230123_habit_birthday.js @@ -0,0 +1,88 @@ +/* eslint-disable no-console */ +import { v4 as uuid } from 'uuid'; +import { model as User } from '../../../website/server/models/user'; + +const MIGRATION_NAME = '20230123_habit_birthday'; +const progressCount = 1000; +let count = 0; + +async function updateUser (user) { + count += 1; + + const inc = { 'balance': 5 }; + const set = {}; + const push = {}; + + set.migration = MIGRATION_NAME; + + if (typeof user.items.gear.owned.armor_special_birthday2022 !== 'undefined') { + set['items.gear.owned.armor_special_birthday2023'] = true; + } else if (typeof user.items.gear.owned.armor_special_birthday2021 !== 'undefined') { + set['items.gear.owned.armor_special_birthday2022'] = true; + } else if (typeof user.items.gear.owned.armor_special_birthday2020 !== 'undefined') { + set['items.gear.owned.armor_special_birthday2021'] = true; + } else if (typeof user.items.gear.owned.armor_special_birthday2019 !== 'undefined') { + set['items.gear.owned.armor_special_birthday2020'] = true; + } else if (typeof user.items.gear.owned.armor_special_birthday2018 !== 'undefined') { + set['items.gear.owned.armor_special_birthday2019'] = true; + } else if (typeof user.items.gear.owned.armor_special_birthday2017 !== 'undefined') { + set['items.gear.owned.armor_special_birthday2018'] = true; + } else if (typeof user.items.gear.owned.armor_special_birthday2016 !== 'undefined') { + set['items.gear.owned.armor_special_birthday2017'] = true; + } else if (typeof user.items.gear.owned.armor_special_birthday2015 !== 'undefined') { + set['items.gear.owned.armor_special_birthday2016'] = true; + } else if (typeof user.items.gear.owned.armor_special_birthday !== 'undefined') { + set['items.gear.owned.armor_special_birthday2015'] = true; + } else { + set['items.gear.owned.armor_special_birthday'] = true; + } + + push.notifications = { + type: 'ITEM_RECEIVED', + data: { + icon: 'notif_head_special_nye', + title: 'Birthday Bash Day 1!', + text: 'Enjoy your new Birthday Robe and 20 Gems on us!', + destination: 'equipment', + }, + seen: false, + }; + + if (count % progressCount === 0) console.warn(`${count} ${user._id}`); + + return await User.update({_id: user._id}, {$inc: inc, $set: set, $push: push}).exec(); +} + +export default async function processUsers () { + let query = { + migration: {$ne: MIGRATION_NAME}, + 'auth.timestamps.loggedin': {$gt: new Date('2022-12-23')}, + }; + + const fields = { + _id: 1, + items: 1, + }; + + while (true) { // eslint-disable-line no-constant-condition + const users = await User // eslint-disable-line no-await-in-loop + .find(query) + .limit(250) + .sort({_id: 1}) + .select(fields) + .lean() + .exec(); + + if (users.length === 0) { + console.warn('All appropriate users found and modified.'); + console.warn(`\n${count} users processed\n`); + break; + } else { + query._id = { + $gt: users[users.length - 1], + }; + } + + await Promise.all(users.map(updateUser)); // eslint-disable-line no-await-in-loop + } +}; diff --git a/migrations/archive/2023/20230127_habit_birthday_day5.js b/migrations/archive/2023/20230127_habit_birthday_day5.js new file mode 100644 index 0000000000..a6ad5f49fd --- /dev/null +++ b/migrations/archive/2023/20230127_habit_birthday_day5.js @@ -0,0 +1,69 @@ +/* eslint-disable no-console */ +import { v4 as uuid } from 'uuid'; +import { model as User } from '../../../website/server/models/user'; + +const MIGRATION_NAME = '20230127_habit_birthday_day5'; +const progressCount = 1000; +let count = 0; + +async function updateUser (user) { + count += 1; + + const set = {}; + const push = {}; + + set.migration = MIGRATION_NAME; + + set['items.gear.owned.back_special_anniversary'] = true; + set['items.gear.owned.body_special_anniversary'] = true; + set['items.gear.owned.eyewear_special_anniversary'] = true; + + push.notifications = { + type: 'ITEM_RECEIVED', + data: { + icon: 'notif_head_special_nye', + title: 'Birthday Bash Day 5!', + text: 'Come celebrate by wearing your new Habitica Hero Cape, Collar, and Mask!', + destination: 'equipment', + }, + seen: false, + }; + + if (count % progressCount === 0) console.warn(`${count} ${user._id}`); + + return await User.update({_id: user._id}, {$set: set, $push: push}).exec(); +} + +export default async function processUsers () { + let query = { + migration: {$ne: MIGRATION_NAME}, + 'auth.timestamps.loggedin': {$gt: new Date('2022-12-23')}, + }; + + const fields = { + _id: 1, + items: 1, + }; + + while (true) { // eslint-disable-line no-constant-condition + const users = await User // eslint-disable-line no-await-in-loop + .find(query) + .limit(250) + .sort({_id: 1}) + .select(fields) + .lean() + .exec(); + + if (users.length === 0) { + console.warn('All appropriate users found and modified.'); + console.warn(`\n${count} users processed\n`); + break; + } else { + query._id = { + $gt: users[users.length - 1], + }; + } + + await Promise.all(users.map(updateUser)); // eslint-disable-line no-await-in-loop + } +}; diff --git a/migrations/archive/2023/20230201_habit_birthday_day10.js b/migrations/archive/2023/20230201_habit_birthday_day10.js new file mode 100644 index 0000000000..9743c6b91f --- /dev/null +++ b/migrations/archive/2023/20230201_habit_birthday_day10.js @@ -0,0 +1,79 @@ +/* eslint-disable no-console */ +import { v4 as uuid } from 'uuid'; +import { model as User } from '../../../website/server/models/user'; + +const MIGRATION_NAME = '20230201_habit_birthday_day10'; +const progressCount = 1000; +let count = 0; + +async function updateUser (user) { + count += 1; + + const set = { + migration: MIGRATION_NAME, + 'purchased.background.birthday_bash': true, + }; + const push = { + notifications: { + type: 'ITEM_RECEIVED', + data: { + icon: 'notif_head_special_nye', + title: 'Birthday Bash Day 10!', + text: 'Join in for the end of our birthday celebrations with 10th Birthday background, Cake, and achievement!', + destination: 'backgrounds', + }, + seen: false, + }, + }; + const inc = { + 'items.food.Cake_Skeleton': 1, + 'items.food.Cake_Base': 1, + 'items.food.Cake_CottonCandyBlue': 1, + 'items.food.Cake_CottonCandyPink': 1, + 'items.food.Cake_Shade': 1, + 'items.food.Cake_White': 1, + 'items.food.Cake_Golden': 1, + 'items.food.Cake_Zombie': 1, + 'items.food.Cake_Desert': 1, + 'items.food.Cake_Red': 1, + 'achievements.habitBirthdays': 1, + }; + + if (count % progressCount === 0) console.warn(`${count} ${user._id}`); + + return await User.update({_id: user._id}, {$set: set, $push: push, $inc: inc }).exec(); +} + +export default async function processUsers () { + let query = { + migration: {$ne: MIGRATION_NAME}, + 'auth.timestamps.loggedin': {$gt: new Date('2022-12-23')}, + }; + + const fields = { + _id: 1, + items: 1, + }; + + while (true) { // eslint-disable-line no-constant-condition + const users = await User // eslint-disable-line no-await-in-loop + .find(query) + .limit(250) + .sort({_id: 1}) + .select(fields) + .lean() + .exec(); + + if (users.length === 0) { + console.warn('All appropriate users found and modified.'); + console.warn(`\n${count} users processed\n`); + break; + } else { + query._id = { + $gt: users[users.length - 1], + }; + } + + await Promise.all(users.map(updateUser)); // eslint-disable-line no-await-in-loop + } +}; diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index 1aefe2cb51..6bd779cb3b 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -12,7 +12,7 @@ const { i18n } = common; describe('Apple Payments', () => { const subKey = 'basic_3mo'; - describe('verifyGemPurchase', () => { + describe('verifyPurchase', () => { let sku; let user; let token; let receipt; let headers; let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let paymentBuyGemsStub; let @@ -54,7 +54,7 @@ describe('Apple Payments', () => { iapIsValidatedStub = sinon.stub(iap, 'isValidated') .returns(false); - await expect(applePayments.verifyGemPurchase({ user, receipt, headers })) + await expect(applePayments.verifyPurchase({ user, receipt, headers })) .to.eventually.be.rejected.and.to.eql({ httpCode: 401, name: 'NotAuthorized', @@ -66,7 +66,7 @@ describe('Apple Payments', () => { iapGetPurchaseDataStub.restore(); iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData').returns([]); - await expect(applePayments.verifyGemPurchase({ user, receipt, headers })) + await expect(applePayments.verifyPurchase({ user, receipt, headers })) .to.eventually.be.rejected.and.to.eql({ httpCode: 401, name: 'NotAuthorized', @@ -76,7 +76,7 @@ describe('Apple Payments', () => { it('errors if the user cannot purchase gems', async () => { sinon.stub(user, 'canGetGems').resolves(false); - await expect(applePayments.verifyGemPurchase({ user, receipt, headers })) + await expect(applePayments.verifyPurchase({ user, receipt, headers })) .to.eventually.be.rejected.and.to.eql({ httpCode: 401, name: 'NotAuthorized', @@ -95,7 +95,7 @@ describe('Apple Payments', () => { transactionId: token, }]); - await expect(applePayments.verifyGemPurchase({ user, receipt, headers })) + await expect(applePayments.verifyPurchase({ user, receipt, headers })) .to.eventually.be.rejected.and.to.eql({ httpCode: 401, name: 'NotAuthorized', @@ -138,7 +138,7 @@ describe('Apple Payments', () => { }]); sinon.stub(user, 'canGetGems').resolves(true); - await applePayments.verifyGemPurchase({ user, receipt, headers }); + await applePayments.verifyPurchase({ user, receipt, headers }); expect(iapSetupStub).to.be.calledOnce; expect(iapValidateStub).to.be.calledOnce; @@ -173,7 +173,7 @@ describe('Apple Payments', () => { }]); const gift = { uuid: receivingUser._id }; - await applePayments.verifyGemPurchase({ + await applePayments.verifyPurchase({ user, gift, receipt, headers, }); diff --git a/test/api/unit/libs/payments/google.test.js b/test/api/unit/libs/payments/google.test.js index 7df7f273ef..4ddbe52fc8 100644 --- a/test/api/unit/libs/payments/google.test.js +++ b/test/api/unit/libs/payments/google.test.js @@ -12,7 +12,7 @@ const { i18n } = common; describe('Google Payments', () => { const subKey = 'basic_3mo'; - describe('verifyGemPurchase', () => { + describe('verifyPurchase', () => { let sku; let user; let token; let receipt; let signature; let headers; const gemsBlock = common.content.gems['21gems']; let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let @@ -48,7 +48,7 @@ describe('Google Payments', () => { iapIsValidatedStub = sinon.stub(iap, 'isValidated') .returns(false); - await expect(googlePayments.verifyGemPurchase({ + await expect(googlePayments.verifyPurchase({ user, receipt, signature, headers, })) .to.eventually.be.rejected.and.to.eql({ @@ -61,7 +61,7 @@ describe('Google Payments', () => { it('should throw an error if productId is invalid', async () => { receipt = `{"token": "${token}", "productId": "invalid"}`; - await expect(googlePayments.verifyGemPurchase({ + await expect(googlePayments.verifyPurchase({ user, receipt, signature, headers, })) .to.eventually.be.rejected.and.to.eql({ @@ -74,7 +74,7 @@ describe('Google Payments', () => { it('should throw an error if user cannot purchase gems', async () => { sinon.stub(user, 'canGetGems').resolves(false); - await expect(googlePayments.verifyGemPurchase({ + await expect(googlePayments.verifyPurchase({ user, receipt, signature, headers, })) .to.eventually.be.rejected.and.to.eql({ @@ -88,7 +88,7 @@ describe('Google Payments', () => { it('purchases gems', async () => { sinon.stub(user, 'canGetGems').resolves(true); - await googlePayments.verifyGemPurchase({ + await googlePayments.verifyPurchase({ user, receipt, signature, headers, }); @@ -120,7 +120,7 @@ describe('Google Payments', () => { await receivingUser.save(); const gift = { uuid: receivingUser._id }; - await googlePayments.verifyGemPurchase({ + await googlePayments.verifyPurchase({ user, gift, receipt, signature, headers, }); diff --git a/test/api/unit/libs/payments/skuItem.test.js b/test/api/unit/libs/payments/skuItem.test.js new file mode 100644 index 0000000000..9e89830583 --- /dev/null +++ b/test/api/unit/libs/payments/skuItem.test.js @@ -0,0 +1,40 @@ +import { + canBuySkuItem, +} from '../../../../../website/server/libs/payments/skuItem'; +import { model as User } from '../../../../../website/server/models/user'; + +describe('payments/skuItems', () => { + let user; + let clock; + + beforeEach(() => { + user = new User(); + clock = null; + }); + afterEach(() => { + if (clock !== null) clock.restore(); + }); + + describe('#canBuySkuItem', () => { + it('returns true for random sku', () => { + expect(canBuySkuItem('something', user)).to.be.true; + }); + + describe('#gryphatrice', () => { + const sku = 'com.habitrpg.android.habitica.iap.pets.gryphatrice-jubilant'; + it('returns true during birthday week', () => { + clock = sinon.useFakeTimers(new Date('2023-01-29')); + expect(canBuySkuItem(sku, user)).to.be.true; + }); + it('returns false outside of birthday week', () => { + clock = sinon.useFakeTimers(new Date('2023-01-20')); + expect(canBuySkuItem(sku, user)).to.be.false; + }); + it('returns false if user already owns it', () => { + clock = sinon.useFakeTimers(new Date('2023-02-01')); + user.items.pets['Gryphatrice-Jubilant'] = 5; + expect(canBuySkuItem(sku, user)).to.be.false; + }); + }); + }); +}); diff --git a/test/api/v3/integration/payments/apple/POST-payments_apple_verifyiap.js b/test/api/v3/integration/payments/apple/POST-payments_apple_verifyiap.js index d142fe2a24..0400eedcf3 100644 --- a/test/api/v3/integration/payments/apple/POST-payments_apple_verifyiap.js +++ b/test/api/v3/integration/payments/apple/POST-payments_apple_verifyiap.js @@ -21,11 +21,11 @@ describe('payments : apple #verify', () => { let verifyStub; beforeEach(async () => { - verifyStub = sinon.stub(applePayments, 'verifyGemPurchase').resolves({}); + verifyStub = sinon.stub(applePayments, 'verifyPurchase').resolves({}); }); afterEach(() => { - applePayments.verifyGemPurchase.restore(); + applePayments.verifyPurchase.restore(); }); it('makes a purchase', async () => { diff --git a/test/api/v3/integration/payments/google/POST-payments_google_verifyiap.js b/test/api/v3/integration/payments/google/POST-payments_google_verifyiap.js index ecdf17dfc0..3bfcb08b8a 100644 --- a/test/api/v3/integration/payments/google/POST-payments_google_verifyiap.js +++ b/test/api/v3/integration/payments/google/POST-payments_google_verifyiap.js @@ -21,11 +21,11 @@ describe('payments : google #verify', () => { let verifyStub; beforeEach(async () => { - verifyStub = sinon.stub(googlePayments, 'verifyGemPurchase').resolves({}); + verifyStub = sinon.stub(googlePayments, 'verifyPurchase').resolves({}); }); afterEach(() => { - googlePayments.verifyGemPurchase.restore(); + googlePayments.verifyPurchase.restore(); }); it('makes a purchase', async () => { diff --git a/website/client/src/app.vue b/website/client/src/app.vue index e9afbb2515..ede7eb8c9c 100644 --- a/website/client/src/app.vue +++ b/website/client/src/app.vue @@ -35,6 +35,7 @@ + + + + + diff --git a/website/client/src/components/header/notifications/itemReceived.vue b/website/client/src/components/header/notifications/itemReceived.vue new file mode 100644 index 0000000000..700f7b47a5 --- /dev/null +++ b/website/client/src/components/header/notifications/itemReceived.vue @@ -0,0 +1,58 @@ + + + diff --git a/website/client/src/components/header/notificationsDropdown.vue b/website/client/src/components/header/notificationsDropdown.vue index 3d83ec3e3e..4cd0bc438e 100644 --- a/website/client/src/components/header/notificationsDropdown.vue +++ b/website/client/src/components/header/notificationsDropdown.vue @@ -123,23 +123,24 @@ import successImage from '@/assets/svg/success.svg'; import starBadge from '@/assets/svg/star-badge.svg'; // Notifications -import NEW_STUFF from './notifications/newStuff'; -import GROUP_TASK_NEEDS_WORK from './notifications/groupTaskNeedsWork'; -import GUILD_INVITATION from './notifications/guildInvitation'; -import PARTY_INVITATION from './notifications/partyInvitation'; +import CARD_RECEIVED from './notifications/cardReceived'; import CHALLENGE_INVITATION from './notifications/challengeInvitation'; -import QUEST_INVITATION from './notifications/questInvitation'; +import GIFT_ONE_GET_ONE from './notifications/g1g1'; import GROUP_TASK_ASSIGNED from './notifications/groupTaskAssigned'; import GROUP_TASK_CLAIMED from './notifications/groupTaskClaimed'; -import UNALLOCATED_STATS_POINTS from './notifications/unallocatedStatsPoints'; -import NEW_MYSTERY_ITEMS from './notifications/newMysteryItems'; -import CARD_RECEIVED from './notifications/cardReceived'; -import NEW_INBOX_MESSAGE from './notifications/newPrivateMessage'; +import GROUP_TASK_NEEDS_WORK from './notifications/groupTaskNeedsWork'; +import GUILD_INVITATION from './notifications/guildInvitation'; +import ITEM_RECEIVED from './notifications/itemReceived'; import NEW_CHAT_MESSAGE from './notifications/newChatMessage'; -import WORLD_BOSS from './notifications/worldBoss'; -import VERIFY_USERNAME from './notifications/verifyUsername'; +import NEW_INBOX_MESSAGE from './notifications/newPrivateMessage'; +import NEW_MYSTERY_ITEMS from './notifications/newMysteryItems'; +import NEW_STUFF from './notifications/newStuff'; import ONBOARDING_COMPLETE from './notifications/onboardingComplete'; -import GIFT_ONE_GET_ONE from './notifications/g1g1'; +import PARTY_INVITATION from './notifications/partyInvitation'; +import QUEST_INVITATION from './notifications/questInvitation'; +import UNALLOCATED_STATS_POINTS from './notifications/unallocatedStatsPoints'; +import VERIFY_USERNAME from './notifications/verifyUsername'; +import WORLD_BOSS from './notifications/worldBoss'; import OnboardingGuide from './onboardingGuide'; export default { @@ -147,24 +148,25 @@ export default { MenuDropdown, MessageCount, // One component for each type - NEW_STUFF, - GROUP_TASK_NEEDS_WORK, - GUILD_INVITATION, - PARTY_INVITATION, + CARD_RECEIVED, CHALLENGE_INVITATION, - QUEST_INVITATION, + GIFT_ONE_GET_ONE, GROUP_TASK_ASSIGNED, GROUP_TASK_CLAIMED, - UNALLOCATED_STATS_POINTS, - NEW_MYSTERY_ITEMS, - CARD_RECEIVED, - NEW_INBOX_MESSAGE, + GROUP_TASK_NEEDS_WORK, + GUILD_INVITATION, + ITEM_RECEIVED, NEW_CHAT_MESSAGE, - WorldBoss: WORLD_BOSS, - VERIFY_USERNAME, - OnboardingGuide, + NEW_INBOX_MESSAGE, + NEW_MYSTERY_ITEMS, + NEW_STUFF, ONBOARDING_COMPLETE, - GIFT_ONE_GET_ONE, + PARTY_INVITATION, + QUEST_INVITATION, + UNALLOCATED_STATS_POINTS, + VERIFY_USERNAME, + WorldBoss: WORLD_BOSS, + OnboardingGuide, }, data () { return { @@ -185,6 +187,7 @@ export default { // NOTE: Those not listed here won't be shown in the notification panel! handledNotifications: [ 'NEW_STUFF', + 'ITEM_RECEIVED', 'GIFT_ONE_GET_ONE', 'GROUP_TASK_NEEDS_WORK', 'GUILD_INVITATION', diff --git a/website/client/src/components/news/birthdayModal.vue b/website/client/src/components/news/birthdayModal.vue new file mode 100644 index 0000000000..d4176e6eb8 --- /dev/null +++ b/website/client/src/components/news/birthdayModal.vue @@ -0,0 +1,877 @@ + + + + + + + + + diff --git a/website/client/src/components/payments/amazonModal.vue b/website/client/src/components/payments/amazonModal.vue index fcae0e01ec..fa332be149 100644 --- a/website/client/src/components/payments/amazonModal.vue +++ b/website/client/src/components/payments/amazonModal.vue @@ -78,6 +78,7 @@ export default { orderReferenceId: null, subscription: null, coupon: null, + sku: null, }, isAmazonSetup: false, amazonButtonEnabled: false, @@ -174,7 +175,10 @@ export default { storePaymentStatusAndReload (url) { let paymentType; - if (this.amazonPayments.type === 'single' && !this.amazonPayments.gift) paymentType = 'gems'; + if (this.amazonPayments.type === 'single') { + if (this.amazonPayments.sku) paymentType = 'sku'; + else if (!this.amazonPayments.gift) paymentType = 'gems'; + } if (this.amazonPayments.type === 'subscription') paymentType = 'subscription'; if (this.amazonPayments.groupId || this.amazonPayments.groupToCreate) paymentType = 'groupPlan'; if (this.amazonPayments.type === 'single' && this.amazonPayments.gift && this.amazonPayments.giftReceiver) { @@ -223,6 +227,7 @@ export default { const data = { orderReferenceId: this.amazonPayments.orderReferenceId, gift: this.amazonPayments.gift, + sku: this.amazonPayments.sku, }; if (this.amazonPayments.gemsBlock) { diff --git a/website/client/src/components/payments/successModal.vue b/website/client/src/components/payments/successModal.vue index 4ac16233bc..620158cd86 100644 --- a/website/client/src/components/payments/successModal.vue +++ b/website/client/src/components/payments/successModal.vue @@ -1,8 +1,8 @@ + + + + @@ -232,9 +269,8 @@ margin-bottom: 16px; } - .check { - width: 35.1px; - height: 28px; + .svg-check { + width: 45px; color: $white; } } @@ -293,6 +329,34 @@ .group-billing-date { width: 269px; } + + .words { + margin-bottom: 16px; + justify-content: center; + font-size: 0.875rem; + color: $gray-50; + line-height: 1.71; + } + + .jub-success { + margin-top: 0px; + margin-bottom: 0px; + } + + .gryph-bg { + width: 110px; + height: 104px; + align-items: center; + justify-content: center; + padding: 16px; + border-radius: 4px; + background-color: $gray-700; + } + .btn-jub { + margin-bottom: 8px; + margin-top: 24px; + } + } .modal-footer { background: $gray-700; @@ -430,6 +494,9 @@ export default { isNewGroup () { return this.paymentData.paymentType === 'groupPlan' && this.paymentData.newGroup; }, + ownsJubilantGryphatrice () { + return this.paymentData.paymentType === 'sku'; // will need to be revised when there are other discrete skus in system + }, }, mounted () { this.$root.$on('habitica:payment-success', data => { @@ -458,6 +525,12 @@ export default { this.sendingInProgress = false; this.$root.$emit('bv::hide::modal', 'payments-success-modal'); }, + closeAndRedirect () { + if (this.$router.history.current.name !== 'stable') { + this.$router.push('/inventory/stable'); + } + this.close(); + }, submit () { if (this.paymentData.group && !this.paymentData.newGroup) { Analytics.track({ diff --git a/website/client/src/mixins/foolPet.js b/website/client/src/mixins/foolPet.js index 6a2c74f9a2..371a28bd6f 100644 --- a/website/client/src/mixins/foolPet.js +++ b/website/client/src/mixins/foolPet.js @@ -26,6 +26,7 @@ export default { 'Fox-Veteran', 'JackOLantern-Glow', 'Gryphon-Gryphatrice', + 'Gryphatrice-Jubilant', 'JackOLantern-RoyalPurple', ]; const BASE_PETS = [ diff --git a/website/client/src/mixins/payments.js b/website/client/src/mixins/payments.js index f22c5ffc5f..65af65e5aa 100644 --- a/website/client/src/mixins/payments.js +++ b/website/client/src/mixins/payments.js @@ -9,7 +9,6 @@ import { CONSTANTS, setLocalSetting } from '@/libs/userlocalManager'; const { STRIPE_PUB_KEY } = process.env; -// const habiticaUrl = `${window.location.protocol}//${window.location.host}`; let stripeInstance = null; export default { @@ -70,6 +69,7 @@ export default { type, giftData, gemsBlock, + sku, } = data; let { url } = data; @@ -93,6 +93,11 @@ export default { url += `?gemsBlock=${gemsBlock.key}`; } + if (type === 'sku') { + appState.sku = sku; + url += `?sku=${sku}`; + } + setLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE, JSON.stringify(appState)); window.open(url, '_blank'); @@ -129,6 +134,7 @@ export default { if (data.group || data.groupToCreate) paymentType = 'groupPlan'; if (data.gift && data.gift.type === 'gems') paymentType = 'gift-gems'; if (data.gift && data.gift.type === 'subscription') paymentType = 'gift-subscription'; + if (data.sku) paymentType = 'sku'; let url = '/stripe/checkout-session'; const postData = {}; @@ -148,6 +154,7 @@ export default { if (data.coupon) postData.coupon = data.coupon; if (data.groupId) postData.groupId = data.groupId; if (data.demographics) postData.demographics = data.demographics; + if (data.sku) postData.sku = data.sku; const response = await axios.post(url, postData); @@ -250,6 +257,7 @@ export default { if (data.type === 'single') { this.amazonPayments.gemsBlock = data.gemsBlock; + this.amazonPayments.sku = data.sku; } if (data.gift) { diff --git a/website/common/locales/en/backgrounds.json b/website/common/locales/en/backgrounds.json index 31352661db..5abbb901a2 100644 --- a/website/common/locales/en/backgrounds.json +++ b/website/common/locales/en/backgrounds.json @@ -857,5 +857,9 @@ "backgroundSteamworksText": "Steamworks", "backgroundSteamworksNotes": "Build mighty contraptions of vapor and steel in a Steamworks.", "backgroundClocktowerText": "Clock Tower", - "backgroundClocktowerNotes": "Situate your secret lair behind the face of a Clock Tower." + "backgroundClocktowerNotes": "Situate your secret lair behind the face of a Clock Tower.", + + "eventBackgrounds": "Event Backgrounds", + "backgroundBirthdayBashText": "Birthday Bash", + "backgroundBirthdayBashNotes": "Habitica's having a birthday party, and everyone's invited!" } diff --git a/website/common/locales/en/gear.json b/website/common/locales/en/gear.json index b5d12552ea..5a7c0054a8 100644 --- a/website/common/locales/en/gear.json +++ b/website/common/locales/en/gear.json @@ -801,7 +801,8 @@ "armorSpecialBirthday2021Notes": "Happy Birthday, Habitica! Wear these Extravagant Party Robes to celebrate this wonderful day. Confers no benefit.", "armorSpecialBirthday2022Text": "Preposterous Party Robes", "armorSpecialBirthday2022Notes": "Happy Birthday, Habitica! Wear these Proposterous Party Robes to celebrate this wonderful day. Confers no benefit.", - + "armorSpecialBirthday2023Text": "Fabulous Party Robes", + "armorSpecialBirthday2023Notes": "Happy Birthday, Habitica! Wear these Fabulous Party Robes to celebrate this wonderful day. Confers no benefit.", "armorSpecialGaymerxText": "Rainbow Warrior Armor", "armorSpecialGaymerxNotes": "In celebration of the GaymerX Conference, this special armor is decorated with a radiant, colorful rainbow pattern! GaymerX is a game convention celebrating LGTBQ and gaming and is open to everyone.", @@ -2680,6 +2681,9 @@ "backSpecialTurkeyTailGildedNotes": "Plumage fit for a parade! Confers no benefit.", "backSpecialNamingDay2020Text": "Royal Purple Gryphon Tail", "backSpecialNamingDay2020Notes": "Happy Naming Day! Swish this fiery, pixely tail about as you celebrate Habitica. Confers no benefit.", + "backSpecialAnniversaryText": "Habitica Hero Cape", + "backSpecialAnniversaryNotes": "Let this proud cape fly in the wind and tell everyone that you're a Habitica Hero. Confers no benefit. Special Edition 10th Birthday Bash Item.", + "backBearTailText": "Bear Tail", "backBearTailNotes": "This tail makes you look like a brave bear! Confers no benefit.", "backCactusTailText": "Cactus Tail", @@ -2711,6 +2715,8 @@ "bodySpecialTakeThisNotes": "These pauldrons were earned by participating in a sponsored Challenge made by Take This. Congratulations! Increases all Stats by <%= attrs %>.", "bodySpecialAetherAmuletText": "Aether Amulet", "bodySpecialAetherAmuletNotes": "This amulet has a mysterious history. Increases Constitution and Strength by <%= attrs %> each.", + "bodySpecialAnniversaryText": "Habitica Hero Collar", + "bodySpecialAnniversaryNotes": "Perfectly complement your royal purple ensemble! Confers no benefit. Special Edition 10th Birthday Bash Item.", "bodySpecialSummerMageText": "Shining Capelet", "bodySpecialSummerMageNotes": "Neither salt water nor fresh water can tarnish this metallic capelet. Confers no benefit. Limited Edition 2014 Summer Gear.", @@ -2913,6 +2919,8 @@ "eyewearSpecialAetherMaskNotes": "This mask has a mysterious history. Increases Intelligence by <%= int %>.", "eyewearSpecialKS2019Text": "Mythic Gryphon Visor", "eyewearSpecialKS2019Notes": "Bold as a gryphon's... hmm, gryphons don't have visors. It reminds you to... oh, who are we kidding, it just looks cool! Confers no benefit.", + "eyewearSpecialAnniversaryText": "Habitica Hero Mask", + "eyewearSpecialAnniversaryNotes": "Look through the eyes of a Habitica Hero - you! Confers no benefit. Special Edition 10th Birthday Bash Item.", "eyewearSpecialSummerRogueText": "Roguish Eyepatch", "eyewearSpecialSummerRogueNotes": "It doesn't take a scallywag to see how stylish this is! Confers no benefit. Limited Edition 2014 Summer Gear.", diff --git a/website/common/locales/en/limited.json b/website/common/locales/en/limited.json index 44b213e424..700a8ab097 100644 --- a/website/common/locales/en/limited.json +++ b/website/common/locales/en/limited.json @@ -209,6 +209,7 @@ "dateEndOctober": "October 31", "dateEndNovember": "November 30", "dateEndDecember": "December 31", + "dateStartFebruary": "February 1", "januaryYYYY": "January <%= year %>", "februaryYYYY": "February <%= year %>", "marchYYYY": "March <%= year %>", @@ -236,5 +237,33 @@ "g1g1Limitations": "This is a limited time event that starts on December 15th at 8:00 AM ET (13:00 UTC) and will end January 8th at 11:59 PM ET (January 9th 04:59 UTC). This promotion only applies when you gift to another Habitican. If you or your gift recipient already have a subscription, the gifted subscription will add months of credit that will only be used after the current subscription is canceled or expires.", "noLongerAvailable": "This item is no longer available.", "gemSaleHow": "Between <%= eventStartMonth %> <%= eventStartOrdinal %> and <%= eventEndOrdinal %>, simply purchase any Gem bundle like usual and your account will be credited with the promotional amount of Gems. More Gems to spend, share, or save for any future releases!", - "gemSaleLimitations": "This promotion only applies during the limited time event. This event starts on <%= eventStartMonth %> <%= eventStartOrdinal %> at 8:00 AM EDT (12:00 UTC) and will end <%= eventStartMonth %> <%= eventEndOrdinal %> at 8:00 PM EDT (00:00 UTC). The promo offer is only available when buying Gems for yourself." -} + "gemSaleLimitations": "This promotion only applies during the limited time event. This event starts on <%= eventStartMonth %> <%= eventStartOrdinal %> at 8:00 AM EDT (12:00 UTC) and will end <%= eventStartMonth %> <%= eventEndOrdinal %> at 8:00 PM EDT (00:00 UTC). The promo offer is only available when buying Gems for yourself.", + "anniversaryLimitations": "This is a limited time event that starts on January 23rd at 8:00 AM ET (13:00 UTC) and will end February 1st at 11:59 PM ET (04:59 UTC). The Limited Edition Jubilant Gryphatrice and ten Magic Hatching Potions will be available to buy during this time. The other Gifts listed in the Four for Free section will be automatically delivered to all accounts that were active in the 30 days prior to day the gift is sent. Accounts created after the gifts are sent will not be able to claim them.", + "limitedEvent": "Limited Event", + "limitedDates": "January 23rd to February 1st", + "celebrateAnniversary": "Celebrate Habitica's 10th Birthday with gifts and exclusive items below!", + "celebrateBirthday": "Celebrate Habitica's 10th Birthday with gifts and exclusive items!", + "jubilantGryphatrice": "Animated Jubilant Gryphatrice Pet", + "limitedEdition": "Limited Edition", + "anniversaryGryphatriceText": "The rare Jubilant Gryphatrice joins the birthday celebrations! Don't miss your chance to own this exclusive animated Pet.", + "anniversaryGryphatricePrice": "Own it today for $9.99 or 60 gems", + "buyNowMoneyButton": "Buy Now for $9.99", + "buyNowGemsButton": "Buy Now for 60 Gems", + "wantToPayWithGemsText": "Want to pay with Gems?", + "wantToPayWithMoneyText": "Want to pay with Stripe, Paypal, or Amazon?", + "ownJubilantGryphatrice": "You own the Jubilant Gryphatrice! Visit the Stable to equip!", + "jubilantSuccess": "You've successfully purchased the Jubilant Gryphatrice!", + "stableVisit": "Visit the Stable to equip!", + "takeMeToStable": "Take me to the Stable", + "plentyOfPotions": "Plenty of Potions", + "plentyOfPotionsText": "We're bringing back 10 of the community's favorite Magic Hatching potions. Head over to The Market to fill out your collection!", + "visitTheMarketButton": "Visit the Market", + "fourForFree": "Four for Free", + "fourForFreeText": "To keep the party going, we'll be giving away Party Robes, 20 Gems, and a limited edition birthday Background and item set that includes a Cape, Pauldrons, and an Eyemask.", + "dayOne": "Day 1", + "dayFive": "Day 5", + "dayTen": "Day 10", + "partyRobes": "Party Robes", + "twentyGems": "20 Gems", + "birthdaySet": "Birthday Set" +} \ No newline at end of file diff --git a/website/common/locales/en/pets.json b/website/common/locales/en/pets.json index 5b2cd13a1a..001ed2ad8c 100644 --- a/website/common/locales/en/pets.json +++ b/website/common/locales/en/pets.json @@ -32,6 +32,7 @@ "royalPurpleJackalope": "Royal Purple Jackalope", "invisibleAether": "Invisible Aether", "gryphatrice": "Gryphatrice", + "jubilantGryphatrice": "Jubilant Gryphatrice", "potion": "<%= potionType %> Potion", "egg": "<%= eggType %> Egg", "eggs": "Eggs", diff --git a/website/common/script/content/appearance/backgrounds.js b/website/common/script/content/appearance/backgrounds.js index 78538ef4f1..49e8daabc9 100644 --- a/website/common/script/content/appearance/backgrounds.js +++ b/website/common/script/content/appearance/backgrounds.js @@ -540,6 +540,11 @@ const backgrounds = { snowy_temple: { }, winter_lake_with_swans: { }, }, + eventBackgrounds: { + birthday_bash: { + price: 0, + }, + }, timeTravelBackgrounds: { airship: { price: 1, @@ -583,7 +588,9 @@ forOwn(backgrounds, (backgroundsInSet, set) => { forOwn(backgroundsInSet, (background, bgKey) => { background.key = bgKey; background.set = set; - background.price = background.price || 7; + if (background.price !== 0) { + background.price = background.price || 7; + } background.text = background.text || t(`background${upperFirst(camelCase(bgKey))}Text`); background.notes = background.notes || t(`background${upperFirst(camelCase(bgKey))}Notes`); diff --git a/website/common/script/content/constants/events.js b/website/common/script/content/constants/events.js index c3e8522062..7d8c34e910 100644 --- a/website/common/script/content/constants/events.js +++ b/website/common/script/content/constants/events.js @@ -10,11 +10,15 @@ const gemsPromo = { export const EVENTS = { noEvent: { - start: '2023-01-31T20:00-05:00', + start: '2023-02-01T23:59-05:00', end: '2023-02-14T08:00-05:00', season: 'normal', npcImageSuffix: '', }, + birthday10: { + start: '2023-01-23T08:00-05:00', + end: '2023-02-01T23:59-05:00', + }, winter2023: { start: '2022-12-20T08:00-05:00', end: '2023-01-31T23:59-05:00', diff --git a/website/common/script/content/gear/sets/special/index.js b/website/common/script/content/gear/sets/special/index.js index 85f7e12284..b899417f28 100644 --- a/website/common/script/content/gear/sets/special/index.js +++ b/website/common/script/content/gear/sets/special/index.js @@ -798,6 +798,12 @@ const armor = { winter2023Healer: { set: 'winter2023CardinalHealerSet', }, + birthday2023: { + text: t('armorSpecialBirthday2023Text'), + notes: t('armorSpecialBirthday2023Notes'), + value: 0, + canOwn: ownsItem('armor_special_birthday2023'), + }, }; const armorStats = { @@ -923,6 +929,12 @@ const back = { value: 0, canOwn: ownsItem('back_special_namingDay2020'), }, + anniversary: { + text: t('backSpecialAnniversaryText'), + notes: t('backSpecialAnniversaryNotes'), + value: 0, + canOwn: ownsItem('back_special_anniversary'), + }, }; const body = { @@ -992,6 +1004,12 @@ const body = { value: 0, canOwn: ownsItem('body_special_namingDay2018'), }, + anniversary: { + text: t('bodySpecialAnniversaryText'), + notes: t('bodySpecialAnniversaryNotes'), + value: 0, + canOwn: ownsItem('body_special_anniversary'), + }, }; const eyewear = { @@ -1140,6 +1158,12 @@ const eyewear = { value: 0, canOwn: ownsItem('eyewear_special_ks2019'), }, + anniversary: { + text: t('eyewearSpecialAnniversaryText'), + notes: t('eyewearSpecialAnniversaryNotes'), + value: 0, + canOwn: ownsItem('eyewear_special_anniversary'), + }, }; const head = { diff --git a/website/common/script/content/hatching-potions.js b/website/common/script/content/hatching-potions.js index 9ba437243d..2d45722a9a 100644 --- a/website/common/script/content/hatching-potions.js +++ b/website/common/script/content/hatching-potions.js @@ -70,13 +70,13 @@ const premium = { value: 2, text: t('hatchingPotionShimmer'), limited: true, - event: EVENTS.spring2022, + event: EVENTS.birthday10, _addlNotes: t('eventAvailabilityReturning', { - availableDate: t('dateEndMarch'), - previousDate: t('marchYYYY', { year: 2020 }), + availableDate: t('dateStartFebruary'), + previousDate: t('marchYYYY', { year: 2022 }), }), canBuy () { - return moment().isBefore(EVENTS.spring2022.end); + return moment().isBetween(EVENTS.birthday10.start, EVENTS.birthday10.end); }, }, Fairy: { @@ -109,13 +109,13 @@ const premium = { value: 2, text: t('hatchingPotionAquatic'), limited: true, - event: EVENTS.summer2022, + event: EVENTS.birthday10, _addlNotes: t('eventAvailabilityReturning', { - availableDate: t('dateEndJuly'), - previousDate: t('augustYYYY', { year: 2020 }), + availableDate: t('dateStartFebruary'), + previousDate: t('julyYYYY', { year: 2022 }), }), canBuy () { - return moment().isBetween(EVENTS.summer2022.start, EVENTS.summer2022.end); + return moment().isBetween(EVENTS.birthday10.start, EVENTS.birthday10.end); }, }, Ember: { @@ -188,12 +188,12 @@ const premium = { text: t('hatchingPotionPeppermint'), limited: true, _addlNotes: t('eventAvailabilityReturning', { - availableDate: t('dateEndJanuary'), - previousDate: t('januaryYYYY', { year: 2018 }), + availableDate: t('dateStartFebruary'), + previousDate: t('januaryYYYY', { year: 2022 }), }), - event: EVENTS.winter2022, + event: EVENTS.birthday10, canBuy () { - return moment().isBetween(EVENTS.winter2022.start, EVENTS.winter2022.end); + return moment().isBetween(EVENTS.birthday10.start, EVENTS.birthday10.end); }, }, StarryNight: { @@ -239,13 +239,13 @@ const premium = { value: 2, text: t('hatchingPotionGlow'), limited: true, - event: EVENTS.fall2021, + event: EVENTS.birthday10, _addlNotes: t('eventAvailabilityReturning', { - availableDate: t('dateEndOctober'), - previousDate: t('septemberYYYY', { year: 2019 }), + availableDate: t('dateStartFebruary'), + previousDate: t('octoberYYYY', { year: 2021 }), }), canBuy () { - return moment().isBefore(EVENTS.fall2021.end); + return moment().isBetween(EVENTS.birthday10.start, EVENTS.birthday10.end); }, }, Frost: { @@ -286,13 +286,13 @@ const premium = { value: 2, text: t('hatchingPotionCelestial'), limited: true, - event: EVENTS.spring2022, + event: EVENTS.birthday10, _addlNotes: t('eventAvailabilityReturning', { - availableDate: t('dateEndMarch'), - previousDate: t('marchYYYY', { year: 2020 }), + availableDate: t('dateStartFebruary'), + previousDate: t('marchYYYY', { year: 2022 }), }), canBuy () { - return moment().isBefore(EVENTS.spring2022.end); + return moment().isBetween(EVENTS.birthday10.start, EVENTS.birthday10.end); }, }, Sunshine: { @@ -399,13 +399,13 @@ const premium = { value: 2, text: t('hatchingPotionSandSculpture'), limited: true, - event: EVENTS.summer2021, + event: EVENTS.birthday10, _addlNotes: t('eventAvailabilityReturning', { - availableDate: t('dateEndJuly'), - previousDate: t('juneYYYY', { year: 2020 }), + availableDate: t('dateStartFebruary'), + previousDate: t('julyYYYY', { year: 2021 }), }), canBuy () { - return moment().isBetween(EVENTS.summer2021.start, EVENTS.summer2021.end); + return moment().isBetween(EVENTS.birthday10.start, EVENTS.birthday10.end); }, }, Windup: { @@ -426,26 +426,26 @@ const premium = { value: 2, text: t('hatchingPotionVampire'), limited: true, - event: EVENTS.fall2022, + event: EVENTS.birthday10, _addlNotes: t('eventAvailabilityReturning', { - availableDate: t('dateEndOctober'), - previousDate: t('octoberYYYY', { year: 2021 }), + availableDate: t('dateStartFebruary'), + previousDate: t('octoberYYYY', { year: 2022 }), }), canBuy () { - return moment().isBetween(EVENTS.fall2022.start, EVENTS.fall2022.end); + return moment().isBetween(EVENTS.birthday10.start, EVENTS.birthday10.end); }, }, AutumnLeaf: { value: 2, text: t('hatchingPotionAutumnLeaf'), limited: true, - event: EVENTS.potions202111, + event: EVENTS.birthday10, _addlNotes: t('eventAvailabilityReturning', { - availableDate: t('dateEndNovember'), - previousDate: t('novemberYYYY', { year: 2020 }), + availableDate: t('dateStartFebruary'), + previousDate: t('novemberYYYY', { year: 2021 }), }), canBuy () { - return moment().isBefore(EVENTS.potions202111.end); + return moment().isBetween(EVENTS.birthday10.start, EVENTS.birthday10.end); }, }, BlackPearl: { @@ -460,12 +460,12 @@ const premium = { text: t('hatchingPotionStainedGlass'), limited: true, _addlNotes: t('eventAvailabilityReturning', { - availableDate: t('dateEndJanuary'), - previousDate: t('januaryYYYY', { year: 2021 }), + availableDate: t('dateStartFebruary'), + previousDate: t('januaryYYYY', { year: 2022 }), }), - event: EVENTS.winter2022, + event: EVENTS.birthday10, canBuy () { - return moment().isBetween(EVENTS.winter2022.start, EVENTS.winter2022.end); + return moment().isBetween(EVENTS.birthday10.start, EVENTS.birthday10.end); }, }, PolkaDot: { @@ -532,12 +532,13 @@ const premium = { value: 2, text: t('hatchingPotionPorcelain'), limited: true, - event: EVENTS.potions202208, - _addlNotes: t('premiumPotionAddlNotes', { - date: t('dateEndAugust'), + event: EVENTS.birthday10, + _addlNotes: t('eventAvailabilityReturning', { + availableDate: t('dateStartFebruary'), + previousDate: t('augustYYYY', { year: 2022 }), }), canBuy () { - return moment().isBetween(EVENTS.potions202208.start, EVENTS.potions202208.end); + return moment().isBetween(EVENTS.birthday10.start, EVENTS.birthday10.end); }, }, }; diff --git a/website/common/script/content/stable.js b/website/common/script/content/stable.js index 9119cfca23..457ecd60ee 100644 --- a/website/common/script/content/stable.js +++ b/website/common/script/content/stable.js @@ -1,4 +1,6 @@ import each from 'lodash/each'; +import moment from 'moment'; +import { EVENTS } from './constants/events'; import { drops as dropEggs, quests as questEggs, @@ -118,6 +120,9 @@ const canFindSpecial = { 'Jackalope-RoyalPurple': true, // subscription 'Wolf-Cerberus': false, // Pet once granted to backers 'Gryphon-Gryphatrice': false, // Pet once granted to kickstarter + + // Birthday Pet + 'Gryphatrice-Jubilant': false, }, mounts: { // Thanksgiving pet ladder @@ -174,6 +179,7 @@ const specialPets = { 'Fox-Veteran': 'veteranFox', 'JackOLantern-Glow': 'glowJackolantern', 'Gryphon-Gryphatrice': 'gryphatrice', + 'Gryphatrice-Jubilant': 'jubilantGryphatrice', 'JackOLantern-RoyalPurple': 'royalPurpleJackolantern', }; @@ -207,6 +213,16 @@ each(specialPets, (translationString, key) => { }; }); +Object.assign(petInfo['Gryphatrice-Jubilant'], { + canBuy () { + return moment().isBetween(EVENTS.birthday10.start, EVENTS.birthday10.end); + }, + currency: 'gems', + event: 'birthday10', + value: 60, + purchaseType: 'pets', +}); + each(specialMounts, (translationString, key) => { mountInfo[key] = { key, diff --git a/website/common/script/errors/commonErrorMessages.js b/website/common/script/errors/commonErrorMessages.js index b71b37c5b4..d434faa4a8 100644 --- a/website/common/script/errors/commonErrorMessages.js +++ b/website/common/script/errors/commonErrorMessages.js @@ -7,6 +7,7 @@ export default { missingTypeParam: '"req.params.type" is required.', missingKeyParam: '"req.params.key" is required.', itemNotFound: 'Item "<%= key %>" not found.', + petNotFound: 'Pet "<%= key %>" not found.', questNotFound: 'Quest "<%= key %>" not found.', spellNotFound: 'Skill "<%= spellId %>" not found.', invalidQuantity: 'Quantity to purchase must be a positive whole number.', diff --git a/website/common/script/ops/buy/buy.js b/website/common/script/ops/buy/buy.js index 07a0ab10e6..f5a0920320 100644 --- a/website/common/script/ops/buy/buy.js +++ b/website/common/script/ops/buy/buy.js @@ -13,6 +13,7 @@ import hourglassPurchase from './hourglassPurchase'; import errorMessage from '../../libs/errorMessage'; import { BuyGemOperation } from './buyGem'; import { BuyQuestWithGemOperation } from './buyQuestGem'; +import { BuyPetWithGemOperation } from './buyPetGem'; import { BuyHourglassMountOperation } from './buyMount'; // @TODO: remove the req option style. Dependency on express structure is an anti-pattern @@ -86,7 +87,12 @@ export default async function buy ( break; } case 'pets': - buyRes = hourglassPurchase(user, req, analytics); + if (key === 'Gryphatrice-Jubilant') { + const buyOp = new BuyPetWithGemOperation(user, req, analytics); + buyRes = await buyOp.purchase(); + } else { + buyRes = hourglassPurchase(user, req, analytics); + } break; case 'quest': { const buyOp = new BuyQuestWithGoldOperation(user, req, analytics); diff --git a/website/common/script/ops/buy/buyPetGem.js b/website/common/script/ops/buy/buyPetGem.js new file mode 100644 index 0000000000..fb1b52d822 --- /dev/null +++ b/website/common/script/ops/buy/buyPetGem.js @@ -0,0 +1,61 @@ +import get from 'lodash/get'; +import { + BadRequest, + NotFound, +} from '../../libs/errors'; +import content from '../../content/index'; + +import errorMessage from '../../libs/errorMessage'; +import { AbstractGemItemOperation } from './abstractBuyOperation'; + +export class BuyPetWithGemOperation extends AbstractGemItemOperation { // eslint-disable-line import/prefer-default-export, max-len + multiplePurchaseAllowed () { // eslint-disable-line class-methods-use-this + return false; + } + + getItemKey () { + return this.key; + } + + getItemValue (item) { // eslint-disable-line class-methods-use-this + return item.value / 4; + } + + getItemType () { // eslint-disable-line class-methods-use-this + return 'pet'; + } + + extractAndValidateParams (user, req) { + this.key = get(req, 'params.key'); + const { key } = this; + if (!key) throw new BadRequest(errorMessage('missingKeyParam')); + + const item = content.petInfo[key]; + + if (!item) throw new NotFound(errorMessage('petNotFound', { key })); + + this.canUserPurchase(user, item); + } + + canUserPurchase (user, item) { + if (item && user.items.pets[item.key]) { + throw new BadRequest(this.i18n('petsAlreadyOwned')); + } + + super.canUserPurchase(user, item); + } + + async executeChanges (user, item, req) { + user.items.pets[item.key] = 5; + if (user.markModified) user.markModified('items.pets'); + + await this.subtractCurrency(user, item); + + return [ + user.items.pets, + this.i18n('messageBought', { + itemText: item.text(req.language), + }), + ]; + } +} diff --git a/website/common/script/ops/unlock.js b/website/common/script/ops/unlock.js index 8ab2711135..342c9dc7a9 100644 --- a/website/common/script/ops/unlock.js +++ b/website/common/script/ops/unlock.js @@ -251,9 +251,10 @@ export default async function unlock (user, req = {}, analytics) { return invalidSet(req); } - cost = getIndividualItemPrice(setType, item, req); - unlockedAlready = alreadyUnlocked(user, setType, firstPath); + if (!unlockedAlready) { + cost = getIndividualItemPrice(setType, item, req); + } // Since only an item is being unlocked here, // remove all the other items from the set diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index e81b171f5c..c422541705 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -975,7 +975,7 @@ api.disableClasses = { * @apiGroup User * * @apiParam (Path) {String="gems","eggs","hatchingPotions","premiumHatchingPotions" - ,"food","quests","gear"} type Type of item to purchase. + ,"food","quests","gear","pets"} type Type of item to purchase. * @apiParam (Path) {String} key Item's key (use "gem" for purchasing gems) * * @apiParam (Body) {Integer} [quantity=1] Count of items to buy. diff --git a/website/server/controllers/top-level/payments/amazon.js b/website/server/controllers/top-level/payments/amazon.js index 4371400161..8aa231d684 100644 --- a/website/server/controllers/top-level/payments/amazon.js +++ b/website/server/controllers/top-level/payments/amazon.js @@ -75,12 +75,14 @@ api.checkout = { middlewares: [authWithHeaders()], async handler (req, res) { const { user } = res.locals; - const { orderReferenceId, gift, gemsBlock } = req.body; + const { + orderReferenceId, gift, gemsBlock, sku, + } = req.body; if (!orderReferenceId) throw new BadRequest('Missing req.body.orderReferenceId'); await amzLib.checkout({ - gemsBlock, gift, user, orderReferenceId, headers: req.headers, + gemsBlock, gift, sku, user, orderReferenceId, headers: req.headers, }); res.respond(200); diff --git a/website/server/controllers/top-level/payments/iap.js b/website/server/controllers/top-level/payments/iap.js index 00ccb31642..7214c63ba0 100644 --- a/website/server/controllers/top-level/payments/iap.js +++ b/website/server/controllers/top-level/payments/iap.js @@ -23,7 +23,7 @@ api.iapAndroidVerify = { middlewares: [authWithHeaders()], async handler (req, res) { if (!req.body.transaction) throw new BadRequest(res.t('missingReceipt')); - const googleRes = await googlePayments.verifyGemPurchase({ + const googleRes = await googlePayments.verifyPurchase({ user: res.locals.user, receipt: req.body.transaction.receipt, signature: req.body.transaction.signature, @@ -120,7 +120,7 @@ api.iapiOSVerify = { middlewares: [authWithHeaders()], async handler (req, res) { if (!req.body.transaction) throw new BadRequest(res.t('missingReceipt')); - const appleRes = await applePayments.verifyGemPurchase({ + const appleRes = await applePayments.verifyPurchase({ user: res.locals.user, receipt: req.body.transaction.receipt, gift: req.body.gift, diff --git a/website/server/controllers/top-level/payments/paypal.js b/website/server/controllers/top-level/payments/paypal.js index 1432ceea90..05d35b3f20 100644 --- a/website/server/controllers/top-level/payments/paypal.js +++ b/website/server/controllers/top-level/payments/paypal.js @@ -27,10 +27,13 @@ api.checkout = { const gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; req.session.gift = req.query.gift; - const { gemsBlock } = req.query; + const { gemsBlock, sku } = req.query; req.session.gemsBlock = gemsBlock; + req.session.sku = sku; - const link = await paypalPayments.checkout({ gift, gemsBlock, user: res.locals.user }); + const link = await paypalPayments.checkout({ + gift, gemsBlock, sku, user: res.locals.user, + }); if (req.query.noRedirect) { res.respond(200); @@ -56,14 +59,15 @@ api.checkoutSuccess = { const { user } = res.locals; const gift = req.session.gift ? JSON.parse(req.session.gift) : undefined; delete req.session.gift; - const { gemsBlock } = req.session; + const { gemsBlock, sku } = req.session; delete req.session.gemsBlock; + delete req.session.sku; if (!paymentId) throw new BadRequest(apiError('missingPaymentId')); if (!customerId) throw new BadRequest(apiError('missingCustomerId')); await paypalPayments.checkoutSuccess({ - user, gemsBlock, gift, paymentId, customerId, headers: req.headers, + user, gemsBlock, gift, paymentId, customerId, headers: req.headers, sku, }); if (req.query.noRedirect) { diff --git a/website/server/controllers/top-level/payments/stripe.js b/website/server/controllers/top-level/payments/stripe.js index 71835d8c41..9fed9d613d 100644 --- a/website/server/controllers/top-level/payments/stripe.js +++ b/website/server/controllers/top-level/payments/stripe.js @@ -27,13 +27,13 @@ api.createCheckoutSession = { async handler (req, res) { const { user } = res.locals; const { - gift, sub: subKey, gemsBlock, coupon, groupId, + gift, sub: subKey, gemsBlock, coupon, groupId, sku, } = req.body; const sub = subKey ? shared.content.subscriptionBlocks[subKey] : false; const session = await stripePayments.createCheckoutSession({ - user, gemsBlock, gift, sub, groupId, coupon, + user, gemsBlock, gift, sub, groupId, coupon, sku, }); res.respond(200, { diff --git a/website/server/libs/payments/amazon.js b/website/server/libs/payments/amazon.js index 6947f3e5ea..9e72340877 100644 --- a/website/server/libs/payments/amazon.js +++ b/website/server/libs/payments/amazon.js @@ -46,6 +46,7 @@ api.constants = { GIFT_TYPE_SUBSCRIPTION: 'subscription', METHOD_BUY_GEMS: 'buyGems', + METHOD_BUY_SKU_ITEM: 'buySkuItem', METHOD_CREATE_SUBSCRIPTION: 'createSubscription', PAYMENT_METHOD: 'Amazon Payments', PAYMENT_METHOD_GIFT: 'Amazon Payments (Gift)', @@ -110,7 +111,7 @@ api.authorize = function authorize (inputSet) { */ api.checkout = async function checkout (options = {}) { const { - gift, user, orderReferenceId, headers, gemsBlock: gemsBlockKey, + gift, user, orderReferenceId, headers, gemsBlock: gemsBlockKey, sku, } = options; let amount; let gemsBlock; @@ -127,6 +128,12 @@ api.checkout = async function checkout (options = {}) { } else if (gift.type === this.constants.GIFT_TYPE_SUBSCRIPTION) { amount = common.content.subscriptionBlocks[gift.subscription.key].price; } + } else if (sku) { + if (sku === 'Pet-Gryphatrice-Jubilant') { + amount = 9.99; + } else { + throw new NotFound('SKU not found.'); + } } else { gemsBlock = getGemsBlock(gemsBlockKey); amount = gemsBlock.price / 100; @@ -171,12 +178,16 @@ api.checkout = async function checkout (options = {}) { // execute payment let method = this.constants.METHOD_BUY_GEMS; + if (sku) { + method = this.constants.METHOD_BUY_SKU_ITEM; + } const data = { user, paymentMethod: this.constants.PAYMENT_METHOD, headers, gemsBlock, + sku, }; if (gift) { diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index 8c0fd44184..61cf05f87d 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -2,13 +2,14 @@ import moment from 'moment'; import shared from '../../../common'; import iap from '../inAppPurchases'; import payments from './payments'; -import { getGemsBlock, validateGiftMessage } from './gems'; +import { validateGiftMessage } from './gems'; import { NotAuthorized, BadRequest, } from '../errors'; import { model as IapPurchaseReceipt } from '../../models/iapPurchaseReceipt'; import { model as User } from '../../models/user'; +import { buySkuItem } from './skuItem'; const api = {}; @@ -22,7 +23,7 @@ api.constants = { RESPONSE_NO_ITEM_PURCHASED: 'NO_ITEM_PURCHASED', }; -api.verifyGemPurchase = async function verifyGemPurchase (options) { +api.verifyPurchase = async function verifyPurchase (options) { const { gift, user, receipt, headers, } = options; @@ -44,7 +45,6 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) { if (purchaseDataList.length === 0) { throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED); } - let correctReceipt = false; // Purchasing one item at a time (processing of await(s) below is sequential not parallel) for (const purchaseData of purchaseDataList) { @@ -62,46 +62,16 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) { userId: user._id, }); - let gemsBlockKey; - switch (purchaseData.productId) { // eslint-disable-line default-case - case 'com.habitrpg.ios.Habitica.4gems': - gemsBlockKey = '4gems'; - break; - case 'com.habitrpg.ios.Habitica.20gems': - case 'com.habitrpg.ios.Habitica.21gems': - gemsBlockKey = '21gems'; - break; - case 'com.habitrpg.ios.Habitica.42gems': - gemsBlockKey = '42gems'; - break; - case 'com.habitrpg.ios.Habitica.84gems': - gemsBlockKey = '84gems'; - break; - } - if (!gemsBlockKey) throw new NotAuthorized(api.constants.RESPONSE_INVALID_ITEM); - const gemsBlock = getGemsBlock(gemsBlockKey); - - if (gift) { - gift.type = 'gems'; - if (!gift.gems) gift.gems = {}; - gift.gems.amount = shared.content.gems[gemsBlock.key].gems; - } - - if (gemsBlock) { - correctReceipt = true; - await payments.buyGems({ // eslint-disable-line no-await-in-loop - user, - gift, - paymentMethod: api.constants.PAYMENT_METHOD_APPLE, - gemsBlock, - headers, - }); - } + await buySkuItem({ // eslint-disable-line no-await-in-loop + user, + gift, + paymentMethod: api.constants.PAYMENT_METHOD_APPLE, + sku: purchaseData.productId, + headers, + }); } } - if (!correctReceipt) throw new NotAuthorized(api.constants.RESPONSE_INVALID_ITEM); - return appleRes; }; diff --git a/website/server/libs/payments/google.js b/website/server/libs/payments/google.js index 9beac9b2a4..c4e7976552 100644 --- a/website/server/libs/payments/google.js +++ b/website/server/libs/payments/google.js @@ -8,7 +8,8 @@ import { } from '../errors'; import { model as IapPurchaseReceipt } from '../../models/iapPurchaseReceipt'; import { model as User } from '../../models/user'; -import { getGemsBlock, validateGiftMessage } from './gems'; +import { validateGiftMessage } from './gems'; +import { buySkuItem } from './skuItem'; const api = {}; @@ -21,7 +22,7 @@ api.constants = { RESPONSE_STILL_VALID: 'SUBSCRIPTION_STILL_VALID', }; -api.verifyGemPurchase = async function verifyGemPurchase (options) { +api.verifyPurchase = async function verifyPurchase (options) { const { gift, user, receipt, signature, headers, } = options; @@ -61,39 +62,11 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) { userId: user._id, }); - let gemsBlockKey; - - switch (receiptObj.productId) { // eslint-disable-line default-case - case 'com.habitrpg.android.habitica.iap.4gems': - gemsBlockKey = '4gems'; - break; - case 'com.habitrpg.android.habitica.iap.20gems': - case 'com.habitrpg.android.habitica.iap.21gems': - gemsBlockKey = '21gems'; - break; - case 'com.habitrpg.android.habitica.iap.42gems': - gemsBlockKey = '42gems'; - break; - case 'com.habitrpg.android.habitica.iap.84gems': - gemsBlockKey = '84gems'; - break; - } - - if (!gemsBlockKey) throw new NotAuthorized(this.constants.RESPONSE_INVALID_ITEM); - - const gemsBlock = getGemsBlock(gemsBlockKey); - - if (gift) { - gift.type = 'gems'; - if (!gift.gems) gift.gems = {}; - gift.gems.amount = shared.content.gems[gemsBlock.key].gems; - } - - await payments.buyGems({ + await buySkuItem({ // eslint-disable-line no-await-in-loop user, gift, - paymentMethod: this.constants.PAYMENT_METHOD_GOOGLE, - gemsBlock, + paymentMethod: api.constants.PAYMENT_METHOD_GOOGLE, + sku: googleRes.productId, headers, }); diff --git a/website/server/libs/payments/payments.js b/website/server/libs/payments/payments.js index 3e93bdb8d4..43bcae2f18 100644 --- a/website/server/libs/payments/payments.js +++ b/website/server/libs/payments/payments.js @@ -11,6 +11,9 @@ import { // eslint-disable-line import/no-cycle import { // eslint-disable-line import/no-cycle buyGems, } from './gems'; +import { // eslint-disable-line import/no-cycle + buySkuItem, +} from './skuItem'; import { paymentConstants } from './constants'; const api = {}; @@ -31,4 +34,6 @@ api.cancelSubscription = cancelSubscription; api.buyGems = buyGems; +api.buySkuItem = buySkuItem; + export default api; diff --git a/website/server/libs/payments/paypal.js b/website/server/libs/payments/paypal.js index 30e0a8dff3..70099e6541 100644 --- a/website/server/libs/payments/paypal.js +++ b/website/server/libs/payments/paypal.js @@ -77,7 +77,9 @@ api.paypalBillingAgreementCancel = util api.ipnVerifyAsync = util.promisify(paypalIpn.verify.bind(paypalIpn)); api.checkout = async function checkout (options = {}) { - const { gift, user, gemsBlock: gemsBlockKey } = options; + const { + gift, gemsBlock: gemsBlockKey, sku, user, + } = options; let amount; let gemsBlock; @@ -99,12 +101,17 @@ api.checkout = async function checkout (options = {}) { amount = Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2); description = 'mo. Habitica Subscription (Gift)'; } + } else if (sku) { + if (sku === 'Pet-Gryphatrice-Jubilant') { + description = 'Jubilant Gryphatrice'; + amount = 9.99; + } } else { gemsBlock = getGemsBlock(gemsBlockKey); amount = gemsBlock.price / 100; } - if (!gift || gift.type === 'gems') { + if (gemsBlock || (gift && gift.type === 'gems')) { const receiver = gift ? gift.member : user; const receiverCanGetGems = await receiver.canGetGems(); if (!receiverCanGetGems) throw new NotAuthorized(shared.i18n.t('groupPolicyCannotGetGems', receiver.preferences.language)); @@ -146,10 +153,10 @@ api.checkout = async function checkout (options = {}) { api.checkoutSuccess = async function checkoutSuccess (options = {}) { const { - user, gift, gemsBlock: gemsBlockKey, paymentId, customerId, + user, gift, gemsBlock: gemsBlockKey, paymentId, customerId, sku, } = options; - let method = 'buyGems'; + let method = sku ? 'buySkuItem' : 'buyGems'; const data = { user, customerId, @@ -164,6 +171,8 @@ api.checkoutSuccess = async function checkoutSuccess (options = {}) { data.paymentMethod = 'PayPal (Gift)'; data.gift = gift; + } else if (sku) { + data.sku = sku; } else { data.gemsBlock = getGemsBlock(gemsBlockKey); } diff --git a/website/server/libs/payments/skuItem.js b/website/server/libs/payments/skuItem.js new file mode 100644 index 0000000000..4820e393e9 --- /dev/null +++ b/website/server/libs/payments/skuItem.js @@ -0,0 +1,108 @@ +import moment from 'moment'; +import { + BadRequest, +} from '../errors'; +import shared from '../../../common'; +import { getAnalyticsServiceByEnvironment } from '../analyticsService'; +import { getGemsBlock, buyGems } from './gems'; // eslint-disable-line import/no-cycle + +const analytics = getAnalyticsServiceByEnvironment(); + +const RESPONSE_INVALID_ITEM = 'INVALID_ITEM_PURCHASED'; + +const EVENTS = { + birthday10: { + start: '2023-01-23T08:00-05:00', + end: '2023-02-01T23:59-05:00', + }, +}; + +function canBuyGryphatrice (user) { + if (!moment().isBetween(EVENTS.birthday10.start, EVENTS.birthday10.end)) return false; + if (user.items.pets['Gryphatrice-Jubilant']) return false; + return true; +} + +async function buyGryphatrice (data) { + // Double check it's available + if (!canBuyGryphatrice(data.user)) throw new BadRequest(); + const key = 'Gryphatrice-Jubilant'; + data.user.items.pets[key] = 5; + data.user.purchased.txnCount += 1; + + analytics.trackPurchase({ + uuid: data.user._id, + itemPurchased: 'Gryphatrice', + sku: `${data.paymentMethod.toLowerCase()}-checkout`, + purchaseType: 'checkout', + paymentMethod: data.paymentMethod, + quantity: 1, + gift: Boolean(data.gift), + purchaseValue: 10, + headers: data.headers, + firstPurchase: data.user.purchased.txnCount === 1, + }); + if (data.user.markModified) data.user.markModified('items.pets'); + await data.user.save(); +} + +export function canBuySkuItem (sku, user) { + switch (sku) { + case 'com.habitrpg.android.habitica.iap.pets.gryphatrice_jubilant': + case 'com.habitrpg.ios.Habitica.pets.gryphatrice_jubilant': + return canBuyGryphatrice(user); + default: + return true; + } +} + +export async function buySkuItem (data) { + let gemsBlockKey; + + switch (data.sku) { // eslint-disable-line default-case + case 'com.habitrpg.android.habitica.iap.4gems': + case 'com.habitrpg.ios.Habitica.4gems': + gemsBlockKey = '4gems'; + break; + case 'com.habitrpg.android.habitica.iap.20gems': + case 'com.habitrpg.android.habitica.iap.21gems': + case 'com.habitrpg.ios.Habitica.20gems': + case 'com.habitrpg.ios.Habitica.21gems': + gemsBlockKey = '21gems'; + break; + case 'com.habitrpg.android.habitica.iap.42gems': + case 'com.habitrpg.ios.Habitica.42gems': + gemsBlockKey = '42gems'; + break; + case 'com.habitrpg.android.habitica.iap.84gems': + case 'com.habitrpg.ios.Habitica.84gems': + gemsBlockKey = '84gems'; + break; + case 'com.habitrpg.android.habitica.iap.pets.gryphatrice_jubilant': + case 'com.habitrpg.ios.Habitica.pets.Gryphatrice_Jubilant': + case 'Pet-Gryphatrice-Jubilant': + case 'price_0MPZ6iZCD0RifGXlLah2furv': + buyGryphatrice(data); + return; + } + + if (gemsBlockKey) { + const gemsBlock = getGemsBlock(gemsBlockKey); + + if (data.gift) { + data.gift.type = 'gems'; + if (!data.gift.gems) data.gift.gems = {}; + data.gift.gems.amount = shared.content.gems[gemsBlock.key].gems; + } + + await buyGems({ + user: data.user, + gift: data.gift, + paymentMethod: data.paymentMethod, + gemsBlock, + headers: data.headers, + }); + return; + } + throw new BadRequest(RESPONSE_INVALID_ITEM); +} diff --git a/website/server/libs/payments/stripe/checkout.js b/website/server/libs/payments/stripe/checkout.js index c17dce6d93..aebb4aeaa0 100644 --- a/website/server/libs/payments/stripe/checkout.js +++ b/website/server/libs/payments/stripe/checkout.js @@ -13,6 +13,7 @@ import shared from '../../../../common'; import { getOneTimePaymentInfo } from './oneTimePayments'; // eslint-disable-line import/no-cycle import { checkSubData } from './subscriptions'; // eslint-disable-line import/no-cycle import { validateGiftMessage } from '../gems'; // eslint-disable-line import/no-cycle +import { buySkuItem } from '../skuItem'; // eslint-disable-line import/no-cycle const BASE_URL = nconf.get('BASE_URL'); @@ -24,6 +25,7 @@ export async function createCheckoutSession (options, stripeInc) { sub, groupId, coupon, + sku, } = options; // @TODO: We need to mock this, but curently we don't have correct @@ -37,6 +39,8 @@ export async function createCheckoutSession (options, stripeInc) { validateGiftMessage(gift, user); } else if (sub) { type = 'subscription'; + } else if (sku) { + type = 'sku'; } const metadata = { @@ -71,6 +75,12 @@ export async function createCheckoutSession (options, stripeInc) { price: sub.key, quantity, }]; + } else if (type === 'sku') { + metadata.sku = sku; + lineItems = [{ + price: sku, + quantity: 1, + }]; } else { const { amount, diff --git a/website/server/libs/payments/stripe/oneTimePayments.js b/website/server/libs/payments/stripe/oneTimePayments.js index cb5274e182..3989d5b507 100644 --- a/website/server/libs/payments/stripe/oneTimePayments.js +++ b/website/server/libs/payments/stripe/oneTimePayments.js @@ -22,6 +22,20 @@ function getGiftAmount (gift) { return `${(gift.gems.amount / 4) * 100}`; } +export async function applySku (session) { + const { metadata } = session; + const { userId, sku } = metadata; + const user = await User.findById(metadata.userId).exec(); + if (!user) throw new NotFound(shared.i18n.t('userWithIDNotFound', { userId })); + if (sku === 'price_0MPZ6iZCD0RifGXlLah2furv') { + await payments.buySkuItem({ + sku, user, paymentMethod: stripeConstants.PAYMENT_METHOD, + }); + } else { + throw new NotFound('SKU not found.'); + } +} + export async function getOneTimePaymentInfo (gemsBlockKey, gift, user) { let receiver = user; diff --git a/website/server/libs/payments/stripe/webhooks.js b/website/server/libs/payments/stripe/webhooks.js index 8a18fc126e..ae4d73e660 100644 --- a/website/server/libs/payments/stripe/webhooks.js +++ b/website/server/libs/payments/stripe/webhooks.js @@ -14,7 +14,7 @@ import { // eslint-disable-line import/no-cycle basicFields as basicGroupFields, } from '../../../models/group'; import shared from '../../../../common'; -import { applyGemPayment } from './oneTimePayments'; // eslint-disable-line import/no-cycle +import { applyGemPayment, applySku } from './oneTimePayments'; // eslint-disable-line import/no-cycle import { applySubscription, handlePaymentMethodChange } from './subscriptions'; // eslint-disable-line import/no-cycle const endpointSecret = nconf.get('STRIPE_WEBHOOKS_ENDPOINT_SECRET'); @@ -69,10 +69,12 @@ export async function handleWebhooks (options, stripeInc) { if (metadata.type === 'edit-card-group' || metadata.type === 'edit-card-user') { await handlePaymentMethodChange(session); - } else if (metadata.type !== 'subscription') { - await applyGemPayment(session); - } else { + } else if (metadata.type === 'subscription') { await applySubscription(session); + } else if (metadata.type === 'sku') { + await applySku(session); + } else { + await applyGemPayment(session); } break; diff --git a/website/server/models/userNotification.js b/website/server/models/userNotification.js index ea9636a1b1..dc270231bf 100644 --- a/website/server/models/userNotification.js +++ b/website/server/models/userNotification.js @@ -31,6 +31,7 @@ const NOTIFICATION_TYPES = [ 'SCORED_TASK', 'UNALLOCATED_STATS_POINTS', 'WON_CHALLENGE', + 'ITEM_RECEIVED', // notify user when they've got goodies via migration // achievement notifications 'ACHIEVEMENT', // generic achievement notification, details inside `notification.data` 'CHALLENGE_JOINED_ACHIEVEMENT',