mirror of
https://github.com/standardnotes/server
synced 2026-01-16 20:04:32 -05:00
Compare commits
46 Commits
@standardn
...
@standardn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d29dc1012 | ||
|
|
b92c4ae650 | ||
|
|
e15d1e52bd | ||
|
|
ce3e259bde | ||
|
|
87361f90b1 | ||
|
|
81be06598c | ||
|
|
9492da6789 | ||
|
|
fce47a0a37 | ||
|
|
92ba682198 | ||
|
|
8df0482eb4 | ||
|
|
37a5cb347d | ||
|
|
77e50655f6 | ||
|
|
eacd2abc00 | ||
|
|
7393954ff6 | ||
|
|
68744379a6 | ||
|
|
90aef905af | ||
|
|
c7cbc8966e | ||
|
|
89502bed63 | ||
|
|
4952b48db6 | ||
|
|
52a257abb1 | ||
|
|
7480fb089b | ||
|
|
0f65c051ab | ||
|
|
7b62c7a967 | ||
|
|
5c3db2cb29 | ||
|
|
7008cbd363 | ||
|
|
cdb7fcf831 | ||
|
|
628aafdd42 | ||
|
|
9d3ef24ba9 | ||
|
|
4189f11fd7 | ||
|
|
5ea9941519 | ||
|
|
7a64494d07 | ||
|
|
4928685198 | ||
|
|
0103233d4a | ||
|
|
ee7075fe60 | ||
|
|
49feadd32a | ||
|
|
45758bf554 | ||
|
|
535d566a94 | ||
|
|
ff1d5db12c | ||
|
|
77a06b2fe7 | ||
|
|
6359030030 | ||
|
|
006f1fccec | ||
|
|
c0f5817d47 | ||
|
|
3da952fa52 | ||
|
|
f1834d58d2 | ||
|
|
b0cde4ab75 | ||
|
|
197c9914ca |
15
.pnp.cjs
generated
15
.pnp.cjs
generated
@@ -2551,6 +2551,7 @@ const RAW_RUNTIME_STATE =
|
||||
["@types/jest", "npm:29.1.1"],\
|
||||
["@types/newrelic", "npm:7.0.3"],\
|
||||
["@types/node", "npm:18.0.3"],\
|
||||
["@types/uuid", "npm:8.3.4"],\
|
||||
["@typescript-eslint/eslint-plugin", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:5.30.5"],\
|
||||
["aws-sdk", "npm:2.1234.0"],\
|
||||
["dayjs", "npm:1.11.6"],\
|
||||
@@ -2563,9 +2564,11 @@ const RAW_RUNTIME_STATE =
|
||||
["mysql2", "npm:2.3.3"],\
|
||||
["newrelic", "npm:9.0.0"],\
|
||||
["reflect-metadata", "npm:0.1.13"],\
|
||||
["shallow-equal-object", "npm:1.1.1"],\
|
||||
["ts-jest", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:29.0.3"],\
|
||||
["typeorm", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:0.3.10"],\
|
||||
["typescript", "patch:typescript@npm%3A4.8.4#optional!builtin<compat/typescript>::version=4.8.4&hash=701156"],\
|
||||
["uuid", "npm:9.0.0"],\
|
||||
["winston", "npm:3.8.2"]\
|
||||
],\
|
||||
"linkType": "SOFT"\
|
||||
@@ -2594,7 +2597,6 @@ const RAW_RUNTIME_STATE =
|
||||
["@standardnotes/api-gateway", "workspace:packages/api-gateway"],\
|
||||
["@newrelic/winston-enricher", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:4.0.0"],\
|
||||
["@sentry/node", "npm:7.5.0"],\
|
||||
["@standardnotes/analytics", "workspace:packages/analytics"],\
|
||||
["@standardnotes/common", "workspace:packages/common"],\
|
||||
["@standardnotes/domain-events", "workspace:packages/domain-events"],\
|
||||
["@standardnotes/domain-events-infra", "workspace:packages/domain-events-infra"],\
|
||||
@@ -2651,7 +2653,6 @@ const RAW_RUNTIME_STATE =
|
||||
["@standardnotes/auth-server", "workspace:packages/auth"],\
|
||||
["@newrelic/winston-enricher", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:4.0.0"],\
|
||||
["@sentry/node", "npm:7.5.0"],\
|
||||
["@standardnotes/analytics", "workspace:packages/analytics"],\
|
||||
["@standardnotes/api", "npm:1.19.0"],\
|
||||
["@standardnotes/common", "workspace:packages/common"],\
|
||||
["@standardnotes/domain-events", "workspace:packages/domain-events"],\
|
||||
@@ -3117,7 +3118,6 @@ const RAW_RUNTIME_STATE =
|
||||
["@standardnotes/syncing-server", "workspace:packages/syncing-server"],\
|
||||
["@newrelic/winston-enricher", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:4.0.0"],\
|
||||
["@sentry/node", "npm:7.5.0"],\
|
||||
["@standardnotes/analytics", "workspace:packages/analytics"],\
|
||||
["@standardnotes/common", "workspace:packages/common"],\
|
||||
["@standardnotes/domain-events", "workspace:packages/domain-events"],\
|
||||
["@standardnotes/domain-events-infra", "workspace:packages/domain-events-infra"],\
|
||||
@@ -12216,6 +12216,15 @@ const RAW_RUNTIME_STATE =
|
||||
"linkType": "HARD"\
|
||||
}]\
|
||||
]],\
|
||||
["shallow-equal-object", [\
|
||||
["npm:1.1.1", {\
|
||||
"packageLocation": "./.yarn/cache/shallow-equal-object-npm-1.1.1-a41b289b2e-9e5e0cd10b.zip/node_modules/shallow-equal-object/",\
|
||||
"packageDependencies": [\
|
||||
["shallow-equal-object", "npm:1.1.1"]\
|
||||
],\
|
||||
"linkType": "HARD"\
|
||||
}]\
|
||||
]],\
|
||||
["shebang-command", [\
|
||||
["npm:2.0.0", {\
|
||||
"packageLocation": "./.yarn/cache/shebang-command-npm-2.0.0-eb2b01921d-5907a8d5fa.zip/node_modules/shebang-command/",\
|
||||
|
||||
BIN
.yarn/cache/shallow-equal-object-npm-1.1.1-a41b289b2e-9e5e0cd10b.zip
vendored
Normal file
BIN
.yarn/cache/shallow-equal-object-npm-1.1.1-a41b289b2e-9e5e0cd10b.zip
vendored
Normal file
Binary file not shown.
@@ -3,6 +3,133 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [2.9.1](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.9.0...@standardnotes/analytics@2.9.1) (2022-11-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **analytics:** generate mrr stats for last 30 days including Today ([b92c4ae](https://github.com/standardnotes/server/commit/b92c4ae650b53db5c0bb2a9cf9afb01caeb8d822))
|
||||
|
||||
# [2.9.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.8.3...@standardnotes/analytics@2.9.0) (2022-11-10)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add mrr for annual, monthly, pro and plus subscription plans ([ce3e259](https://github.com/standardnotes/server/commit/ce3e259bdedd10796fb4469f0eabd64bc326a115))
|
||||
|
||||
## [2.8.3](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.8.2...@standardnotes/analytics@2.8.3) (2022-11-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **analytics:** add subscription id to error logs ([81be065](https://github.com/standardnotes/server/commit/81be06598c918279f98a8ba6b59ea1b3803c949c))
|
||||
|
||||
## [2.8.2](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.8.1...@standardnotes/analytics@2.8.2) (2022-11-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **analytics:** add monthly mrr to the report ([fce47a0](https://github.com/standardnotes/server/commit/fce47a0a37a67b3edf3ea0b6ccda43c54dbd9870))
|
||||
|
||||
## [2.8.1](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.8.0...@standardnotes/analytics@2.8.1) (2022-11-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **analytics:** add persisting mrr for this month and this year as well ([8df0482](https://github.com/standardnotes/server/commit/8df0482eb4bfd63b9639fd786c9b6952ad7f801d))
|
||||
|
||||
# [2.8.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.7.3...@standardnotes/analytics@2.8.0) (2022-11-10)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add calculating monthly recurring revenue ([77e5065](https://github.com/standardnotes/server/commit/77e50655f6fa7f9c28e13f8b8bc6de246c0454f0))
|
||||
|
||||
## [2.7.3](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.7.2...@standardnotes/analytics@2.7.3) (2022-11-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **analytics:** arhcitecture arrangements for use case execution ([7393954](https://github.com/standardnotes/server/commit/7393954ff6ece6143f7661104299172548db90ee))
|
||||
|
||||
## [2.7.2](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.7.1...@standardnotes/analytics@2.7.2) (2022-11-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **analytics:** mrr column types ([90aef90](https://github.com/standardnotes/server/commit/90aef905af05b8c1c86c7bd383df6b2b502f7c91))
|
||||
|
||||
## [2.7.1](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.7.0...@standardnotes/analytics@2.7.1) (2022-11-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **analytics:** add missing created at column ([89502be](https://github.com/standardnotes/server/commit/89502bed638b17301e42e0d5916635b0a59f585d))
|
||||
|
||||
# [2.7.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.6.0...@standardnotes/analytics@2.7.0) (2022-11-09)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add saving revenue modifications upon subscription canceled ([52a257a](https://github.com/standardnotes/server/commit/52a257abb16034134a50474fbbb2493a00c58b99))
|
||||
|
||||
# [2.6.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.5.0...@standardnotes/analytics@2.6.0) (2022-11-09)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add saving revenue modifications upon subscription refunded ([0f65c05](https://github.com/standardnotes/server/commit/0f65c051abcff805e920f91d338e5fadda7905a9))
|
||||
|
||||
# [2.5.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.4.0...@standardnotes/analytics@2.5.0) (2022-11-09)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add saving revenue modifications upon subscription expired ([5c3db2c](https://github.com/standardnotes/server/commit/5c3db2cb29a929e44b63eb8226ce4ad1d14f8a99))
|
||||
|
||||
# [2.4.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.3.1...@standardnotes/analytics@2.4.0) (2022-11-09)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add saving revenue modifications upon subscription renewed ([cdb7fcf](https://github.com/standardnotes/server/commit/cdb7fcf8311fecfabe3ef9eb656cd6ec57b87de0))
|
||||
|
||||
## [2.3.1](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.3.0...@standardnotes/analytics@2.3.1) (2022-11-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **analytics:** missing injectable annotation ([9d3ef24](https://github.com/standardnotes/server/commit/9d3ef24ba94ad28976a211d40f94f1bce8d0d305))
|
||||
|
||||
# [2.3.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.2.0...@standardnotes/analytics@2.3.0) (2022-11-09)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add saving revenue modifications upon subscription purchased ([5ea9941](https://github.com/standardnotes/server/commit/5ea9941519ffb3027527130ec869da14abc5e994))
|
||||
|
||||
# [2.2.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.1.0...@standardnotes/analytics@2.2.0) (2022-11-08)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add persistence for revenue modifications ([4928685](https://github.com/standardnotes/server/commit/49286851989f557d3b391b6b535a9aa307fbef50))
|
||||
* **analytics:** create new ddd architecture for persisting revenue modifications ([0103233](https://github.com/standardnotes/server/commit/0103233d4a1e222e7c9b059475c1cdc3b2617455))
|
||||
|
||||
# [2.1.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.52.0...@standardnotes/analytics@2.1.0) (2022-11-07)
|
||||
|
||||
### Features
|
||||
|
||||
* remove analytics scope from other services in favor of a separate service ([ff1d5db](https://github.com/standardnotes/server/commit/ff1d5db12c93f8e51c07c3aecb9fed4be48ea96a))
|
||||
|
||||
# [1.52.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.51.0...@standardnotes/analytics@1.52.0) (2022-11-07)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add handling subscription reactivated events ([6359030](https://github.com/standardnotes/server/commit/63590300308975097f4d092a4f140f479093a1ae))
|
||||
|
||||
# [1.51.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.50.0...@standardnotes/analytics@1.51.0) (2022-11-07)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add handling subscription expired events ([c0f5817](https://github.com/standardnotes/server/commit/c0f5817d4753410ee5d997c1b94e340b400cb5d9))
|
||||
|
||||
# [1.50.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.49.0...@standardnotes/analytics@1.50.0) (2022-11-07)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add handling subscription purchased events ([f1834d5](https://github.com/standardnotes/server/commit/f1834d58d2215c81322e82a0ec279617103b3260))
|
||||
|
||||
# [1.49.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.48.0...@standardnotes/analytics@1.49.0) (2022-11-07)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add handling subscription refunded event ([197c991](https://github.com/standardnotes/server/commit/197c9914caf8fb31ce3ee69d05381d2a6416b366))
|
||||
|
||||
# [1.48.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.47.0...@standardnotes/analytics@1.48.0) (2022-11-07)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ContainerConfigLoader } from '../src/Bootstrap/Container'
|
||||
import TYPES from '../src/Bootstrap/Types'
|
||||
import { Env } from '../src/Bootstrap/Env'
|
||||
import { DomainEventFactoryInterface } from '../src/Domain/Event/DomainEventFactoryInterface'
|
||||
import { CalculateMonthlyRecurringRevenue } from '../src/Domain/UseCase/CalculateMonthlyRecurringRevenue/CalculateMonthlyRecurringRevenue'
|
||||
|
||||
const requestReport = async (
|
||||
analyticsStore: AnalyticsStoreInterface,
|
||||
@@ -22,7 +23,10 @@ const requestReport = async (
|
||||
domainEventFactory: DomainEventFactoryInterface,
|
||||
domainEventPublisher: DomainEventPublisherInterface,
|
||||
periodKeyGenerator: PeriodKeyGeneratorInterface,
|
||||
calculateMonthlyRecurringRevenue: CalculateMonthlyRecurringRevenue,
|
||||
): Promise<void> => {
|
||||
await calculateMonthlyRecurringRevenue.execute({})
|
||||
|
||||
const analyticsOverTime: Array<{
|
||||
name: string
|
||||
period: number
|
||||
@@ -34,8 +38,6 @@ const requestReport = async (
|
||||
}> = []
|
||||
|
||||
const thirtyDaysAnalyticsNames = [
|
||||
AnalyticsActivity.GeneralActivity,
|
||||
AnalyticsActivity.EditingItems,
|
||||
AnalyticsActivity.SubscriptionPurchased,
|
||||
AnalyticsActivity.Register,
|
||||
AnalyticsActivity.SubscriptionRenewed,
|
||||
@@ -80,9 +82,6 @@ const requestReport = async (
|
||||
}> = []
|
||||
const yesterdayActivityNames = [
|
||||
AnalyticsActivity.LimitedDiscountOfferPurchased,
|
||||
AnalyticsActivity.GeneralActivity,
|
||||
AnalyticsActivity.GeneralActivityFreeUsers,
|
||||
AnalyticsActivity.GeneralActivityPaidUsers,
|
||||
AnalyticsActivity.PaymentFailed,
|
||||
AnalyticsActivity.PaymentSuccess,
|
||||
AnalyticsActivity.NewCustomersChurn,
|
||||
@@ -101,6 +100,39 @@ const requestReport = async (
|
||||
})
|
||||
}
|
||||
|
||||
const statisticsOverTime: Array<{
|
||||
name: string
|
||||
period: number
|
||||
counts: Array<{
|
||||
periodKey: string
|
||||
totalCount: number
|
||||
}>
|
||||
}> = []
|
||||
|
||||
const thirtyDaysStatisticsNames = [
|
||||
StatisticsMeasure.MRR,
|
||||
StatisticsMeasure.AnnualPlansMRR,
|
||||
StatisticsMeasure.MonthlyPlansMRR,
|
||||
StatisticsMeasure.PlusPlansMRR,
|
||||
StatisticsMeasure.ProPlansMRR,
|
||||
]
|
||||
for (const statisticName of thirtyDaysStatisticsNames) {
|
||||
statisticsOverTime.push({
|
||||
name: statisticName,
|
||||
period: Period.Last30DaysIncludingToday,
|
||||
counts: await statisticsStore.calculateTotalCountOverPeriod(statisticName, Period.Last30DaysIncludingToday),
|
||||
})
|
||||
}
|
||||
|
||||
const monthlyStatisticsNames = [StatisticsMeasure.MRR]
|
||||
for (const statisticName of monthlyStatisticsNames) {
|
||||
statisticsOverTime.push({
|
||||
name: statisticName,
|
||||
period: Period.ThisYear,
|
||||
counts: await statisticsStore.calculateTotalCountOverPeriod(statisticName, Period.ThisYear),
|
||||
})
|
||||
}
|
||||
|
||||
const statisticMeasureNames = [
|
||||
StatisticsMeasure.Income,
|
||||
StatisticsMeasure.PlusSubscriptionInitialAnnualPaymentsIncome,
|
||||
@@ -116,9 +148,6 @@ const requestReport = async (
|
||||
StatisticsMeasure.SubscriptionLength,
|
||||
StatisticsMeasure.RegistrationToSubscriptionTime,
|
||||
StatisticsMeasure.RemainingSubscriptionTimePercentage,
|
||||
StatisticsMeasure.NotesCountFreeUsers,
|
||||
StatisticsMeasure.NotesCountPaidUsers,
|
||||
StatisticsMeasure.FilesCount,
|
||||
StatisticsMeasure.NewCustomers,
|
||||
StatisticsMeasure.TotalCustomers,
|
||||
]
|
||||
@@ -141,29 +170,6 @@ const requestReport = async (
|
||||
}
|
||||
}
|
||||
|
||||
const periodKeys = periodKeyGenerator.getDiscretePeriodKeys(Period.Last7Days)
|
||||
const retentionOverDays: Array<{
|
||||
firstPeriodKey: string
|
||||
secondPeriodKey: string
|
||||
value: number
|
||||
}> = []
|
||||
for (let i = 0; i < periodKeys.length; i++) {
|
||||
for (let j = 0; j < periodKeys.length - i; j++) {
|
||||
const dailyRetention = await analyticsStore.calculateActivitiesRetention({
|
||||
firstActivity: AnalyticsActivity.Register,
|
||||
firstActivityPeriodKey: periodKeys[i],
|
||||
secondActivity: AnalyticsActivity.GeneralActivity,
|
||||
secondActivityPeriodKey: periodKeys[i + j],
|
||||
})
|
||||
|
||||
retentionOverDays.push({
|
||||
firstPeriodKey: periodKeys[i],
|
||||
secondPeriodKey: periodKeys[i + j],
|
||||
value: dailyRetention,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const monthlyPeriodKeys = periodKeyGenerator.getDiscretePeriodKeys(Period.ThisYear)
|
||||
const churnRates: Array<{
|
||||
rate: number
|
||||
@@ -201,22 +207,10 @@ const requestReport = async (
|
||||
}
|
||||
|
||||
const event = domainEventFactory.createDailyAnalyticsReportGeneratedEvent({
|
||||
applicationStatistics: await statisticsStore.getYesterdayApplicationUsage(),
|
||||
snjsStatistics: await statisticsStore.getYesterdaySNJSUsage(),
|
||||
outOfSyncIncidents: await statisticsStore.getYesterdayOutOfSyncIncidents(),
|
||||
activityStatistics: yesterdayActivityStatistics,
|
||||
activityStatisticsOverTime: analyticsOverTime,
|
||||
statisticsOverTime,
|
||||
statisticMeasures,
|
||||
retentionStatistics: [
|
||||
{
|
||||
firstActivity: AnalyticsActivity.Register,
|
||||
secondActivity: AnalyticsActivity.GeneralActivity,
|
||||
retention: {
|
||||
periodKeys,
|
||||
values: retentionOverDays,
|
||||
},
|
||||
},
|
||||
],
|
||||
churn: {
|
||||
periodKeys: monthlyPeriodKeys,
|
||||
values: churnRates,
|
||||
@@ -240,9 +234,19 @@ void container.load().then((container) => {
|
||||
const domainEventFactory: DomainEventFactoryInterface = container.get(TYPES.DomainEventFactory)
|
||||
const domainEventPublisher: DomainEventPublisherInterface = container.get(TYPES.DomainEventPublisher)
|
||||
const periodKeyGenerator: PeriodKeyGeneratorInterface = container.get(TYPES.PeriodKeyGenerator)
|
||||
const calculateMonthlyRecurringRevenue: CalculateMonthlyRecurringRevenue = container.get(
|
||||
TYPES.CalculateMonthlyRecurringRevenue,
|
||||
)
|
||||
|
||||
Promise.resolve(
|
||||
requestReport(analyticsStore, statisticsStore, domainEventFactory, domainEventPublisher, periodKeyGenerator),
|
||||
requestReport(
|
||||
analyticsStore,
|
||||
statisticsStore,
|
||||
domainEventFactory,
|
||||
domainEventPublisher,
|
||||
periodKeyGenerator,
|
||||
calculateMonthlyRecurringRevenue,
|
||||
),
|
||||
)
|
||||
.then(() => {
|
||||
logger.info('Usage report generation complete')
|
||||
|
||||
@@ -7,4 +7,5 @@ module.exports = {
|
||||
transform: {
|
||||
...tsjPreset.transform,
|
||||
},
|
||||
coveragePathIgnorePatterns: ['/Infra/'],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm'
|
||||
|
||||
export class addRevenueModifications1667912580964 implements MigrationInterface {
|
||||
name = 'addRevenueModifications1667912580964'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'CREATE TABLE `revenue_modifications` (`uuid` varchar(36) NOT NULL, `subscription_id` int NOT NULL, `user_email` varchar(255) NOT NULL, `user_uuid` varchar(36) NOT NULL, `event_type` varchar(255) NOT NULL, `subscription_plan` varchar(255) NOT NULL, `billing_frequency` int NOT NULL, `new_customer` tinyint NOT NULL, `previous_mrr` int NOT NULL, `new_mrr` int NOT NULL, INDEX `email` (`user_email`), INDEX `user_uuid` (`user_uuid`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB',
|
||||
)
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('DROP INDEX `user_uuid` ON `revenue_modifications`')
|
||||
await queryRunner.query('DROP INDEX `email` ON `revenue_modifications`')
|
||||
await queryRunner.query('DROP TABLE `revenue_modifications`')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm'
|
||||
|
||||
export class addMissingCreatedAt1667994036734 implements MigrationInterface {
|
||||
name = 'addMissingCreatedAt1667994036734'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE `revenue_modifications` ADD `created_at` bigint NOT NULL')
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE `revenue_modifications` DROP COLUMN `created_at`')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm'
|
||||
|
||||
export class fixMrrFloatingColumns1667995681714 implements MigrationInterface {
|
||||
name = 'fixMrrFloatingColumns1667995681714'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE `revenue_modifications` DROP COLUMN `previous_mrr`')
|
||||
await queryRunner.query('ALTER TABLE `revenue_modifications` ADD `previous_mrr` float NOT NULL')
|
||||
await queryRunner.query('ALTER TABLE `revenue_modifications` DROP COLUMN `new_mrr`')
|
||||
await queryRunner.query('ALTER TABLE `revenue_modifications` ADD `new_mrr` float NOT NULL')
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE `revenue_modifications` DROP COLUMN `new_mrr`')
|
||||
await queryRunner.query('ALTER TABLE `revenue_modifications` ADD `new_mrr` int NOT NULL')
|
||||
await queryRunner.query('ALTER TABLE `revenue_modifications` DROP COLUMN `previous_mrr`')
|
||||
await queryRunner.query('ALTER TABLE `revenue_modifications` ADD `previous_mrr` int NOT NULL')
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
{
|
||||
"name": "@standardnotes/analytics",
|
||||
"version": "1.48.0",
|
||||
"version": "2.9.1",
|
||||
"engines": {
|
||||
"node": ">=14.0.0 <17.0.0"
|
||||
},
|
||||
"private": true,
|
||||
"description": "Analytics tools for Standard Notes projects",
|
||||
"main": "dist/src/index.js",
|
||||
"author": "Standard Notes",
|
||||
"types": "dist/src/index.d.ts",
|
||||
"files": [
|
||||
"dist/src/**/*.js",
|
||||
"dist/src/**/*.d.ts"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -32,6 +29,7 @@
|
||||
"@types/jest": "^29.1.1",
|
||||
"@types/newrelic": "^7.0.3",
|
||||
"@types/node": "^18.0.0",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.0",
|
||||
"eslint": "^8.14.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
@@ -54,7 +52,9 @@
|
||||
"mysql2": "^2.3.3",
|
||||
"newrelic": "^9.0.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"shallow-equal-object": "^1.1.1",
|
||||
"typeorm": "^0.3.6",
|
||||
"uuid": "^9.0.0",
|
||||
"winston": "^3.8.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,19 @@ import { PaymentFailedEventHandler } from '../Domain/Handler/PaymentFailedEventH
|
||||
import { PaymentSuccessEventHandler } from '../Domain/Handler/PaymentSuccessEventHandler'
|
||||
import { SubscriptionCancelledEventHandler } from '../Domain/Handler/SubscriptionCancelledEventHandler'
|
||||
import { SubscriptionRenewedEventHandler } from '../Domain/Handler/SubscriptionRenewedEventHandler'
|
||||
import { SubscriptionRefundedEventHandler } from '../Domain/Handler/SubscriptionRefundedEventHandler'
|
||||
import { SubscriptionPurchasedEventHandler } from '../Domain/Handler/SubscriptionPurchasedEventHandler'
|
||||
import { SubscriptionExpiredEventHandler } from '../Domain/Handler/SubscriptionExpiredEventHandler'
|
||||
import { SubscriptionReactivatedEventHandler } from '../Domain/Handler/SubscriptionReactivatedEventHandler'
|
||||
import { RefundProcessedEventHandler } from '../Domain/Handler/RefundProcessedEventHandler'
|
||||
import { RevenueModificationRepositoryInterface } from '../Domain/Revenue/RevenueModificationRepositoryInterface'
|
||||
import { MySQLRevenueModificationRepository } from '../Infra/MySQL/MySQLRevenueModificationRepository'
|
||||
import { TypeORMRevenueModification } from '../Infra/TypeORM/TypeORMRevenueModification'
|
||||
import { MapInterface } from '../Domain/Map/MapInterface'
|
||||
import { RevenueModification } from '../Domain/Revenue/RevenueModification'
|
||||
import { RevenueModificationMap } from '../Domain/Map/RevenueModificationMap'
|
||||
import { SaveRevenueModification } from '../Domain/UseCase/SaveRevenueModification/SaveRevenueModification'
|
||||
import { CalculateMonthlyRecurringRevenue } from '../Domain/UseCase/CalculateMonthlyRecurringRevenue/CalculateMonthlyRecurringRevenue'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const newrelicFormatter = require('@newrelic/winston-enricher')
|
||||
@@ -111,14 +124,24 @@ export class ContainerConfigLoader {
|
||||
container
|
||||
.bind<AnalyticsEntityRepositoryInterface>(TYPES.AnalyticsEntityRepository)
|
||||
.to(MySQLAnalyticsEntityRepository)
|
||||
container
|
||||
.bind<RevenueModificationRepositoryInterface>(TYPES.RevenueModificationRepository)
|
||||
.to(MySQLRevenueModificationRepository)
|
||||
|
||||
// ORM
|
||||
container
|
||||
.bind<Repository<AnalyticsEntity>>(TYPES.ORMAnalyticsEntityRepository)
|
||||
.toConstantValue(AppDataSource.getRepository(AnalyticsEntity))
|
||||
container
|
||||
.bind<Repository<TypeORMRevenueModification>>(TYPES.ORMRevenueModificationRepository)
|
||||
.toConstantValue(AppDataSource.getRepository(TypeORMRevenueModification))
|
||||
|
||||
// Use Case
|
||||
container.bind<GetUserAnalyticsId>(TYPES.GetUserAnalyticsId).to(GetUserAnalyticsId)
|
||||
container.bind<SaveRevenueModification>(TYPES.SaveRevenueModification).to(SaveRevenueModification)
|
||||
container
|
||||
.bind<CalculateMonthlyRecurringRevenue>(TYPES.CalculateMonthlyRecurringRevenue)
|
||||
.to(CalculateMonthlyRecurringRevenue)
|
||||
|
||||
// Hanlders
|
||||
container.bind<UserRegisteredEventHandler>(TYPES.UserRegisteredEventHandler).to(UserRegisteredEventHandler)
|
||||
@@ -133,6 +156,24 @@ export class ContainerConfigLoader {
|
||||
container
|
||||
.bind<SubscriptionRenewedEventHandler>(TYPES.SubscriptionRenewedEventHandler)
|
||||
.to(SubscriptionRenewedEventHandler)
|
||||
container
|
||||
.bind<SubscriptionRefundedEventHandler>(TYPES.SubscriptionRefundedEventHandler)
|
||||
.to(SubscriptionRefundedEventHandler)
|
||||
container
|
||||
.bind<SubscriptionPurchasedEventHandler>(TYPES.SubscriptionPurchasedEventHandler)
|
||||
.to(SubscriptionPurchasedEventHandler)
|
||||
container
|
||||
.bind<SubscriptionExpiredEventHandler>(TYPES.SubscriptionExpiredEventHandler)
|
||||
.to(SubscriptionExpiredEventHandler)
|
||||
container
|
||||
.bind<SubscriptionReactivatedEventHandler>(TYPES.SubscriptionReactivatedEventHandler)
|
||||
.to(SubscriptionReactivatedEventHandler)
|
||||
container.bind<RefundProcessedEventHandler>(TYPES.RefundProcessedEventHandler).to(RefundProcessedEventHandler)
|
||||
|
||||
// Maps
|
||||
container
|
||||
.bind<MapInterface<RevenueModification, TypeORMRevenueModification>>(TYPES.RevenueModificationMap)
|
||||
.to(RevenueModificationMap)
|
||||
|
||||
// Services
|
||||
container.bind<DomainEventFactory>(TYPES.DomainEventFactory).to(DomainEventFactory)
|
||||
@@ -159,6 +200,16 @@ export class ContainerConfigLoader {
|
||||
|
||||
const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
|
||||
['USER_REGISTERED', container.get(TYPES.UserRegisteredEventHandler)],
|
||||
['ACCOUNT_DELETION_REQUESTED', container.get(TYPES.AccountDeletionRequestedEventHandler)],
|
||||
['PAYMENT_FAILED', container.get(TYPES.PaymentFailedEventHandler)],
|
||||
['PAYMENT_SUCCESS', container.get(TYPES.PaymentSuccessEventHandler)],
|
||||
['SUBSCRIPTION_CANCELLED', container.get(TYPES.SubscriptionCancelledEventHandler)],
|
||||
['SUBSCRIPTION_RENEWED', container.get(TYPES.SubscriptionRenewedEventHandler)],
|
||||
['SUBSCRIPTION_REFUNDED', container.get(TYPES.SubscriptionRefundedEventHandler)],
|
||||
['SUBSCRIPTION_PURCHASED', container.get(TYPES.SubscriptionPurchasedEventHandler)],
|
||||
['SUBSCRIPTION_EXPIRED', container.get(TYPES.SubscriptionExpiredEventHandler)],
|
||||
['SUBSCRIPTION_REACTIVATED', container.get(TYPES.SubscriptionReactivatedEventHandler)],
|
||||
['REFUND_PROCESSED', container.get(TYPES.RefundProcessedEventHandler)],
|
||||
])
|
||||
|
||||
if (env.get('SQS_QUEUE_URL', true)) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DataSource, LoggerOptions } from 'typeorm'
|
||||
|
||||
import { AnalyticsEntity } from '../Domain/Entity/AnalyticsEntity'
|
||||
import { TypeORMRevenueModification } from '../Infra/TypeORM/TypeORMRevenueModification'
|
||||
|
||||
import { Env } from './Env'
|
||||
|
||||
@@ -36,7 +37,7 @@ export const AppDataSource = new DataSource({
|
||||
],
|
||||
removeNodeErrorCount: 10,
|
||||
},
|
||||
entities: [AnalyticsEntity],
|
||||
entities: [AnalyticsEntity, TypeORMRevenueModification],
|
||||
migrations: [env.get('DB_MIGRATIONS_PATH', true) ?? 'dist/migrations/*.js'],
|
||||
migrationsRun: true,
|
||||
logging: <LoggerOptions>env.get('DB_DEBUG_LEVEL'),
|
||||
|
||||
@@ -13,10 +13,14 @@ const TYPES = {
|
||||
NEW_RELIC_ENABLED: Symbol.for('NEW_RELIC_ENABLED'),
|
||||
// Repositories
|
||||
AnalyticsEntityRepository: Symbol.for('AnalyticsEntityRepository'),
|
||||
RevenueModificationRepository: Symbol.for('RevenueModificationRepository'),
|
||||
// ORM
|
||||
ORMAnalyticsEntityRepository: Symbol.for('ORMAnalyticsEntityRepository'),
|
||||
ORMRevenueModificationRepository: Symbol.for('ORMRevenueModificationRepository'),
|
||||
// Use Case
|
||||
GetUserAnalyticsId: Symbol.for('GetUserAnalyticsId'),
|
||||
SaveRevenueModification: Symbol.for('SaveRevenueModification'),
|
||||
CalculateMonthlyRecurringRevenue: Symbol.for('CalculateMonthlyRecurringRevenue'),
|
||||
// Handlers
|
||||
UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'),
|
||||
AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'),
|
||||
@@ -24,6 +28,13 @@ const TYPES = {
|
||||
PaymentSuccessEventHandler: Symbol.for('PaymentSuccessEventHandler'),
|
||||
SubscriptionCancelledEventHandler: Symbol.for('SubscriptionCancelledEventHandler'),
|
||||
SubscriptionRenewedEventHandler: Symbol.for('SubscriptionRenewedEventHandler'),
|
||||
SubscriptionRefundedEventHandler: Symbol.for('SubscriptionRefundedEventHandler'),
|
||||
SubscriptionPurchasedEventHandler: Symbol.for('SubscriptionPurchasedEventHandler'),
|
||||
SubscriptionExpiredEventHandler: Symbol.for('SubscriptionExpiredEventHandler'),
|
||||
SubscriptionReactivatedEventHandler: Symbol.for('SubscriptionReactivatedEventHandler'),
|
||||
RefundProcessedEventHandler: Symbol.for('RefundProcessedEventHandler'),
|
||||
// Maps
|
||||
RevenueModificationMap: Symbol.for('RevenueModificationMap'),
|
||||
// Services
|
||||
DomainEventPublisher: Symbol.for('DomainEventPublisher'),
|
||||
DomainEventSubscriberFactory: Symbol.for('DomainEventSubscriberFactory'),
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
export enum AnalyticsActivity {
|
||||
GeneralActivity = 'general-activity',
|
||||
GeneralActivityFreeUsers = 'general-activity-free-users',
|
||||
GeneralActivityPaidUsers = 'general-activity-paid-users',
|
||||
EditingItems = 'editing-items',
|
||||
CheckingIntegrity = 'checking-integrity',
|
||||
Login = 'login',
|
||||
Register = 'register',
|
||||
DeleteAccount = 'DeleteAccount',
|
||||
SubscriptionPurchased = 'subscription-purchased',
|
||||
@@ -13,8 +7,6 @@ export enum AnalyticsActivity {
|
||||
SubscriptionCancelled = 'subscription-cancelled',
|
||||
SubscriptionExpired = 'subscription-expired',
|
||||
SubscriptionReactivated = 'subscription-reactivated',
|
||||
EmailUnbackedUpData = 'email-unbacked-up-data',
|
||||
EmailBackup = 'email-backup',
|
||||
LimitedDiscountOfferPurchased = 'limited-discount-offer-purchased',
|
||||
PaymentFailed = 'payment-failed',
|
||||
PaymentSuccess = 'payment-success',
|
||||
|
||||
16
packages/analytics/src/Domain/Common/Email.spec.ts
Normal file
16
packages/analytics/src/Domain/Common/Email.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Email } from './Email'
|
||||
|
||||
describe('Email', () => {
|
||||
it('should create a value object', () => {
|
||||
const valueOrError = Email.create('test@test.te')
|
||||
|
||||
expect(valueOrError.isFailed()).toBeFalsy()
|
||||
expect(valueOrError.getValue().value).toEqual('test@test.te')
|
||||
})
|
||||
|
||||
it('should not create an invalid value object', () => {
|
||||
const valueOrError = Email.create('')
|
||||
|
||||
expect(valueOrError.isFailed()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
21
packages/analytics/src/Domain/Common/Email.ts
Normal file
21
packages/analytics/src/Domain/Common/Email.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ValueObject } from '../Core/ValueObject'
|
||||
import { Result } from '../Core/Result'
|
||||
import { EmailProps } from './EmailProps'
|
||||
|
||||
export class Email extends ValueObject<EmailProps> {
|
||||
get value(): string {
|
||||
return this.props.value
|
||||
}
|
||||
|
||||
private constructor(props: EmailProps) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
static create(email: string): Result<Email> {
|
||||
if (!!email === false || email.length === 0) {
|
||||
return Result.fail<Email>('Email cannot be empty')
|
||||
} else {
|
||||
return Result.ok<Email>(new Email({ value: email }))
|
||||
}
|
||||
}
|
||||
}
|
||||
3
packages/analytics/src/Domain/Common/EmailProps.ts
Normal file
3
packages/analytics/src/Domain/Common/EmailProps.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface EmailProps {
|
||||
value: string
|
||||
}
|
||||
16
packages/analytics/src/Domain/Common/Uuid.spec.ts
Normal file
16
packages/analytics/src/Domain/Common/Uuid.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Uuid } from './Uuid'
|
||||
|
||||
describe('Uuid', () => {
|
||||
it('should create a value object', () => {
|
||||
const valueOrError = Uuid.create('1-2-3')
|
||||
|
||||
expect(valueOrError.isFailed()).toBeFalsy()
|
||||
expect(valueOrError.getValue().value).toEqual('1-2-3')
|
||||
})
|
||||
|
||||
it('should not create an invalid value object', () => {
|
||||
const valueOrError = Uuid.create('')
|
||||
|
||||
expect(valueOrError.isFailed()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
21
packages/analytics/src/Domain/Common/Uuid.ts
Normal file
21
packages/analytics/src/Domain/Common/Uuid.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ValueObject } from '../Core/ValueObject'
|
||||
import { Result } from '../Core/Result'
|
||||
import { UuidProps } from './UuidProps'
|
||||
|
||||
export class Uuid extends ValueObject<UuidProps> {
|
||||
get value(): string {
|
||||
return this.props.value
|
||||
}
|
||||
|
||||
private constructor(props: UuidProps) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
static create(uuid: string): Result<Uuid> {
|
||||
if (!!uuid === false || uuid.length === 0) {
|
||||
return Result.fail<Uuid>('Uuid cannot be empty')
|
||||
} else {
|
||||
return Result.ok<Uuid>(new Uuid({ value: uuid }))
|
||||
}
|
||||
}
|
||||
}
|
||||
3
packages/analytics/src/Domain/Common/UuidProps.ts
Normal file
3
packages/analytics/src/Domain/Common/UuidProps.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface UuidProps {
|
||||
value: string
|
||||
}
|
||||
10
packages/analytics/src/Domain/Core/Aggregate.ts
Normal file
10
packages/analytics/src/Domain/Core/Aggregate.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
import { Entity } from './Entity'
|
||||
import { UniqueEntityId } from './UniqueEntityId'
|
||||
|
||||
export abstract class Aggregate<T> extends Entity<T> {
|
||||
get id(): UniqueEntityId {
|
||||
return this._id
|
||||
}
|
||||
}
|
||||
27
packages/analytics/src/Domain/Core/Entity.ts
Normal file
27
packages/analytics/src/Domain/Core/Entity.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
import { UniqueEntityId } from './UniqueEntityId'
|
||||
|
||||
export abstract class Entity<T> {
|
||||
protected readonly _id: UniqueEntityId
|
||||
|
||||
constructor(public readonly props: T, id?: UniqueEntityId) {
|
||||
this._id = id ? id : new UniqueEntityId()
|
||||
}
|
||||
|
||||
public equals(object?: Entity<T>): boolean {
|
||||
if (object == null || object == undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this === object) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!(object instanceof Entity)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this._id.equals(object._id)
|
||||
}
|
||||
}
|
||||
24
packages/analytics/src/Domain/Core/Id.ts
Normal file
24
packages/analytics/src/Domain/Core/Id.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
export class Id<T> {
|
||||
constructor(private value: T) {}
|
||||
|
||||
equals(id?: Id<T>): boolean {
|
||||
if (id === null || id === undefined) {
|
||||
return false
|
||||
}
|
||||
if (!(id instanceof this.constructor)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return id.toValue() === this.value
|
||||
}
|
||||
|
||||
toString() {
|
||||
return String(this.value)
|
||||
}
|
||||
|
||||
toValue(): T {
|
||||
return this.value
|
||||
}
|
||||
}
|
||||
35
packages/analytics/src/Domain/Core/Result.ts
Normal file
35
packages/analytics/src/Domain/Core/Result.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
export class Result<T> {
|
||||
constructor(private isSuccess: boolean, private error?: string, private value?: T) {
|
||||
Object.freeze(this)
|
||||
}
|
||||
|
||||
isFailed(): boolean {
|
||||
return !this.isSuccess
|
||||
}
|
||||
|
||||
getValue(): T {
|
||||
if (!this.isSuccess) {
|
||||
throw new Error(`Cannot get value of an unsuccessfull result: ${this.error}`)
|
||||
}
|
||||
|
||||
return this.value as T
|
||||
}
|
||||
|
||||
getError(): string {
|
||||
if (this.isSuccess || this.error === undefined) {
|
||||
throw new Error('Cannot get an error of a successfull result')
|
||||
}
|
||||
|
||||
return this.error
|
||||
}
|
||||
|
||||
static ok<U>(value?: U): Result<U> {
|
||||
return new Result<U>(true, undefined, value)
|
||||
}
|
||||
|
||||
static fail<U>(error: string): Result<U> {
|
||||
return new Result<U>(false, error)
|
||||
}
|
||||
}
|
||||
10
packages/analytics/src/Domain/Core/UniqueEntityId.ts
Normal file
10
packages/analytics/src/Domain/Core/UniqueEntityId.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { Id } from './Id'
|
||||
|
||||
export class UniqueEntityId extends Id<string | number> {
|
||||
constructor(id?: string | number) {
|
||||
super(id ? id : uuid())
|
||||
}
|
||||
}
|
||||
24
packages/analytics/src/Domain/Core/ValueObject.ts
Normal file
24
packages/analytics/src/Domain/Core/ValueObject.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
import { shallowEqual } from 'shallow-equal-object'
|
||||
|
||||
import { ValueObjectProps } from './ValueObjectProps'
|
||||
|
||||
export abstract class ValueObject<T extends ValueObjectProps> {
|
||||
public readonly props: T
|
||||
|
||||
constructor(props: T) {
|
||||
this.props = Object.freeze(props)
|
||||
}
|
||||
|
||||
equals(valueObject?: ValueObject<T>): boolean {
|
||||
if (valueObject === null || valueObject === undefined) {
|
||||
return false
|
||||
}
|
||||
if (valueObject.props === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
return shallowEqual(this.props, valueObject.props)
|
||||
}
|
||||
}
|
||||
4
packages/analytics/src/Domain/Core/ValueObjectProps.ts
Normal file
4
packages/analytics/src/Domain/Core/ValueObjectProps.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface ValueObjectProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[index: string]: any
|
||||
}
|
||||
@@ -22,18 +22,6 @@ describe('DomainEventFactory', () => {
|
||||
it('should create a DAILY_ANALYTICS_REPORT_GENERATED event', () => {
|
||||
expect(
|
||||
createFactory().createDailyAnalyticsReportGeneratedEvent({
|
||||
snjsStatistics: [
|
||||
{
|
||||
version: '1-2-3',
|
||||
count: 2,
|
||||
},
|
||||
],
|
||||
applicationStatistics: [
|
||||
{
|
||||
version: '2-3-4',
|
||||
count: 45,
|
||||
},
|
||||
],
|
||||
activityStatistics: [
|
||||
{
|
||||
name: AnalyticsActivity.Register,
|
||||
@@ -63,21 +51,16 @@ describe('DomainEventFactory', () => {
|
||||
totalCount: 123,
|
||||
},
|
||||
],
|
||||
outOfSyncIncidents: 324,
|
||||
retentionStatistics: [
|
||||
statisticsOverTime: [
|
||||
{
|
||||
firstActivity: AnalyticsActivity.Register,
|
||||
secondActivity: AnalyticsActivity.Login,
|
||||
retention: {
|
||||
periodKeys: ['2022-10-9'],
|
||||
values: [
|
||||
{
|
||||
firstPeriodKey: AnalyticsActivity.Register,
|
||||
secondPeriodKey: AnalyticsActivity.Login,
|
||||
value: 12,
|
||||
},
|
||||
],
|
||||
},
|
||||
name: StatisticsMeasure.MRR,
|
||||
period: Period.Last30Days,
|
||||
counts: [
|
||||
{
|
||||
periodKey: '2022-10-9',
|
||||
totalCount: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
churn: {
|
||||
@@ -120,10 +103,16 @@ describe('DomainEventFactory', () => {
|
||||
totalCount: 123,
|
||||
},
|
||||
],
|
||||
applicationStatistics: [
|
||||
statisticsOverTime: [
|
||||
{
|
||||
count: 45,
|
||||
version: '2-3-4',
|
||||
counts: [
|
||||
{
|
||||
periodKey: '2022-10-9',
|
||||
totalCount: 3,
|
||||
},
|
||||
],
|
||||
name: 'mrr',
|
||||
period: 9,
|
||||
},
|
||||
],
|
||||
churn: {
|
||||
@@ -135,29 +124,6 @@ describe('DomainEventFactory', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
outOfSyncIncidents: 324,
|
||||
retentionStatistics: [
|
||||
{
|
||||
firstActivity: 'register',
|
||||
retention: {
|
||||
periodKeys: ['2022-10-9'],
|
||||
values: [
|
||||
{
|
||||
firstPeriodKey: 'register',
|
||||
secondPeriodKey: 'login',
|
||||
value: 12,
|
||||
},
|
||||
],
|
||||
},
|
||||
secondActivity: 'login',
|
||||
},
|
||||
],
|
||||
snjsStatistics: [
|
||||
{
|
||||
count: 2,
|
||||
version: '1-2-3',
|
||||
},
|
||||
],
|
||||
statisticMeasures: [
|
||||
{
|
||||
average: 23,
|
||||
|
||||
@@ -9,14 +9,6 @@ export class DomainEventFactory implements DomainEventFactoryInterface {
|
||||
constructor(@inject(TYPES.Timer) private timer: TimerInterface) {}
|
||||
|
||||
createDailyAnalyticsReportGeneratedEvent(dto: {
|
||||
snjsStatistics: Array<{
|
||||
version: string
|
||||
count: number
|
||||
}>
|
||||
applicationStatistics: Array<{
|
||||
version: string
|
||||
count: number
|
||||
}>
|
||||
activityStatistics: Array<{
|
||||
name: string
|
||||
retention: number
|
||||
@@ -38,18 +30,13 @@ export class DomainEventFactory implements DomainEventFactoryInterface {
|
||||
}>
|
||||
totalCount: number
|
||||
}>
|
||||
outOfSyncIncidents: number
|
||||
retentionStatistics: Array<{
|
||||
firstActivity: string
|
||||
secondActivity: string
|
||||
retention: {
|
||||
periodKeys: Array<string>
|
||||
values: Array<{
|
||||
firstPeriodKey: string
|
||||
secondPeriodKey: string
|
||||
value: number
|
||||
}>
|
||||
}
|
||||
statisticsOverTime: Array<{
|
||||
name: string
|
||||
period: number
|
||||
counts: Array<{
|
||||
periodKey: string
|
||||
totalCount: number
|
||||
}>
|
||||
}>
|
||||
churn: {
|
||||
periodKeys: Array<string>
|
||||
|
||||
@@ -2,14 +2,6 @@ import { DailyAnalyticsReportGeneratedEvent } from '@standardnotes/domain-events
|
||||
|
||||
export interface DomainEventFactoryInterface {
|
||||
createDailyAnalyticsReportGeneratedEvent(dto: {
|
||||
snjsStatistics: Array<{
|
||||
version: string
|
||||
count: number
|
||||
}>
|
||||
applicationStatistics: Array<{
|
||||
version: string
|
||||
count: number
|
||||
}>
|
||||
activityStatistics: Array<{
|
||||
name: string
|
||||
retention: number
|
||||
@@ -31,18 +23,13 @@ export interface DomainEventFactoryInterface {
|
||||
}>
|
||||
totalCount: number
|
||||
}>
|
||||
outOfSyncIncidents: number
|
||||
retentionStatistics: Array<{
|
||||
firstActivity: string
|
||||
secondActivity: string
|
||||
retention: {
|
||||
periodKeys: Array<string>
|
||||
values: Array<{
|
||||
firstPeriodKey: string
|
||||
secondPeriodKey: string
|
||||
value: number
|
||||
}>
|
||||
}
|
||||
statisticsOverTime: Array<{
|
||||
name: string
|
||||
period: number
|
||||
counts: Array<{
|
||||
periodKey: string
|
||||
totalCount: number
|
||||
}>
|
||||
}>
|
||||
churn: {
|
||||
periodKeys: Array<string>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'reflect-metadata'
|
||||
|
||||
import { RefundProcessedEvent } from '@standardnotes/domain-events'
|
||||
import { Period, StatisticsMeasure, StatisticsStoreInterface } from '@standardnotes/analytics'
|
||||
|
||||
import { RefundProcessedEventHandler } from './RefundProcessedEventHandler'
|
||||
import { StatisticsMeasure } from '../Statistics/StatisticsMeasure'
|
||||
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
|
||||
import { Period } from '../Time/Period'
|
||||
|
||||
describe('RefundProcessedEventHandler', () => {
|
||||
let event: RefundProcessedEvent
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Period, StatisticsMeasure, StatisticsStoreInterface } from '@standardnotes/analytics'
|
||||
import { DomainEventHandlerInterface, RefundProcessedEvent } from '@standardnotes/domain-events'
|
||||
import { inject, injectable } from 'inversify'
|
||||
|
||||
import TYPES from '../../Bootstrap/Types'
|
||||
import { StatisticsMeasure } from '../Statistics/StatisticsMeasure'
|
||||
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
|
||||
import { Period } from '../Time/Period'
|
||||
|
||||
@injectable()
|
||||
export class RefundProcessedEventHandler implements DomainEventHandlerInterface {
|
||||
@@ -9,16 +9,32 @@ import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
|
||||
import { StatisticsMeasure } from '../Statistics/StatisticsMeasure'
|
||||
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
|
||||
import { Period } from '../Time/Period'
|
||||
import { Result } from '../Core/Result'
|
||||
import { RevenueModification } from '../Revenue/RevenueModification'
|
||||
import { SaveRevenueModification } from '../UseCase/SaveRevenueModification/SaveRevenueModification'
|
||||
import { Logger } from 'winston'
|
||||
|
||||
describe('SubscriptionCancelledEventHandler', () => {
|
||||
let event: SubscriptionCancelledEvent
|
||||
let getUserAnalyticsId: GetUserAnalyticsId
|
||||
let analyticsStore: AnalyticsStoreInterface
|
||||
let statisticsStore: StatisticsStoreInterface
|
||||
let saveRevenueModification: SaveRevenueModification
|
||||
let logger: Logger
|
||||
|
||||
const createHandler = () => new SubscriptionCancelledEventHandler(getUserAnalyticsId, analyticsStore, statisticsStore)
|
||||
const createHandler = () =>
|
||||
new SubscriptionCancelledEventHandler(
|
||||
getUserAnalyticsId,
|
||||
analyticsStore,
|
||||
statisticsStore,
|
||||
saveRevenueModification,
|
||||
logger,
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
logger = {} as jest.Mocked<Logger>
|
||||
logger.error = jest.fn()
|
||||
|
||||
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
|
||||
getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 3 })
|
||||
|
||||
@@ -30,6 +46,7 @@ describe('SubscriptionCancelledEventHandler', () => {
|
||||
|
||||
event = {} as jest.Mocked<SubscriptionCancelledEvent>
|
||||
event.createdAt = new Date(1)
|
||||
event.type = 'SUBSCRIPTION_CANCELLED'
|
||||
event.payload = {
|
||||
subscriptionId: 1,
|
||||
userEmail: 'test@test.com',
|
||||
@@ -41,7 +58,13 @@ describe('SubscriptionCancelledEventHandler', () => {
|
||||
timestamp: 1,
|
||||
offline: false,
|
||||
replaced: false,
|
||||
userExistingSubscriptionsCount: 1,
|
||||
billingFrequency: 1,
|
||||
payAmount: 12.99,
|
||||
}
|
||||
|
||||
saveRevenueModification = {} as jest.Mocked<SaveRevenueModification>
|
||||
saveRevenueModification.execute = jest.fn().mockReturnValue(Result.ok<RevenueModification>())
|
||||
})
|
||||
|
||||
it('should track subscription cancelled statistics', async () => {
|
||||
@@ -55,6 +78,7 @@ describe('SubscriptionCancelledEventHandler', () => {
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
expect(saveRevenueModification.execute).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not track statistics for subscriptions that are in a legacy 5 year plan', async () => {
|
||||
@@ -65,5 +89,16 @@ describe('SubscriptionCancelledEventHandler', () => {
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(statisticsStore.incrementMeasure).not.toHaveBeenCalled()
|
||||
expect(saveRevenueModification.execute).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should log failure to save revenue modification', async () => {
|
||||
saveRevenueModification.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
|
||||
|
||||
event.payload.timestamp = 1642395451516000
|
||||
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(logger.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { DomainEventHandlerInterface, SubscriptionCancelledEvent } from '@standardnotes/domain-events'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import { Logger } from 'winston'
|
||||
|
||||
import TYPES from '../../Bootstrap/Types'
|
||||
import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
|
||||
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
|
||||
import { Email } from '../Common/Email'
|
||||
import { StatisticsMeasure } from '../Statistics/StatisticsMeasure'
|
||||
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
|
||||
import { SubscriptionEventType } from '../Subscription/SubscriptionEventType'
|
||||
import { SubscriptionPlanName } from '../Subscription/SubscriptionPlanName'
|
||||
import { Period } from '../Time/Period'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
import { SaveRevenueModification } from '../UseCase/SaveRevenueModification/SaveRevenueModification'
|
||||
|
||||
@injectable()
|
||||
export class SubscriptionCancelledEventHandler implements DomainEventHandlerInterface {
|
||||
@@ -15,10 +20,12 @@ export class SubscriptionCancelledEventHandler implements DomainEventHandlerInte
|
||||
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
|
||||
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
|
||||
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
|
||||
@inject(TYPES.SaveRevenueModification) private saveRevenueModification: SaveRevenueModification,
|
||||
@inject(TYPES.Logger) private logger: Logger,
|
||||
) {}
|
||||
|
||||
async handle(event: SubscriptionCancelledEvent): Promise<void> {
|
||||
const { analyticsId } = await this.getUserAnalyticsId.execute({ userEmail: event.payload.userEmail })
|
||||
const { analyticsId, userUuid } = await this.getUserAnalyticsId.execute({ userEmail: event.payload.userEmail })
|
||||
await this.analyticsStore.markActivity([AnalyticsActivity.SubscriptionCancelled], analyticsId, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
@@ -26,6 +33,23 @@ export class SubscriptionCancelledEventHandler implements DomainEventHandlerInte
|
||||
])
|
||||
|
||||
await this.trackSubscriptionStatistics(event)
|
||||
|
||||
const result = await this.saveRevenueModification.execute({
|
||||
billingFrequency: event.payload.billingFrequency,
|
||||
eventType: SubscriptionEventType.create(event.type).getValue(),
|
||||
newSubscriber: event.payload.userExistingSubscriptionsCount === 1,
|
||||
payedAmount: event.payload.payAmount,
|
||||
planName: SubscriptionPlanName.create(event.payload.subscriptionName).getValue(),
|
||||
subscriptionId: event.payload.subscriptionId,
|
||||
userEmail: Email.create(event.payload.userEmail).getValue(),
|
||||
userUuid,
|
||||
})
|
||||
|
||||
if (result.isFailed()) {
|
||||
this.logger.error(
|
||||
`[${event.type}][${event.payload.subscriptionId}] Could not save revenue modification: ${result.getError()}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async trackSubscriptionStatistics(event: SubscriptionCancelledEvent) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'reflect-metadata'
|
||||
|
||||
import { SubscriptionName } from '@standardnotes/common'
|
||||
import { SubscriptionExpiredEvent } from '@standardnotes/domain-events'
|
||||
|
||||
import { SubscriptionExpiredEventHandler } from './SubscriptionExpiredEventHandler'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
|
||||
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
|
||||
import { SaveRevenueModification } from '../UseCase/SaveRevenueModification/SaveRevenueModification'
|
||||
import { Result } from '../Core/Result'
|
||||
import { RevenueModification } from '../Revenue/RevenueModification'
|
||||
import { Logger } from 'winston'
|
||||
|
||||
describe('SubscriptionExpiredEventHandler', () => {
|
||||
let event: SubscriptionExpiredEvent
|
||||
let getUserAnalyticsId: GetUserAnalyticsId
|
||||
let analyticsStore: AnalyticsStoreInterface
|
||||
let statisticsStore: StatisticsStoreInterface
|
||||
let saveRevenueModification: SaveRevenueModification
|
||||
let logger: Logger
|
||||
|
||||
const createHandler = () =>
|
||||
new SubscriptionExpiredEventHandler(
|
||||
getUserAnalyticsId,
|
||||
analyticsStore,
|
||||
statisticsStore,
|
||||
saveRevenueModification,
|
||||
logger,
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
logger = {} as jest.Mocked<Logger>
|
||||
logger.error = jest.fn()
|
||||
|
||||
event = {} as jest.Mocked<SubscriptionExpiredEvent>
|
||||
event.createdAt = new Date(1)
|
||||
event.type = 'SUBSCRIPTION_EXPIRED'
|
||||
event.payload = {
|
||||
subscriptionId: 1,
|
||||
userEmail: 'test@test.com',
|
||||
subscriptionName: SubscriptionName.PlusPlan,
|
||||
timestamp: 1,
|
||||
offline: false,
|
||||
totalActiveSubscriptionsCount: 123,
|
||||
userExistingSubscriptionsCount: 2,
|
||||
billingFrequency: 1,
|
||||
payAmount: 12.99,
|
||||
}
|
||||
|
||||
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
|
||||
getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 3 })
|
||||
|
||||
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
|
||||
analyticsStore.markActivity = jest.fn()
|
||||
|
||||
statisticsStore = {} as jest.Mocked<StatisticsStoreInterface>
|
||||
statisticsStore.setMeasure = jest.fn()
|
||||
|
||||
saveRevenueModification = {} as jest.Mocked<SaveRevenueModification>
|
||||
saveRevenueModification.execute = jest.fn().mockReturnValue(Result.ok<RevenueModification>())
|
||||
})
|
||||
|
||||
it('should update analytics and statistics', async () => {
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(analyticsStore.markActivity).toHaveBeenCalled()
|
||||
expect(statisticsStore.setMeasure).toHaveBeenCalled()
|
||||
expect(saveRevenueModification.execute).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should log failure to save revenue modification', async () => {
|
||||
saveRevenueModification.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
|
||||
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(logger.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { DomainEventHandlerInterface, SubscriptionExpiredEvent } from '@standardnotes/domain-events'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import { Logger } from 'winston'
|
||||
|
||||
import TYPES from '../../Bootstrap/Types'
|
||||
import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
|
||||
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
|
||||
import { Email } from '../Common/Email'
|
||||
import { StatisticsMeasure } from '../Statistics/StatisticsMeasure'
|
||||
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
|
||||
import { SubscriptionEventType } from '../Subscription/SubscriptionEventType'
|
||||
import { SubscriptionPlanName } from '../Subscription/SubscriptionPlanName'
|
||||
import { Period } from '../Time/Period'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
import { SaveRevenueModification } from '../UseCase/SaveRevenueModification/SaveRevenueModification'
|
||||
|
||||
@injectable()
|
||||
export class SubscriptionExpiredEventHandler implements DomainEventHandlerInterface {
|
||||
constructor(
|
||||
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
|
||||
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
|
||||
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
|
||||
@inject(TYPES.SaveRevenueModification) private saveRevenueModification: SaveRevenueModification,
|
||||
@inject(TYPES.Logger) private logger: Logger,
|
||||
) {}
|
||||
|
||||
async handle(event: SubscriptionExpiredEvent): Promise<void> {
|
||||
const { analyticsId, userUuid } = await this.getUserAnalyticsId.execute({ userEmail: event.payload.userEmail })
|
||||
await this.analyticsStore.markActivity(
|
||||
[AnalyticsActivity.SubscriptionExpired, AnalyticsActivity.ExistingCustomersChurn],
|
||||
analyticsId,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
|
||||
await this.statisticsStore.setMeasure(
|
||||
StatisticsMeasure.TotalCustomers,
|
||||
event.payload.totalActiveSubscriptionsCount,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth, Period.ThisYear],
|
||||
)
|
||||
|
||||
const result = await this.saveRevenueModification.execute({
|
||||
billingFrequency: event.payload.billingFrequency,
|
||||
eventType: SubscriptionEventType.create(event.type).getValue(),
|
||||
newSubscriber: event.payload.userExistingSubscriptionsCount === 1,
|
||||
payedAmount: event.payload.payAmount,
|
||||
planName: SubscriptionPlanName.create(event.payload.subscriptionName).getValue(),
|
||||
subscriptionId: event.payload.subscriptionId,
|
||||
userEmail: Email.create(event.payload.userEmail).getValue(),
|
||||
userUuid,
|
||||
})
|
||||
|
||||
if (result.isFailed()) {
|
||||
this.logger.error(
|
||||
`[${event.type}][${event.payload.subscriptionId}] Could not save revenue modification: ${result.getError()}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'reflect-metadata'
|
||||
|
||||
import { SubscriptionName } from '@standardnotes/common'
|
||||
import { SubscriptionPurchasedEvent } from '@standardnotes/domain-events'
|
||||
|
||||
import { SubscriptionPurchasedEventHandler } from './SubscriptionPurchasedEventHandler'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
|
||||
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
|
||||
import { Period } from '../Time/Period'
|
||||
import { SaveRevenueModification } from '../UseCase/SaveRevenueModification/SaveRevenueModification'
|
||||
import { Result } from '../Core/Result'
|
||||
import { RevenueModification } from '../Revenue/RevenueModification'
|
||||
import { Logger } from 'winston'
|
||||
|
||||
describe('SubscriptionPurchasedEventHandler', () => {
|
||||
let event: SubscriptionPurchasedEvent
|
||||
let subscriptionExpiresAt: number
|
||||
let getUserAnalyticsId: GetUserAnalyticsId
|
||||
let analyticsStore: AnalyticsStoreInterface
|
||||
let statisticsStore: StatisticsStoreInterface
|
||||
let saveRevenueModification: SaveRevenueModification
|
||||
let logger: Logger
|
||||
|
||||
const createHandler = () =>
|
||||
new SubscriptionPurchasedEventHandler(
|
||||
getUserAnalyticsId,
|
||||
analyticsStore,
|
||||
statisticsStore,
|
||||
saveRevenueModification,
|
||||
logger,
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
logger = {} as jest.Mocked<Logger>
|
||||
logger.error = jest.fn()
|
||||
|
||||
statisticsStore = {} as jest.Mocked<StatisticsStoreInterface>
|
||||
statisticsStore.incrementMeasure = jest.fn()
|
||||
statisticsStore.setMeasure = jest.fn()
|
||||
|
||||
event = {} as jest.Mocked<SubscriptionPurchasedEvent>
|
||||
event.createdAt = new Date(1)
|
||||
event.type = 'SUBSCRIPTION_PURCHASED'
|
||||
event.payload = {
|
||||
subscriptionId: 1,
|
||||
userEmail: 'test@test.com',
|
||||
subscriptionName: SubscriptionName.ProPlan,
|
||||
subscriptionExpiresAt,
|
||||
timestamp: 60,
|
||||
offline: false,
|
||||
discountCode: null,
|
||||
limitedDiscountPurchased: false,
|
||||
newSubscriber: true,
|
||||
totalActiveSubscriptionsCount: 123,
|
||||
userRegisteredAt: 23,
|
||||
billingFrequency: 12,
|
||||
payAmount: 29.99,
|
||||
}
|
||||
|
||||
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
|
||||
getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 3 })
|
||||
|
||||
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
|
||||
analyticsStore.markActivity = jest.fn()
|
||||
analyticsStore.unmarkActivity = jest.fn()
|
||||
|
||||
saveRevenueModification = {} as jest.Mocked<SaveRevenueModification>
|
||||
saveRevenueModification.execute = jest.fn().mockReturnValue(Result.ok<RevenueModification>())
|
||||
})
|
||||
|
||||
it('should mark subscription creation statistics', async () => {
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(statisticsStore.incrementMeasure).toHaveBeenCalled()
|
||||
expect(saveRevenueModification.execute).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not measure registration to subscription time if this is not user's first subscription", async () => {
|
||||
event.payload.newSubscriber = false
|
||||
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(statisticsStore.incrementMeasure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update analytics on limited discount offer purchasing', async () => {
|
||||
event.payload.limitedDiscountPurchased = true
|
||||
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(analyticsStore.markActivity).toHaveBeenCalledWith(['limited-discount-offer-purchased'], 3, [Period.Today])
|
||||
})
|
||||
|
||||
it('should log failure to save revenue modification', async () => {
|
||||
saveRevenueModification.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
|
||||
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(logger.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { DomainEventHandlerInterface, SubscriptionPurchasedEvent } from '@standardnotes/domain-events'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import { Logger } from 'winston'
|
||||
|
||||
import TYPES from '../../Bootstrap/Types'
|
||||
import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
|
||||
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
|
||||
import { Email } from '../Common/Email'
|
||||
import { StatisticsMeasure } from '../Statistics/StatisticsMeasure'
|
||||
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
|
||||
import { SubscriptionEventType } from '../Subscription/SubscriptionEventType'
|
||||
import { SubscriptionPlanName } from '../Subscription/SubscriptionPlanName'
|
||||
import { Period } from '../Time/Period'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
import { SaveRevenueModification } from '../UseCase/SaveRevenueModification/SaveRevenueModification'
|
||||
|
||||
@injectable()
|
||||
export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInterface {
|
||||
constructor(
|
||||
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
|
||||
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
|
||||
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
|
||||
@inject(TYPES.SaveRevenueModification) private saveRevenueModification: SaveRevenueModification,
|
||||
@inject(TYPES.Logger) private logger: Logger,
|
||||
) {}
|
||||
|
||||
async handle(event: SubscriptionPurchasedEvent): Promise<void> {
|
||||
const { analyticsId, userUuid } = await this.getUserAnalyticsId.execute({ userEmail: event.payload.userEmail })
|
||||
await this.analyticsStore.markActivity([AnalyticsActivity.SubscriptionPurchased], analyticsId, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
await this.analyticsStore.unmarkActivity(
|
||||
[AnalyticsActivity.ExistingCustomersChurn, AnalyticsActivity.NewCustomersChurn],
|
||||
analyticsId,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
|
||||
if (event.payload.limitedDiscountPurchased) {
|
||||
await this.analyticsStore.markActivity([AnalyticsActivity.LimitedDiscountOfferPurchased], analyticsId, [
|
||||
Period.Today,
|
||||
])
|
||||
}
|
||||
|
||||
if (event.payload.newSubscriber) {
|
||||
await this.statisticsStore.incrementMeasure(
|
||||
StatisticsMeasure.RegistrationToSubscriptionTime,
|
||||
event.payload.timestamp - event.payload.userRegisteredAt,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
await this.statisticsStore.incrementMeasure(StatisticsMeasure.NewCustomers, 1, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
Period.ThisYear,
|
||||
])
|
||||
await this.statisticsStore.setMeasure(
|
||||
StatisticsMeasure.TotalCustomers,
|
||||
event.payload.totalActiveSubscriptionsCount,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth, Period.ThisYear],
|
||||
)
|
||||
}
|
||||
|
||||
const result = await this.saveRevenueModification.execute({
|
||||
billingFrequency: event.payload.billingFrequency,
|
||||
eventType: SubscriptionEventType.create(event.type).getValue(),
|
||||
newSubscriber: event.payload.newSubscriber,
|
||||
payedAmount: event.payload.payAmount,
|
||||
planName: SubscriptionPlanName.create(event.payload.subscriptionName).getValue(),
|
||||
subscriptionId: event.payload.subscriptionId,
|
||||
userEmail: Email.create(event.payload.userEmail).getValue(),
|
||||
userUuid,
|
||||
})
|
||||
|
||||
if (result.isFailed()) {
|
||||
this.logger.error(
|
||||
`[${event.type}][${event.payload.subscriptionId}] Could not save revenue modification: ${result.getError()}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,21 @@
|
||||
import 'reflect-metadata'
|
||||
|
||||
import { RoleName, SubscriptionName } from '@standardnotes/common'
|
||||
import { SubscriptionName } from '@standardnotes/common'
|
||||
import { SubscriptionReactivatedEvent } from '@standardnotes/domain-events'
|
||||
import { Logger } from 'winston'
|
||||
|
||||
import { User } from '../User/User'
|
||||
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
|
||||
import { SubscriptionReactivatedEventHandler } from './SubscriptionReactivatedEventHandler'
|
||||
import { AnalyticsStoreInterface, Period } from '@standardnotes/analytics'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
|
||||
import { Period } from '../Time/Period'
|
||||
|
||||
describe('SubscriptionReactivatedEventHandler', () => {
|
||||
let userRepository: UserRepositoryInterface
|
||||
let logger: Logger
|
||||
let user: User
|
||||
let event: SubscriptionReactivatedEvent
|
||||
let getUserAnalyticsId: GetUserAnalyticsId
|
||||
let analyticsStore: AnalyticsStoreInterface
|
||||
|
||||
const createHandler = () =>
|
||||
new SubscriptionReactivatedEventHandler(userRepository, analyticsStore, getUserAnalyticsId, logger)
|
||||
const createHandler = () => new SubscriptionReactivatedEventHandler(analyticsStore, getUserAnalyticsId)
|
||||
|
||||
beforeEach(() => {
|
||||
user = {
|
||||
uuid: '123',
|
||||
email: 'test@test.com',
|
||||
roles: Promise.resolve([
|
||||
{
|
||||
name: RoleName.ProUser,
|
||||
},
|
||||
]),
|
||||
} as jest.Mocked<User>
|
||||
|
||||
userRepository = {} as jest.Mocked<UserRepositoryInterface>
|
||||
userRepository.findOneByEmail = jest.fn().mockReturnValue(user)
|
||||
userRepository.save = jest.fn().mockReturnValue(user)
|
||||
|
||||
event = {} as jest.Mocked<SubscriptionReactivatedEvent>
|
||||
event.createdAt = new Date(1)
|
||||
event.payload = {
|
||||
@@ -52,10 +32,6 @@ describe('SubscriptionReactivatedEventHandler', () => {
|
||||
|
||||
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
|
||||
analyticsStore.markActivity = jest.fn()
|
||||
|
||||
logger = {} as jest.Mocked<Logger>
|
||||
logger.info = jest.fn()
|
||||
logger.warn = jest.fn()
|
||||
})
|
||||
|
||||
it('should mark subscription reactivated activity for analytics', async () => {
|
||||
@@ -67,12 +43,4 @@ describe('SubscriptionReactivatedEventHandler', () => {
|
||||
Period.ThisMonth,
|
||||
])
|
||||
})
|
||||
|
||||
it('should not do anything if no user is found for specified email', async () => {
|
||||
userRepository.findOneByEmail = jest.fn().mockReturnValue(null)
|
||||
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(analyticsStore.markActivity).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,30 +1,21 @@
|
||||
import { AnalyticsActivity, AnalyticsStoreInterface, Period } from '@standardnotes/analytics'
|
||||
import { DomainEventHandlerInterface, SubscriptionReactivatedEvent } from '@standardnotes/domain-events'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import { Logger } from 'winston'
|
||||
|
||||
import TYPES from '../../Bootstrap/Types'
|
||||
import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
|
||||
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
|
||||
import { Period } from '../Time/Period'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
|
||||
|
||||
@injectable()
|
||||
export class SubscriptionReactivatedEventHandler implements DomainEventHandlerInterface {
|
||||
constructor(
|
||||
@inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface,
|
||||
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
|
||||
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
|
||||
@inject(TYPES.Logger) private logger: Logger,
|
||||
) {}
|
||||
|
||||
async handle(event: SubscriptionReactivatedEvent): Promise<void> {
|
||||
const user = await this.userRepository.findOneByEmail(event.payload.userEmail)
|
||||
|
||||
if (user === null) {
|
||||
this.logger.warn(`Could not find user with email: ${event.payload.userEmail}`)
|
||||
return
|
||||
}
|
||||
|
||||
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid })
|
||||
const { analyticsId } = await this.getUserAnalyticsId.execute({ userEmail: event.payload.userEmail })
|
||||
await this.analyticsStore.markActivity([AnalyticsActivity.SubscriptionReactivated], analyticsId, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'reflect-metadata'
|
||||
|
||||
import { SubscriptionName } from '@standardnotes/common'
|
||||
import { SubscriptionRefundedEvent } from '@standardnotes/domain-events'
|
||||
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
|
||||
|
||||
import { SubscriptionRefundedEventHandler } from './SubscriptionRefundedEventHandler'
|
||||
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
|
||||
import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
|
||||
import { Period } from '../Time/Period'
|
||||
import { Result } from '../Core/Result'
|
||||
import { RevenueModification } from '../Revenue/RevenueModification'
|
||||
import { SaveRevenueModification } from '../UseCase/SaveRevenueModification/SaveRevenueModification'
|
||||
import { Logger } from 'winston'
|
||||
|
||||
describe('SubscriptionRefundedEventHandler', () => {
|
||||
let event: SubscriptionRefundedEvent
|
||||
let getUserAnalyticsId: GetUserAnalyticsId
|
||||
let analyticsStore: AnalyticsStoreInterface
|
||||
let statisticsStore: StatisticsStoreInterface
|
||||
let saveRevenueModification: SaveRevenueModification
|
||||
let logger: Logger
|
||||
|
||||
const createHandler = () =>
|
||||
new SubscriptionRefundedEventHandler(
|
||||
getUserAnalyticsId,
|
||||
analyticsStore,
|
||||
statisticsStore,
|
||||
saveRevenueModification,
|
||||
logger,
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
logger = {} as jest.Mocked<Logger>
|
||||
logger.error = jest.fn()
|
||||
|
||||
event = {} as jest.Mocked<SubscriptionRefundedEvent>
|
||||
event.createdAt = new Date(1)
|
||||
event.type = 'SUBSCRIPTION_REFUNDED'
|
||||
event.payload = {
|
||||
subscriptionId: 1,
|
||||
userEmail: 'test@test.com',
|
||||
subscriptionName: SubscriptionName.PlusPlan,
|
||||
timestamp: 1,
|
||||
offline: false,
|
||||
userExistingSubscriptionsCount: 3,
|
||||
totalActiveSubscriptionsCount: 1,
|
||||
billingFrequency: 1,
|
||||
payAmount: 12.99,
|
||||
}
|
||||
|
||||
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
|
||||
getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 3 })
|
||||
|
||||
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
|
||||
analyticsStore.markActivity = jest.fn()
|
||||
analyticsStore.wasActivityDone = jest.fn().mockReturnValue(true)
|
||||
|
||||
statisticsStore = {} as jest.Mocked<StatisticsStoreInterface>
|
||||
statisticsStore.setMeasure = jest.fn()
|
||||
|
||||
saveRevenueModification = {} as jest.Mocked<SaveRevenueModification>
|
||||
saveRevenueModification.execute = jest.fn().mockReturnValue(Result.ok<RevenueModification>())
|
||||
})
|
||||
|
||||
it('should mark churn for new customer', async () => {
|
||||
event.payload.userExistingSubscriptionsCount = 1
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(analyticsStore.markActivity).toHaveBeenCalledWith([AnalyticsActivity.SubscriptionRefunded], 3, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
|
||||
expect(analyticsStore.markActivity).toHaveBeenCalledWith([AnalyticsActivity.NewCustomersChurn], 3, [
|
||||
Period.ThisMonth,
|
||||
])
|
||||
|
||||
expect(saveRevenueModification.execute).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should mark churn for existing customer', async () => {
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(analyticsStore.markActivity).toHaveBeenCalledWith([AnalyticsActivity.ExistingCustomersChurn], 3, [
|
||||
Period.ThisMonth,
|
||||
])
|
||||
})
|
||||
|
||||
it('should not mark churn if customer did not purchase subscription in defined analytic periods', async () => {
|
||||
analyticsStore.wasActivityDone = jest.fn().mockReturnValue(false)
|
||||
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(analyticsStore.markActivity).not.toHaveBeenCalledWith([AnalyticsActivity.ExistingCustomersChurn], 3, [
|
||||
Period.ThisMonth,
|
||||
])
|
||||
})
|
||||
|
||||
it('should log failure to save revenue modification', async () => {
|
||||
saveRevenueModification.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
|
||||
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(logger.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { DomainEventHandlerInterface, SubscriptionRefundedEvent } from '@standardnotes/domain-events'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import { Logger } from 'winston'
|
||||
|
||||
import TYPES from '../../Bootstrap/Types'
|
||||
import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
|
||||
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
|
||||
import { Email } from '../Common/Email'
|
||||
import { StatisticsMeasure } from '../Statistics/StatisticsMeasure'
|
||||
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
|
||||
import { SubscriptionEventType } from '../Subscription/SubscriptionEventType'
|
||||
import { SubscriptionPlanName } from '../Subscription/SubscriptionPlanName'
|
||||
import { Period } from '../Time/Period'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
import { SaveRevenueModification } from '../UseCase/SaveRevenueModification/SaveRevenueModification'
|
||||
|
||||
@injectable()
|
||||
export class SubscriptionRefundedEventHandler implements DomainEventHandlerInterface {
|
||||
constructor(
|
||||
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
|
||||
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
|
||||
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
|
||||
@inject(TYPES.SaveRevenueModification) private saveRevenueModification: SaveRevenueModification,
|
||||
@inject(TYPES.Logger) private logger: Logger,
|
||||
) {}
|
||||
|
||||
async handle(event: SubscriptionRefundedEvent): Promise<void> {
|
||||
const { analyticsId, userUuid } = await this.getUserAnalyticsId.execute({ userEmail: event.payload.userEmail })
|
||||
await this.analyticsStore.markActivity([AnalyticsActivity.SubscriptionRefunded], analyticsId, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
|
||||
await this.markChurnActivity(analyticsId, event)
|
||||
|
||||
const result = await this.saveRevenueModification.execute({
|
||||
billingFrequency: event.payload.billingFrequency,
|
||||
eventType: SubscriptionEventType.create(event.type).getValue(),
|
||||
newSubscriber: event.payload.userExistingSubscriptionsCount === 1,
|
||||
payedAmount: event.payload.payAmount,
|
||||
planName: SubscriptionPlanName.create(event.payload.subscriptionName).getValue(),
|
||||
subscriptionId: event.payload.subscriptionId,
|
||||
userEmail: Email.create(event.payload.userEmail).getValue(),
|
||||
userUuid,
|
||||
})
|
||||
|
||||
if (result.isFailed()) {
|
||||
this.logger.error(
|
||||
`[${event.type}][${event.payload.subscriptionId}] Could not save revenue modification: ${result.getError()}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async markChurnActivity(analyticsId: number, event: SubscriptionRefundedEvent): Promise<void> {
|
||||
const churnActivity =
|
||||
event.payload.userExistingSubscriptionsCount > 1
|
||||
? AnalyticsActivity.ExistingCustomersChurn
|
||||
: AnalyticsActivity.NewCustomersChurn
|
||||
|
||||
for (const period of [Period.ThisMonth, Period.ThisWeek, Period.Today]) {
|
||||
const customerPurchasedInPeriod = await this.analyticsStore.wasActivityDone(
|
||||
AnalyticsActivity.SubscriptionPurchased,
|
||||
analyticsId,
|
||||
period,
|
||||
)
|
||||
if (customerPurchasedInPeriod) {
|
||||
await this.analyticsStore.markActivity([churnActivity], analyticsId, [period])
|
||||
}
|
||||
}
|
||||
|
||||
await this.statisticsStore.setMeasure(
|
||||
StatisticsMeasure.TotalCustomers,
|
||||
event.payload.totalActiveSubscriptionsCount,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth, Period.ThisYear],
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,17 +6,28 @@ import { SubscriptionRenewedEvent } from '@standardnotes/domain-events'
|
||||
import { SubscriptionRenewedEventHandler } from './SubscriptionRenewedEventHandler'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
|
||||
import { SaveRevenueModification } from '../UseCase/SaveRevenueModification/SaveRevenueModification'
|
||||
import { RevenueModification } from '../Revenue/RevenueModification'
|
||||
import { Result } from '../Core/Result'
|
||||
import { Logger } from 'winston'
|
||||
|
||||
describe('SubscriptionRenewedEventHandler', () => {
|
||||
let event: SubscriptionRenewedEvent
|
||||
let getUserAnalyticsId: GetUserAnalyticsId
|
||||
let analyticsStore: AnalyticsStoreInterface
|
||||
let saveRevenueModification: SaveRevenueModification
|
||||
let logger: Logger
|
||||
|
||||
const createHandler = () => new SubscriptionRenewedEventHandler(getUserAnalyticsId, analyticsStore)
|
||||
const createHandler = () =>
|
||||
new SubscriptionRenewedEventHandler(getUserAnalyticsId, analyticsStore, saveRevenueModification, logger)
|
||||
|
||||
beforeEach(() => {
|
||||
logger = {} as jest.Mocked<Logger>
|
||||
logger.error = jest.fn()
|
||||
|
||||
event = {} as jest.Mocked<SubscriptionRenewedEvent>
|
||||
event.createdAt = new Date(1)
|
||||
event.type = 'SUBSCRIPTION_RENEWED'
|
||||
event.payload = {
|
||||
subscriptionId: 1,
|
||||
userEmail: 'test@test.com',
|
||||
@@ -24,6 +35,8 @@ describe('SubscriptionRenewedEventHandler', () => {
|
||||
subscriptionExpiresAt: 2,
|
||||
timestamp: 1,
|
||||
offline: false,
|
||||
billingFrequency: 1,
|
||||
payAmount: 12.99,
|
||||
}
|
||||
|
||||
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
|
||||
@@ -32,6 +45,9 @@ describe('SubscriptionRenewedEventHandler', () => {
|
||||
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
|
||||
analyticsStore.markActivity = jest.fn()
|
||||
analyticsStore.unmarkActivity = jest.fn()
|
||||
|
||||
saveRevenueModification = {} as jest.Mocked<SaveRevenueModification>
|
||||
saveRevenueModification.execute = jest.fn().mockReturnValue(Result.ok<RevenueModification>())
|
||||
})
|
||||
|
||||
it('should track subscription renewed statistics', async () => {
|
||||
@@ -39,5 +55,14 @@ describe('SubscriptionRenewedEventHandler', () => {
|
||||
|
||||
expect(analyticsStore.markActivity).toHaveBeenCalled()
|
||||
expect(analyticsStore.unmarkActivity).toHaveBeenCalled()
|
||||
expect(saveRevenueModification.execute).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should log failure to save revenue modification', async () => {
|
||||
saveRevenueModification.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
|
||||
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(logger.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,16 +6,23 @@ import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyti
|
||||
import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
|
||||
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
|
||||
import { Period } from '../Time/Period'
|
||||
import { SaveRevenueModification } from '../UseCase/SaveRevenueModification/SaveRevenueModification'
|
||||
import { Email } from '../Common/Email'
|
||||
import { SubscriptionEventType } from '../Subscription/SubscriptionEventType'
|
||||
import { SubscriptionPlanName } from '../Subscription/SubscriptionPlanName'
|
||||
import { Logger } from 'winston'
|
||||
|
||||
@injectable()
|
||||
export class SubscriptionRenewedEventHandler implements DomainEventHandlerInterface {
|
||||
constructor(
|
||||
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
|
||||
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
|
||||
@inject(TYPES.SaveRevenueModification) private saveRevenueModification: SaveRevenueModification,
|
||||
@inject(TYPES.Logger) private logger: Logger,
|
||||
) {}
|
||||
|
||||
async handle(event: SubscriptionRenewedEvent): Promise<void> {
|
||||
const { analyticsId } = await this.getUserAnalyticsId.execute({ userEmail: event.payload.userEmail })
|
||||
const { analyticsId, userUuid } = await this.getUserAnalyticsId.execute({ userEmail: event.payload.userEmail })
|
||||
await this.analyticsStore.markActivity([AnalyticsActivity.SubscriptionRenewed], analyticsId, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
@@ -26,5 +33,22 @@ export class SubscriptionRenewedEventHandler implements DomainEventHandlerInterf
|
||||
analyticsId,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
|
||||
const result = await this.saveRevenueModification.execute({
|
||||
billingFrequency: event.payload.billingFrequency,
|
||||
eventType: SubscriptionEventType.create(event.type).getValue(),
|
||||
newSubscriber: false,
|
||||
payedAmount: event.payload.payAmount,
|
||||
planName: SubscriptionPlanName.create(event.payload.subscriptionName).getValue(),
|
||||
subscriptionId: event.payload.subscriptionId,
|
||||
userEmail: Email.create(event.payload.userEmail).getValue(),
|
||||
userUuid,
|
||||
})
|
||||
|
||||
if (result.isFailed()) {
|
||||
this.logger.error(
|
||||
`[${event.type}][${event.payload.subscriptionId}] Could not save revenue modification: ${result.getError()}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
packages/analytics/src/Domain/Map/MapInterface.ts
Normal file
4
packages/analytics/src/Domain/Map/MapInterface.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface MapInterface<T, U> {
|
||||
toDomain(persistence: U): T
|
||||
toPersistence(domain: T): U
|
||||
}
|
||||
81
packages/analytics/src/Domain/Map/RevenueModificationMap.ts
Normal file
81
packages/analytics/src/Domain/Map/RevenueModificationMap.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { injectable } from 'inversify'
|
||||
|
||||
import { TypeORMRevenueModification } from '../../Infra/TypeORM/TypeORMRevenueModification'
|
||||
import { UniqueEntityId } from '../Core/UniqueEntityId'
|
||||
import { MonthlyRevenue } from '../Revenue/MonthlyRevenue'
|
||||
import { RevenueModification } from '../Revenue/RevenueModification'
|
||||
import { Subscription } from '../Subscription/Subscription'
|
||||
import { User } from '../User/User'
|
||||
import { MapInterface } from './MapInterface'
|
||||
import { Email } from '../Common/Email'
|
||||
import { SubscriptionPlanName } from '../Subscription/SubscriptionPlanName'
|
||||
import { SubscriptionEventType } from '../Subscription/SubscriptionEventType'
|
||||
|
||||
@injectable()
|
||||
export class RevenueModificationMap implements MapInterface<RevenueModification, TypeORMRevenueModification> {
|
||||
toDomain(persistence: TypeORMRevenueModification): RevenueModification {
|
||||
const userOrError = User.create(
|
||||
{
|
||||
email: Email.create(persistence.userEmail).getValue(),
|
||||
},
|
||||
new UniqueEntityId(persistence.userUuid),
|
||||
)
|
||||
if (userOrError.isFailed()) {
|
||||
throw new Error(`Could not create user: ${userOrError.getError()}`)
|
||||
}
|
||||
const user = userOrError.getValue()
|
||||
|
||||
const subscriptionOrError = Subscription.create(
|
||||
{
|
||||
billingFrequency: persistence.billingFrequency,
|
||||
isFirstSubscriptionForUser: persistence.isNewCustomer,
|
||||
payedAmount: persistence.billingFrequency * persistence.newMonthlyRevenue,
|
||||
planName: SubscriptionPlanName.create(persistence.subscriptionPlan).getValue(),
|
||||
},
|
||||
new UniqueEntityId(persistence.subscriptionId),
|
||||
)
|
||||
if (subscriptionOrError.isFailed()) {
|
||||
throw new Error(`Could not create subscription: ${subscriptionOrError.getError()}`)
|
||||
}
|
||||
const subscription = subscriptionOrError.getValue()
|
||||
|
||||
const previousMonthlyRevenueOrError = MonthlyRevenue.create(persistence.previousMonthlyRevenue)
|
||||
const newMonthlyRevenueOrError = MonthlyRevenue.create(persistence.newMonthlyRevenue)
|
||||
|
||||
const revenuModificationOrError = RevenueModification.create(
|
||||
{
|
||||
user,
|
||||
subscription,
|
||||
eventType: SubscriptionEventType.create(persistence.eventType).getValue(),
|
||||
previousMonthlyRevenue: previousMonthlyRevenueOrError.getValue(),
|
||||
newMonthlyRevenue: newMonthlyRevenueOrError.getValue(),
|
||||
createdAt: persistence.createdAt,
|
||||
},
|
||||
new UniqueEntityId(persistence.uuid),
|
||||
)
|
||||
|
||||
if (revenuModificationOrError.isFailed()) {
|
||||
throw new Error(`Could not map revenue modification to domain: ${revenuModificationOrError.getError()}`)
|
||||
}
|
||||
|
||||
return revenuModificationOrError.getValue()
|
||||
}
|
||||
|
||||
toPersistence(domain: RevenueModification): TypeORMRevenueModification {
|
||||
const { subscription, user } = domain.props
|
||||
const persistence = new TypeORMRevenueModification()
|
||||
persistence.uuid = domain.id.toString()
|
||||
persistence.billingFrequency = subscription.props.billingFrequency
|
||||
persistence.eventType = domain.props.eventType.value
|
||||
persistence.isNewCustomer = subscription.props.isFirstSubscriptionForUser
|
||||
persistence.newMonthlyRevenue = domain.props.newMonthlyRevenue.value
|
||||
persistence.previousMonthlyRevenue = domain.props.previousMonthlyRevenue.value
|
||||
persistence.subscriptionId = subscription.id.toValue() as number
|
||||
persistence.subscriptionPlan = subscription.props.planName.value
|
||||
persistence.userEmail = user.props.email.value
|
||||
persistence.userUuid = user.id.toString()
|
||||
persistence.createdAt = domain.props.createdAt
|
||||
|
||||
return persistence
|
||||
}
|
||||
}
|
||||
16
packages/analytics/src/Domain/Revenue/MonthlyRevenue.spec.ts
Normal file
16
packages/analytics/src/Domain/Revenue/MonthlyRevenue.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { MonthlyRevenue } from './MonthlyRevenue'
|
||||
|
||||
describe('MonthlyRevenue', () => {
|
||||
it('should create a value object', () => {
|
||||
const valueOrError = MonthlyRevenue.create(123)
|
||||
|
||||
expect(valueOrError.isFailed()).toBeFalsy()
|
||||
expect(valueOrError.getValue().value).toEqual(123)
|
||||
})
|
||||
|
||||
it('should not create an invalid value object', () => {
|
||||
const valueOrError = MonthlyRevenue.create(-3)
|
||||
|
||||
expect(valueOrError.isFailed()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
21
packages/analytics/src/Domain/Revenue/MonthlyRevenue.ts
Normal file
21
packages/analytics/src/Domain/Revenue/MonthlyRevenue.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ValueObject } from '../Core/ValueObject'
|
||||
import { Result } from '../Core/Result'
|
||||
import { MonthlyRevenueProps } from './MonthlyRevenueProps'
|
||||
|
||||
export class MonthlyRevenue extends ValueObject<MonthlyRevenueProps> {
|
||||
get value(): number {
|
||||
return this.props.value
|
||||
}
|
||||
|
||||
private constructor(props: MonthlyRevenueProps) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
static create(revenue: number): Result<MonthlyRevenue> {
|
||||
if (isNaN(revenue) || revenue < 0) {
|
||||
return Result.fail<MonthlyRevenue>(`Monthly revenue must be a non-negative number. Supplied: ${revenue}`)
|
||||
} else {
|
||||
return Result.ok<MonthlyRevenue>(new MonthlyRevenue({ value: revenue }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface MonthlyRevenueProps {
|
||||
value: number
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Email } from '../Common/Email'
|
||||
import { Subscription } from '../Subscription/Subscription'
|
||||
import { SubscriptionEventType } from '../Subscription/SubscriptionEventType'
|
||||
import { SubscriptionPlanName } from '../Subscription/SubscriptionPlanName'
|
||||
import { User } from '../User/User'
|
||||
import { MonthlyRevenue } from './MonthlyRevenue'
|
||||
import { RevenueModification } from './RevenueModification'
|
||||
|
||||
describe('RevenueModification', () => {
|
||||
let user: User
|
||||
let subscription: Subscription
|
||||
|
||||
beforeEach(() => {
|
||||
subscription = Subscription.create({
|
||||
billingFrequency: 12,
|
||||
isFirstSubscriptionForUser: true,
|
||||
payedAmount: 123,
|
||||
planName: SubscriptionPlanName.create('PRO_PLAN').getValue(),
|
||||
}).getValue()
|
||||
user = User.create({
|
||||
email: Email.create('test@test.te').getValue(),
|
||||
}).getValue()
|
||||
})
|
||||
|
||||
it('should create an aggregate for purchased subscription', () => {
|
||||
const revenueModification = RevenueModification.create({
|
||||
createdAt: 2,
|
||||
eventType: SubscriptionEventType.create('SUBSCRIPTION_PURCHASED').getValue(),
|
||||
previousMonthlyRevenue: MonthlyRevenue.create(123).getValue(),
|
||||
newMonthlyRevenue: MonthlyRevenue.create(45).getValue(),
|
||||
subscription,
|
||||
user,
|
||||
}).getValue()
|
||||
|
||||
expect(revenueModification.id.toString()).toHaveLength(36)
|
||||
expect(revenueModification.props.newMonthlyRevenue.value).toEqual(45)
|
||||
})
|
||||
})
|
||||
14
packages/analytics/src/Domain/Revenue/RevenueModification.ts
Normal file
14
packages/analytics/src/Domain/Revenue/RevenueModification.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Aggregate } from '../Core/Aggregate'
|
||||
import { Result } from '../Core/Result'
|
||||
import { UniqueEntityId } from '../Core/UniqueEntityId'
|
||||
import { RevenueModificationProps } from './RevenueModificationProps'
|
||||
|
||||
export class RevenueModification extends Aggregate<RevenueModificationProps> {
|
||||
private constructor(props: RevenueModificationProps, id?: UniqueEntityId) {
|
||||
super(props, id)
|
||||
}
|
||||
|
||||
static create(props: RevenueModificationProps, id?: UniqueEntityId): Result<RevenueModification> {
|
||||
return Result.ok<RevenueModification>(new RevenueModification(props, id))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { MonthlyRevenue } from './MonthlyRevenue'
|
||||
import { Subscription } from '../Subscription/Subscription'
|
||||
import { User } from '../User/User'
|
||||
import { SubscriptionEventType } from '../Subscription/SubscriptionEventType'
|
||||
|
||||
export interface RevenueModificationProps {
|
||||
user: User
|
||||
subscription: Subscription
|
||||
eventType: SubscriptionEventType
|
||||
previousMonthlyRevenue: MonthlyRevenue
|
||||
newMonthlyRevenue: MonthlyRevenue
|
||||
createdAt: number
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Uuid } from '../Common/Uuid'
|
||||
import { RevenueModification } from './RevenueModification'
|
||||
|
||||
export interface RevenueModificationRepositoryInterface {
|
||||
findLastByUserUuid(userUuid: Uuid): Promise<RevenueModification | null>
|
||||
sumMRRDiff(dto: { planName?: string; billingFrequency?: number }): Promise<number>
|
||||
save(revenueModification: RevenueModification): Promise<RevenueModification>
|
||||
}
|
||||
@@ -13,9 +13,11 @@ export enum StatisticsMeasure {
|
||||
RegistrationToSubscriptionTime = 'registration-to-subscription-time',
|
||||
RemainingSubscriptionTimePercentage = 'remaining-subscription-time-percentage',
|
||||
Refunds = 'refunds',
|
||||
NotesCountFreeUsers = 'notes-count-free-users',
|
||||
NotesCountPaidUsers = 'notes-count-paid-users',
|
||||
FilesCount = 'files-count',
|
||||
NewCustomers = 'new-customers',
|
||||
TotalCustomers = 'total-customers',
|
||||
MRR = 'mrr',
|
||||
MonthlyPlansMRR = 'monthly-plans-mrr',
|
||||
AnnualPlansMRR = 'annual-plans-mrr',
|
||||
ProPlansMRR = 'pro-plans-mrr',
|
||||
PlusPlansMRR = 'plus-plans-mrr',
|
||||
}
|
||||
|
||||
@@ -13,4 +13,8 @@ export interface StatisticsStoreInterface {
|
||||
getMeasureAverage(measure: StatisticsMeasure, period: Period): Promise<number>
|
||||
getMeasureTotal(measure: StatisticsMeasure, periodOrPeriodKey: Period | string): Promise<number>
|
||||
getMeasureIncrementCounts(measure: StatisticsMeasure, period: Period): Promise<number>
|
||||
calculateTotalCountOverPeriod(
|
||||
measure: StatisticsMeasure,
|
||||
period: Period,
|
||||
): Promise<Array<{ periodKey: string; totalCount: number }>>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Subscription } from './Subscription'
|
||||
import { SubscriptionPlanName } from './SubscriptionPlanName'
|
||||
|
||||
describe('Subscription', () => {
|
||||
it('should create an entity', () => {
|
||||
const subscription = Subscription.create({
|
||||
billingFrequency: 1,
|
||||
isFirstSubscriptionForUser: true,
|
||||
payedAmount: 12.99,
|
||||
planName: SubscriptionPlanName.create('PRO_PLAN').getValue(),
|
||||
}).getValue()
|
||||
|
||||
expect(subscription.id.toString()).toHaveLength(36)
|
||||
})
|
||||
})
|
||||
18
packages/analytics/src/Domain/Subscription/Subscription.ts
Normal file
18
packages/analytics/src/Domain/Subscription/Subscription.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Entity } from '../Core/Entity'
|
||||
import { Result } from '../Core/Result'
|
||||
import { UniqueEntityId } from '../Core/UniqueEntityId'
|
||||
import { SubscriptionProps } from './SubscriptionProps'
|
||||
|
||||
export class Subscription extends Entity<SubscriptionProps> {
|
||||
get id(): UniqueEntityId {
|
||||
return this._id
|
||||
}
|
||||
|
||||
private constructor(props: SubscriptionProps, id?: UniqueEntityId) {
|
||||
super(props, id)
|
||||
}
|
||||
|
||||
static create(props: SubscriptionProps, id?: UniqueEntityId): Result<Subscription> {
|
||||
return Result.ok<Subscription>(new Subscription(props, id))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { SubscriptionEventType } from './SubscriptionEventType'
|
||||
|
||||
describe('SubscriptionEventType', () => {
|
||||
it('should create a value object', () => {
|
||||
const valueOrError = SubscriptionEventType.create('SUBSCRIPTION_PURCHASED')
|
||||
|
||||
expect(valueOrError.isFailed()).toBeFalsy()
|
||||
expect(valueOrError.getValue().value).toEqual('SUBSCRIPTION_PURCHASED')
|
||||
})
|
||||
|
||||
it('should not create an invalid value object', () => {
|
||||
const valueOrError = SubscriptionEventType.create('SUBSCRIPTION_REACTIVATED')
|
||||
|
||||
expect(valueOrError.isFailed()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ValueObject } from '../Core/ValueObject'
|
||||
import { Result } from '../Core/Result'
|
||||
import { SubscriptionEventTypeProps } from './SubscriptionEventTypeProps'
|
||||
|
||||
export class SubscriptionEventType extends ValueObject<SubscriptionEventTypeProps> {
|
||||
get value(): string {
|
||||
return this.props.value
|
||||
}
|
||||
|
||||
private constructor(props: SubscriptionEventTypeProps) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
static create(subscriptionEventType: string): Result<SubscriptionEventType> {
|
||||
if (
|
||||
![
|
||||
'SUBSCRIPTION_PURCHASED',
|
||||
'SUBSCRIPTION_RENEWED',
|
||||
'SUBSCRIPTION_EXPIRED',
|
||||
'SUBSCRIPTION_REFUNDED',
|
||||
'SUBSCRIPTION_CANCELLED',
|
||||
'SUBSCRIPTION_DATA_MIGRATED',
|
||||
].includes(subscriptionEventType)
|
||||
) {
|
||||
return Result.fail<SubscriptionEventType>(`Invalid subscription event type ${subscriptionEventType}`)
|
||||
} else {
|
||||
return Result.ok<SubscriptionEventType>(new SubscriptionEventType({ value: subscriptionEventType }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface SubscriptionEventTypeProps {
|
||||
value: string
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { SubscriptionPlanName } from './SubscriptionPlanName'
|
||||
|
||||
describe('SubscriptionPlanName', () => {
|
||||
it('should create a value object', () => {
|
||||
const valueOrError = SubscriptionPlanName.create('PRO_PLAN')
|
||||
|
||||
expect(valueOrError.isFailed()).toBeFalsy()
|
||||
expect(valueOrError.getValue().value).toEqual('PRO_PLAN')
|
||||
})
|
||||
|
||||
it('should not create an invalid value object', () => {
|
||||
const valueOrError = SubscriptionPlanName.create('TEST')
|
||||
|
||||
expect(valueOrError.isFailed()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ValueObject } from '../Core/ValueObject'
|
||||
import { Result } from '../Core/Result'
|
||||
import { SubscriptionPlanNameProps } from './SubscriptionPlanNameProps'
|
||||
|
||||
export class SubscriptionPlanName extends ValueObject<SubscriptionPlanNameProps> {
|
||||
get value(): string {
|
||||
return this.props.value
|
||||
}
|
||||
|
||||
private constructor(props: SubscriptionPlanNameProps) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
static create(subscriptionPlanName: string): Result<SubscriptionPlanName> {
|
||||
if (!['PRO_PLAN', 'PLUS_PLAN'].includes(subscriptionPlanName)) {
|
||||
return Result.fail<SubscriptionPlanName>(`Invalid subscription plan name ${subscriptionPlanName}`)
|
||||
} else {
|
||||
return Result.ok<SubscriptionPlanName>(new SubscriptionPlanName({ value: subscriptionPlanName }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface SubscriptionPlanNameProps {
|
||||
value: string
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { SubscriptionPlanName } from '../Subscription/SubscriptionPlanName'
|
||||
|
||||
export interface SubscriptionProps {
|
||||
planName: SubscriptionPlanName
|
||||
isFirstSubscriptionForUser: boolean
|
||||
payedAmount: number
|
||||
billingFrequency: number
|
||||
}
|
||||
@@ -26,4 +26,5 @@ export enum Period {
|
||||
OctoberThisYear,
|
||||
NovemberThisYear,
|
||||
DecemberThisYear,
|
||||
Last30DaysIncludingToday,
|
||||
}
|
||||
|
||||
@@ -62,6 +62,41 @@ describe('PeriodKeyGenerator', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should generate period keys for last 30 days including Today', () => {
|
||||
expect(createGenerator().getDiscretePeriodKeys(Period.Last30DaysIncludingToday)).toEqual([
|
||||
'2022-4-25',
|
||||
'2022-4-26',
|
||||
'2022-4-27',
|
||||
'2022-4-28',
|
||||
'2022-4-29',
|
||||
'2022-4-30',
|
||||
'2022-5-1',
|
||||
'2022-5-2',
|
||||
'2022-5-3',
|
||||
'2022-5-4',
|
||||
'2022-5-5',
|
||||
'2022-5-6',
|
||||
'2022-5-7',
|
||||
'2022-5-8',
|
||||
'2022-5-9',
|
||||
'2022-5-10',
|
||||
'2022-5-11',
|
||||
'2022-5-12',
|
||||
'2022-5-13',
|
||||
'2022-5-14',
|
||||
'2022-5-15',
|
||||
'2022-5-16',
|
||||
'2022-5-17',
|
||||
'2022-5-18',
|
||||
'2022-5-19',
|
||||
'2022-5-20',
|
||||
'2022-5-21',
|
||||
'2022-5-22',
|
||||
'2022-5-23',
|
||||
'2022-5-24',
|
||||
])
|
||||
})
|
||||
|
||||
it('should generate period keys for this year', () => {
|
||||
expect(createGenerator().getDiscretePeriodKeys(Period.ThisYear)).toEqual([
|
||||
'2022-1',
|
||||
|
||||
@@ -33,6 +33,12 @@ export class PeriodKeyGenerator implements PeriodKeyGeneratorInterface {
|
||||
periodKeys.unshift(this.getDailyKey(this.getDateNDaysBefore(i)))
|
||||
}
|
||||
|
||||
return periodKeys
|
||||
case Period.Last30DaysIncludingToday:
|
||||
for (let i = 0; i <= 29; i++) {
|
||||
periodKeys.unshift(this.getDailyKey(this.getDateNDaysBefore(i)))
|
||||
}
|
||||
|
||||
return periodKeys
|
||||
case Period.Last7Days:
|
||||
for (let i = 1; i <= 7; i++) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'reflect-metadata'
|
||||
|
||||
import { RevenueModificationRepositoryInterface } from '../../Revenue/RevenueModificationRepositoryInterface'
|
||||
import { StatisticsMeasure } from '../../Statistics/StatisticsMeasure'
|
||||
import { StatisticsStoreInterface } from '../../Statistics/StatisticsStoreInterface'
|
||||
import { Period } from '../../Time/Period'
|
||||
|
||||
import { CalculateMonthlyRecurringRevenue } from './CalculateMonthlyRecurringRevenue'
|
||||
|
||||
describe('CalculateMonthlyRecurringRevenue', () => {
|
||||
let revenueModificationRepository: RevenueModificationRepositoryInterface
|
||||
let statisticsStore: StatisticsStoreInterface
|
||||
|
||||
const createUseCase = () => new CalculateMonthlyRecurringRevenue(revenueModificationRepository, statisticsStore)
|
||||
|
||||
beforeEach(() => {
|
||||
revenueModificationRepository = {} as jest.Mocked<RevenueModificationRepositoryInterface>
|
||||
revenueModificationRepository.sumMRRDiff = jest.fn().mockReturnValue(123.45)
|
||||
|
||||
statisticsStore = {} as jest.Mocked<StatisticsStoreInterface>
|
||||
statisticsStore.setMeasure = jest.fn()
|
||||
})
|
||||
|
||||
it('should calculate the MRR diff and persist it as a statistic', async () => {
|
||||
await createUseCase().execute({})
|
||||
|
||||
expect(statisticsStore.setMeasure).toHaveBeenCalledWith(StatisticsMeasure.MRR, 123.45, [
|
||||
Period.Today,
|
||||
Period.ThisMonth,
|
||||
Period.ThisYear,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { SubscriptionBillingFrequency, SubscriptionName } from '@standardnotes/common'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import TYPES from '../../../Bootstrap/Types'
|
||||
import { Result } from '../../Core/Result'
|
||||
import { MonthlyRevenue } from '../../Revenue/MonthlyRevenue'
|
||||
import { RevenueModificationRepositoryInterface } from '../../Revenue/RevenueModificationRepositoryInterface'
|
||||
import { StatisticsMeasure } from '../../Statistics/StatisticsMeasure'
|
||||
import { StatisticsStoreInterface } from '../../Statistics/StatisticsStoreInterface'
|
||||
import { Period } from '../../Time/Period'
|
||||
import { DomainUseCaseInterface } from '../DomainUseCaseInterface'
|
||||
import { CalculateMonthlyRecurringRevenueDTO } from './CalculateMonthlyRecurringRevenueDTO'
|
||||
|
||||
@injectable()
|
||||
export class CalculateMonthlyRecurringRevenue implements DomainUseCaseInterface<MonthlyRevenue> {
|
||||
constructor(
|
||||
@inject(TYPES.RevenueModificationRepository)
|
||||
private revenueModificationRepository: RevenueModificationRepositoryInterface,
|
||||
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
|
||||
) {}
|
||||
|
||||
async execute(_dto: CalculateMonthlyRecurringRevenueDTO): Promise<Result<MonthlyRevenue>> {
|
||||
const mrrDiff = await this.revenueModificationRepository.sumMRRDiff({})
|
||||
|
||||
await this.statisticsStore.setMeasure(StatisticsMeasure.MRR, mrrDiff, [
|
||||
Period.Today,
|
||||
Period.ThisMonth,
|
||||
Period.ThisYear,
|
||||
])
|
||||
|
||||
const monthlyPlansMrrDiff = await this.revenueModificationRepository.sumMRRDiff({
|
||||
billingFrequency: SubscriptionBillingFrequency.Monthly,
|
||||
})
|
||||
|
||||
await this.statisticsStore.setMeasure(StatisticsMeasure.MonthlyPlansMRR, monthlyPlansMrrDiff, [
|
||||
Period.Today,
|
||||
Period.ThisMonth,
|
||||
Period.ThisYear,
|
||||
])
|
||||
|
||||
const annualPlansMrrDiff = await this.revenueModificationRepository.sumMRRDiff({
|
||||
billingFrequency: SubscriptionBillingFrequency.Annual,
|
||||
})
|
||||
|
||||
await this.statisticsStore.setMeasure(StatisticsMeasure.AnnualPlansMRR, annualPlansMrrDiff, [
|
||||
Period.Today,
|
||||
Period.ThisMonth,
|
||||
Period.ThisYear,
|
||||
])
|
||||
|
||||
const proPlansMrrDiff = await this.revenueModificationRepository.sumMRRDiff({
|
||||
planName: SubscriptionName.ProPlan,
|
||||
})
|
||||
|
||||
await this.statisticsStore.setMeasure(StatisticsMeasure.ProPlansMRR, proPlansMrrDiff, [
|
||||
Period.Today,
|
||||
Period.ThisMonth,
|
||||
Period.ThisYear,
|
||||
])
|
||||
|
||||
const plusPlansMrrDiff = await this.revenueModificationRepository.sumMRRDiff({
|
||||
planName: SubscriptionName.PlusPlan,
|
||||
})
|
||||
|
||||
await this.statisticsStore.setMeasure(StatisticsMeasure.PlusPlansMRR, plusPlansMrrDiff, [
|
||||
Period.Today,
|
||||
Period.ThisMonth,
|
||||
Period.ThisYear,
|
||||
])
|
||||
|
||||
return MonthlyRevenue.create(mrrDiff)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable @typescript-eslint/no-empty-interface */
|
||||
export interface CalculateMonthlyRecurringRevenueDTO {}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Result } from '../Core/Result'
|
||||
|
||||
export interface DomainUseCaseInterface<T> {
|
||||
execute(...args: any[]): Promise<Result<T>>
|
||||
}
|
||||
@@ -12,7 +12,11 @@ describe('GetUserAnalyticsId', () => {
|
||||
const createUseCase = () => new GetUserAnalyticsId(analyticsEntityRepository)
|
||||
|
||||
beforeEach(() => {
|
||||
analyticsEntity = { id: 123 } as jest.Mocked<AnalyticsEntity>
|
||||
analyticsEntity = {
|
||||
id: 123,
|
||||
userUuid: '1-2-3',
|
||||
userEmail: 'test@test.te',
|
||||
} as jest.Mocked<AnalyticsEntity>
|
||||
|
||||
analyticsEntityRepository = {} as jest.Mocked<AnalyticsEntityRepositoryInterface>
|
||||
analyticsEntityRepository.findOneByUserUuid = jest.fn().mockReturnValue(analyticsEntity)
|
||||
@@ -20,11 +24,11 @@ describe('GetUserAnalyticsId', () => {
|
||||
})
|
||||
|
||||
it('should return analytics id for a user by uuid', async () => {
|
||||
expect(await createUseCase().execute({ userUuid: '1-2-3' })).toEqual({ analyticsId: 123 })
|
||||
expect(await (await createUseCase().execute({ userUuid: '1-2-3' })).analyticsId).toEqual(123)
|
||||
})
|
||||
|
||||
it('should return analytics id for a user by email', async () => {
|
||||
expect(await createUseCase().execute({ userEmail: 'test@test.te' })).toEqual({ analyticsId: 123 })
|
||||
expect(await (await createUseCase().execute({ userEmail: 'test@test.te' })).analyticsId).toEqual(123)
|
||||
})
|
||||
|
||||
it('should throw error if user is missing analytics entity', async () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { inject, injectable } from 'inversify'
|
||||
import TYPES from '../../../Bootstrap/Types'
|
||||
import { Email } from '../../Common/Email'
|
||||
import { Uuid } from '../../Common/Uuid'
|
||||
import { AnalyticsEntityRepositoryInterface } from '../../Entity/AnalyticsEntityRepositoryInterface'
|
||||
import { UseCaseInterface } from '../UseCaseInterface'
|
||||
import { GetUserAnalyticsIdDTO } from './GetUserAnalyticsIdDTO'
|
||||
@@ -25,6 +27,8 @@ export class GetUserAnalyticsId implements UseCaseInterface {
|
||||
|
||||
return {
|
||||
analyticsId: analyticsEntity.id,
|
||||
userUuid: Uuid.create(analyticsEntity.userUuid).getValue(),
|
||||
userEmail: Email.create(analyticsEntity.userEmail).getValue(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { Email } from '../../Common/Email'
|
||||
import { Uuid } from '../../Common/Uuid'
|
||||
|
||||
export type GetUserAnalyticsIdResponse = {
|
||||
analyticsId: number
|
||||
userEmail: Email
|
||||
userUuid: Uuid
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import 'reflect-metadata'
|
||||
|
||||
import { TimerInterface } from '@standardnotes/time'
|
||||
|
||||
import { Email } from '../../Common/Email'
|
||||
import { Uuid } from '../../Common/Uuid'
|
||||
import { MonthlyRevenue } from '../../Revenue/MonthlyRevenue'
|
||||
|
||||
import { RevenueModification } from '../../Revenue/RevenueModification'
|
||||
import { RevenueModificationRepositoryInterface } from '../../Revenue/RevenueModificationRepositoryInterface'
|
||||
import { SubscriptionEventType } from '../../Subscription/SubscriptionEventType'
|
||||
import { SubscriptionPlanName } from '../../Subscription/SubscriptionPlanName'
|
||||
import { SaveRevenueModification } from './SaveRevenueModification'
|
||||
import { User } from '../../User/User'
|
||||
import { Result } from '../../Core/Result'
|
||||
import { Subscription } from '../../Subscription/Subscription'
|
||||
|
||||
describe('SaveRevenueModification', () => {
|
||||
let revenueModificationRepository: RevenueModificationRepositoryInterface
|
||||
let previousMonthlyRevenueModification: RevenueModification
|
||||
let timer: TimerInterface
|
||||
|
||||
const createUseCase = () => new SaveRevenueModification(revenueModificationRepository, timer)
|
||||
|
||||
beforeEach(() => {
|
||||
const previousMonthlyRevenue = {
|
||||
value: 2,
|
||||
} as jest.Mocked<MonthlyRevenue>
|
||||
previousMonthlyRevenueModification = {
|
||||
props: {},
|
||||
} as jest.Mocked<RevenueModification>
|
||||
previousMonthlyRevenueModification.props.newMonthlyRevenue = previousMonthlyRevenue
|
||||
|
||||
revenueModificationRepository = {} as jest.Mocked<RevenueModificationRepositoryInterface>
|
||||
revenueModificationRepository.findLastByUserUuid = jest.fn().mockReturnValue(previousMonthlyRevenueModification)
|
||||
revenueModificationRepository.save = jest.fn()
|
||||
|
||||
timer = {} as jest.Mocked<TimerInterface>
|
||||
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1)
|
||||
})
|
||||
|
||||
it('should persist a revenue modification for subscription purchased event', async () => {
|
||||
const revenueOrError = await createUseCase().execute({
|
||||
billingFrequency: 1,
|
||||
eventType: SubscriptionEventType.create('SUBSCRIPTION_PURCHASED').getValue(),
|
||||
newSubscriber: true,
|
||||
payedAmount: 12.99,
|
||||
planName: SubscriptionPlanName.create('PRO_PLAN').getValue(),
|
||||
subscriptionId: 1234,
|
||||
userEmail: Email.create('test@test.te').getValue(),
|
||||
userUuid: Uuid.create('1-2-3').getValue(),
|
||||
})
|
||||
|
||||
expect(revenueOrError.isFailed()).toBeFalsy()
|
||||
const revenue = revenueOrError.getValue()
|
||||
|
||||
expect(revenue.props.newMonthlyRevenue.value).toEqual(12.99)
|
||||
})
|
||||
|
||||
it('should persist a revenue modification for subscription expired event', async () => {
|
||||
const revenueOrError = await createUseCase().execute({
|
||||
billingFrequency: 1,
|
||||
eventType: SubscriptionEventType.create('SUBSCRIPTION_EXPIRED').getValue(),
|
||||
newSubscriber: true,
|
||||
payedAmount: 12.99,
|
||||
planName: SubscriptionPlanName.create('PRO_PLAN').getValue(),
|
||||
subscriptionId: 1234,
|
||||
userEmail: Email.create('test@test.te').getValue(),
|
||||
userUuid: Uuid.create('1-2-3').getValue(),
|
||||
})
|
||||
|
||||
expect(revenueOrError.isFailed()).toBeFalsy()
|
||||
const revenue = revenueOrError.getValue()
|
||||
|
||||
expect(revenue.props.newMonthlyRevenue.value).toEqual(0)
|
||||
})
|
||||
|
||||
it('should persist a revenue modification for subscription cancelled event', async () => {
|
||||
const revenueOrError = await createUseCase().execute({
|
||||
billingFrequency: 1,
|
||||
eventType: SubscriptionEventType.create('SUBSCRIPTION_CANCELLED').getValue(),
|
||||
newSubscriber: true,
|
||||
payedAmount: 2,
|
||||
planName: SubscriptionPlanName.create('PRO_PLAN').getValue(),
|
||||
subscriptionId: 1234,
|
||||
userEmail: Email.create('test@test.te').getValue(),
|
||||
userUuid: Uuid.create('1-2-3').getValue(),
|
||||
})
|
||||
|
||||
expect(revenueOrError.isFailed()).toBeFalsy()
|
||||
const revenue = revenueOrError.getValue()
|
||||
|
||||
expect(revenue.props.newMonthlyRevenue.value).toEqual(2)
|
||||
})
|
||||
|
||||
it('should persist a revenue modification for subscription purchased event if previous revenue modification did not exist', async () => {
|
||||
revenueModificationRepository.findLastByUserUuid = jest.fn().mockReturnValue(null)
|
||||
|
||||
const revenueOrError = await createUseCase().execute({
|
||||
billingFrequency: 1,
|
||||
eventType: SubscriptionEventType.create('SUBSCRIPTION_PURCHASED').getValue(),
|
||||
newSubscriber: true,
|
||||
payedAmount: 12.99,
|
||||
planName: SubscriptionPlanName.create('PRO_PLAN').getValue(),
|
||||
subscriptionId: 1234,
|
||||
userEmail: Email.create('test@test.te').getValue(),
|
||||
userUuid: Uuid.create('1-2-3').getValue(),
|
||||
})
|
||||
|
||||
expect(revenueOrError.isFailed()).toBeFalsy()
|
||||
const revenue = revenueOrError.getValue()
|
||||
|
||||
expect(revenue.props.newMonthlyRevenue.value).toEqual(12.99)
|
||||
})
|
||||
|
||||
it('should not persist a revenue modification if failed to create user', async () => {
|
||||
const mock = jest.spyOn(User, 'create')
|
||||
mock.mockReturnValue(Result.fail('Oops'))
|
||||
|
||||
const revenueOrError = await createUseCase().execute({
|
||||
billingFrequency: 1,
|
||||
eventType: SubscriptionEventType.create('SUBSCRIPTION_PURCHASED').getValue(),
|
||||
newSubscriber: true,
|
||||
payedAmount: 12.99,
|
||||
planName: SubscriptionPlanName.create('PRO_PLAN').getValue(),
|
||||
subscriptionId: 1234,
|
||||
userEmail: Email.create('test@test.te').getValue(),
|
||||
userUuid: Uuid.create('1-2-3').getValue(),
|
||||
})
|
||||
|
||||
expect(revenueOrError.isFailed()).toBeTruthy()
|
||||
|
||||
mock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not persist a revenue modification if failed to create a subscription', async () => {
|
||||
const mock = jest.spyOn(Subscription, 'create')
|
||||
mock.mockReturnValue(Result.fail('Oops'))
|
||||
|
||||
const revenueOrError = await createUseCase().execute({
|
||||
billingFrequency: 1,
|
||||
eventType: SubscriptionEventType.create('SUBSCRIPTION_PURCHASED').getValue(),
|
||||
newSubscriber: true,
|
||||
payedAmount: 12.99,
|
||||
planName: SubscriptionPlanName.create('PRO_PLAN').getValue(),
|
||||
subscriptionId: 1234,
|
||||
userEmail: Email.create('test@test.te').getValue(),
|
||||
userUuid: Uuid.create('1-2-3').getValue(),
|
||||
})
|
||||
|
||||
expect(revenueOrError.isFailed()).toBeTruthy()
|
||||
|
||||
mock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not persist a revenue modification if failed to create a previous monthly revenue', async () => {
|
||||
const mock = jest.spyOn(MonthlyRevenue, 'create')
|
||||
mock.mockReturnValue(Result.fail('Oops'))
|
||||
|
||||
const revenueOrError = await createUseCase().execute({
|
||||
billingFrequency: 1,
|
||||
eventType: SubscriptionEventType.create('SUBSCRIPTION_PURCHASED').getValue(),
|
||||
newSubscriber: true,
|
||||
payedAmount: 12.99,
|
||||
planName: SubscriptionPlanName.create('PRO_PLAN').getValue(),
|
||||
subscriptionId: 1234,
|
||||
userEmail: Email.create('test@test.te').getValue(),
|
||||
userUuid: Uuid.create('1-2-3').getValue(),
|
||||
})
|
||||
|
||||
expect(revenueOrError.isFailed()).toBeTruthy()
|
||||
|
||||
mock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not persist a revenue modification if failed to create a next monthly revenue', async () => {
|
||||
const mock = jest.spyOn(MonthlyRevenue, 'create')
|
||||
mock.mockReturnValueOnce(Result.ok()).mockReturnValueOnce(Result.fail('Oops'))
|
||||
|
||||
const revenueOrError = await createUseCase().execute({
|
||||
billingFrequency: 1,
|
||||
eventType: SubscriptionEventType.create('SUBSCRIPTION_PURCHASED').getValue(),
|
||||
newSubscriber: true,
|
||||
payedAmount: 12.99,
|
||||
planName: SubscriptionPlanName.create('PRO_PLAN').getValue(),
|
||||
subscriptionId: 1234,
|
||||
userEmail: Email.create('test@test.te').getValue(),
|
||||
userUuid: Uuid.create('1-2-3').getValue(),
|
||||
})
|
||||
|
||||
expect(revenueOrError.isFailed()).toBeTruthy()
|
||||
|
||||
mock.mockRestore()
|
||||
})
|
||||
|
||||
it('should not persist a revenue modification if failed to create it', async () => {
|
||||
const mock = jest.spyOn(RevenueModification, 'create')
|
||||
mock.mockReturnValue(Result.fail('Oops'))
|
||||
|
||||
const revenueOrError = await createUseCase().execute({
|
||||
billingFrequency: 1,
|
||||
eventType: SubscriptionEventType.create('SUBSCRIPTION_PURCHASED').getValue(),
|
||||
newSubscriber: true,
|
||||
payedAmount: 12.99,
|
||||
planName: SubscriptionPlanName.create('PRO_PLAN').getValue(),
|
||||
subscriptionId: 1234,
|
||||
userEmail: Email.create('test@test.te').getValue(),
|
||||
userUuid: Uuid.create('1-2-3').getValue(),
|
||||
})
|
||||
|
||||
expect(revenueOrError.isFailed()).toBeTruthy()
|
||||
|
||||
mock.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { inject, injectable } from 'inversify'
|
||||
|
||||
import TYPES from '../../../Bootstrap/Types'
|
||||
import { UniqueEntityId } from '../../Core/UniqueEntityId'
|
||||
import { MonthlyRevenue } from '../../Revenue/MonthlyRevenue'
|
||||
import { RevenueModification } from '../../Revenue/RevenueModification'
|
||||
import { RevenueModificationRepositoryInterface } from '../../Revenue/RevenueModificationRepositoryInterface'
|
||||
import { Subscription } from '../../Subscription/Subscription'
|
||||
import { User } from '../../User/User'
|
||||
import { Result } from '../../Core/Result'
|
||||
import { DomainUseCaseInterface } from '../DomainUseCaseInterface'
|
||||
import { SaveRevenueModificationDTO } from './SaveRevenueModificationDTO'
|
||||
import { TimerInterface } from '@standardnotes/time'
|
||||
import { SubscriptionEventType } from '../../Subscription/SubscriptionEventType'
|
||||
|
||||
@injectable()
|
||||
export class SaveRevenueModification implements DomainUseCaseInterface<RevenueModification> {
|
||||
constructor(
|
||||
@inject(TYPES.RevenueModificationRepository)
|
||||
private revenueModificationRepository: RevenueModificationRepositoryInterface,
|
||||
@inject(TYPES.Timer) private timer: TimerInterface,
|
||||
) {}
|
||||
|
||||
async execute(dto: SaveRevenueModificationDTO): Promise<Result<RevenueModification>> {
|
||||
const userOrError = User.create(
|
||||
{
|
||||
email: dto.userEmail,
|
||||
},
|
||||
new UniqueEntityId(dto.userUuid.value),
|
||||
)
|
||||
if (userOrError.isFailed()) {
|
||||
return Result.fail<RevenueModification>(userOrError.getError())
|
||||
}
|
||||
const user = userOrError.getValue()
|
||||
|
||||
const subscriptionOrError = Subscription.create(
|
||||
{
|
||||
isFirstSubscriptionForUser: dto.newSubscriber,
|
||||
payedAmount: dto.payedAmount,
|
||||
planName: dto.planName,
|
||||
billingFrequency: dto.billingFrequency,
|
||||
},
|
||||
new UniqueEntityId(dto.subscriptionId),
|
||||
)
|
||||
if (subscriptionOrError.isFailed()) {
|
||||
return Result.fail<RevenueModification>(subscriptionOrError.getError())
|
||||
}
|
||||
const subscription = subscriptionOrError.getValue()
|
||||
|
||||
const previousMonthlyRevenueOrError = MonthlyRevenue.create(0)
|
||||
if (previousMonthlyRevenueOrError.isFailed()) {
|
||||
return Result.fail<RevenueModification>(previousMonthlyRevenueOrError.getError())
|
||||
}
|
||||
let previousMonthlyRevenue = previousMonthlyRevenueOrError.getValue()
|
||||
|
||||
const previousRevenueModification = await this.revenueModificationRepository.findLastByUserUuid(dto.userUuid)
|
||||
if (previousRevenueModification !== null) {
|
||||
previousMonthlyRevenue = previousRevenueModification.props.newMonthlyRevenue
|
||||
}
|
||||
const newMonthlyRevenueOrError = this.calculateNewMonthlyRevenue(
|
||||
subscription,
|
||||
previousMonthlyRevenue,
|
||||
dto.eventType,
|
||||
)
|
||||
if (newMonthlyRevenueOrError.isFailed()) {
|
||||
return Result.fail<RevenueModification>(newMonthlyRevenueOrError.getError())
|
||||
}
|
||||
const newMonthlyRevenue = newMonthlyRevenueOrError.getValue()
|
||||
|
||||
const revenueModificationOrError = RevenueModification.create({
|
||||
eventType: dto.eventType,
|
||||
subscription,
|
||||
user,
|
||||
previousMonthlyRevenue,
|
||||
newMonthlyRevenue,
|
||||
createdAt: this.timer.getTimestampInMicroseconds(),
|
||||
})
|
||||
|
||||
if (revenueModificationOrError.isFailed()) {
|
||||
return Result.fail<RevenueModification>(revenueModificationOrError.getError())
|
||||
}
|
||||
const revenueModification = revenueModificationOrError.getValue()
|
||||
|
||||
await this.revenueModificationRepository.save(revenueModification)
|
||||
|
||||
return Result.ok<RevenueModification>(revenueModification)
|
||||
}
|
||||
|
||||
private calculateNewMonthlyRevenue(
|
||||
subscription: Subscription,
|
||||
previousMonthlyRevenue: MonthlyRevenue,
|
||||
eventType: SubscriptionEventType,
|
||||
): Result<MonthlyRevenue> {
|
||||
let revenue = 0
|
||||
switch (eventType.value) {
|
||||
case 'SUBSCRIPTION_PURCHASED':
|
||||
case 'SUBSCRIPTION_RENEWED':
|
||||
case 'SUBSCRIPTION_DATA_MIGRATED':
|
||||
revenue = subscription.props.payedAmount / subscription.props.billingFrequency
|
||||
break
|
||||
case 'SUBSCRIPTION_EXPIRED':
|
||||
case 'SUBSCRIPTION_REFUNDED':
|
||||
revenue = 0
|
||||
break
|
||||
case 'SUBSCRIPTION_CANCELLED':
|
||||
revenue = previousMonthlyRevenue.value
|
||||
break
|
||||
}
|
||||
|
||||
const monthlyRevenueOrError = MonthlyRevenue.create(revenue)
|
||||
|
||||
if (monthlyRevenueOrError.isFailed()) {
|
||||
return Result.fail<MonthlyRevenue>(monthlyRevenueOrError.getError())
|
||||
}
|
||||
|
||||
return Result.ok<MonthlyRevenue>(monthlyRevenueOrError.getValue())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Email } from '../../Common/Email'
|
||||
import { Uuid } from '../../Common/Uuid'
|
||||
import { SubscriptionEventType } from '../../Subscription/SubscriptionEventType'
|
||||
import { SubscriptionPlanName } from '../../Subscription/SubscriptionPlanName'
|
||||
|
||||
export interface SaveRevenueModificationDTO {
|
||||
eventType: SubscriptionEventType
|
||||
payedAmount: number
|
||||
planName: SubscriptionPlanName
|
||||
newSubscriber: boolean
|
||||
userUuid: Uuid
|
||||
userEmail: Email
|
||||
subscriptionId: number
|
||||
billingFrequency: number
|
||||
}
|
||||
12
packages/analytics/src/Domain/User/User.spec.ts
Normal file
12
packages/analytics/src/Domain/User/User.spec.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Email } from '../Common/Email'
|
||||
import { User } from './User'
|
||||
|
||||
describe('User', () => {
|
||||
it('should create an entity', () => {
|
||||
const user = User.create({
|
||||
email: Email.create('test@test.te').getValue(),
|
||||
}).getValue()
|
||||
|
||||
expect(user.id.toString()).toHaveLength(36)
|
||||
})
|
||||
})
|
||||
18
packages/analytics/src/Domain/User/User.ts
Normal file
18
packages/analytics/src/Domain/User/User.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Entity } from '../Core/Entity'
|
||||
import { Result } from '../Core/Result'
|
||||
import { UniqueEntityId } from '../Core/UniqueEntityId'
|
||||
import { UserProps } from './UserProps'
|
||||
|
||||
export class User extends Entity<UserProps> {
|
||||
get id(): UniqueEntityId {
|
||||
return this._id
|
||||
}
|
||||
|
||||
private constructor(props: UserProps, id?: UniqueEntityId) {
|
||||
super(props, id)
|
||||
}
|
||||
|
||||
public static create(props: UserProps, id?: UniqueEntityId): Result<User> {
|
||||
return Result.ok<User>(new User(props, id))
|
||||
}
|
||||
}
|
||||
5
packages/analytics/src/Domain/User/UserProps.ts
Normal file
5
packages/analytics/src/Domain/User/UserProps.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Email } from '../Common/Email'
|
||||
|
||||
export interface UserProps {
|
||||
email: Email
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export * from './Analytics/AnalyticsActivity'
|
||||
export * from './Analytics/AnalyticsStoreInterface'
|
||||
export * from './Statistics/StatisticsMeasure'
|
||||
export * from './Statistics/StatisticsStoreInterface'
|
||||
export * from './Time/Period'
|
||||
export * from './Time/PeriodKeyGenerator'
|
||||
export * from './Time/PeriodKeyGeneratorInterface'
|
||||
@@ -1,60 +0,0 @@
|
||||
import 'reflect-metadata'
|
||||
|
||||
import { Repository, SelectQueryBuilder } from 'typeorm'
|
||||
|
||||
import { AnalyticsEntity } from '../../Domain/Entity/AnalyticsEntity'
|
||||
|
||||
import { MySQLAnalyticsEntityRepository } from './MySQLAnalyticsEntityRepository'
|
||||
|
||||
describe('MySQLAnalyticsEntityRepository', () => {
|
||||
let ormRepository: Repository<AnalyticsEntity>
|
||||
let analyticsEntity: AnalyticsEntity
|
||||
let queryBuilder: SelectQueryBuilder<AnalyticsEntity>
|
||||
|
||||
const createRepository = () => new MySQLAnalyticsEntityRepository(ormRepository)
|
||||
|
||||
beforeEach(() => {
|
||||
analyticsEntity = {} as jest.Mocked<AnalyticsEntity>
|
||||
|
||||
queryBuilder = {} as jest.Mocked<SelectQueryBuilder<AnalyticsEntity>>
|
||||
|
||||
ormRepository = {} as jest.Mocked<Repository<AnalyticsEntity>>
|
||||
ormRepository.save = jest.fn()
|
||||
ormRepository.remove = jest.fn()
|
||||
ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder)
|
||||
})
|
||||
|
||||
it('should save', async () => {
|
||||
await createRepository().save(analyticsEntity)
|
||||
|
||||
expect(ormRepository.save).toHaveBeenCalledWith(analyticsEntity)
|
||||
})
|
||||
|
||||
it('should remove', async () => {
|
||||
await createRepository().remove(analyticsEntity)
|
||||
|
||||
expect(ormRepository.remove).toHaveBeenCalledWith(analyticsEntity)
|
||||
})
|
||||
|
||||
it('should find one by user uuid', async () => {
|
||||
queryBuilder.where = jest.fn().mockReturnThis()
|
||||
queryBuilder.getOne = jest.fn().mockReturnValue(analyticsEntity)
|
||||
|
||||
const result = await createRepository().findOneByUserUuid('123')
|
||||
|
||||
expect(queryBuilder.where).toHaveBeenCalledWith('analytics_entity.user_uuid = :userUuid', { userUuid: '123' })
|
||||
|
||||
expect(result).toEqual(analyticsEntity)
|
||||
})
|
||||
|
||||
it('should find one by user email', async () => {
|
||||
queryBuilder.where = jest.fn().mockReturnThis()
|
||||
queryBuilder.getOne = jest.fn().mockReturnValue(analyticsEntity)
|
||||
|
||||
const result = await createRepository().findOneByUserEmail('test@test.te')
|
||||
|
||||
expect(queryBuilder.where).toHaveBeenCalledWith('analytics_entity.user_email = :email', { email: 'test@test.te' })
|
||||
|
||||
expect(result).toEqual(analyticsEntity)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { inject, injectable } from 'inversify'
|
||||
import { Repository } from 'typeorm'
|
||||
|
||||
import TYPES from '../../Bootstrap/Types'
|
||||
import { Uuid } from '../../Domain/Common/Uuid'
|
||||
import { MapInterface } from '../../Domain/Map/MapInterface'
|
||||
import { RevenueModification } from '../../Domain/Revenue/RevenueModification'
|
||||
import { RevenueModificationRepositoryInterface } from '../../Domain/Revenue/RevenueModificationRepositoryInterface'
|
||||
import { TypeORMRevenueModification } from '../TypeORM/TypeORMRevenueModification'
|
||||
|
||||
@injectable()
|
||||
export class MySQLRevenueModificationRepository implements RevenueModificationRepositoryInterface {
|
||||
constructor(
|
||||
@inject(TYPES.ORMRevenueModificationRepository)
|
||||
private ormRepository: Repository<TypeORMRevenueModification>,
|
||||
@inject(TYPES.RevenueModificationMap)
|
||||
private revenueModificationMap: MapInterface<RevenueModification, TypeORMRevenueModification>,
|
||||
) {}
|
||||
|
||||
async sumMRRDiff(dto: { planName?: string; billingFrequency?: number }): Promise<number> {
|
||||
const query = this.ormRepository.createQueryBuilder().select('sum(new_mrr - previous_mrr)', 'mrrDiff')
|
||||
|
||||
if (dto.planName !== undefined) {
|
||||
query.where('subscription_plan = :planName', { planName: dto.planName })
|
||||
}
|
||||
if (dto.billingFrequency !== undefined) {
|
||||
query.where('billing_frequency = :billingFrequency', { billingFrequency: dto.billingFrequency })
|
||||
}
|
||||
|
||||
const result = await query.getRawOne()
|
||||
|
||||
if (result === undefined) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return +(+result.mrrDiff).toFixed(2)
|
||||
}
|
||||
|
||||
async findLastByUserUuid(userUuid: Uuid): Promise<RevenueModification | null> {
|
||||
const persistence = await this.ormRepository
|
||||
.createQueryBuilder()
|
||||
.where('user_uuid = :userUuid', { userUuid: userUuid.value })
|
||||
.orderBy('created_at', 'DESC')
|
||||
.limit(1)
|
||||
.getOne()
|
||||
|
||||
if (persistence === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.revenueModificationMap.toDomain(persistence)
|
||||
}
|
||||
|
||||
async save(revenueModification: RevenueModification): Promise<RevenueModification> {
|
||||
let persistence = this.revenueModificationMap.toPersistence(revenueModification)
|
||||
|
||||
persistence = await this.ormRepository.save(persistence)
|
||||
|
||||
return this.revenueModificationMap.toDomain(persistence)
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import * as IORedis from 'ioredis'
|
||||
import { Period } from '../../Domain'
|
||||
import { AnalyticsActivity } from '../../Domain/Analytics/AnalyticsActivity'
|
||||
import { PeriodKeyGeneratorInterface } from '../../Domain/Time/PeriodKeyGeneratorInterface'
|
||||
|
||||
import { RedisAnalyticsStore } from './RedisAnalyticsStore'
|
||||
|
||||
describe('RedisAnalyticsStore', () => {
|
||||
let redisClient: IORedis.Redis
|
||||
let pipeline: IORedis.Pipeline
|
||||
let periodKeyGenerator: PeriodKeyGeneratorInterface
|
||||
|
||||
const createStore = () => new RedisAnalyticsStore(periodKeyGenerator, redisClient)
|
||||
|
||||
beforeEach(() => {
|
||||
pipeline = {} as jest.Mocked<IORedis.Pipeline>
|
||||
pipeline.incr = jest.fn()
|
||||
pipeline.setbit = jest.fn()
|
||||
pipeline.exec = jest.fn()
|
||||
|
||||
redisClient = {} as jest.Mocked<IORedis.Redis>
|
||||
redisClient.pipeline = jest.fn().mockReturnValue(pipeline)
|
||||
redisClient.incr = jest.fn()
|
||||
redisClient.setbit = jest.fn()
|
||||
redisClient.getbit = jest.fn().mockReturnValue(1)
|
||||
redisClient.bitop = jest.fn()
|
||||
redisClient.expire = jest.fn()
|
||||
|
||||
periodKeyGenerator = {} as jest.Mocked<PeriodKeyGeneratorInterface>
|
||||
periodKeyGenerator.getPeriodKey = jest.fn().mockReturnValue('period-key')
|
||||
})
|
||||
|
||||
it('should calculate total count over time of activities', async () => {
|
||||
redisClient.bitcount = jest.fn().mockReturnValue(70)
|
||||
|
||||
periodKeyGenerator.getDiscretePeriodKeys = jest.fn().mockReturnValue(['2022-4-24', '2022-4-25', '2022-4-26'])
|
||||
|
||||
await createStore().calculateActivityTotalCountOverTime(AnalyticsActivity.EditingItems, Period.Last30Days)
|
||||
|
||||
expect(redisClient.bitop).toHaveBeenCalledTimes(1)
|
||||
expect(redisClient.bitop).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'OR',
|
||||
'bitmap:action:editing-items:timespan:2022-4-24-2022-4-26',
|
||||
'bitmap:action:editing-items:timespan:2022-4-24',
|
||||
'bitmap:action:editing-items:timespan:2022-4-25',
|
||||
'bitmap:action:editing-items:timespan:2022-4-26',
|
||||
)
|
||||
expect(redisClient.bitcount).toHaveBeenCalledWith('bitmap:action:editing-items:timespan:2022-4-24-2022-4-26')
|
||||
})
|
||||
|
||||
it('should not calculate total count over time of activities if period is unsupported', async () => {
|
||||
let caughtError = null
|
||||
try {
|
||||
await createStore().calculateActivityTotalCountOverTime(AnalyticsActivity.EditingItems, Period.LastWeek)
|
||||
} catch (error) {
|
||||
caughtError = error
|
||||
}
|
||||
|
||||
expect(caughtError).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should calculate total count changes of activities', async () => {
|
||||
periodKeyGenerator.getDiscretePeriodKeys = jest.fn().mockReturnValue(['2022-4-24', '2022-4-25', '2022-4-26'])
|
||||
|
||||
redisClient.bitcount = jest.fn().mockReturnValueOnce(70).mockReturnValueOnce(71).mockReturnValueOnce(72)
|
||||
|
||||
expect(
|
||||
await createStore().calculateActivityChangesTotalCount(AnalyticsActivity.EditingItems, Period.Last30Days),
|
||||
).toEqual([
|
||||
{
|
||||
periodKey: '2022-4-24',
|
||||
totalCount: 70,
|
||||
},
|
||||
{
|
||||
periodKey: '2022-4-25',
|
||||
totalCount: 71,
|
||||
},
|
||||
{
|
||||
periodKey: '2022-4-26',
|
||||
totalCount: 72,
|
||||
},
|
||||
])
|
||||
|
||||
expect(redisClient.bitcount).toHaveBeenNthCalledWith(1, 'bitmap:action:editing-items:timespan:2022-4-24')
|
||||
expect(redisClient.bitcount).toHaveBeenNthCalledWith(2, 'bitmap:action:editing-items:timespan:2022-4-25')
|
||||
expect(redisClient.bitcount).toHaveBeenNthCalledWith(3, 'bitmap:action:editing-items:timespan:2022-4-26')
|
||||
})
|
||||
|
||||
it('should throw error on calculating total count changes of activities on unsupported period', async () => {
|
||||
periodKeyGenerator.getDiscretePeriodKeys = jest.fn().mockReturnValue(['2022-4-24', '2022-4-25', '2022-4-26'])
|
||||
|
||||
redisClient.bitcount = jest.fn().mockReturnValueOnce(70).mockReturnValueOnce(71).mockReturnValueOnce(72)
|
||||
|
||||
let caughtError = null
|
||||
try {
|
||||
await createStore().calculateActivityChangesTotalCount(AnalyticsActivity.EditingItems, Period.LastWeek)
|
||||
} catch (error) {
|
||||
caughtError = error
|
||||
}
|
||||
|
||||
expect(caughtError).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should calculate total count of activities by period', async () => {
|
||||
redisClient.bitcount = jest.fn().mockReturnValue(70)
|
||||
|
||||
expect(await createStore().calculateActivityTotalCount(AnalyticsActivity.EditingItems, Period.Yesterday)).toEqual(
|
||||
70,
|
||||
)
|
||||
|
||||
expect(redisClient.bitcount).toHaveBeenCalledWith('bitmap:action:editing-items:timespan:period-key')
|
||||
})
|
||||
|
||||
it('should calculate total count of activities by period key', async () => {
|
||||
redisClient.bitcount = jest.fn().mockReturnValue(70)
|
||||
|
||||
expect(await createStore().calculateActivityTotalCount(AnalyticsActivity.EditingItems, '2022-10-03')).toEqual(70)
|
||||
|
||||
expect(redisClient.bitcount).toHaveBeenCalledWith('bitmap:action:editing-items:timespan:2022-10-03')
|
||||
})
|
||||
|
||||
it('should calculate activity retention', async () => {
|
||||
redisClient.bitcount = jest.fn().mockReturnValueOnce(7).mockReturnValueOnce(10)
|
||||
|
||||
expect(
|
||||
await createStore().calculateActivityRetention(
|
||||
AnalyticsActivity.EditingItems,
|
||||
Period.DayBeforeYesterday,
|
||||
Period.Yesterday,
|
||||
),
|
||||
).toEqual(70)
|
||||
|
||||
expect(redisClient.bitop).toHaveBeenCalledWith(
|
||||
'AND',
|
||||
'bitmap:action:editing-items-editing-items:timespan:period-key',
|
||||
'bitmap:action:editing-items:timespan:period-key',
|
||||
'bitmap:action:editing-items:timespan:period-key',
|
||||
)
|
||||
})
|
||||
|
||||
it('shoud tell if activity was done', async () => {
|
||||
await createStore().wasActivityDone(AnalyticsActivity.EditingItems, 123, Period.Yesterday)
|
||||
|
||||
expect(redisClient.getbit).toHaveBeenCalledWith('bitmap:action:editing-items:timespan:period-key', 123)
|
||||
})
|
||||
|
||||
it('should mark activity as done', async () => {
|
||||
await createStore().markActivity([AnalyticsActivity.EditingItems], 123, [Period.Today])
|
||||
|
||||
expect(pipeline.setbit).toBeCalledTimes(1)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(1, 'bitmap:action:editing-items:timespan:period-key', 123, 1)
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should mark activities as done', async () => {
|
||||
await createStore().markActivity([AnalyticsActivity.EditingItems, AnalyticsActivity.EmailUnbackedUpData], 123, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
])
|
||||
|
||||
expect(pipeline.setbit).toBeCalledTimes(4)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(1, 'bitmap:action:editing-items:timespan:period-key', 123, 1)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(2, 'bitmap:action:editing-items:timespan:period-key', 123, 1)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'bitmap:action:email-unbacked-up-data:timespan:period-key',
|
||||
123,
|
||||
1,
|
||||
)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'bitmap:action:email-unbacked-up-data:timespan:period-key',
|
||||
123,
|
||||
1,
|
||||
)
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should unmark activity as done', async () => {
|
||||
await createStore().unmarkActivity([AnalyticsActivity.EditingItems], 123, [Period.Today])
|
||||
|
||||
expect(pipeline.setbit).toBeCalledTimes(1)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(1, 'bitmap:action:editing-items:timespan:period-key', 123, 0)
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should unmark activities as done', async () => {
|
||||
await createStore().unmarkActivity([AnalyticsActivity.EditingItems, AnalyticsActivity.EmailUnbackedUpData], 123, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
])
|
||||
|
||||
expect(pipeline.setbit).toBeCalledTimes(4)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(1, 'bitmap:action:editing-items:timespan:period-key', 123, 0)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(2, 'bitmap:action:editing-items:timespan:period-key', 123, 0)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'bitmap:action:email-unbacked-up-data:timespan:period-key',
|
||||
123,
|
||||
0,
|
||||
)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'bitmap:action:email-unbacked-up-data:timespan:period-key',
|
||||
123,
|
||||
0,
|
||||
)
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,144 +0,0 @@
|
||||
import * as IORedis from 'ioredis'
|
||||
|
||||
import { Period, PeriodKeyGeneratorInterface } from '../../Domain'
|
||||
import { StatisticsMeasure } from '../../Domain/Statistics/StatisticsMeasure'
|
||||
|
||||
import { RedisStatisticsStore } from './RedisStatisticsStore'
|
||||
|
||||
describe('RedisStatisticsStore', () => {
|
||||
let redisClient: IORedis.Redis
|
||||
let periodKeyGenerator: PeriodKeyGeneratorInterface
|
||||
let pipeline: IORedis.Pipeline
|
||||
|
||||
const createStore = () => new RedisStatisticsStore(periodKeyGenerator, redisClient)
|
||||
|
||||
beforeEach(() => {
|
||||
pipeline = {} as jest.Mocked<IORedis.Pipeline>
|
||||
pipeline.incr = jest.fn()
|
||||
pipeline.incrbyfloat = jest.fn()
|
||||
pipeline.set = jest.fn()
|
||||
pipeline.setbit = jest.fn()
|
||||
pipeline.exec = jest.fn()
|
||||
|
||||
redisClient = {} as jest.Mocked<IORedis.Redis>
|
||||
redisClient.pipeline = jest.fn().mockReturnValue(pipeline)
|
||||
redisClient.incr = jest.fn()
|
||||
redisClient.setbit = jest.fn()
|
||||
redisClient.getbit = jest.fn().mockReturnValue(1)
|
||||
|
||||
periodKeyGenerator = {} as jest.Mocked<PeriodKeyGeneratorInterface>
|
||||
periodKeyGenerator.getPeriodKey = jest.fn().mockReturnValue('period-key')
|
||||
})
|
||||
|
||||
it('should get yesterday out of sync incidents', async () => {
|
||||
redisClient.get = jest.fn().mockReturnValue(1)
|
||||
|
||||
expect(await createStore().getYesterdayOutOfSyncIncidents()).toEqual(1)
|
||||
})
|
||||
|
||||
it('should default to 0 yesterday out of sync incidents', async () => {
|
||||
redisClient.get = jest.fn().mockReturnValue(null)
|
||||
|
||||
expect(await createStore().getYesterdayOutOfSyncIncidents()).toEqual(0)
|
||||
})
|
||||
|
||||
it('should get yesterday application version usage', async () => {
|
||||
redisClient.keys = jest
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
'count:action:application-request:1.2.3:timespan:2022-3-10',
|
||||
'count:action:application-request:2.3.4:timespan:2022-3-10',
|
||||
])
|
||||
redisClient.get = jest.fn().mockReturnValueOnce(3).mockReturnValueOnce(4)
|
||||
|
||||
expect(await createStore().getYesterdayApplicationUsage()).toEqual([
|
||||
{ count: 3, version: '1.2.3' },
|
||||
{ count: 4, version: '2.3.4' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should get yesterday snjs version usage', async () => {
|
||||
redisClient.keys = jest
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
'count:action:snjs-request:1.2.3:timespan:2022-3-10',
|
||||
'count:action:snjs-request:2.3.4:timespan:2022-3-10',
|
||||
])
|
||||
redisClient.get = jest.fn().mockReturnValueOnce(3).mockReturnValueOnce(4)
|
||||
|
||||
expect(await createStore().getYesterdaySNJSUsage()).toEqual([
|
||||
{ count: 3, version: '1.2.3' },
|
||||
{ count: 4, version: '2.3.4' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should increment application version usage', async () => {
|
||||
await createStore().incrementApplicationVersionUsage('1.2.3')
|
||||
|
||||
expect(pipeline.incr).toHaveBeenCalled()
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should increment snjs version usage', async () => {
|
||||
await createStore().incrementSNJSVersionUsage('1.2.3')
|
||||
|
||||
expect(pipeline.incr).toHaveBeenCalled()
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should increment out of sync incedent count', async () => {
|
||||
await createStore().incrementOutOfSyncIncidents()
|
||||
|
||||
expect(pipeline.incr).toHaveBeenCalled()
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set a value to a measure', async () => {
|
||||
await createStore().setMeasure(StatisticsMeasure.Income, 2, [Period.Today, Period.ThisMonth])
|
||||
|
||||
expect(pipeline.set).toHaveBeenCalledTimes(2)
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should increment measure by a value', async () => {
|
||||
await createStore().incrementMeasure(StatisticsMeasure.Income, 2, [Period.Today, Period.ThisMonth])
|
||||
|
||||
expect(pipeline.incr).toHaveBeenCalledTimes(2)
|
||||
expect(pipeline.incrbyfloat).toHaveBeenCalledTimes(2)
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should count a measurement average', async () => {
|
||||
redisClient.get = jest.fn().mockReturnValueOnce('5').mockReturnValueOnce('2')
|
||||
|
||||
expect(await createStore().getMeasureAverage(StatisticsMeasure.Income, Period.Today)).toEqual(2 / 5)
|
||||
})
|
||||
|
||||
it('should count a measurement average - 0 increments', async () => {
|
||||
redisClient.get = jest.fn().mockReturnValueOnce(null).mockReturnValueOnce(null)
|
||||
|
||||
expect(await createStore().getMeasureAverage(StatisticsMeasure.Income, Period.Today)).toEqual(0)
|
||||
})
|
||||
|
||||
it('should count a measurement average - 0 total value', async () => {
|
||||
redisClient.get = jest.fn().mockReturnValueOnce(5).mockReturnValueOnce(null)
|
||||
|
||||
expect(await createStore().getMeasureAverage(StatisticsMeasure.Income, Period.Today)).toEqual(0)
|
||||
})
|
||||
|
||||
it('should retrieve a measurement total for period', async () => {
|
||||
redisClient.get = jest.fn().mockReturnValueOnce(5)
|
||||
|
||||
expect(await createStore().getMeasureTotal(StatisticsMeasure.Income, Period.Today)).toEqual(5)
|
||||
|
||||
expect(redisClient.get).toHaveBeenCalledWith('count:measure:income:timespan:period-key')
|
||||
})
|
||||
|
||||
it('should retrieve a measurement total for period key', async () => {
|
||||
redisClient.get = jest.fn().mockReturnValueOnce(5)
|
||||
|
||||
expect(await createStore().getMeasureTotal(StatisticsMeasure.Income, '2022-10-03')).toEqual(5)
|
||||
|
||||
expect(redisClient.get).toHaveBeenCalledWith('count:measure:income:timespan:2022-10-03')
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,42 @@
|
||||
import * as IORedis from 'ioredis'
|
||||
|
||||
import { Period, PeriodKeyGeneratorInterface } from '../../Domain'
|
||||
import { StatisticsMeasure } from '../../Domain/Statistics/StatisticsMeasure'
|
||||
|
||||
import { StatisticsStoreInterface } from '../../Domain/Statistics/StatisticsStoreInterface'
|
||||
import { Period } from '../../Domain/Time/Period'
|
||||
import { PeriodKeyGeneratorInterface } from '../../Domain/Time/PeriodKeyGeneratorInterface'
|
||||
|
||||
export class RedisStatisticsStore implements StatisticsStoreInterface {
|
||||
constructor(private periodKeyGenerator: PeriodKeyGeneratorInterface, private redisClient: IORedis.Redis) {}
|
||||
|
||||
async calculateTotalCountOverPeriod(
|
||||
measure: StatisticsMeasure,
|
||||
period: Period,
|
||||
): Promise<{ periodKey: string; totalCount: number }[]> {
|
||||
if (
|
||||
![
|
||||
Period.Last30Days,
|
||||
Period.ThisYear,
|
||||
Period.Q1ThisYear,
|
||||
Period.Q2ThisYear,
|
||||
Period.Q3ThisYear,
|
||||
Period.Q4ThisYear,
|
||||
].includes(period)
|
||||
) {
|
||||
throw new Error(`Unsuporrted period: ${period}`)
|
||||
}
|
||||
const periodKeys = this.periodKeyGenerator.getDiscretePeriodKeys(period)
|
||||
const counts = []
|
||||
for (const periodKey of periodKeys) {
|
||||
counts.push({
|
||||
periodKey,
|
||||
totalCount: await this.getMeasureTotal(measure, periodKey),
|
||||
})
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
async getMeasureIncrementCounts(measure: StatisticsMeasure, period: Period): Promise<number> {
|
||||
const increments = await this.redisClient.get(
|
||||
`count:increments:${measure}:timespan:${this.periodKeyGenerator.getPeriodKey(period)}`,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm'
|
||||
|
||||
@Entity({ name: 'revenue_modifications' })
|
||||
export class TypeORMRevenueModification {
|
||||
@PrimaryColumn({
|
||||
type: 'uuid',
|
||||
length: 36,
|
||||
})
|
||||
declare uuid: string
|
||||
|
||||
@Column({
|
||||
name: 'subscription_id',
|
||||
})
|
||||
declare subscriptionId: number
|
||||
|
||||
@Column({
|
||||
name: 'user_email',
|
||||
length: 255,
|
||||
})
|
||||
@Index('email')
|
||||
declare userEmail: string
|
||||
|
||||
@Column({
|
||||
name: 'user_uuid',
|
||||
length: 36,
|
||||
})
|
||||
@Index('user_uuid')
|
||||
declare userUuid: string
|
||||
|
||||
@Column({
|
||||
name: 'event_type',
|
||||
})
|
||||
declare eventType: string
|
||||
|
||||
@Column({
|
||||
name: 'subscription_plan',
|
||||
})
|
||||
declare subscriptionPlan: string
|
||||
|
||||
@Column({
|
||||
name: 'billing_frequency',
|
||||
})
|
||||
declare billingFrequency: number
|
||||
|
||||
@Column({
|
||||
name: 'new_customer',
|
||||
})
|
||||
declare isNewCustomer: boolean
|
||||
|
||||
@Column({
|
||||
name: 'previous_mrr',
|
||||
type: 'float',
|
||||
})
|
||||
declare previousMonthlyRevenue: number
|
||||
|
||||
@Column({
|
||||
name: 'new_mrr',
|
||||
type: 'float',
|
||||
})
|
||||
declare newMonthlyRevenue: number
|
||||
|
||||
@Column({
|
||||
name: 'created_at',
|
||||
type: 'bigint',
|
||||
})
|
||||
declare createdAt: number
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './Redis/RedisAnalyticsStore'
|
||||
export * from './Redis/RedisStatisticsStore'
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './Domain'
|
||||
export * from './Infra'
|
||||
@@ -3,6 +3,52 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.37.6](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.37.5...@standardnotes/api-gateway@1.37.6) (2022-11-10)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.37.5](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.37.4...@standardnotes/api-gateway@1.37.5) (2022-11-09)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.37.4](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.37.3...@standardnotes/api-gateway@1.37.4) (2022-11-09)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.37.3](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.37.2...@standardnotes/api-gateway@1.37.3) (2022-11-09)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.37.2](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.37.1...@standardnotes/api-gateway@1.37.2) (2022-11-09)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.37.1](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.37.0...@standardnotes/api-gateway@1.37.1) (2022-11-09)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
# [1.37.0](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.14...@standardnotes/api-gateway@1.37.0) (2022-11-07)
|
||||
|
||||
### Features
|
||||
|
||||
* remove analytics scope from other services in favor of a separate service ([ff1d5db](https://github.com/standardnotes/api-gateway/commit/ff1d5db12c93f8e51c07c3aecb9fed4be48ea96a))
|
||||
|
||||
## [1.36.14](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.13...@standardnotes/api-gateway@1.36.14) (2022-11-07)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.36.13](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.12...@standardnotes/api-gateway@1.36.13) (2022-11-07)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.36.12](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.11...@standardnotes/api-gateway@1.36.12) (2022-11-07)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.36.11](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.10...@standardnotes/api-gateway@1.36.11) (2022-11-07)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.36.10](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.9...@standardnotes/api-gateway@1.36.10) (2022-11-07)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/api-gateway",
|
||||
"version": "1.36.10",
|
||||
"version": "1.37.6",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
@@ -22,7 +22,6 @@
|
||||
"dependencies": {
|
||||
"@newrelic/winston-enricher": "^4.0.0",
|
||||
"@sentry/node": "^7.3.0",
|
||||
"@standardnotes/analytics": "workspace:*",
|
||||
"@standardnotes/common": "workspace:^",
|
||||
"@standardnotes/domain-events": "workspace:*",
|
||||
"@standardnotes/domain-events-infra": "workspace:*",
|
||||
|
||||
@@ -2,14 +2,6 @@ import * as winston from 'winston'
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import Redis from 'ioredis'
|
||||
import { Container } from 'inversify'
|
||||
import {
|
||||
AnalyticsStoreInterface,
|
||||
PeriodKeyGenerator,
|
||||
PeriodKeyGeneratorInterface,
|
||||
RedisAnalyticsStore,
|
||||
RedisStatisticsStore,
|
||||
StatisticsStoreInterface,
|
||||
} from '@standardnotes/analytics'
|
||||
import { Timer, TimerInterface } from '@standardnotes/time'
|
||||
|
||||
import { Env } from './Env'
|
||||
@@ -18,7 +10,6 @@ import { AuthMiddleware } from '../Controller/AuthMiddleware'
|
||||
import { HttpServiceInterface } from '../Service/Http/HttpServiceInterface'
|
||||
import { HttpService } from '../Service/Http/HttpService'
|
||||
import { SubscriptionTokenAuthMiddleware } from '../Controller/SubscriptionTokenAuthMiddleware'
|
||||
import { StatisticsMiddleware } from '../Controller/StatisticsMiddleware'
|
||||
import { CrossServiceTokenCacheInterface } from '../Service/Cache/CrossServiceTokenCacheInterface'
|
||||
import { RedisCrossServiceTokenCache } from '../Infra/Redis/RedisCrossServiceTokenCache'
|
||||
import { WebSocketAuthMiddleware } from '../Controller/WebSocketAuthMiddleware'
|
||||
@@ -79,17 +70,9 @@ export class ContainerConfigLoader {
|
||||
container
|
||||
.bind<SubscriptionTokenAuthMiddleware>(TYPES.SubscriptionTokenAuthMiddleware)
|
||||
.to(SubscriptionTokenAuthMiddleware)
|
||||
container.bind<StatisticsMiddleware>(TYPES.StatisticsMiddleware).to(StatisticsMiddleware)
|
||||
|
||||
// Services
|
||||
container.bind<HttpServiceInterface>(TYPES.HTTPService).to(HttpService)
|
||||
container.bind<PeriodKeyGeneratorInterface>(TYPES.PeriodKeyGenerator).toConstantValue(new PeriodKeyGenerator())
|
||||
container
|
||||
.bind<AnalyticsStoreInterface>(TYPES.AnalyticsStore)
|
||||
.toConstantValue(new RedisAnalyticsStore(container.get(TYPES.PeriodKeyGenerator), container.get(TYPES.Redis)))
|
||||
container
|
||||
.bind<StatisticsStoreInterface>(TYPES.StatisticsStore)
|
||||
.toConstantValue(new RedisStatisticsStore(container.get(TYPES.PeriodKeyGenerator), container.get(TYPES.Redis)))
|
||||
container.bind<CrossServiceTokenCacheInterface>(TYPES.CrossServiceTokenCache).to(RedisCrossServiceTokenCache)
|
||||
container.bind<TimerInterface>(TYPES.Timer).toConstantValue(new Timer())
|
||||
|
||||
|
||||
@@ -15,17 +15,13 @@ const TYPES = {
|
||||
REDIS_EVENTS_CHANNEL: Symbol.for('REDIS_EVENTS_CHANNEL'),
|
||||
CROSS_SERVICE_TOKEN_CACHE_TTL: Symbol.for('CROSS_SERVICE_TOKEN_CACHE_TTL'),
|
||||
// Middleware
|
||||
StatisticsMiddleware: Symbol.for('StatisticsMiddleware'),
|
||||
AuthMiddleware: Symbol.for('AuthMiddleware'),
|
||||
WebSocketAuthMiddleware: Symbol.for('WebSocketAuthMiddleware'),
|
||||
SubscriptionTokenAuthMiddleware: Symbol.for('SubscriptionTokenAuthMiddleware'),
|
||||
// Services
|
||||
HTTPService: Symbol.for('HTTPService'),
|
||||
CrossServiceTokenCache: Symbol.for('CrossServiceTokenCache'),
|
||||
AnalyticsStore: Symbol.for('AnalyticsStore'),
|
||||
StatisticsStore: Symbol.for('StatisticsStore'),
|
||||
Timer: Symbol.for('Timer'),
|
||||
PeriodKeyGenerator: Symbol.for('PeriodKeyGenerator'),
|
||||
}
|
||||
|
||||
export default TYPES
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CrossServiceTokenData } from '@standardnotes/security'
|
||||
import { RoleName } from '@standardnotes/common'
|
||||
import { AnalyticsActivity, AnalyticsStoreInterface, Period } from '@standardnotes/analytics'
|
||||
import { TimerInterface } from '@standardnotes/time'
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
import { inject, injectable } from 'inversify'
|
||||
@@ -21,7 +20,6 @@ export class AuthMiddleware extends BaseMiddleware {
|
||||
@inject(TYPES.CROSS_SERVICE_TOKEN_CACHE_TTL) private crossServiceTokenCacheTTL: number,
|
||||
@inject(TYPES.CrossServiceTokenCache) private crossServiceTokenCache: CrossServiceTokenCacheInterface,
|
||||
@inject(TYPES.Timer) private timer: TimerInterface,
|
||||
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
|
||||
@inject(TYPES.Logger) private logger: Logger,
|
||||
) {
|
||||
super()
|
||||
@@ -80,17 +78,6 @@ export class AuthMiddleware extends BaseMiddleware {
|
||||
decodedToken.roles.length === 1 &&
|
||||
decodedToken.roles.find((role) => role.name === RoleName.CoreUser) !== undefined
|
||||
|
||||
await this.analyticsStore.markActivity(
|
||||
[
|
||||
AnalyticsActivity.GeneralActivity,
|
||||
response.locals.freeUser
|
||||
? AnalyticsActivity.GeneralActivityFreeUsers
|
||||
: AnalyticsActivity.GeneralActivityPaidUsers,
|
||||
],
|
||||
decodedToken.analyticsId as number,
|
||||
[Period.Today],
|
||||
)
|
||||
|
||||
if (this.crossServiceTokenCacheTTL && !crossServiceTokenFetchedFromCache) {
|
||||
await this.crossServiceTokenCache.set({
|
||||
authorizationHeaderValue: authHeaderValue,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { controller, all, BaseHttpController, httpPost, httpGet, results, httpDe
|
||||
import TYPES from '../Bootstrap/Types'
|
||||
import { HttpServiceInterface } from '../Service/Http/HttpServiceInterface'
|
||||
|
||||
@controller('', TYPES.StatisticsMiddleware)
|
||||
@controller('')
|
||||
export class LegacyController extends BaseHttpController {
|
||||
private AUTH_ROUTES: Map<string, string>
|
||||
private PARAMETRIZED_AUTH_ROUTES: Map<string, string>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import { BaseMiddleware } from 'inversify-express-utils'
|
||||
import { Logger } from 'winston'
|
||||
import { StatisticsStoreInterface } from '@standardnotes/analytics'
|
||||
|
||||
import TYPES from '../Bootstrap/Types'
|
||||
|
||||
@injectable()
|
||||
export class StatisticsMiddleware extends BaseMiddleware {
|
||||
constructor(
|
||||
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
|
||||
@inject(TYPES.Logger) private logger: Logger,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async handler(request: Request, _response: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const snjsVersion = request.headers['x-snjs-version'] ?? 'unknown'
|
||||
await this.statisticsStore.incrementSNJSVersionUsage(snjsVersion as string)
|
||||
|
||||
const applicationVersion = request.headers['x-application-version'] ?? 'unknown'
|
||||
await this.statisticsStore.incrementApplicationVersionUsage(applicationVersion as string)
|
||||
} catch (error) {
|
||||
this.logger.error(`Could not store analytics data: ${(error as Error).message}`)
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { BaseHttpController, controller, httpGet, httpPost } from 'inversify-exp
|
||||
import TYPES from '../../Bootstrap/Types'
|
||||
import { HttpServiceInterface } from '../../Service/Http/HttpServiceInterface'
|
||||
|
||||
@controller('/v1', TYPES.StatisticsMiddleware)
|
||||
@controller('/v1')
|
||||
export class ActionsController extends BaseHttpController {
|
||||
constructor(@inject(TYPES.HTTPService) private httpService: HttpServiceInterface) {
|
||||
super()
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BaseHttpController, controller, httpPost } from 'inversify-express-util
|
||||
import TYPES from '../../Bootstrap/Types'
|
||||
import { HttpServiceInterface } from '../../Service/Http/HttpServiceInterface'
|
||||
|
||||
@controller('/v1/files', TYPES.StatisticsMiddleware)
|
||||
@controller('/v1/files')
|
||||
export class FilesController extends BaseHttpController {
|
||||
constructor(@inject(TYPES.HTTPService) private httpService: HttpServiceInterface) {
|
||||
super()
|
||||
|
||||
@@ -4,7 +4,7 @@ import { inject } from 'inversify'
|
||||
import TYPES from '../../Bootstrap/Types'
|
||||
import { HttpServiceInterface } from '../../Service/Http/HttpServiceInterface'
|
||||
|
||||
@controller('/v1', TYPES.StatisticsMiddleware)
|
||||
@controller('/v1')
|
||||
export class InvoicesController extends BaseHttpController {
|
||||
constructor(@inject(TYPES.HTTPService) private httpService: HttpServiceInterface) {
|
||||
super()
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BaseHttpController, controller, httpGet, httpPost } from 'inversify-exp
|
||||
import TYPES from '../../Bootstrap/Types'
|
||||
import { HttpServiceInterface } from '../../Service/Http/HttpServiceInterface'
|
||||
|
||||
@controller('/v1/items', TYPES.StatisticsMiddleware, TYPES.AuthMiddleware)
|
||||
@controller('/v1/items', TYPES.AuthMiddleware)
|
||||
export class ItemsController extends BaseHttpController {
|
||||
constructor(@inject(TYPES.HTTPService) private httpService: HttpServiceInterface) {
|
||||
super()
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BaseHttpController, controller, httpGet, httpPost } from 'inversify-exp
|
||||
import TYPES from '../../Bootstrap/Types'
|
||||
import { HttpServiceInterface } from '../../Service/Http/HttpServiceInterface'
|
||||
|
||||
@controller('/v1/offline', TYPES.StatisticsMiddleware)
|
||||
@controller('/v1/offline')
|
||||
export class OfflineController extends BaseHttpController {
|
||||
constructor(@inject(TYPES.HTTPService) private httpService: HttpServiceInterface) {
|
||||
super()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user