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"
>
@@ -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 }}
+
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 @@
+
+
+
{{ group.name }}
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ {{ group.leader }}
+
+
+
+
+
+
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 @@
+
+
+
+
{{ $t("groupAdmin") }}
+
+
+
+
+
+
+
+
+
+
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.',
+ }));
+}