Merge branch 'release' into develop

This commit is contained in:
SabreCat
2022-11-15 19:29:37 -06:00
19 changed files with 418 additions and 148 deletions

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "habitica", "name": "habitica",
"version": "4.249.0", "version": "4.249.3",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {

View File

@@ -1,7 +1,7 @@
{ {
"name": "habitica", "name": "habitica",
"description": "A habit tracker app which treats your goals like a Role Playing Game.", "description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "4.249.0", "version": "4.249.3",
"main": "./website/server/index.js", "main": "./website/server/index.js",
"dependencies": { "dependencies": {
"@babel/core": "^7.19.6", "@babel/core": "^7.19.6",

View File

@@ -417,6 +417,7 @@ describe('Apple Payments', () => {
it('errors when a user is using the same subscription', async () => { it('errors when a user is using the same subscription', async () => {
user = new User(); user = new User();
user.purchased.plan.dateUpdated = moment().subtract(1, 'hours').toDate();
await user.save(); await user.save();
payments.createSubscription.restore(); payments.createSubscription.restore();
@@ -430,6 +431,8 @@ describe('Apple Payments', () => {
}]); }]);
await applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing); await applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing);
user.purchased.plan.dateUpdated = moment().subtract(1, 'hours').toDate();
await user.save();
await expect(applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing)) await expect(applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing))
.to.eventually.be.rejected.and.to.eql({ .to.eventually.be.rejected.and.to.eql({

View File

@@ -370,6 +370,10 @@ describe('payments/index', () => {
}); });
context('Purchasing a subscription for self', () => { context('Purchasing a subscription for self', () => {
beforeEach(() => {
data.user.purchased.plan.dateUpdated = moment().subtract(1, 'hours').toDate();
});
it('creates a subscription', async () => { it('creates a subscription', async () => {
expect(user.purchased.plan.planId).to.not.exist; expect(user.purchased.plan.planId).to.not.exist;
@@ -396,6 +400,7 @@ describe('payments/index', () => {
user.purchased.plan = plan; user.purchased.plan = plan;
user.purchased.plan.dateTerminated = moment(new Date()).add(2, 'months'); user.purchased.plan.dateTerminated = moment(new Date()).add(2, 'months');
expect(user.purchased.plan.extraMonths).to.eql(0); expect(user.purchased.plan.extraMonths).to.eql(0);
data.user.purchased.plan.dateUpdated = moment().subtract(1, 'hours').toDate();
await api.createSubscription(data); await api.createSubscription(data);
@@ -406,6 +411,7 @@ describe('payments/index', () => {
user.purchased.plan = plan; user.purchased.plan = plan;
user.purchased.plan.dateTerminated = moment(new Date()).subtract(2, 'months'); user.purchased.plan.dateTerminated = moment(new Date()).subtract(2, 'months');
expect(user.purchased.plan.extraMonths).to.eql(0); expect(user.purchased.plan.extraMonths).to.eql(0);
data.user.purchased.plan.dateUpdated = moment().subtract(1, 'hours').toDate();
await api.createSubscription(data); await api.createSubscription(data);
@@ -415,6 +421,7 @@ describe('payments/index', () => {
it('does not reset Gold-to-Gems cap on additional subscription', async () => { it('does not reset Gold-to-Gems cap on additional subscription', async () => {
user.purchased.plan = plan; user.purchased.plan = plan;
user.purchased.plan.gemsBought = 10; user.purchased.plan.gemsBought = 10;
data.user.purchased.plan.dateUpdated = moment().subtract(1, 'hours').toDate();
await api.createSubscription(data); await api.createSubscription(data);
@@ -551,6 +558,10 @@ describe('payments/index', () => {
}); });
context('Block subscription perks', () => { context('Block subscription perks', () => {
beforeEach(() => {
data.user.purchased.plan.dateUpdated = moment().subtract(1, 'hours').toDate();
});
it('adds block months to plan.consecutive.offset', async () => { it('adds block months to plan.consecutive.offset', async () => {
await api.createSubscription(data); await api.createSubscription(data);
@@ -587,6 +598,7 @@ describe('payments/index', () => {
data.sub.key = 'basic_12mo'; data.sub.key = 'basic_12mo';
await api.createSubscription(data); await api.createSubscription(data);
data.user.purchased.plan.dateUpdated = moment().subtract(1, 'hours').toDate();
await api.createSubscription(data); await api.createSubscription(data);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(25); expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(25);
@@ -753,6 +765,7 @@ describe('payments/index', () => {
now: mayMysteryItemTimeframe, now: mayMysteryItemTimeframe,
toFake: ['Date'], toFake: ['Date'],
}); });
data.user.purchased.plan.dateUpdated = moment().subtract(1, 'hours').toDate();
}); });
afterEach(() => { afterEach(() => {

View File

@@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<path fill="#DE3F3F" d="M0 5.667 3.333 1h9.334L16 5.667l-8 8.666z"/>
<path fill="#FFF" opacity=".25" d="M4.667 5.533 4 2.333h4zM11.333 5.533l.667-3.2H8z"/>
<path fill="#FFF" opacity=".5" d="M4.667 5.533 8 2.333l3.333 3.2zM1.733 5.533 4 2.333l.667 3.2z"/>
<path fill="#34313A" opacity=".11" d="M14.267 5.533 12 2.333l-.667 3.2zM1.733 5.533h2.934L8 12.4z"/>
<path fill="#FFF" opacity=".5" d="M14.267 5.533h-2.934L8 12.4z"/>
<path fill="#FFF" opacity=".25" d="M4.667 5.533h6.666L8 12.4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 675 B

View File

@@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<path d="M3 12.606v1.778c0 .208.093.408.262.53 1.842 1.347 6.923 1.347 8.766 0a.655.655 0 0 0 .26-.53v-1.778c0-1.621-.831-3.177-2.091-4.104a.666.666 0 0 1 0-1.08c1.26-.927 2.092-2.483 2.092-4.105V1.54a.652.652 0 0 0-.261-.53c-1.843-1.346-6.924-1.346-8.766 0A.65.65 0 0 0 3 1.54v1.777c0 1.622.832 3.178 2.092 4.105.368.27.368.81 0 1.08C3.832 9.429 3 10.985 3 12.606" fill="#F19595"/>
<path d="M7.644 1.327c1.51 0 2.684.274 3.318.587v1.403c0 1.169-.594 2.332-1.551 3.036a2.006 2.006 0 0 0-.818 1.609c0 .63.305 1.232.817 1.608.958.705 1.552 1.868 1.552 3.036v1.404c-.634.313-1.809.587-3.318.587-1.508 0-2.683-.274-3.317-.587v-1.404c0-1.168.594-2.331 1.551-3.035.513-.377.817-.978.817-1.609 0-.63-.304-1.232-.816-1.609-.958-.704-1.552-1.867-1.552-3.036V1.914c.634-.313 1.809-.587 3.317-.587" fill-opacity=".9" fill="#FFF"/>
<path d="M7.797 2.324c-1.132 0-2.331.105-2.343.385-.01.226-.005.664.914 1.13.893.453 1.06 1.282 1.546 1.282.564 0 .596-.477 1.284-.95.71-.488.823-1.148.815-1.408-.011-.363-1.084-.439-2.216-.439" fill="#DE3F3F"/>
<path d="M9.198 4.17c.71-.487.823-1.146.815-1.407-.009-.288-.684-.395-1.526-.427.236.12.543.377.467.88-.078.525-.904 1.105-.77 1.568.025.09.069.162.124.221.247-.17.408-.502.89-.835" fill="#B01515"/>
<path d="M7.644 9.17c-.344 0-.433.628-.933 1.018-.613.478-1.196 1.067-1.356 1.914-.131.698-.012.785.148.834.16.049 1.386.257 2.588 0 1.203-.258 1.87-.737 1.755-1.227-.111-.466-.448-.865-1.068-1.325-.593-.44-.79-1.214-1.134-1.214" fill="#DE3F3F"/>
<path d="M5.503 12.936c.16.05 1.386.257 2.588 0 .956-.205 1.574-.55 1.729-.929a.096.096 0 0 0-.005-.023c-.067-.256-1.073-.41-2.325-.207-1.192.192-2.158.586-2.153 1.03.037.08.097.108.166.129" fill="#B01515"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -49,6 +49,7 @@
<transactions <transactions
:hero="hero" :hero="hero"
:reset-counter="resetCounter"
/> />
<contributor-details <contributor-details

View File

@@ -30,6 +30,10 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
resetCounter: {
type: Number,
required: true,
},
}, },
data () { data () {
return { return {
@@ -38,6 +42,14 @@ export default {
hourglassTransactions: [], hourglassTransactions: [],
}; };
}, },
watch: {
resetCounter () {
if (this.expand) {
this.expand = !this.expand;
this.toggleTransactionsOpen();
}
},
},
methods: { methods: {
async toggleTransactionsOpen () { async toggleTransactionsOpen () {
this.expand = !this.expand; this.expand = !this.expand;

View File

@@ -1,105 +1,308 @@
<template> <template>
<div class="row"> <div>
<div class="col-6"> <div class="clearfix">
<h1>{{ $t('gemTransactions') }}</h1> <div class="mb-4 float-left">
<span v-if="gemTransactions.length === 0">{{ $t('noGemTransactions') }}</span> <button
<table class="table"> class="page-header btn-flat tab-button textCondensed"
<tr :class="{'active': selectedTab === 'gems'}"
v-for="entry in gemTransactions" @click="selectTab('gems')"
:key="entry.createdAt"
> >
<td> {{ $t('gems') }}
<span </button>
v-b-tooltip.hover="entry.createdAt" <button
>{{ entry.createdAt | timeAgo }}</span> class="page-header btn-flat tab-button textCondensed"
</td> :class="{'active': selectedTab === 'hourglass'}"
<td> @click="selectTab('hourglass')"
<span
class="svg-icon inline icon-24"
aria-hidden="true"
v-html="icons.gem"
></span>
<span
class="amount gems"
:class="entry.amount < 0 ? 'deducted' : 'added'"
>{{ entry.amount * 4 }}</span>
</td>
<td>
<span>{{ transactionTypeText(entry.transactionType) }}</span>
</td>
<td>
<span v-html="entryReferenceText(entry)"></span>
</td>
</tr>
</table>
</div>
<div class="col-6">
<h1>{{ $t('hourglassTransactions') }}</h1>
<span v-if="hourglassTransactions.length === 0">{{ $t('noHourglassTransactions') }}</span>
<table class="table">
<tr
v-for="entry in hourglassTransactions"
:key="entry.createdAt"
> >
<td> {{ $t('mysticHourglass', { amount: ''}) }}
<span </button>
v-b-tooltip.hover="entry.createdAt" </div>
>{{ entry.createdAt | timeAgo }}</span>
</td>
<td>
<span
class="svg-icon inline icon-24"
aria-hidden="true"
v-html="icons.hourglass"
></span>
<span
class="amount hourglasses"
:class="entry.amount < 0 ? 'deducted' : 'added'"
>{{ entry.amount }}</span>
</td>
<td>
<span>{{ transactionTypeText(entry.transactionType) }}</span>
</td>
<td>
<span v-html="entryReferenceText(entry)"></span>
</td>
</tr>
</table>
</div> </div>
<div class="row">
<div class="col-12" v-if="selectedTab === 'gems'">
<span v-if="gemTransactions.length === 0">
{{ $t('noGemTransactions') }}
</span>
<table class="table">
<tr>
<th v-once class="timestamp-column">
{{ $t('timestamp')}}
</th>
<th v-once class="amount-column">
{{ $t('amount')}}
</th>
<th v-once class="action-column">
{{ $t('action')}}
</th>
<th v-once class="note-column">
{{ $t('note')}}
</th>
</tr>
<tr
v-for="entry in gemTransactions"
:key="entry.createdAt"
>
<td>
<span
v-b-tooltip.hover="entry.createdAt"
>{{ entry.createdAt | timeAgo }}</span>
</td>
<td>
<div class="amount-with-icon" :id="entry.id">
<span
class="svg-icon inline icon-16 my-1"
aria-hidden="true"
v-html="entry.amount < 0 ? icons.gemRed : icons.gem"
></span>
<span
class="amount gems"
:class="entry.amount | addedDeducted"
>{{ entry.amount * 4 }}</span>
</div>
<b-popover
v-if="typeof entry.currentAmount !== 'undefined'"
ref="popover"
:target="entry.id"
triggers="hover focus click"
placement="bottom"
>
<div class="remaining-amount-popover-content">
{{ $t('remainingBalance') }}:
<span
class="svg-icon inline icon-16 ml-1"
aria-hidden="true"
v-html="icons.gem"
></span>
<span
class="amount gems"
>{{ entry.currentAmount * 4 }}</span>
</div>
</b-popover>
</td>
<td class="entry-action">
<span v-html="transactionTypeText(entry.transactionType)"></span>
</td>
<td>
<span v-if="transactionTypes.gifted.includes(entry.transactionType)">
<router-link
class="user-link"
:to="{'name': 'userProfile', 'params': {'userId': entry.reference}}"
>
@{{ entry.referenceText }}
</router-link>
</span>
<span v-else-if="transactionTypes.challenges.includes(entry.transactionType)">
<router-link
class="challenge-link"
:to="{ name: 'challenge', params: { challengeId: entry.reference } }">
<span
v-markdown="entry.referenceText"
></span>
</router-link>
</span>
<span v-else v-html="entryReferenceText(entry)"></span>
<span v-if="entry.reference">
({{entry.reference}})
</span>
</td>
</tr>
</table>
</div>
<div class="col-12" v-if="selectedTab === 'hourglass'">
<span v-if="hourglassTransactions.length === 0">
{{ $t('noHourglassTransactions') }}
</span>
<table class="table">
<tr>
<th v-once class="timestamp-column">
{{ $t('timestamp')}}
</th>
<th v-once class="amount-column">
{{ $t('amount')}}
</th>
<th v-once class="action-column">
{{ $t('action')}}
</th>
<th v-once class="note-column">
{{ $t('note')}}
</th>
</tr>
<tr
v-for="entry in hourglassTransactions"
:key="entry.createdAt"
>
<td>
<span
v-b-tooltip.hover="entry.createdAt"
>{{ entry.createdAt | timeAgo }}</span>
</td>
<td>
<div class="amount-with-icon" :id="entry.id">
<span
class="svg-icon inline icon-16 my-1"
aria-hidden="true"
v-html="entry.amount < 0 ? icons.hourglassRed : icons.hourglass"
></span>
<span
class="amount hourglasses"
:class="entry.amount | addedDeducted"
>{{ entry.amount }}</span>
</div>
<b-popover
v-if="typeof entry.currentAmount !== 'undefined'"
ref="popover"
:target="entry.id"
triggers="hover focus click"
placement="bottom"
>
<div class="remaining-amount-popover-content">
{{ $t('remainingBalance') }}:
<span
class="svg-icon inline icon-16 ml-1"
aria-hidden="true"
v-html="icons.hourglass"
></span>
<span
class="amount gems"
>{{ entry.currentAmount }}</span>
</div>
</b-popover>
</td>
<td class="entry-action">
<span v-html="transactionTypeText(entry.transactionType)"></span>
</td>
<td>
<span v-html="entryReferenceText(entry)"></span>
</td>
</tr>
</table>
</div>
</div>
</div> </div>
</template> </template>
<style lang="scss"> <style lang="scss" scoped>
@import '~@/assets/scss/colors.scss'; @import '~@/assets/scss/colors.scss';
.page-header.btn-flat {
background: transparent;
}
.tab-button {
height: 2rem;
font-size: 24px;
font-weight: bold;
font-stretch: condensed;
line-height: 1.33;
letter-spacing: normal;
color: $gray-10;
margin-right: 1.125rem;
padding-left: 0;
padding-right: 0;
padding-bottom: 2.5rem;
&.active, &:hover {
color: $purple-300;
box-shadow: 0px -0.25rem 0px $purple-300 inset;
outline: none;
}
}
.amount-column {
white-space: nowrap;
}
.svg-icon { .svg-icon {
vertical-align: middle; vertical-align: middle;
} }
.amount { .amount {
font-weight: bold; font-weight: bold;
font-size: 1.1rem;
margin-left: 4px; margin-left: 4px;
} }
.added::before { .added::before {
content: "+"; content: "+";
} }
.gems { .gems {
color: $gems-color; color: $green-10;
&.deducted { &.deducted {
color: $red-10; color: $maroon-50;
} }
} }
.hourglasses { .hourglasses {
font-weight: bold; font-weight: bold;
color: $hourglass-color; color: $green-10;
&.deducted { &.deducted {
color: $red-10; color: $maroon-50;
}
}
.amount-with-icon {
display: inline-flex;
}
.remaining-amount-popover-content {
display: flex;
font-size: 12px;
line-height: 1.33;
color: $white;
}
table {
line-height: 1.71;
color: $gray-50;
}
th {
border-top: 0 !important;
padding: 0.25rem 0.5rem !important;
font-weight: bold;
line-height: 1.71;
color: $gray-50;
}
td {
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
line-height: 1.71;
color: $gray-50;
}
th, td {
padding-top: 0.35rem !important;
padding-bottom: 0.35rem !important;
}
.timestamp-column, .action-column {
width: 20%;
}
.amount-column {
width: 10%;
}
.note-column {
width: 50%;
}
.challenge-link, .user-link {
color: $blue-10 !important;
}
.entry-action {
b {
text-transform: uppercase;
} }
} }
</style> </style>
@@ -107,9 +310,15 @@
<script> <script>
import moment from 'moment'; import moment from 'moment';
import svgGem from '@/assets/svg/gem.svg'; import svgGem from '@/assets/svg/gem.svg';
import svgGemRed from '@/assets/svg/gem-red.svg';
import svgHourglass from '@/assets/svg/hourglass.svg'; import svgHourglass from '@/assets/svg/hourglass.svg';
import svgHourglassRed from '@/assets/svg/hourglass-red.svg';
import markdownDirective from '@/directives/markdown';
export default { export default {
directives: {
markdown: markdownDirective,
},
filters: { filters: {
timeAgo (value) { timeAgo (value) {
return moment(value).fromNow(); return moment(value).fromNow();
@@ -118,6 +327,13 @@ export default {
// @TODO: Vue doesn't support this so we cant user preference // @TODO: Vue doesn't support this so we cant user preference
return moment(value).toDate().toString(); return moment(value).toDate().toString();
}, },
addedDeducted (amount) {
if (amount === 0) {
return '';
}
return amount < 0 ? 'deducted' : 'added';
},
}, },
props: { props: {
gemTransactions: { gemTransactions: {
@@ -133,11 +349,21 @@ export default {
return { return {
icons: Object.freeze({ icons: Object.freeze({
gem: svgGem, gem: svgGem,
gemRed: svgGemRed,
hourglass: svgHourglass, hourglass: svgHourglass,
hourglassRed: svgHourglassRed,
}),
selectedTab: 'gems',
transactionTypes: Object.freeze({
gifted: ['gift_send', 'gift_receive'],
challenges: ['create_challenge', 'create_bank_challenge'],
}), }),
}; };
}, },
methods: { methods: {
selectTab (type) {
this.selectedTab = type;
},
entryReferenceText (entry) { entryReferenceText (entry) {
if (entry.reference === undefined && entry.referenceText === undefined) { if (entry.reference === undefined && entry.referenceText === undefined) {
return ''; return '';

View File

@@ -830,8 +830,8 @@
"backgrounds112022": "SET 102: Released November 2022", "backgrounds112022": "SET 102: Released November 2022",
"backgroundAmongGiantMushroomsText": "Among Giant Mushrooms", "backgroundAmongGiantMushroomsText": "Among Giant Mushrooms",
"backgroundAmongGiantMushroomsNotes": "Marvel at Giant Mushrooms.", "backgroundAmongGiantMushroomsNotes": "Marvel at Giant Mushrooms.",
"backgroundMistAutumnForestText": "Misty Autumn Forest", "backgroundMistyAutumnForestText": "Misty Autumn Forest",
"backgroundMistAutumnForestNotes": "Wander through a Misty Autumn Forest.", "backgroundMistyAutumnForestNotes": "Wander through a Misty Autumn Forest.",
"backgroundAutumnBridgeText": "Bridge in Autumn", "backgroundAutumnBridgeText": "Bridge in Autumn",
"backgroundAutumnBridgeNotes": "Admire the beauty of a Bridge in Autumn.", "backgroundAutumnBridgeNotes": "Admire the beauty of a Bridge in Autumn.",

View File

@@ -777,7 +777,7 @@
"questRobotUnlockText": "Unlocks purchasable Robot Eggs in the Market", "questRobotUnlockText": "Unlocks purchasable Robot Eggs in the Market",
"rockingReptilesText": "Rocking Reptiles Quest Bundle", "rockingReptilesText": "Rocking Reptiles Quest Bundle",
"rockingReptilesNotes": "Contains 'The Insta-Gator,' 'The Serpent of Distraction,' and 'The Veloci-Rapper.' Available until September 30.", "rockingReptilesNotes": "Contains 'The Insta-Gator,' 'The Serpent of Distraction,' and 'The Veloci-Rapper.' Available until November 30.",
"delightfulDinosText": "Delightful Dinos Quest Bundle", "delightfulDinosText": "Delightful Dinos Quest Bundle",
"delightfulDinosNotes": "Contains 'The Pterror-dactyl,' 'The Trampling Triceratops,' and 'The Dinosaur Unearthed.' Available until May 31.", "delightfulDinosNotes": "Contains 'The Pterror-dactyl,' 'The Trampling Triceratops,' and 'The Dinosaur Unearthed.' Available until May 31.",

View File

@@ -192,27 +192,32 @@
"everywhere": "Everywhere", "everywhere": "Everywhere",
"onlyPrivateSpaces": "Only in private spaces", "onlyPrivateSpaces": "Only in private spaces",
"bannedSlurUsedInProfile": "Your Display Name or About text contained a slur, and your chat privileges have been revoked.", "bannedSlurUsedInProfile": "Your Display Name or About text contained a slur, and your chat privileges have been revoked.",
"timestamp": "Timestamp",
"amount": "Amount",
"action": "Action",
"note": "Note",
"remainingBalance": "Remaining Balance",
"transactions": "Transactions", "transactions": "Transactions",
"gemTransactions": "Gem Transactions",
"hourglassTransactions": "Hourglass Transactions", "hourglassTransactions": "Hourglass Transactions",
"noGemTransactions": "You don't have any gem transactions yet.", "noGemTransactions": "You don't have any gem transactions yet.",
"noHourglassTransactions": "You don't have any hourglass transactions yet.", "noHourglassTransactions": "You don't have any hourglass transactions yet.",
"transaction_debug": "Debug Action", "transaction_debug": "Debug Action",
"transaction_buy_money": "Bought with money", "transaction_buy_money": "<b>Bought</b> with money",
"transaction_buy_gold": "Bought with gold", "transaction_buy_gold": "<b>Bought</b> with gold",
"transaction_contribution": "Through contribution", "transaction_contribution": "<b>Tier</b> change",
"transaction_spend": "Spent on", "transaction_spend": "<b>Spent</b> on",
"transaction_gift_send": "Gifted to", "transaction_gift_send": "<b>Gifted</b> to",
"transaction_gift_receive": "Received from", "transaction_gift_receive": "<b>Received</b> from",
"transaction_create_challenge": "Created challenge", "transaction_create_challenge": "<b>Created</b> challenge",
"transaction_create_bank_challenge": "<b>Created</b> bank challenge",
"transaction_create_bank_challenge": "Created bank challenge", "transaction_create_bank_challenge": "Created bank challenge",
"transaction_create_guild": "Created guild", "transaction_create_guild": "<b>Created</b> guild",
"transaction_change_class": "Changed class", "transaction_change_class": "<b>Class</b> change",
"transaction_rebirth": "Used Orb of Rebirth", "transaction_rebirth": "Used Orb of Rebirth",
"transaction_release_pets": "Released pets", "transaction_release_pets": "Released pets",
"transaction_release_mounts": "Released mounts", "transaction_release_mounts": "Released mounts",
"transaction_reroll": "Used Fortify Potion", "transaction_reroll": "Used Fortify Potion",
"transaction_subscription_perks": "From subscription perk", "transaction_subscription_perks": "<b>Subscription</b> perk",
"transaction_admin_update_balance": "Admin given", "transaction_admin_update_balance": "<b>Admin</b> given",
"transaction_admin_update_hourglasses": "Admin updated" "transaction_admin_update_hourglasses": "<b>Admin</b> updated"
} }

View File

@@ -208,8 +208,9 @@ const bundles = {
'snake', 'snake',
'velociraptor', 'velociraptor',
], ],
event: EVENTS.bundle202211,
canBuy () { canBuy () {
return moment().isBetween('2019-09-10', '2019-10-02'); return moment().isBetween(EVENTS.bundle202211.start, EVENTS.bundle202211.end);
}, },
type: 'quests', type: 'quests',
value: 7, value: 7,

View File

@@ -9,9 +9,21 @@ const gemsPromo = {
}; };
export const EVENTS = { export const EVENTS = {
noEvent: {
start: '2022-11-30T20:00-05:00',
end: '2022-12-20T08:00-05:00',
season: 'normal',
npcImageSuffix: '',
},
bundle202211: {
start: '2022-11-15T08:00-05:00',
end: '2022-11-30T20:00-05:00',
season: 'normal',
npcImageSuffix: '',
},
afterGala: { afterGala: {
start: '2022-10-31T20:00-04:00', start: '2022-10-31T20:00-04:00',
end: '2022-12-21T08:00-04:00', end: '2022-11-15T08:00-05:00',
season: 'normal', season: 'normal',
npcImageSuffix: '', npcImageSuffix: '',
}, },

View File

@@ -122,26 +122,26 @@ const premium = {
value: 2, value: 2,
text: t('hatchingPotionEmber'), text: t('hatchingPotionEmber'),
limited: true, limited: true,
event: EVENTS.potions202111, event: EVENTS.bundle202211,
_addlNotes: t('eventAvailabilityReturning', { _addlNotes: t('eventAvailabilityReturning', {
availableDate: t('dateEndNovember'), availableDate: t('dateEndNovember'),
previousDate: t('novemberYYYY', { year: 2019 }), previousDate: t('novemberYYYY', { year: 2021 }),
}), }),
canBuy () { canBuy () {
return moment().isBefore(EVENTS.potions202111.end); return moment().isBetween(EVENTS.bundle202211.start, EVENTS.bundle202211.end);
}, },
}, },
Thunderstorm: { Thunderstorm: {
value: 2, value: 2,
text: t('hatchingPotionThunderstorm'), text: t('hatchingPotionThunderstorm'),
limited: true, limited: true,
event: EVENTS.potions202108, event: EVENTS.bundle202211,
_addlNotes: t('eventAvailabilityReturning', { _addlNotes: t('eventAvailabilityReturning', {
availableDate: t('dateEndAugust'), availableDate: t('dateEndNovember'),
previousDate: t('novemberYYYY', { year: 2019 }), previousDate: t('novemberYYYY', { year: 2021 }),
}), }),
canBuy () { canBuy () {
return moment().isBetween(EVENTS.potions202108.start, EVENTS.potions202108.end); return moment().isBetween(EVENTS.bundle202211.start, EVENTS.bundle202211.end);
}, },
}, },
Spooky: { Spooky: {
@@ -251,12 +251,13 @@ const premium = {
value: 2, value: 2,
text: t('hatchingPotionFrost'), text: t('hatchingPotionFrost'),
limited: true, limited: true,
event: EVENTS.bundle202211,
_addlNotes: t('eventAvailabilityReturning', { _addlNotes: t('eventAvailabilityReturning', {
availableDate: t('dateEndNovember'), availableDate: t('dateEndNovember'),
previousDate: t('novemberYYYY', { year: 2018 }), previousDate: t('novemberYYYY', { year: 2020 }),
}), }),
canBuy () { canBuy () {
return moment().isBefore('2020-12-02'); return moment().isBetween(EVENTS.bundle202211.start, EVENTS.bundle202211.end);
}, },
}, },
IcySnow: { IcySnow: {

View File

@@ -5,7 +5,7 @@ import { EVENTS } from './constants';
// path: 'premiumHatchingPotions.Rainbow', // path: 'premiumHatchingPotions.Rainbow',
const featuredItems = { const featuredItems = {
market () { market () {
if (moment().isBetween(EVENTS.fall2022.start, EVENTS.fall2022.end)) { if (moment().isBetween(EVENTS.bundle202211.start, EVENTS.bundle202211.end)) {
return [ return [
{ {
type: 'armoire', type: 'armoire',
@@ -13,15 +13,15 @@ const featuredItems = {
}, },
{ {
type: 'premiumHatchingPotion', type: 'premiumHatchingPotion',
path: 'premiumHatchingPotions.Vampire', path: 'premiumHatchingPotions.Frost',
}, },
{ {
type: 'premiumHatchingPotion', type: 'premiumHatchingPotion',
path: 'premiumHatchingPotions.Ghost', path: 'premiumHatchingPotions.Ember',
}, },
{ {
type: 'premiumHatchingPotion', type: 'premiumHatchingPotion',
path: 'premiumHatchingPotions.Shadow', path: 'premiumHatchingPotions.Thunderstorm',
}, },
]; ];
} }
@@ -32,47 +32,47 @@ const featuredItems = {
}, },
{ {
type: 'food', type: 'food',
path: 'food.Potatoe', path: 'food.Milk',
}, },
{ {
type: 'hatchingPotions', type: 'hatchingPotions',
path: 'hatchingPotions.Desert', path: 'hatchingPotions.White',
}, },
{ {
type: 'eggs', type: 'eggs',
path: 'eggs.Dragon', path: 'eggs.Fox',
}, },
]; ];
}, },
quests () { quests () {
if (moment().isBetween(EVENTS.bundle202210.start, EVENTS.bundle202210.end)) { if (moment().isBetween(EVENTS.bundle202211.start, EVENTS.bundle202211.end)) {
return [ return [
{ {
type: 'bundles', type: 'bundles',
path: 'bundles.witchyFamiliars', path: 'bundles.rockingReptiles',
}, },
{ {
type: 'quests', type: 'quests',
path: 'quests.snake', path: 'quests.peacock',
}, },
{ {
type: 'quests', type: 'quests',
path: 'quests.owl', path: 'quests.harpy',
}, },
]; ];
} }
return [ return [
{ {
type: 'quests', type: 'quests',
path: 'quests.guineapig', path: 'quests.axolotl',
}, },
{ {
type: 'quests', type: 'quests',
path: 'quests.onyx', path: 'quests.stone',
}, },
{ {
type: 'quests', type: 'quests',
path: 'quests.rooster', path: 'quests.whale',
}, },
]; ];
}, },

View File

@@ -267,7 +267,7 @@ api.updateHero = {
const hero = await User.findById(heroId).exec(); const hero = await User.findById(heroId).exec();
if (!hero) throw new NotFound(res.t('userWithIDNotFound', { userId: heroId })); if (!hero) throw new NotFound(res.t('userWithIDNotFound', { userId: heroId }));
if (updateData.balance) { if (updateData.balance && updateData.balance !== hero.balance) {
await hero.updateBalance(updateData.balance - hero.balance, 'admin_update_balance', '', 'Given by Habitica staff'); await hero.updateBalance(updateData.balance - hero.balance, 'admin_update_balance', '', 'Given by Habitica staff');
hero.balance = updateData.balance; hero.balance = updateData.balance;

View File

@@ -13,10 +13,10 @@ import { // eslint-disable-line import/no-cycle
model as Group, model as Group,
basicFields as basicGroupFields, basicFields as basicGroupFields,
} from '../../models/group'; } from '../../models/group';
import { model as User } from '../../models/user'; // eslint-disable-line import/no-cycle
import { import {
NotAuthorized, NotAuthorized,
NotFound, NotFound,
TooManyRequests,
} from '../errors'; } from '../errors';
import shared from '../../../common'; import shared from '../../../common';
import { sendNotification as sendPushNotification } from '../pushNotifications'; // eslint-disable-line import/no-cycle import { sendNotification as sendPushNotification } from '../pushNotifications'; // eslint-disable-line import/no-cycle
@@ -92,19 +92,9 @@ async function prepareSubscriptionValues (data) {
let emailType = 'subscription-begins'; let emailType = 'subscription-begins';
let recipientIsSubscribed = recipient.isSubscribed(); let recipientIsSubscribed = recipient.isSubscribed();
if (data.user && !data.gift && !data.groupId) { if (data.user && !data.gift && !data.groupId && data.customerId !== 'group-plan') {
const unlockedUser = await User.findOneAndUpdate( if (moment().diff(data.user.purchased.plan.dateUpdated, 'minutes') < 3) {
{ throw new TooManyRequests('Subscription already processed, likely duplicate request');
_id: data.user._id,
$or: [
{ _subSignature: 'NOT_RUNNING' },
{ _subSignature: { $exists: false } },
],
},
{ $set: { _subSignature: 'SUB_IN_PROGRESS' } },
);
if (!unlockedUser) {
throw new NotFound('User not found or subscription already processing.');
} }
} }
@@ -356,6 +346,10 @@ async function createSubscription (data) {
} }
} }
if (group) await group.save();
if (data.user && data.user.isModified()) await data.user.save();
if (data.gift) await data.gift.member.save();
slack.sendSubscriptionNotification({ slack.sendSubscriptionNotification({
buyer: { buyer: {
id: data.user._id, id: data.user._id,
@@ -372,24 +366,6 @@ async function createSubscription (data) {
groupId, groupId,
autoRenews, autoRenews,
}); });
if (group) {
await group.save();
}
if (data.user) {
if (data.user.isModified()) {
await data.user.save();
}
if (!data.gift && !data.groupId) {
await User.findOneAndUpdate(
{ _id: data.user._id },
{ $set: { _subSignature: 'NOT_RUNNING' } },
);
}
}
if (data.gift) {
await data.gift.member.save();
}
} }
// Cancels a subscription or group plan, setting termination to happen later // Cancels a subscription or group plan, setting termination to happen later

View File

@@ -5,7 +5,7 @@ import baseModel from '../libs/baseModel';
const { Schema } = mongoose; const { Schema } = mongoose;
export const currencies = ['gems', 'hourglasses']; export const currencies = ['gems', 'hourglasses'];
export const transactionTypes = ['buy_money', 'buy_gold', 'spend', 'gift_send', 'gift_receive', 'debug', 'create_challenge', 'create_bank_challenge', 'create_guild', 'change_class', 'rebirth', 'release_pets', 'release_mounts', 'reroll', 'contribution', 'subscription_perks', 'admin_update_balance', 'admin_update_hourglasses']; export const transactionTypes = ['buy_money', 'buy_gold', 'spend', 'gift_send', 'gifted_with_money', 'gift_receive', 'debug', 'create_challenge', 'create_bank_challenge', 'create_guild', 'change_class', 'rebirth', 'release_pets', 'release_mounts', 'reroll', 'contribution', 'subscription_perks', 'admin_update_balance', 'admin_update_hourglasses'];
export const schema = new Schema({ export const schema = new Schema({
currency: { $type: String, enum: currencies, required: true }, currency: { $type: String, enum: currencies, required: true },