From e9829b8b60c94087ea0562be4821fbf52acff3e3 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Tue, 9 Sep 2025 23:41:03 +0200 Subject: [PATCH] Phillip/admin deleter (#15466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor sending jobs to worker server * remove unused imports * add delete button to adminpanel * June 2025 content build (#15437) * chore: June 2025 content build * chore: typo fixing * chore: corrections to summer 2025 mage armor, spritesheet * fix(css): rebuild spritesmith-main --------- Co-authored-by: Kalista Payne * fix(script): don't use extremely costly regex * fix(logging): don't spam empty error events * Translated using Weblate (Ukrainian) Currently translated at 100.0% (134 of 134 strings) Translated using Weblate (Hungarian) Currently translated at 100.0% (280 of 280 strings) Translated using Weblate (French) Currently translated at 100.0% (280 of 280 strings) Translated using Weblate (Spanish) Currently translated at 99.6% (279 of 280 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 97.4% (840 of 862 strings) Translated using Weblate (German) Currently translated at 99.8% (907 of 908 strings) Translated using Weblate (Dutch) Currently translated at 79.3% (219 of 276 strings) Translated using Weblate (Dutch) Currently translated at 28.1% (69 of 245 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 97.4% (840 of 862 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 97.5% (402 of 412 strings) Translated using Weblate (Dutch) Currently translated at 91.5% (377 of 412 strings) Translated using Weblate (Dutch) Currently translated at 85.2% (774 of 908 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (91 of 91 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (908 of 908 strings) Translated using Weblate (Slovak) Currently translated at 63.4% (106 of 167 strings) Translated using Weblate (Hungarian) Currently translated at 100.0% (908 of 908 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (908 of 908 strings) Translated using Weblate (Slovak) Currently translated at 2.0% (5 of 245 strings) Translated using Weblate (French) Currently translated at 100.0% (908 of 908 strings) Translated using Weblate (Russian) Currently translated at 64.4% (158 of 245 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 97.0% (837 of 862 strings) Translated using Weblate (German) Currently translated at 97.9% (844 of 862 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (91 of 91 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 97.3% (401 of 412 strings) Translated using Weblate (Portuguese) Currently translated at 95.3% (393 of 412 strings) Translated using Weblate (Slovak) Currently translated at 45.6% (413 of 905 strings) Translated using Weblate (Slovak) Currently translated at 50.8% (85 of 167 strings) Translated using Weblate (Russian) Currently translated at 99.1% (113 of 114 strings) Translated using Weblate (Russian) Currently translated at 64.0% (157 of 245 strings) Translated using Weblate (Russian) Currently translated at 64.0% (157 of 245 strings) Translated using Weblate (Russian) Currently translated at 62.0% (152 of 245 strings) Translated using Weblate (Russian) Currently translated at 62.0% (152 of 245 strings) Translated using Weblate (Russian) Currently translated at 60.8% (149 of 245 strings) Translated using Weblate (Russian) Currently translated at 60.8% (149 of 245 strings) Translated using Weblate (Russian) Currently translated at 60.4% (148 of 245 strings) Translated using Weblate (Russian) Currently translated at 60.4% (148 of 245 strings) Translated using Weblate (Russian) Currently translated at 60.0% (147 of 245 strings) Translated using Weblate (Russian) Currently translated at 60.0% (147 of 245 strings) Translated using Weblate (Russian) Currently translated at 57.9% (142 of 245 strings) Translated using Weblate (Russian) Currently translated at 57.9% (142 of 245 strings) Translated using Weblate (Russian) Currently translated at 56.7% (139 of 245 strings) Translated using Weblate (Russian) Currently translated at 56.7% (139 of 245 strings) Translated using Weblate (Russian) Currently translated at 56.3% (138 of 245 strings) Translated using Weblate (Russian) Currently translated at 56.3% (138 of 245 strings) Translated using Weblate (Russian) Currently translated at 53.8% (132 of 245 strings) Translated using Weblate (Russian) Currently translated at 53.8% (132 of 245 strings) Translated using Weblate (Russian) Currently translated at 53.4% (131 of 245 strings) Translated using Weblate (Russian) Currently translated at 53.4% (131 of 245 strings) Translated using Weblate (Russian) Currently translated at 48.9% (120 of 245 strings) Translated using Weblate (Russian) Currently translated at 48.9% (120 of 245 strings) Translated using Weblate (Russian) Currently translated at 48.5% (119 of 245 strings) Translated using Weblate (Russian) Currently translated at 48.5% (119 of 245 strings) Translated using Weblate (Russian) Currently translated at 46.9% (115 of 245 strings) Translated using Weblate (Russian) Currently translated at 46.9% (115 of 245 strings) Translated using Weblate (Russian) Currently translated at 46.9% (115 of 245 strings) Translated using Weblate (Russian) Currently translated at 46.9% (115 of 245 strings) Translated using Weblate (Russian) Currently translated at 46.9% (115 of 245 strings) Translated using Weblate (Russian) Currently translated at 46.9% (115 of 245 strings) Translated using Weblate (Russian) Currently translated at 46.9% (115 of 245 strings) Translated using Weblate (Russian) Currently translated at 46.9% (115 of 245 strings) Translated using Weblate (Russian) Currently translated at 46.9% (115 of 245 strings) Translated using Weblate (Russian) Currently translated at 46.9% (115 of 245 strings) Translated using Weblate (Russian) Currently translated at 46.9% (115 of 245 strings) Translated using Weblate (Russian) Currently translated at 46.9% (115 of 245 strings) Translated using Weblate (Russian) Currently translated at 46.9% (115 of 245 strings) Translated using Weblate (Russian) Currently translated at 45.3% (111 of 245 strings) Translated using Weblate (Russian) Currently translated at 45.3% (111 of 245 strings) Translated using Weblate (Russian) Currently translated at 45.3% (111 of 245 strings) Translated using Weblate (Russian) Currently translated at 45.3% (111 of 245 strings) Translated using Weblate (Russian) Currently translated at 44.4% (109 of 245 strings) Translated using Weblate (German) Currently translated at 99.9% (3324 of 3325 strings) Translated using Weblate (Russian) Currently translated at 44.4% (109 of 245 strings) Translated using Weblate (Russian) Currently translated at 44.4% (109 of 245 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (91 of 91 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (94 of 94 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 93.8% (107 of 114 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (22 of 22 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 99.7% (429 of 430 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 95.1% (820 of 862 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 99.6% (902 of 905 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (167 of 167 strings) Translated using Weblate (Portuguese) Currently translated at 100.0% (167 of 167 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 95.1% (820 of 862 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 95.1% (820 of 862 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 95.1% (820 of 862 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 93.8% (107 of 114 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 93.6% (3114 of 3325 strings) Translated using Weblate (Portuguese) Currently translated at 53.9% (1793 of 3325 strings) Translated using Weblate (Dutch) Currently translated at 78.1% (2600 of 3325 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 99.5% (242 of 243 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 95.1% (820 of 862 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 96.6% (398 of 412 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 99.6% (902 of 905 strings) Translated using Weblate (Italian) Currently translated at 99.1% (113 of 114 strings) Translated using Weblate (Italian) Currently translated at 87.3% (2903 of 3325 strings) Translated using Weblate (Italian) Currently translated at 17.1% (42 of 245 strings) Translated using Weblate (Italian) Currently translated at 99.0% (408 of 412 strings) Translated using Weblate (Italian) Currently translated at 92.7% (102 of 110 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 99.0% (3292 of 3325 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 98.7% (3285 of 3325 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 98.7% (3285 of 3325 strings) Translated using Weblate (Slovak) Currently translated at 100.0% (134 of 134 strings) Translated using Weblate (Slovak) Currently translated at 100.0% (412 of 412 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (91 of 91 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (905 of 905 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 98.1% (3262 of 3325 strings) Co-authored-by: Andrea Co-authored-by: Artem StolyROV Co-authored-by: Céu Co-authored-by: David Kaya Co-authored-by: Filip Betko Co-authored-by: FingerTiao <787170918@qq.com> Co-authored-by: Irina Shcherbinina Co-authored-by: Jaime Martí Co-authored-by: Mencius Co-authored-by: Natalie Luhrs Co-authored-by: Nikita Maximov Co-authored-by: Sophie LE MASLE Co-authored-by: Summer_GUI Co-authored-by: Tetiana Co-authored-by: Tom Co-authored-by: Toro Mor Co-authored-by: V Aar Co-authored-by: Viktor Révész Co-authored-by: Weblate Co-authored-by: razil Co-authored-by: Волкозмей Co-authored-by: Данила Мальцев Co-authored-by: Татьяна Куклева Translate-URL: https://translate.habitica.com/projects/habitica/achievements/pt/ Translate-URL: https://translate.habitica.com/projects/habitica/achievements/pt_BR/ Translate-URL: https://translate.habitica.com/projects/habitica/achievements/sk/ Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/de/ Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/es/ Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/fr/ Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/hu/ Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/nl/ Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt_BR/ Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/sk/ Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/uk/ Translate-URL: https://translate.habitica.com/projects/habitica/challenge/it/ Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/uk/ Translate-URL: https://translate.habitica.com/projects/habitica/content/it/ Translate-URL: https://translate.habitica.com/projects/habitica/content/nl/ Translate-URL: https://translate.habitica.com/projects/habitica/content/pt/ Translate-URL: https://translate.habitica.com/projects/habitica/content/pt_BR/ Translate-URL: https://translate.habitica.com/projects/habitica/content/sk/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/it/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/nl/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/ru/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/sk/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/it/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/nl/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt_BR/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/zh_Hans/ Translate-URL: https://translate.habitica.com/projects/habitica/generic/pt_BR/ Translate-URL: https://translate.habitica.com/projects/habitica/groups/pt_BR/ Translate-URL: https://translate.habitica.com/projects/habitica/limited/es/ Translate-URL: https://translate.habitica.com/projects/habitica/limited/fr/ Translate-URL: https://translate.habitica.com/projects/habitica/limited/hu/ Translate-URL: https://translate.habitica.com/projects/habitica/limited/nl/ Translate-URL: https://translate.habitica.com/projects/habitica/loginincentives/pt_BR/ Translate-URL: https://translate.habitica.com/projects/habitica/npc/sk/ Translate-URL: https://translate.habitica.com/projects/habitica/npc/uk/ Translate-URL: https://translate.habitica.com/projects/habitica/pets/it/ Translate-URL: https://translate.habitica.com/projects/habitica/pets/pt_BR/ Translate-URL: https://translate.habitica.com/projects/habitica/pets/ru/ Translate-URL: https://translate.habitica.com/projects/habitica/quests/pt_BR/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/de/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/ Translation: Habitica/Achievements Translation: Habitica/Backgrounds Translation: Habitica/Challenge Translation: Habitica/Communityguidelines Translation: Habitica/Content Translation: Habitica/Faq Translation: Habitica/Gear Translation: Habitica/Generic Translation: Habitica/Groups Translation: Habitica/Limited Translation: Habitica/Loginincentives Translation: Habitica/Npc Translation: Habitica/Pets Translation: Habitica/Quests Translation: Habitica/Questscontent * 5.36.4 * chore(deps): bump serialize-javascript in /website/client (#15395) Bumps [serialize-javascript](https://github.com/yahoo/serialize-javascript) from 6.0.1 to 6.0.2. - [Release notes](https://github.com/yahoo/serialize-javascript/releases) - [Commits](https://github.com/yahoo/serialize-javascript/compare/v6.0.1...v6.0.2) --- updated-dependencies: - dependency-name: serialize-javascript dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump axios from 1.7.4 to 1.8.2 (#15401) Bumps [axios](https://github.com/axios/axios) from 1.7.4 to 1.8.2. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.7.4...v1.8.2) --- updated-dependencies: - dependency-name: axios dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump prismjs from 1.29.0 to 1.30.0 (#15403) Bumps [prismjs](https://github.com/PrismJS/prism) from 1.29.0 to 1.30.0. - [Release notes](https://github.com/PrismJS/prism/releases) - [Changelog](https://github.com/PrismJS/prism/blob/master/CHANGELOG.md) - [Commits](https://github.com/PrismJS/prism/compare/v1.29.0...v1.30.0) --- updated-dependencies: - dependency-name: prismjs dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump @babel/runtime-corejs2 in /website/client (#15406) Bumps [@babel/runtime-corejs2](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime-corejs2) from 7.23.6 to 7.26.10. - [Release notes](https://github.com/babel/babel/releases) - [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-runtime-corejs2) --- updated-dependencies: - dependency-name: "@babel/runtime-corejs2" dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump @babel/helpers in /website/client (#15407) Bumps [@babel/helpers](https://github.com/babel/babel/tree/HEAD/packages/babel-helpers) from 7.23.6 to 7.26.10. - [Release notes](https://github.com/babel/babel/releases) - [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-helpers) --- updated-dependencies: - dependency-name: "@babel/helpers" dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump @babel/runtime from 7.23.9 to 7.26.10 (#15410) Bumps [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) from 7.23.9 to 7.26.10. - [Release notes](https://github.com/babel/babel/releases) - [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-runtime) --- updated-dependencies: - dependency-name: "@babel/runtime" dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump http-proxy-middleware in /website/client (#15427) Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.6 to 2.0.9. - [Release notes](https://github.com/chimurai/http-proxy-middleware/releases) - [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.9/CHANGELOG.md) - [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.9) --- updated-dependencies: - dependency-name: http-proxy-middleware dependency-version: 2.0.9 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Optimize database access for some use cases (#15444) * optimize query when listing challenge tasks * Optimize query for checking if user is party leader * correct worker call * remove unused priority * fix tests * don’t use body with delete * add detailed information about sub payment for google and apple * Support paypal details for subscription in admin panel * stripe payment details * fix imports * fix tests * fix deleting account * begin building group admin panel * fix convertig sub to group plan * improve sub status display * fix lint * fix long line * fix sub state display * lint fix * fix * delete amplitude data by default * improve searching for email in admin panel * correctly call method * move delete button in admin panel * fix(lint): whitespace * fix(style): indent * fix(typo): humand --------- Signed-off-by: dependabot[bot] Co-authored-by: Natalie <78037386+CuriousMagpie@users.noreply.github.com> Co-authored-by: Kalista Payne Co-authored-by: Weblate Co-authored-by: Andrea Co-authored-by: Artem StolyROV Co-authored-by: Céu Co-authored-by: David Kaya Co-authored-by: Filip Betko Co-authored-by: FingerTiao <787170918@qq.com> Co-authored-by: Irina Shcherbinina Co-authored-by: Jaime Martí Co-authored-by: Mencius Co-authored-by: Natalie Luhrs Co-authored-by: Nikita Maximov Co-authored-by: Sophie LE MASLE Co-authored-by: Summer_GUI Co-authored-by: Tetiana Co-authored-by: Tom Co-authored-by: Toro Mor Co-authored-by: V Aar Co-authored-by: Viktor Révész Co-authored-by: razil Co-authored-by: Волкозмей Co-authored-by: Данила Мальцев Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Kalista Payne --- test/api/unit/libs/email.test.js | 6 +- test/api/unit/libs/payments/apple.test.js | 96 ++++--- test/api/unit/libs/payments/google.test.js | 92 ++++--- .../components/admin/admin-panel/search.vue | 2 +- .../admin/admin-panel/user-support/index.vue | 74 ++++++ .../user-support/partyAndQuest.vue | 6 +- .../user-support/subscriptionAndPerks.vue | 234 ++++++++++++++++-- .../admin-panel/user-support/userHistory.vue | 2 +- .../client/src/components/admin/container.vue | 7 + .../client/src/components/admin/formRow.vue | 47 ++++ .../admin/groups/group-support/groupData.vue | 45 ++++ .../admin/groups/group-support/index.vue | 69 ++++++ .../admin/groups/group-support/members.vue | 29 +++ .../src/components/admin/groups/index.vue | 93 +++++++ .../src/components/admin/supportContainer.vue | 53 ++++ website/client/src/router/index.js | 24 ++ website/client/src/store/actions/admin.js | 31 +++ .../client/src/store/actions/adminPanel.js | 13 - website/client/src/store/actions/hall.js | 6 + website/client/src/store/actions/index.js | 4 +- website/client/vite.config.mjs | 2 +- website/common/locales/en/admin.json | 6 +- website/server/controllers/api-v3/hall.js | 31 ++- website/server/controllers/api-v4/admin.js | 80 +++++- website/server/controllers/api-v4/members.js | 34 ++- website/server/libs/email.js | 39 +-- website/server/libs/payments/apple.js | 78 +++--- website/server/libs/payments/google.js | 66 +++-- website/server/libs/payments/groupPayments.js | 1 - website/server/libs/payments/paypal.js | 45 ++++ .../libs/payments/stripe/subscriptions.js | 20 ++ website/server/libs/worker.js | 33 +++ 32 files changed, 1154 insertions(+), 214 deletions(-) create mode 100644 website/client/src/components/admin/formRow.vue create mode 100644 website/client/src/components/admin/groups/group-support/groupData.vue create mode 100644 website/client/src/components/admin/groups/group-support/index.vue create mode 100644 website/client/src/components/admin/groups/group-support/members.vue create mode 100644 website/client/src/components/admin/groups/index.vue create mode 100644 website/client/src/components/admin/supportContainer.vue create mode 100644 website/client/src/store/actions/admin.js delete mode 100644 website/client/src/store/actions/adminPanel.js create mode 100644 website/server/libs/worker.js diff --git a/test/api/unit/libs/email.test.js b/test/api/unit/libs/email.test.js index 1b34f8d03f..49b2e89538 100644 --- a/test/api/unit/libs/email.test.js +++ b/test/api/unit/libs/email.test.js @@ -150,7 +150,7 @@ describe('emails', () => { sendTxn(mailingInfo, emailType); expect(got.post).to.be.called; - expect(got.post).to.be.calledWith('undefined/job', sinon.match({ + expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({ json: { data: { emailType: sinon.match.same(emailType), @@ -234,7 +234,7 @@ describe('emails', () => { sendTxn(mailingInfo, emailType); expect(got.post).to.be.called; - expect(got.post).to.be.calledWith('undefined/job', sinon.match({ + expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({ json: { data: { emailType: sinon.match.same(emailType), @@ -254,7 +254,7 @@ describe('emails', () => { sendTxn(mailingInfo, emailType, variables); expect(got.post).to.be.called; - expect(got.post).to.be.calledWith('undefined/job', sinon.match({ + expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({ json: { data: { variables: sinon.match(value => value[0].name === 'BASE_URL', 'matches variables'), diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index f7435609d1..3f297fe097 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -12,11 +12,33 @@ const { i18n } = common; describe('Apple Payments', () => { const subKey = 'basic_3mo'; + let iapSetupStub; + let iapValidateStub; + let iapIsValidatedStub; + let iapIsCanceledStub; + let iapIsExpiredStub; + let paymentBuySkuStub; + let iapGetPurchaseDataStub; + let validateGiftMessageStub; + let paymentsCreateSubscritionStub; + + beforeEach(() => { + iapSetupStub = sinon.stub(iap, 'setup').resolves(); + iapValidateStub = sinon.stub(iap, 'validate').resolves({}); + }); + + afterEach(() => { + iap.setup.restore(); + iap.validate.restore(); + iap.isValidated.restore(); + iap.isExpired.restore(); + iap.isCanceled.restore(); + iap.getPurchaseData.restore(); + }); + describe('verifyPurchase', () => { let sku; let user; let token; let receipt; let headers; - let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let paymentBuySkuStub; let - iapGetPurchaseDataStub; let validateGiftMessageStub; beforeEach(() => { token = 'testToken'; @@ -25,13 +47,9 @@ describe('Apple Payments', () => { receipt = `{"token": "${token}", "productId": "${sku}"}`; headers = {}; - iapSetupStub = sinon.stub(iap, 'setup') - .resolves(); - iapValidateStub = sinon.stub(iap, 'validate') - .resolves({}); iapIsValidatedStub = sinon.stub(iap, 'isValidated').returns(true); - sinon.stub(iap, 'isExpired').returns(false); - sinon.stub(iap, 'isCanceled').returns(false); + iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false); + iapIsExpiredStub = sinon.stub(iap, 'isExpired').returns(false); iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') .returns([{ productId: 'com.habitrpg.ios.Habitica.21gems', @@ -42,12 +60,6 @@ describe('Apple Payments', () => { }); afterEach(() => { - iap.setup.restore(); - iap.validate.restore(); - iap.isValidated.restore(); - iap.isExpired.restore(); - iap.isCanceled.restore(); - iap.getPurchaseData.restore(); payments.buySkuItem.restore(); gems.validateGiftMessage.restore(); }); @@ -209,9 +221,6 @@ describe('Apple Payments', () => { describe('subscribe', () => { let sub; let sku; let user; let token; let receipt; let headers; let nextPaymentProcessing; - let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; - let paymentsCreateSubscritionStub; let - iapGetPurchaseDataStub; beforeEach(() => { sub = common.content.subscriptionBlocks[subKey]; @@ -223,12 +232,10 @@ describe('Apple Payments', () => { nextPaymentProcessing = moment.utc().add({ days: 2 }); user = new User(); - iapSetupStub = sinon.stub(iap, 'setup') - .resolves(); - iapValidateStub = sinon.stub(iap, 'validate') - .resolves({}); iapIsValidatedStub = sinon.stub(iap, 'isValidated') .returns(true); + iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false); + iapIsExpiredStub = sinon.stub(iap, 'isExpired').returns(false); iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') .returns([{ expirationDate: moment.utc().subtract({ day: 1 }).toDate(), @@ -250,10 +257,6 @@ describe('Apple Payments', () => { }); afterEach(() => { - iap.setup.restore(); - iap.validate.restore(); - iap.isValidated.restore(); - iap.getPurchaseData.restore(); if (payments.createSubscription.restore) payments.createSubscription.restore(); }); @@ -270,6 +273,29 @@ describe('Apple Payments', () => { }); }); + it('should throw an error if no active subscription is found', async () => { + iap.isCanceled.restore(); + iapIsCanceledStub = sinon.stub(iap, 'isCanceled') + .returns(true); + + iap.getPurchaseData.restore(); + iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') + .returns([{ + expirationDate: moment.utc().add({ day: -2 }).toDate(), + purchaseDate: new Date(), + productId: 'subscription1month', + transactionId: token, + originalTransactionId: token, + }]); + + await expect(applePayments.subscribe(user, receipt, headers, nextPaymentProcessing)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: applePayments.constants.RESPONSE_NO_ITEM_PURCHASED, + }); + }); + const subOptions = [ { sku: 'subscription1month', @@ -574,8 +600,7 @@ describe('Apple Payments', () => { describe('cancelSubscribe ', () => { let user; let token; let receipt; let headers; let customerId; let expirationDate; - let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let iapGetPurchaseDataStub; let - paymentCancelSubscriptionSpy; + let paymentCancelSubscriptionSpy; beforeEach(async () => { token = 'test-token'; @@ -584,8 +609,7 @@ describe('Apple Payments', () => { customerId = 'test-customerId'; expirationDate = moment.utc(); - iapSetupStub = sinon.stub(iap, 'setup') - .resolves(); + iapValidateStub.restore(); iapValidateStub = sinon.stub(iap, 'validate') .resolves({ expirationDate, @@ -593,8 +617,8 @@ describe('Apple Payments', () => { iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') .returns([{ expirationDate: expirationDate.toDate() }]); iapIsValidatedStub = sinon.stub(iap, 'isValidated').returns(true); - sinon.stub(iap, 'isCanceled').returns(false); - sinon.stub(iap, 'isExpired').returns(true); + iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false); + iapIsExpiredStub = sinon.stub(iap, 'isExpired').returns(true); user = new User(); user.profile.name = 'sender'; user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE; @@ -606,13 +630,7 @@ describe('Apple Payments', () => { }); afterEach(() => { - iap.setup.restore(); - iap.validate.restore(); - iap.isValidated.restore(); - iap.isExpired.restore(); - iap.isCanceled.restore(); - iap.getPurchaseData.restore(); - payments.cancelSubscription.restore(); + paymentCancelSubscriptionSpy.restore(); }); it('should throw an error if we are missing a subscription', async () => { @@ -695,6 +713,8 @@ describe('Apple Payments', () => { expect(iapIsValidatedStub).to.be.calledWith({ expirationDate, }); + expect(iapIsCanceledStub).to.be.calledOnce; + expect(iapIsExpiredStub).to.be.calledOnce; expect(iapGetPurchaseDataStub).to.be.calledOnce; expect(paymentCancelSubscriptionSpy).to.be.calledOnce; diff --git a/test/api/unit/libs/payments/google.test.js b/test/api/unit/libs/payments/google.test.js index eb9b3574ba..96fc42b210 100644 --- a/test/api/unit/libs/payments/google.test.js +++ b/test/api/unit/libs/payments/google.test.js @@ -11,12 +11,36 @@ const { i18n } = common; describe('Google Payments', () => { const subKey = 'basic_3mo'; + let iapSetupStub; + let iapValidateStub; + let iapIsValidatedStub; + let paymentBuySkuStub; + let validateGiftMessageStub; + + beforeEach(() => { + iapSetupStub = sinon.stub(iap, 'setup') + .resolves(); + iapIsValidatedStub = sinon.stub(iap, 'isValidated') + .returns(true); + sinon.stub(iap, 'isCanceled').returns(false); + sinon.stub(iap, 'isExpired').returns(false); + paymentBuySkuStub = sinon.stub(payments, 'buySkuItem').resolves({}); + validateGiftMessageStub = sinon.stub(gems, 'validateGiftMessage'); + }); + + afterEach(() => { + iap.setup.restore(); + iap.validate.restore(); + iap.isValidated.restore(); + iap.isCanceled.restore(); + iap.isExpired.restore(); + payments.buySkuItem.restore(); + gems.validateGiftMessage.restore(); + }); describe('verifyPurchase', () => { let sku; let user; let token; let receipt; let signature; let headers; - let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let - paymentBuySkuStub; let validateGiftMessageStub; beforeEach(() => { sku = 'com.habitrpg.android.habitica.iap.21gems'; @@ -25,21 +49,7 @@ describe('Google Payments', () => { signature = ''; headers = {}; - iapSetupStub = sinon.stub(iap, 'setup') - .resolves(); iapValidateStub = sinon.stub(iap, 'validate').resolves({ productId: sku }); - iapIsValidatedStub = sinon.stub(iap, 'isValidated') - .returns(true); - paymentBuySkuStub = sinon.stub(payments, 'buySkuItem').resolves({}); - validateGiftMessageStub = sinon.stub(gems, 'validateGiftMessage'); - }); - - afterEach(() => { - iap.setup.restore(); - iap.validate.restore(); - iap.isValidated.restore(); - payments.buySkuItem.restore(); - gems.validateGiftMessage.restore(); }); it('should throw an error if receipt is invalid', async () => { @@ -160,8 +170,7 @@ describe('Google Payments', () => { describe('subscribe', () => { let sub; let sku; let user; let token; let receipt; let signature; let headers; let nextPaymentProcessing; - let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let - paymentsCreateSubscritionStub; + let paymentsCreateSubscritionStub; beforeEach(() => { sub = common.content.subscriptionBlocks[subKey]; @@ -173,19 +182,12 @@ describe('Google Payments', () => { signature = ''; nextPaymentProcessing = moment.utc().add({ days: 2 }); - iapSetupStub = sinon.stub(iap, 'setup') - .resolves(); iapValidateStub = sinon.stub(iap, 'validate') .resolves({}); - iapIsValidatedStub = sinon.stub(iap, 'isValidated') - .returns(true); paymentsCreateSubscritionStub = sinon.stub(payments, 'createSubscription').resolves({}); }); afterEach(() => { - iap.setup.restore(); - iap.validate.restore(); - iap.isValidated.restore(); payments.createSubscription.restore(); }); @@ -243,7 +245,7 @@ describe('Google Payments', () => { describe('cancelSubscribe ', () => { let user; let token; let receipt; let signature; let headers; let customerId; let expirationDate; - let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let iapGetPurchaseDataStub; let + let iapGetPurchaseDataStub; let paymentCancelSubscriptionSpy; beforeEach(async () => { @@ -253,17 +255,12 @@ describe('Google Payments', () => { signature = ''; customerId = 'test-customerId'; expirationDate = moment.utc(); - - iapSetupStub = sinon.stub(iap, 'setup') - .resolves(); iapValidateStub = sinon.stub(iap, 'validate') .resolves({ expirationDate, }); iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') .returns([{ expirationDate: expirationDate.toDate(), autoRenewing: false }]); - iapIsValidatedStub = sinon.stub(iap, 'isValidated') - .returns(true); user = new User(); user.profile.name = 'sender'; @@ -276,9 +273,6 @@ describe('Google Payments', () => { }); afterEach(() => { - iap.setup.restore(); - iap.validate.restore(); - iap.isValidated.restore(); iap.getPurchaseData.restore(); payments.cancelSubscription.restore(); }); @@ -308,6 +302,8 @@ describe('Google Payments', () => { }); it('should cancel a user subscription', async () => { + iap.isCanceled.restore(); + iap.isCanceled = sinon.stub(iap, 'isCanceled').returns(true); await googlePayments.cancelSubscribe(user, headers); expect(iapSetupStub).to.be.calledOnce; @@ -332,11 +328,20 @@ describe('Google Payments', () => { }); it('should cancel a user subscription with multiple inactive subscriptions', async () => { + iap.isCanceled.restore(); + iap.isCanceled = sinon.stub(iap, 'isCanceled').returns(true); const laterDate = moment.utc().add(7, 'days'); iap.getPurchaseData.restore(); iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') - .returns([{ expirationDate, autoRenewing: false }, - { expirationDate: laterDate, autoRenewing: false }, + .returns([{ + startTimeMillis: expirationDate.valueOf(), + expirationDate, + autoRenewing: false, + }, { + startTimeMillis: laterDate.valueOf(), + expirationDate: laterDate, + autoRenewing: false, + }, ]); await googlePayments.cancelSubscribe(user, headers); @@ -365,7 +370,12 @@ describe('Google Payments', () => { iap.getPurchaseData.restore(); iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') .returns([{ autoRenewing: true }]); - await googlePayments.cancelSubscribe(user, headers); + await expect(googlePayments.cancelSubscribe(user, headers)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: googlePayments.constants.RESPONSE_STILL_VALID, + }); expect(iapSetupStub).to.be.calledOnce; expect(iapValidateStub).to.be.calledOnce; @@ -388,8 +398,12 @@ describe('Google Payments', () => { .returns([{ expirationDate, autoRenewing: false }, { autoRenewing: true }, { expirationDate, autoRenewing: false }]); - await googlePayments.cancelSubscribe(user, headers); - + await expect(googlePayments.cancelSubscribe(user, headers)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: googlePayments.constants.RESPONSE_STILL_VALID, + }); expect(iapSetupStub).to.be.calledOnce; expect(iapValidateStub).to.be.calledOnce; expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, { diff --git a/website/client/src/components/admin/admin-panel/search.vue b/website/client/src/components/admin/admin-panel/search.vue index 066d4727fb..812cb99cbb 100644 --- a/website/client/src/components/admin/admin-panel/search.vue +++ b/website/client/src/components/admin/admin-panel/search.vue @@ -81,7 +81,7 @@ export default { watch: { userIdentifier () { this.isSearching = true; - this.$store.dispatch('adminPanel:searchUsers', { userIdentifier: this.userIdentifier }).then(users => { + this.$store.dispatch('admin:searchUsers', { userIdentifier: this.userIdentifier }).then(users => { this.isSearching = false; if (users.length === 1) { this.loadUser(users[0]._id); diff --git a/website/client/src/components/admin/admin-panel/user-support/index.vue b/website/client/src/components/admin/admin-panel/user-support/index.vue index ce9e090de2..8b836a309e 100644 --- a/website/client/src/components/admin/admin-panel/user-support/index.vue +++ b/website/client/src/components/admin/admin-panel/user-support/index.vue @@ -5,6 +5,12 @@ class="row" >
+ + + +

+ Are you sure you want to delete this member? +

+

+ Please note: This action cannot be undone! +

+
+
+ + +
+
+ + +
+
+
+
@@ -184,6 +237,8 @@ export default { hasParty: false, partyNotExistError: false, adminHasPrivForParty: true, + deleteHabiticaAccount: true, + deleteAmplitudeData: true, }; }, watch: { @@ -249,6 +304,25 @@ export default { this.resetCounter += 1; // tell child components to reinstantiate from scratch }, + confirmDeleteHero () { + if (this.hero._id === this.user._id) { + window.alert('You cannot delete your own account.'); + return; + } + this.$root.$emit('bv::show::modal', 'delete-member-modal'); + }, + deleteHero () { + this.$store.dispatch('hall:deleteHero', { + uuid: this.hero._id, + deleteHabiticaAccount: this.deleteHabiticaAccount, + deleteAmplitudeData: this.deleteAmplitudeData, + }).then(() => { + this.$root.$emit('bv::hide::modal', 'delete-member-modal'); + this.$router.push({ name: 'adminPanel' }); + }).catch(err => { + window.alert(err); + }); + }, hasUnsavedChanges (...comparisons) { for (const index in comparisons) { if (index && comparisons[index]) { diff --git a/website/client/src/components/admin/admin-panel/user-support/partyAndQuest.vue b/website/client/src/components/admin/admin-panel/user-support/partyAndQuest.vue index 5c7b7dc329..763540c60b 100644 --- a/website/client/src/components/admin/admin-panel/user-support/partyAndQuest.vue +++ b/website/client/src/components/admin/admin-panel/user-support/partyAndQuest.vue @@ -37,7 +37,11 @@ Party ID - {{ groupPartyData._id }} + + {{ groupPartyData._id }} +
diff --git a/website/client/src/components/admin/admin-panel/user-support/subscriptionAndPerks.vue b/website/client/src/components/admin/admin-panel/user-support/subscriptionAndPerks.vue index e54402a432..8e7f91388c 100644 --- a/website/client/src/components/admin/admin-panel/user-support/subscriptionAndPerks.vue +++ b/website/client/src/components/admin/admin-panel/user-support/subscriptionAndPerks.vue @@ -15,6 +15,25 @@ :class="{ 'open': expand }" > Subscription, Monthly Perks + + Active + + + Active until {{ dateFormat(hero.purchased.plan.dateTerminated) }} + + + Inactive + + -
The subscription does not have a termination date and is active. @@ -419,6 +441,79 @@ > + +
+

Payment Details

+
+
+
+ +
+
+
+
+ + + Yes + No + + {{ formatDate(value) }} + + --- + {{ value }} + +
+ +
@import '@/assets/scss/colors.scss'; -.input-group-append { - width: auto; - - .input-group-text { - border-bottom-right-radius: 2px; - border-top-right-radius: 2px; - font-weight: 600; - font-size: 0.8rem; - color: $gray-200; + .form-group { + margin-bottom: 0.4rem; + } + + .input-group-append { + width: auto; + + .input-group-text { + border-bottom-right-radius: 2px; + border-top-right-radius: 2px; + font-weight: 600; + font-size: 0.8rem; + color: $gray-200; + } + } + + .info-icon { + font-size: 0.8rem; + color: $purple-400; + cursor: pointer; + margin-left: 0.2rem; + background-color: $gray-500; + padding: 0.1rem 0.3rem; + border-radius: 0.2rem; + } + + .info-icon:hover { + background-color: $purple-400; + color: white; } -} diff --git a/website/client/src/components/admin/admin-panel/user-support/userHistory.vue b/website/client/src/components/admin/admin-panel/user-support/userHistory.vue index c613aa7578..e4928fd90a 100644 --- a/website/client/src/components/admin/admin-panel/user-support/userHistory.vue +++ b/website/client/src/components/admin/admin-panel/user-support/userHistory.vue @@ -226,7 +226,7 @@ export default { } }, async retrieveUserHistory () { - const history = await this.$store.dispatch('adminPanel:getUserHistory', { userIdentifier: this.hero._id }); + const history = await this.$store.dispatch('admin:getUserHistory', { userIdentifier: this.hero._id }); this.armoire = history.armoire; this.questInviteResponses = history.questInviteResponses; this.cron = history.cron; diff --git a/website/client/src/components/admin/container.vue b/website/client/src/components/admin/container.vue index 3a467cd37a..ec3f6c8d73 100644 --- a/website/client/src/components/admin/container.vue +++ b/website/client/src/components/admin/container.vue @@ -8,6 +8,13 @@ > {{ $t('adminPanel') }} + + {{ $t('groupAdmin') }} + +
+ +
+ + + + +
+
+ + + diff --git a/website/client/src/components/admin/groups/group-support/groupData.vue b/website/client/src/components/admin/groups/group-support/groupData.vue new file mode 100644 index 0000000000..865a5aaddb --- /dev/null +++ b/website/client/src/components/admin/groups/group-support/groupData.vue @@ -0,0 +1,45 @@ + + + diff --git a/website/client/src/components/admin/groups/group-support/index.vue b/website/client/src/components/admin/groups/group-support/index.vue new file mode 100644 index 0000000000..87ce62d740 --- /dev/null +++ b/website/client/src/components/admin/groups/group-support/index.vue @@ -0,0 +1,69 @@ + + + diff --git a/website/client/src/components/admin/groups/group-support/members.vue b/website/client/src/components/admin/groups/group-support/members.vue new file mode 100644 index 0000000000..90912853fb --- /dev/null +++ b/website/client/src/components/admin/groups/group-support/members.vue @@ -0,0 +1,29 @@ + + + diff --git a/website/client/src/components/admin/groups/index.vue b/website/client/src/components/admin/groups/index.vue new file mode 100644 index 0000000000..d8e4e8354f --- /dev/null +++ b/website/client/src/components/admin/groups/index.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/website/client/src/components/admin/supportContainer.vue b/website/client/src/components/admin/supportContainer.vue new file mode 100644 index 0000000000..7acf2354f2 --- /dev/null +++ b/website/client/src/components/admin/supportContainer.vue @@ -0,0 +1,53 @@ + + + diff --git a/website/client/src/router/index.js b/website/client/src/router/index.js index 0f23f2afe0..ecca6389d0 100644 --- a/website/client/src/router/index.js +++ b/website/client/src/router/index.js @@ -24,6 +24,8 @@ const AdminContainerPage = () => import(/* webpackChunkName: "admin-panel" */'@/ const AdminPanelPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel'); const AdminPanelUserPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel/user-support'); const AdminPanelSearchPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel/search'); +const GroupAdminPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/groups'); +const GroupAdminGroupPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/groups/group-support'); const BlockerPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/blocker'); // Tasks @@ -216,6 +218,28 @@ const router = new VueRouter({ }, ], }, + { + name: 'groupAdmin', + path: 'groups', + component: GroupAdminPage, + meta: { + privilegeNeeded: [ // any one of these is enough to give access + 'groupSupport', + ], + }, + children: [ + { + name: 'groupAdminGroup', + path: ':groupId', + component: GroupAdminGroupPage, + meta: { + privilegeNeeded: [ + 'groupsSupport', + ], + }, + }, + ], + }, { name: 'blockers', path: 'blockers', diff --git a/website/client/src/store/actions/admin.js b/website/client/src/store/actions/admin.js new file mode 100644 index 0000000000..17d4c55b75 --- /dev/null +++ b/website/client/src/store/actions/admin.js @@ -0,0 +1,31 @@ +import axios from 'axios'; + +export async function searchUsers (store, payload) { + const url = `/api/v4/admin/search/${payload.userIdentifier}`; + const response = await axios.get(url); + return response.data.data; +} + +export async function getUserHistory (store, payload) { + const url = `/api/v4/admin/user/${payload.userIdentifier}/history`; + const response = await axios.get(url); + return response.data.data; +} + +export async function getSubscriptionPaymentDetails (store, payload) { + const url = `/api/v4/admin/user/${payload.userIdentifier}/subscription-payment-details`; + const response = await axios.get(url); + return response.data.data; +} + +export async function getGroup (store, payload) { + const url = `/api/v4/admin/groups/${payload.groupId}`; + const response = await axios.get(url); + return response.data.data; +} + +export async function updateGroup (store, payload) { + const url = `/api/v4/admin/groups/${payload.groupId || payload.group._id}`; + const response = await axios.put(url, payload.group); + return response.data.data; +} diff --git a/website/client/src/store/actions/adminPanel.js b/website/client/src/store/actions/adminPanel.js deleted file mode 100644 index 5084295db1..0000000000 --- a/website/client/src/store/actions/adminPanel.js +++ /dev/null @@ -1,13 +0,0 @@ -import axios from 'axios'; - -export async function searchUsers (store, payload) { - const url = `/api/v4/admin/search/${payload.userIdentifier}`; - const response = await axios.get(url); - return response.data.data; -} - -export async function getUserHistory (store, payload) { - const url = `/api/v4/admin/user/${payload.userIdentifier}/history`; - const response = await axios.get(url); - return response.data.data; -} diff --git a/website/client/src/store/actions/hall.js b/website/client/src/store/actions/hall.js index 8aa3a277ea..eff9e37cea 100644 --- a/website/client/src/store/actions/hall.js +++ b/website/client/src/store/actions/hall.js @@ -38,3 +38,9 @@ export async function getHeroGroupPlans (store, payload) { const response = await axios.get(url); return response.data.data; } + +export async function deleteHero (store, payload) { + const url = `/api/v4/members/${payload.uuid}?deleteAccount=${payload.deleteHabiticaAccount}&deleteAmplitude=${payload.deleteAmplitudeData}`; + const response = await axios.delete(url); + return response.data.data; +} diff --git a/website/client/src/store/actions/index.js b/website/client/src/store/actions/index.js index 2b994c718c..8f504aee8b 100644 --- a/website/client/src/store/actions/index.js +++ b/website/client/src/store/actions/index.js @@ -1,6 +1,6 @@ import { flattenAndNamespace } from '@/libs/store/helpers/internals'; -import * as adminPanel from './adminPanel'; +import * as admin from './admin'; import * as common from './common'; import * as user from './user'; import * as tasks from './tasks'; @@ -26,7 +26,7 @@ import * as blockers from './blockers'; // Example: fetch in user.js -> 'user:fetch' const actions = flattenAndNamespace({ - adminPanel, + admin, common, user, tasks, diff --git a/website/client/vite.config.mjs b/website/client/vite.config.mjs index c67a085fbf..c91472fed9 100644 --- a/website/client/vite.config.mjs +++ b/website/client/vite.config.mjs @@ -36,7 +36,7 @@ const envVars = [ 'TIME_TRAVEL_ENABLED', 'DEBUG_ENABLED', 'CONTENT_SWITCHOVER_TIME_OFFSET', - // TODO necessary? if yes how not to mess up with vue cli? 'NODE_ENV' + 'PLAY_CONSOLE_ORDERS_BASE_URL', ]; const envObject = {}; diff --git a/website/common/locales/en/admin.json b/website/common/locales/en/admin.json index 9e21dcc161..bdce1a529f 100644 --- a/website/common/locales/en/admin.json +++ b/website/common/locales/en/admin.json @@ -3,5 +3,9 @@ "siteBlockers": "Site Blockers", "newsroom": "Newsroom", "adminBlockerTypeDescription": "IP-Address - Block access for a specific IP-Address\n\nClient - Block access for a client based on the \"x-client\" header.\n\nE-Mail - Blocks e-mails from being used for signup.", - "adminBlockerAreaDescription": "A blocker can either apply to the full site, completely blocking any access. Or it can apply to purchases, which still allows the site to be accessed." + "adminBlockerAreaDescription": "A blocker can either apply to the full site, completely blocking any access. Or it can apply to purchases, which still allows the site to be accessed.", + "groupAdmin": "Group Admin", + "groupSupportDescription": "Manage groups and their members. You can search for groups by ID, or load your own group by leaving the field blank.", + "groupData": "Group Data", + "groupPlanSubscription": "Group Plan Subscription" } diff --git a/website/server/controllers/api-v3/hall.js b/website/server/controllers/api-v3/hall.js index dd415eeb5e..78ad8669d8 100644 --- a/website/server/controllers/api-v3/hall.js +++ b/website/server/controllers/api-v3/hall.js @@ -320,16 +320,16 @@ api.updateHero = { if (plan.extraMonths || plan.extraMonths === 0) { hero.purchased.plan.extraMonths = plan.extraMonths; } - if (plan.customerId) { + if (plan.customerId || plan.customerId === '') { hero.purchased.plan.customerId = plan.customerId; } - if (plan.paymentMethod) { + if (plan.paymentMethod || plan.customerId === '') { hero.purchased.plan.paymentMethod = plan.paymentMethod; } - if (plan.planId) { + if (plan.planId || plan.customerId === '') { hero.purchased.plan.planId = plan.planId; } - if (plan.owner) { + if (plan.owner || plan.customerId === '') { hero.purchased.plan.owner = plan.owner; } if (plan.hourglassPromoReceived) { @@ -341,8 +341,7 @@ api.updateHero = { const group = await Group.getGroup({ user: hero, groupId: groupID }); if (!group) throw new NotFound(res.t('groupNotFound')); if (group.hasNotCancelled()) { - hero.purchased.plan.customerId = null; - hero.purchased.plan.paymentMethod = null; + hero.purchased.plan.paymentMethod = 'groupPlan'; await addSubToGroupUser(hero, group); await group.updateGroupPlan(); } else { @@ -352,34 +351,34 @@ api.updateHero = { } if (updateData.stats) { - if (updateData.stats.hp) { + if (updateData.stats.hp || updateData.stats.hp === 0) { hero.stats.hp = updateData.stats.hp; } - if (updateData.stats.mp) { + if (updateData.stats.mp || updateData.stats.mp === 0) { hero.stats.mp = updateData.stats.mp; } - if (updateData.stats.exp) { + if (updateData.stats.exp || updateData.stats.exp === 0) { hero.stats.exp = updateData.stats.exp; } - if (updateData.stats.gp) { + if (updateData.stats.gp || updateData.stats.gp === 0) { hero.stats.gp = updateData.stats.gp; } - if (updateData.stats.lvl) { + if (updateData.stats.lvl || updateData.stats.lvl === 0) { hero.stats.lvl = updateData.stats.lvl; } - if (updateData.stats.points) { + if (updateData.stats.points || updateData.stats.points === 0) { hero.stats.points = updateData.stats.points; } - if (updateData.stats.str) { + if (updateData.stats.str || updateData.stats.str === 0) { hero.stats.str = updateData.stats.str; } - if (updateData.stats.int) { + if (updateData.stats.int || updateData.stats.int === 0) { hero.stats.int = updateData.stats.int; } - if (updateData.stats.per) { + if (updateData.stats.per || updateData.stats.per === 0) { hero.stats.per = updateData.stats.per; } - if (updateData.stats.con) { + if (updateData.stats.con || updateData.stats.con === 0) { hero.stats.con = updateData.stats.con; } if (updateData.stats.buffs) { diff --git a/website/server/controllers/api-v4/admin.js b/website/server/controllers/api-v4/admin.js index bd4aaedbd2..747a0c777d 100644 --- a/website/server/controllers/api-v4/admin.js +++ b/website/server/controllers/api-v4/admin.js @@ -1,14 +1,22 @@ import validator from 'validator'; import merge from 'lodash/merge'; +import uniqBy from 'lodash/uniqBy'; import { v4 as uuid } from 'uuid'; import { authWithHeaders } from '../../middlewares/auth'; import { ensurePermission } from '../../middlewares/ensureAccessRight'; import { model as User } from '../../models/user'; import { model as UserHistory } from '../../models/userHistory'; +import { model as Group } from '../../models/group'; import { model as Blocker } from '../../models/blocker'; import { NotFound, } from '../../libs/errors'; +import apple from '../../libs/payments/apple'; +import google from '../../libs/payments/google'; +import paypal from '../../libs/payments/paypal'; +import { + getSubscriptionPaymentDetails as getStripeSubscriptionPaymentDetails, +} from '../../libs/payments/stripe/subscriptions'; const api = {}; @@ -40,8 +48,6 @@ api.searchHero = { const { userIdentifier } = req.params; - const re = new RegExp(String.raw`^${userIdentifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`); - let query; let users = []; if (validator.isUUID(userIdentifier)) { @@ -54,7 +60,7 @@ api.searchHero = { 'auth.facebook.emails.value', ]; for (const field of emailFields) { - const emailQuery = { [field]: userIdentifier }; + const emailQuery = { [field]: userIdentifier.toLowerCase() }; // eslint-disable-next-line no-await-in-loop const found = await User.findOne(emailQuery) .select('contributor backer profile auth') @@ -65,6 +71,7 @@ api.searchHero = { } } } else { + const re = new RegExp(String.raw`^${userIdentifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`); query = { 'auth.local.lowerCaseUsername': { $regex: re, $options: 'i' } }; } @@ -76,7 +83,8 @@ api.searchHero = { .lean() .exec(); } - res.respond(200, users); + + res.respond(200, uniqBy(users, '_id')); }, }; @@ -188,4 +196,68 @@ api.deleteBlocker = { }, }; +api.validateSubscriptionPaymentDetails = { + method: 'GET', + url: '/admin/user/:userId/subscription-payment-details', + middlewares: [authWithHeaders(), ensurePermission('userSupport')], + async handler (req, res) { + req.checkParams('userId', res.t('heroIdRequired')).notEmpty().isUUID(); + + const validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + const { userId } = req.params; + + const user = await User.findById(userId) + .select('purchased') + .lean() + .exec(); + + if (!user) throw new NotFound(res.t('userWithIDNotFound', { userId })); + if (!user.purchased || !user.purchased.plan || !user.purchased.plan.paymentMethod || !user.purchased.plan.paymentMethod === '') { + throw new NotFound(res.t('subscriptionNotFoundForUser', { userId })); + } + + let paymentDetails; + if (user.purchased.plan.paymentMethod === 'Apple') { + paymentDetails = await apple.getSubscriptionPaymentDetails(userId, user.purchased.plan); + } else if (user.purchased.plan.paymentMethod === 'Google') { + paymentDetails = await google.getSubscriptionPaymentDetails(userId, user.purchased.plan); + } else if (user.purchased.plan.paymentMethod === 'Paypal') { + paymentDetails = await paypal.getSubscriptionPaymentDetails({ user }); + } else if (user.purchased.plan.paymentMethod === 'Stripe') { + paymentDetails = await getStripeSubscriptionPaymentDetails(user); + } else if (user.purchased.plan.paymentMethod === 'Amazon Payments') { + throw new NotFound(res.t('amazonSubscriptionNotValidated')); + } else if (user.purchased.plan.paymentMethod === 'Gift') { + throw new NotFound(res.t('giftSubscriptionNotValidated')); + } else { + throw new NotFound(res.t('unknownSubscriptionPaymentMethod', { method: user.purchased.paymentMethod })); + } + res.respond(200, paymentDetails); + }, +}; + +api.getGroup = { + method: 'GET', + url: '/admin/groups/:groupId', + middlewares: [authWithHeaders(), ensurePermission('groupSupport')], + async handler (req, res) { + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID(); + + const validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + const { groupId } = req.params; + + const group = await Group.findById(groupId) + .lean() + .exec(); + + if (!group) throw new NotFound(res.t('groupNotFound')); + + res.respond(200, group); + }, +}; + export default api; diff --git a/website/server/controllers/api-v4/members.js b/website/server/controllers/api-v4/members.js index ce045e08a3..9717907433 100644 --- a/website/server/controllers/api-v4/members.js +++ b/website/server/controllers/api-v4/members.js @@ -1,3 +1,4 @@ +import { sendJob } from '../../libs/worker'; import { authWithHeaders } from '../../middlewares/auth'; import { ensurePermission } from '../../middlewares/ensureAccessRight'; import { TransactionModel as Transaction } from '../../models/transaction'; @@ -5,9 +6,9 @@ import { TransactionModel as Transaction } from '../../models/transaction'; const api = {}; /** - * @api {get} /api/v4/user/purchase-history Get users purchase history - * @apiName UserGetPurchaseHistory - * @apiGroup User + * @api {get} /api/v4/members/:memberId/purchase-history Get members purchase history + * @apiName MemberGetPurchaseHistory + * @apiGroup Member * */ api.purchaseHistory = { @@ -31,4 +32,31 @@ api.purchaseHistory = { }, }; +/** + * @api {delete} /api/v4/members/:memberId Delete a user + * @apiName DeleteMember + * @apiGroup Member + * + */ +api.deleteMember = { + method: 'DELETE', + middlewares: [authWithHeaders(), ensurePermission('userSupport')], + url: '/members/:memberId', + async handler (req, res) { + req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); + req.checkQuery('deleteAccount').optional().isIn(['true', 'false']); + req.checkQuery('deleteAmplitude').optional().isIn(['true', 'false']); + const validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + sendJob('delete-user', { + data: { + userId: req.params.memberId, + deleteAccount: req.query.deleteAccount === 'true', + deleteAmplitude: req.query.deleteAmplitude === 'true', + }, + }); + res.respond(200, {}); + }, +}; + export default api; diff --git a/website/server/libs/email.js b/website/server/libs/email.js index bca8b4b294..41ce24e8ce 100644 --- a/website/server/libs/email.js +++ b/website/server/libs/email.js @@ -1,18 +1,10 @@ import nconf from 'nconf'; -import got from 'got'; import { TAVERN_ID } from '../models/group'; // eslint-disable-line import/no-cycle import { encrypt } from './encryption'; -import logger from './logger'; import common from '../../common'; +import { sendJob } from './worker'; const IS_PROD = nconf.get('IS_PROD'); -const EMAIL_SERVER = { - url: nconf.get('EMAIL_SERVER_URL'), - auth: { - user: nconf.get('EMAIL_SERVER_AUTH_USER'), - password: nconf.get('EMAIL_SERVER_AUTH_PASSWORD'), - }, -}; const BASE_URL = nconf.get('BASE_URL'); export function getUserInfo (user, fields = []) { @@ -156,29 +148,14 @@ export async function sendTxn (mailingInfoArray, emailType, variables, personalV } if (IS_PROD && mailingInfoArray.length > 0) { - return got.post(`${EMAIL_SERVER.url}/job`, { - retry: 5, // retry the http request to the email server 5 times - timeout: 60000, // wait up to 60s before timing out - username: EMAIL_SERVER.auth.user, - password: EMAIL_SERVER.auth.password, - json: { - type: 'email', - data: { - emailType, - to: mailingInfoArray, - variables, - personalVariables, - }, - options: { - priority: 'high', - attempts: 5, - backoff: { delay: 10 * 60 * 1000, type: 'fixed' }, - }, + return sendJob('email', { + data: { + emailType, + to: mailingInfoArray, + variables, + personalVariables, }, - }).json().catch(err => logger.error(err, { - extraMessage: 'Error while sending an email.', - emailType, - })); + }); } return null; diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index 8a8ab56ade..bc7a364ba0 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -74,7 +74,7 @@ api.verifyPurchase = async function verifyPurchase (options) { return appleRes; }; -api.subscribe = async function subscribe (user, receipt, headers, nextPaymentProcessing) { +async function findSubscriptionPurchase (receipt, onlyActive = true) { await iap.setup(); const appleRes = await iap.validate(iap.APPLE, receipt); @@ -85,18 +85,56 @@ api.subscribe = async function subscribe (user, receipt, headers, nextPaymentPro if (purchaseDataList.length === 0) { throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED); } - let purchase; let newestDate; for (const purchaseData of purchaseDataList) { - const datePurchased = new Date(Number(purchaseData.purchaseDate)); - const dateTerminated = new Date(Number(purchaseData.expirationDate)); - if ((!newestDate || datePurchased > newestDate) && dateTerminated > new Date()) { - purchase = purchaseData; - newestDate = datePurchased; + let datePurchased; + if (purchaseData.purchaseDate instanceof Date) { + datePurchased = purchaseData.purchaseDate; + } else { + datePurchased = new Date(Number(purchaseData.purchaseDateMs || purchaseData.purchaseDate)); + } + const dateTerminated = new Date(Number(purchaseData.expirationDate || 0)); + if ((!newestDate || datePurchased > newestDate)) { + if (!onlyActive || dateTerminated > new Date()) { + purchase = purchaseData; + newestDate = datePurchased; + } } } + if (!purchase) { + throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED); + } + return { + purchase, + isCanceled: iap.isCanceled(purchase), + isExpired: iap.isExpired(purchase), + expirationDate: new Date(Number(purchase.expirationDate)), + }; +} + +api.getSubscriptionPaymentDetails = async function getDetails (userId, subscriptionPlan) { + if (!subscriptionPlan || !subscriptionPlan.additionalData) { + throw new NotAuthorized(shared.i18n.t('missingSubscription')); + } + const details = await findSubscriptionPurchase(subscriptionPlan.additionalData); + return { + customerId: details.purchase.originalTransactionId || details.purchase.transactionId, + purchaseDate: new Date(Number(details.purchase.purchaseDateMs)), + originalPurchaseDate: new Date(Number(details.purchase.originalPurchaseDateMs)), + expirationDate: details.isCanceled || details.isExpired ? details.expirationDate : null, + nextPaymentDate: details.isCanceled || details.isExpired ? null : details.expirationDate, + productId: details.purchase.productId, + transactionId: details.purchase.transactionId, + isCanceled: details.isCanceled, + isExpired: details.isExpired, + }; +}; + +api.subscribe = async function subscribe (user, receipt, headers, nextPaymentProcessing) { + const details = await findSubscriptionPurchase(receipt); + const { purchase } = details; let subCode; switch (purchase.productId) { // eslint-disable-line default-case @@ -250,37 +288,17 @@ api.noRenewSubscribe = async function noRenewSubscribe (options) { api.cancelSubscribe = async function cancelSubscribe (user, headers) { const { plan } = user.purchased; - if (plan.paymentMethod !== api.constants.PAYMENT_METHOD_APPLE) throw new NotAuthorized(shared.i18n.t('missingSubscription')); - await iap.setup(); - try { - const appleRes = await iap.validate(iap.APPLE, plan.additionalData); - - const isValidated = iap.isValidated(appleRes); - if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT); - - const purchases = iap.getPurchaseData(appleRes); - if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT); - let newestDate; - let newestPurchase; - - for (const purchaseData of purchases) { - const datePurchased = new Date(Number(purchaseData.purchaseDate)); - if (!newestDate || datePurchased > newestDate) { - newestDate = datePurchased; - newestPurchase = purchaseData; - } - } - - if (!iap.isCanceled(newestPurchase) && !iap.isExpired(newestPurchase)) { + const details = await findSubscriptionPurchase(plan.additionalData, false); + if (!details.isCanceled && !details.isExpired) { throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID); } await payments.cancelSubscription({ user, - nextBill: new Date(Number(newestPurchase.expirationDate)), + nextBill: new Date(Number(details.expirationDate)), paymentMethod: this.constants.PAYMENT_METHOD_APPLE, headers, }); diff --git a/website/server/libs/payments/google.js b/website/server/libs/payments/google.js index 94ec7ca570..17edda1442 100644 --- a/website/server/libs/payments/google.js +++ b/website/server/libs/payments/google.js @@ -72,6 +72,53 @@ api.verifyPurchase = async function verifyPurchase (options) { return googleRes; }; +async function findSubscriptionPurchase (additionalData) { + const googleRes = await iap.validate(iap.GOOGLE, additionalData); + + const isValidated = iap.isValidated(googleRes); + if (!isValidated) throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT); + + const purchases = iap.getPurchaseData(googleRes); + if (purchases.length === 0) throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT); + + let purchase; + let newestDate; + + for (const i in purchases) { + if (Object.prototype.hasOwnProperty.call(purchases, i)) { + const thisPurchase = purchases[i]; + const purchaseDate = new Date(Number(thisPurchase.startTimeMillis)); + if (!newestDate || purchaseDate > newestDate) { + newestDate = purchaseDate; + purchase = purchases[i]; + } + } + } + return { + purchase, + isCanceled: iap.isCanceled(purchase), + isExpired: iap.isExpired(purchase), + expirationDate: new Date(Number(purchase.expirationDate)), + }; +} + +api.getSubscriptionPaymentDetails = async function getDetails (userId, subscriptionPlan) { + if (!subscriptionPlan || !subscriptionPlan.additionalData) { + throw new NotAuthorized(shared.i18n.t('missingSubscription')); + } + const details = await findSubscriptionPurchase(subscriptionPlan.additionalData); + return { + customerId: details.purchase.purchaseToken, + originalPurchaseDate: new Date(Number(details.purchase.startTimeMillis)), + expirationDate: details.isCanceled || details.isExpired ? details.expirationDate : null, + nextPaymentDate: details.isCanceled || details.isExpired ? null : details.expirationDate, + productId: details.purchase.productId, + transactionId: details.purchase.orderId, + isCanceled: details.isCanceled, + isExpired: details.isExpired, + }; +}; + api.subscribe = async function subscribe ( sku, user, @@ -213,22 +260,11 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) { let dateTerminated; try { - const googleRes = await iap.validate(iap.GOOGLE, plan.additionalData); - - const isValidated = iap.isValidated(googleRes); - if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT); - - const purchases = iap.getPurchaseData(googleRes); - if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT); - for (const i in purchases) { - if (Object.prototype.hasOwnProperty.call(purchases, i)) { - const purchase = purchases[i]; - if (purchase.autoRenewing !== false) return; - if (!dateTerminated || Number(purchase.expirationDate) > Number(dateTerminated)) { - dateTerminated = new Date(Number(purchase.expirationDate)); - } - } + const details = await findSubscriptionPurchase(plan.additionalData); + if (!details.isCanceled && !details.isExpired) { + throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID); } + dateTerminated = details.expirationDate; } catch (err) { // Status:410 means that the subsctiption isn't active anymore and we can safely delete it if (err && err.message === 'Status:410') { diff --git a/website/server/libs/payments/groupPayments.js b/website/server/libs/payments/groupPayments.js index 92b167086a..8b9003c85d 100644 --- a/website/server/libs/payments/groupPayments.js +++ b/website/server/libs/payments/groupPayments.js @@ -180,7 +180,6 @@ async function addSubToGroupUser (member, group) { } // save unused hourglass and mystery items - plan.perkMonthCount = memberPlan.perkMonthCount; plan.consecutive.trinkets = memberPlan.consecutive.trinkets; plan.mysteryItems = memberPlan.mysteryItems; diff --git a/website/server/libs/payments/paypal.js b/website/server/libs/payments/paypal.js index 70099e6541..370604b617 100644 --- a/website/server/libs/payments/paypal.js +++ b/website/server/libs/payments/paypal.js @@ -223,6 +223,51 @@ api.subscribeSuccess = async function subscribeSuccess (options = {}) { }); }; +api.getSubscriptionPaymentDetails = async function getSubscriptionPaymentDetails (options = {}) { + const { user, groupId } = options; + let customerId; + if (groupId) { + const groupFields = basicGroupFields.concat(' purchased'); + const group = await Group.getGroup({ + user, groupId, populateLeader: false, groupFields, + }); + + if (!group) { + throw new NotFound(i18n.t('groupNotFound')); + } + + if (group.leader !== user._id) { + throw new NotAuthorized(i18n.t('onlyGroupLeaderCanManageSubscription')); + } + customerId = group.purchased.plan.customerId; + } else { + customerId = user.purchased.plan.customerId; + } + if (!customerId) throw new NotAuthorized(i18n.t('missingSubscription')); + + const customer = await this.paypalBillingAgreementGet(customerId); + if (!customer) throw new NotFound(i18n.t('subscriptionNotFound')); + + console.log('PayPal subscription details:', customer); + return { + customerId: customer.id, + originalPurchaseDate: customer.start_date, + expirationDate: customer.agreement_details.ended_at + ? customer.agreement_details.ended_at + : null, + nextPaymentDate: customer.agreement_details.next_billing_date + ? customer.agreement_details.next_billing_date + : null, + lastPaymentDate: customer.agreement_details.last_payment_date + ? customer.agreement_details.last_payment_date + : null, + productId: customer.description, + transactionId: customer.id, + isCanceled: customer.agreement_details.state === 'Inactive', + failedPayments: customer.agreement_details.failed_payment_count, + }; +}; + /** * Cancel a PayPal Subscription * diff --git a/website/server/libs/payments/stripe/subscriptions.js b/website/server/libs/payments/stripe/subscriptions.js index 360ba64d8c..d5062cfce6 100644 --- a/website/server/libs/payments/stripe/subscriptions.js +++ b/website/server/libs/payments/stripe/subscriptions.js @@ -33,6 +33,26 @@ export async function checkSubData (sub, isGroup = false, coupon) { } } +export async function getSubscriptionPaymentDetails (user) { + const stripeApi = getStripeApi(); + + const { plan } = user.purchased; + const customer = await stripeApi.customers.retrieve(plan.customerId); + const paymentIntents = await stripeApi.paymentIntents.search({ + query: `customer:'${plan.customerId}'`, + }); + const lastPayment = paymentIntents.data.length > 0 + ? paymentIntents.data[0] + : null; + console.log(paymentIntents.data); + console.log(customer); + return { + customerId: customer.id, + originalPurchaseDate: new Date(Number(customer.created) * 1000), + lastPaymentDate: new Date(Number(lastPayment.created) * 1000), + }; +} + export async function applySubscription (session) { const { metadata, customer: customerId, subscription: subscriptionId } = session; const { diff --git a/website/server/libs/worker.js b/website/server/libs/worker.js new file mode 100644 index 0000000000..8f4d518ae8 --- /dev/null +++ b/website/server/libs/worker.js @@ -0,0 +1,33 @@ +import got from 'got'; +import nconf from 'nconf'; +import logger from './logger'; + +const EMAIL_SERVER = { + url: nconf.get('EMAIL_SERVER_URL'), + auth: { + user: nconf.get('EMAIL_SERVER_AUTH_USER'), + password: nconf.get('EMAIL_SERVER_AUTH_PASSWORD'), + }, +}; + +export function sendJob (type, config) { + const { data, options } = config; + const usedOptions = { + backoff: { delay: 10 * 60 * 1000, type: 'exponential' }, + ...options, + }; + + return got.post(`${EMAIL_SERVER.url}/job`, { + retry: 5, // retry the http request to the email server 5 times + timeout: 60000, // wait up to 60s before timing out + username: EMAIL_SERVER.auth.user, + password: EMAIL_SERVER.auth.password, + json: { + type, + data, + options: usedOptions, + }, + }).json().catch(err => logger.error(err, { + extraMessage: 'Error while sending an email.', + })); +}