diff --git a/satellite/console/service.go b/satellite/console/service.go index e36bacc3b..507893c71 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -309,15 +309,27 @@ func (paymentService PaymentsService) BillingHistory(ctx context.Context) (billi remaining = 0 } + var couponStatus string + + switch coupon.Status { + case 0: + couponStatus = "Active" + case 1: + couponStatus = "Used" + default: + couponStatus = "Expired" + } + billingHistory = append(billingHistory, &BillingHistoryItem{ ID: coupon.ID.String(), Description: coupon.Description, Amount: coupon.Amount, Remaining: remaining, - Status: "Added as Free Credits", + Status: couponStatus, Link: "", Start: coupon.Created, + End: coupon.ExpirationDate(), Type: Coupon, }, ) diff --git a/web/satellite/src/api/payments.ts b/web/satellite/src/api/payments.ts index f40797d8a..4c202ee23 100644 --- a/web/satellite/src/api/payments.ts +++ b/web/satellite/src/api/payments.ts @@ -11,7 +11,7 @@ import { TokenDeposit, } from '@/types/payments'; import { HttpClient } from '@/utils/httpClient'; -import { toUnixTimestamp } from '@/utils/time'; +import { Time } from '@/utils/time'; /** * PaymentsHttpApi is a http implementation of Payments API. @@ -71,8 +71,8 @@ export class PaymentsHttpApi implements PaymentsApi { * projectsUsageAndCharges returns usage and how much money current user will be charged for each project which he owns. */ public async projectsUsageAndCharges(start: Date, end: Date): Promise { - const since = toUnixTimestamp(start).toString(); - const before = toUnixTimestamp(end).toString(); + const since = Time.toUnixTimestamp(start).toString(); + const before = Time.toUnixTimestamp(end).toString(); const path = `${this.ROOT_PATH}/account/charges?from=${since}&to=${before}`; const response = await this.client.get(path); @@ -211,7 +211,6 @@ export class PaymentsHttpApi implements PaymentsApi { } const paymentsHistoryItems = await response.json(); - if (paymentsHistoryItems) { return paymentsHistoryItems.map(item => new PaymentsHistoryItem( @@ -223,7 +222,9 @@ export class PaymentsHttpApi implements PaymentsApi { item.link, new Date(item.start), new Date(item.end), - item.type), + item.type, + item.remaining, + ), ); } diff --git a/web/satellite/src/components/account/billing/BillingArea.vue b/web/satellite/src/components/account/billing/BillingArea.vue index a99e84534..44f4c9b20 100644 --- a/web/satellite/src/components/account/billing/BillingArea.vue +++ b/web/satellite/src/components/account/billing/BillingArea.vue @@ -20,9 +20,17 @@
-
+
{ - return item.type === PaymentsHistoryItemType.Transaction || item.type === PaymentsHistoryItemType.Coupon; + return item.type === PaymentsHistoryItemType.Transaction || item.type === PaymentsHistoryItemType.DepositBonus; }).slice(0, 3); } } diff --git a/web/satellite/src/components/account/billing/estimatedCostsAndCredits/UsageAndChargesItem.vue b/web/satellite/src/components/account/billing/estimatedCostsAndCredits/UsageAndChargesItem.vue index a612d4c95..454c76d62 100644 --- a/web/satellite/src/components/account/billing/estimatedCostsAndCredits/UsageAndChargesItem.vue +++ b/web/satellite/src/components/account/billing/estimatedCostsAndCredits/UsageAndChargesItem.vue @@ -62,7 +62,7 @@ import { Project } from '@/types/projects'; import { Size } from '@/utils/bytesSize'; import { SegmentEvent } from '@/utils/constants/analyticsEventNames'; import { SHORT_MONTHS_NAMES } from '@/utils/constants/date'; -import { toUnixTimestamp } from '@/utils/time'; +import { Time } from '@/utils/time'; @Component({ components: { @@ -149,8 +149,8 @@ export default class UsageAndChargesItem extends Vue { url.pathname = 'usage-report'; url.searchParams.append('projectID', projectID); - url.searchParams.append('since', toUnixTimestamp(startDate).toString()); - url.searchParams.append('before', toUnixTimestamp(endDate).toString()); + url.searchParams.append('since', Time.toUnixTimestamp(startDate).toString()); + url.searchParams.append('before', Time.toUnixTimestamp(endDate).toString()); this.$segment.track(SegmentEvent.REPORT_DOWNLOADED, { start_date: startDate, diff --git a/web/satellite/src/components/account/billing/freeCredits/CreditsDropdown.vue b/web/satellite/src/components/account/billing/freeCredits/CreditsDropdown.vue new file mode 100644 index 000000000..3af0ba313 --- /dev/null +++ b/web/satellite/src/components/account/billing/freeCredits/CreditsDropdown.vue @@ -0,0 +1,66 @@ +// Copyright (C) 2020 Storj Labs, Inc. +// See LICENSE for copying information. + + + + + + diff --git a/web/satellite/src/components/account/billing/freeCredits/CreditsHistory.vue b/web/satellite/src/components/account/billing/freeCredits/CreditsHistory.vue new file mode 100644 index 000000000..36f77e37a --- /dev/null +++ b/web/satellite/src/components/account/billing/freeCredits/CreditsHistory.vue @@ -0,0 +1,170 @@ +// Copyright (C) 2019 Storj Labs, Inc. +// See LICENSE for copying information. + + + + + + diff --git a/web/satellite/src/components/account/billing/freeCredits/CreditsItem.vue b/web/satellite/src/components/account/billing/freeCredits/CreditsItem.vue new file mode 100644 index 000000000..b94fb1e37 --- /dev/null +++ b/web/satellite/src/components/account/billing/freeCredits/CreditsItem.vue @@ -0,0 +1,75 @@ +// Copyright (C) 2019 Storj Labs, Inc. +// See LICENSE for copying information. + + + + + + diff --git a/web/satellite/src/components/account/billing/freeCredits/SortingHeader.vue b/web/satellite/src/components/account/billing/freeCredits/SortingHeader.vue new file mode 100644 index 000000000..1a5f92a47 --- /dev/null +++ b/web/satellite/src/components/account/billing/freeCredits/SortingHeader.vue @@ -0,0 +1,53 @@ +// Copyright (C) 2019 Storj Labs, Inc. +// See LICENSE for copying information. + + + + + + diff --git a/web/satellite/src/router/index.ts b/web/satellite/src/router/index.ts index c336fc9cd..34c01a5bb 100644 --- a/web/satellite/src/router/index.ts +++ b/web/satellite/src/router/index.ts @@ -7,6 +7,7 @@ import Router, { RouteRecord } from 'vue-router'; import AccountArea from '@/components/account/AccountArea.vue'; import AccountBilling from '@/components/account/billing/BillingArea.vue'; import DetailedHistory from '@/components/account/billing/depositAndBillingHistory/DetailedHistory.vue'; +import CreditsHistory from '@/components/account/billing/freeCredits/CreditsHistory.vue'; import SettingsArea from '@/components/account/SettingsArea.vue'; import ApiKeysArea from '@/components/apiKeys/ApiKeysArea.vue'; import Page404 from '@/components/errors/Page404.vue'; @@ -43,6 +44,7 @@ export abstract class RouteConfig { public static Billing = new NavigationLink('billing', 'Billing'); public static BillingHistory = new NavigationLink('billing-history', 'Billing History'); public static DepositHistory = new NavigationLink('deposit-history', 'Deposit History'); + public static CreditsHistory = new NavigationLink('credits-history', 'Credits History'); // TODO: disabled until implementation // public static Referral = new NavigationLink('referral', 'Referral'); @@ -57,6 +59,7 @@ export const notProjectRelatedRoutes = [ RouteConfig.Billing.name, RouteConfig.BillingHistory.name, RouteConfig.DepositHistory.name, + RouteConfig.CreditsHistory.name, RouteConfig.Settings.name, // RouteConfig.Referral.name, ]; @@ -111,6 +114,11 @@ export const router = new Router({ name: RouteConfig.DepositHistory.name, component: DetailedHistory, }, + { + path: RouteConfig.CreditsHistory.path, + name: RouteConfig.CreditsHistory.name, + component: CreditsHistory, + }, // { // path: RouteConfig.Referral.path, // name: RouteConfig.Referral.name, diff --git a/web/satellite/src/types/payments.ts b/web/satellite/src/types/payments.ts index e18002b3e..7f81a266a 100644 --- a/web/satellite/src/types/payments.ts +++ b/web/satellite/src/types/payments.ts @@ -114,6 +114,7 @@ export class PaymentsHistoryItem { public readonly start: Date = new Date(), public readonly end: Date = new Date(), public readonly type: PaymentsHistoryItemType = PaymentsHistoryItemType.Invoice, + public readonly remaining: number = 0, ) {} public get quantity(): Amount { @@ -155,6 +156,8 @@ export enum PaymentsHistoryItemType { Charge = 2, // Coupon is a promotional coupon item. Coupon = 3, + // DepositBonus is a 10% bonus for using Coinpayments transactions. + DepositBonus = 4, } /** diff --git a/web/satellite/src/utils/time.ts b/web/satellite/src/utils/time.ts index 68f1f4eac..57290cd80 100644 --- a/web/satellite/src/utils/time.ts +++ b/web/satellite/src/utils/time.ts @@ -1,10 +1,15 @@ -// Copyright (C) 2019 Storj Labs, Inc. +// Copyright (C) 2020 Storj Labs, Inc. // See LICENSE for copying information. /** - * toUnixTimestamp converts Date to unix timestamp. - * @param time + * Time holds methods to operate over timestamps. */ -export function toUnixTimestamp(time: Date): number { - return Math.floor(time.getTime() / 1000); +export class Time { + /** + * toUnixTimestamp converts Date to unix timestamp. + * @param time + */ + public static toUnixTimestamp(time: Date): number { + return Math.floor(time.getTime() / 1000); + } } diff --git a/web/satellite/static/images/account/billing/expand.svg b/web/satellite/static/images/account/billing/expand.svg new file mode 100644 index 000000000..15189bddd --- /dev/null +++ b/web/satellite/static/images/account/billing/expand.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/satellite/static/images/account/billing/hide.svg b/web/satellite/static/images/account/billing/hide.svg new file mode 100644 index 000000000..a09149c36 --- /dev/null +++ b/web/satellite/static/images/account/billing/hide.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/satellite/tests/unit/account/billing/depositAndBillingHistory/__snapshots__/PeriodSelection.spec.ts.snap b/web/satellite/tests/unit/account/billing/depositAndBillingHistory/__snapshots__/PeriodSelection.spec.ts.snap index b4dca1cf3..1eec6e451 100644 --- a/web/satellite/tests/unit/account/billing/depositAndBillingHistory/__snapshots__/PeriodSelection.spec.ts.snap +++ b/web/satellite/tests/unit/account/billing/depositAndBillingHistory/__snapshots__/PeriodSelection.spec.ts.snap @@ -8,7 +8,14 @@ exports[`PeriodSelection renders correctly 1`] = `
- +
`; @@ -21,7 +28,7 @@ exports[`PeriodSelection renders correctly with dropdown 1`] = `
-
+
diff --git a/web/satellite/tests/unit/account/billing/freeCredits/CreditsDropdown.spec.ts b/web/satellite/tests/unit/account/billing/freeCredits/CreditsDropdown.spec.ts new file mode 100644 index 000000000..75931d70a --- /dev/null +++ b/web/satellite/tests/unit/account/billing/freeCredits/CreditsDropdown.spec.ts @@ -0,0 +1,57 @@ +// Copyright (C) 2020 Storj Labs, Inc. +// See LICENSE for copying information. + +import sinon from 'sinon'; +import { VNode } from 'vue'; +import { DirectiveBinding } from 'vue/types/options'; + +import CreditsDropdown from '@/components/account/billing/freeCredits/CreditsDropdown.vue'; + +import { createLocalVue, mount } from '@vue/test-utils'; + +const localVue = createLocalVue(); + +let clickOutsideEvent: EventListener; + +localVue.directive('click-outside', { + bind: function (el: HTMLElement, binding: DirectiveBinding, vnode: VNode) { + clickOutsideEvent = function(event: Event): void { + if (el === event.target) { + return; + } + + if (vnode.context) { + vnode.context[binding.expression](event); + } + }; + + document.body.addEventListener('click', clickOutsideEvent); + }, + unbind: function(): void { + document.body.removeEventListener('click', clickOutsideEvent); + }, +}); + +describe('CreditsDropdown', (): void => { + it('renders correctly', (): void => { + const wrapper = mount(CreditsDropdown, { + localVue, + }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('clicks work correctly', async (): Promise => { + const clickSpy = sinon.spy(); + const wrapper = mount(CreditsDropdown, { + localVue, + methods: { + redirect: clickSpy, + }, + }); + + await wrapper.find('.credits-dropdown__link-container').trigger('click'); + + expect(clickSpy.callCount).toBe(1); + }); +}); diff --git a/web/satellite/tests/unit/account/billing/freeCredits/CreditsHistory.spec.ts b/web/satellite/tests/unit/account/billing/freeCredits/CreditsHistory.spec.ts new file mode 100644 index 000000000..01851e375 --- /dev/null +++ b/web/satellite/tests/unit/account/billing/freeCredits/CreditsHistory.spec.ts @@ -0,0 +1,67 @@ +// Copyright (C) 2020 Storj Labs, Inc. +// See LICENSE for copying information. + +import sinon from 'sinon'; +import Vuex from 'vuex'; + +import CreditsHistory from '@/components/account/billing/freeCredits/CreditsHistory.vue'; + +import { PaymentsHttpApi } from '@/api/payments'; +import { router } from '@/router'; +import { makePaymentsModule, PAYMENTS_MUTATIONS } from '@/store/modules/payments'; +import { makeProjectsModule, PROJECTS_MUTATIONS } from '@/store/modules/projects'; +import { PaymentsHistoryItem, PaymentsHistoryItemType } from '@/types/payments'; +import { Project } from '@/types/projects'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; + +import { ProjectsApiMock } from '../../../mock/api/projects'; + +const localVue = createLocalVue(); +const projectsApi = new ProjectsApiMock(); +const projectsModule = makeProjectsModule(projectsApi); +const paymentsApi = new PaymentsHttpApi(); +const paymentsModule = makePaymentsModule(paymentsApi); +const itemInvoice = new PaymentsHistoryItem('testId', 'Invoice', 500, 500, 'test', 'test', new Date(1), new Date(1), PaymentsHistoryItemType.Invoice); +const itemCharge = new PaymentsHistoryItem('testId1', 'Charge', 500, 500, 'test', 'test', new Date(1), new Date(1), PaymentsHistoryItemType.Charge); +const itemTransaction = new PaymentsHistoryItem('testId2', 'Transaction', 500, 500, 'test', 'test', new Date(1), new Date(1), PaymentsHistoryItemType.Transaction); +const coupon = new PaymentsHistoryItem('testId', 'desc', 275, 0, 'test', '', new Date(1), new Date(1), PaymentsHistoryItemType.Coupon, 275); +const coupon1 = new PaymentsHistoryItem('testId', 'desc', 500, 0, 'test', '', new Date(1), new Date(1), PaymentsHistoryItemType.Coupon, 300); +const project = new Project('id', 'projectName', 'projectDescription', 'test', 'testOwnerId', false); +const clickSpy = sinon.spy(); + +localVue.use(Vuex); +localVue.filter('centsToDollars', (cents: number): string => { + return `$${(cents / 100).toFixed(2)}`; +}); + +const store = new Vuex.Store({ modules: { paymentsModule, projectsModule }}); +store.commit(PROJECTS_MUTATIONS.SET_PROJECTS, [project]); +store.commit(PROJECTS_MUTATIONS.SELECT_PROJECT, project.id); +store.commit(PAYMENTS_MUTATIONS.SET_PAYMENTS_HISTORY, [itemInvoice, itemCharge, itemTransaction, coupon, coupon1]); + +describe('CreditsHistory', (): void => { + it('renders correctly', (): void => { + const wrapper = shallowMount(CreditsHistory, { + localVue, + store, + router, + }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('click on back works correctly', async (): Promise => { + const wrapper = shallowMount(CreditsHistory, { + localVue, + store, + router, + methods: { + onBackToBillingClick: clickSpy, + }, + }); + + await wrapper.find('.credit-history__back-area').trigger('click'); + + expect(clickSpy.callCount).toBe(1); + }); +}); diff --git a/web/satellite/tests/unit/account/billing/freeCredits/CreditsItem.spec.ts b/web/satellite/tests/unit/account/billing/freeCredits/CreditsItem.spec.ts new file mode 100644 index 000000000..89e240437 --- /dev/null +++ b/web/satellite/tests/unit/account/billing/freeCredits/CreditsItem.spec.ts @@ -0,0 +1,51 @@ +// Copyright (C) 2020 Storj Labs, Inc. +// See LICENSE for copying information. + +import CreditsItem from '@/components/account/billing/freeCredits/CreditsItem.vue'; + +import { PaymentsHistoryItem, PaymentsHistoryItemType } from '@/types/payments'; +import { createLocalVue, mount } from '@vue/test-utils'; + +const localVue = createLocalVue(); +const couponActive = new PaymentsHistoryItem('testId', 'desc', 275, 0, 'Active', '', new Date(1), new Date(1), PaymentsHistoryItemType.Coupon, 275); +const couponExpired = new PaymentsHistoryItem('testId', 'desc', 275, 0, 'Expired', '', new Date(1), new Date(1), PaymentsHistoryItemType.Coupon, 0); +const couponUsed = new PaymentsHistoryItem('testId', 'desc', 500, 0, 'Used', '', new Date(1), new Date(1), PaymentsHistoryItemType.Coupon, 0); + +localVue.filter('centsToDollars', (cents: number): string => { + return `$${(cents / 100).toFixed(2)}`; +}); + +describe('CreditsItem', (): void => { + it('renders correctly if not expired', (): void => { + const wrapper = mount(CreditsItem, { + localVue, + propsData: { + creditsItem: couponActive, + }, + }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('renders correctly if expired', (): void => { + const wrapper = mount(CreditsItem, { + localVue, + propsData: { + creditsItem: couponExpired, + }, + }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('renders correctly if used', (): void => { + const wrapper = mount(CreditsItem, { + localVue, + propsData: { + creditsItem: couponUsed, + }, + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/web/satellite/tests/unit/account/billing/freeCredits/SortingHeader.spec.ts b/web/satellite/tests/unit/account/billing/freeCredits/SortingHeader.spec.ts new file mode 100644 index 000000000..708322489 --- /dev/null +++ b/web/satellite/tests/unit/account/billing/freeCredits/SortingHeader.spec.ts @@ -0,0 +1,18 @@ +// Copyright (C) 2020 Storj Labs, Inc. +// See LICENSE for copying information. + +import SortingHeader from '@/components/account/billing/freeCredits/SortingHeader.vue'; + +import { createLocalVue, mount } from '@vue/test-utils'; + +const localVue = createLocalVue(); + +describe('SortingHeader', (): void => { + it('renders correctly', (): void => { + const wrapper = mount(SortingHeader, { + localVue, + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/web/satellite/tests/unit/account/billing/freeCredits/__snapshots__/CreditsDropdown.spec.ts.snap b/web/satellite/tests/unit/account/billing/freeCredits/__snapshots__/CreditsDropdown.spec.ts.snap new file mode 100644 index 000000000..8afdfc190 --- /dev/null +++ b/web/satellite/tests/unit/account/billing/freeCredits/__snapshots__/CreditsDropdown.spec.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CreditsDropdown renders correctly 1`] = ` +
+ +
+`; diff --git a/web/satellite/tests/unit/account/billing/freeCredits/__snapshots__/CreditsHistory.spec.ts.snap b/web/satellite/tests/unit/account/billing/freeCredits/__snapshots__/CreditsHistory.spec.ts.snap new file mode 100644 index 000000000..2bc1705a6 --- /dev/null +++ b/web/satellite/tests/unit/account/billing/freeCredits/__snapshots__/CreditsHistory.spec.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CreditsHistory renders correctly 1`] = ` +
+
+ +

Back to Billing

+
+

Free Credits

+
+

$5.75

DETAILS + + + +
+
+`; diff --git a/web/satellite/tests/unit/account/billing/freeCredits/__snapshots__/CreditsItem.spec.ts.snap b/web/satellite/tests/unit/account/billing/freeCredits/__snapshots__/CreditsItem.spec.ts.snap new file mode 100644 index 000000000..489a2520e --- /dev/null +++ b/web/satellite/tests/unit/account/billing/freeCredits/__snapshots__/CreditsItem.spec.ts.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CreditsItem renders correctly if expired 1`] = ` +
+

Trial Credit

+

Expired

+

50 GB ($2.75)

+

$0.00

+
+`; + +exports[`CreditsItem renders correctly if not expired 1`] = ` +
+

Trial Credit

+

Active

+

50 GB ($2.75)

+

$2.75

+
+`; + +exports[`CreditsItem renders correctly if used 1`] = ` +
+

Trial Credit

+

Used

+

90 GB ($5.00)

+

$0.00

+
+`; diff --git a/web/satellite/tests/unit/account/billing/freeCredits/__snapshots__/SortingHeader.spec.ts.snap b/web/satellite/tests/unit/account/billing/freeCredits/__snapshots__/SortingHeader.spec.ts.snap new file mode 100644 index 000000000..eabfb99bd --- /dev/null +++ b/web/satellite/tests/unit/account/billing/freeCredits/__snapshots__/SortingHeader.spec.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SortingHeader renders correctly 1`] = ` +
+
+

CREDIT TYPE

+
+
+

STATUS

+
+
+

EARNED AMOUNT

+
+
+

AVAILABLE

+
+
+`;