mirror of
https://github.com/standardnotes/server
synced 2026-01-23 23:01:09 -05:00
Compare commits
16 Commits
@standardn
...
@standardn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad60b95537 | ||
|
|
8a98f746eb | ||
|
|
27cfd0ccf6 | ||
|
|
82bb85174d | ||
|
|
8ceef4acbf | ||
|
|
b6118c17e1 | ||
|
|
a7fb622e69 | ||
|
|
39337c1c4f | ||
|
|
1f970aaf69 | ||
|
|
0a5b7e13cd | ||
|
|
1ce2b9eb44 | ||
|
|
477f146725 | ||
|
|
d7b02c4da9 | ||
|
|
40e673379b | ||
|
|
6ce9a4e834 | ||
|
|
c5a07a888a |
@@ -3,6 +3,30 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [1.32.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.31.1...@standardnotes/analytics@1.32.0) (2022-09-30)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add tracking total customers count ([8a98f74](https://github.com/standardnotes/server/commit/8a98f746eb13c25f7940286aca594e2304232bdf))
|
||||
|
||||
## [1.31.1](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.31.0...@standardnotes/analytics@1.31.1) (2022-09-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** fix calculating new and existing customers churn ([82bb851](https://github.com/standardnotes/server/commit/82bb85174d94a5e03f364604a1c07a9b1633920d))
|
||||
|
||||
# [1.31.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.30.0...@standardnotes/analytics@1.31.0) (2022-09-30)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add measuring new customers ([b6118c1](https://github.com/standardnotes/server/commit/b6118c17e176ba0acc93b95a38e32748ac851410))
|
||||
|
||||
# [1.30.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.29.1...@standardnotes/analytics@1.30.0) (2022-09-30)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add tracking churn activity ([39337c1](https://github.com/standardnotes/server/commit/39337c1c4f799f39672eeb8c9d050e7cbb19878a))
|
||||
|
||||
## [1.29.1](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.29.0...@standardnotes/analytics@1.29.1) (2022-09-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/analytics",
|
||||
"version": "1.29.1",
|
||||
"version": "1.32.0",
|
||||
"engines": {
|
||||
"node": ">=14.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -11,9 +11,12 @@ export enum AnalyticsActivity {
|
||||
SubscriptionRenewed = 'subscription-renewed',
|
||||
SubscriptionRefunded = 'subscription-refunded',
|
||||
SubscriptionCancelled = 'subscription-cancelled',
|
||||
SubscriptionExpired = 'subscription-expired',
|
||||
EmailUnbackedUpData = 'email-unbacked-up-data',
|
||||
EmailBackup = 'email-backup',
|
||||
LimitedDiscountOfferPurchased = 'limited-discount-offer-purchased',
|
||||
PaymentFailed = 'payment-failed',
|
||||
PaymentSuccess = 'payment-success',
|
||||
NewCustomersChurn = 'new-customers-churn',
|
||||
ExistingCustomersChurn = 'existing-customers-churn',
|
||||
}
|
||||
|
||||
@@ -8,4 +8,6 @@ export enum StatisticsMeasure {
|
||||
NotesCountFreeUsers = 'notes-count-free-users',
|
||||
NotesCountPaidUsers = 'notes-count-paid-users',
|
||||
FilesCount = 'files-count',
|
||||
NewCustomers = 'new-customers',
|
||||
TotalCustomers = 'total-customers',
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface StatisticsStoreInterface {
|
||||
getYesterdayApplicationUsage(): Promise<Array<{ version: string; count: number }>>
|
||||
getYesterdayOutOfSyncIncidents(): Promise<number>
|
||||
incrementMeasure(measure: StatisticsMeasure, value: number, periods: Period[]): Promise<void>
|
||||
setMeasure(measure: StatisticsMeasure, value: number, periods: Period[]): Promise<void>
|
||||
getMeasureAverage(measure: StatisticsMeasure, period: Period): Promise<number>
|
||||
getMeasureTotal(measure: StatisticsMeasure, period: Period): Promise<number>
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum Period {
|
||||
WeekBeforeLastWeek,
|
||||
ThisMonth,
|
||||
LastMonth,
|
||||
ThisYear,
|
||||
Last30Days,
|
||||
Last7Days,
|
||||
Q1ThisYear,
|
||||
|
||||
@@ -49,11 +49,19 @@ export class PeriodKeyGenerator implements PeriodKeyGeneratorInterface {
|
||||
return this.getMonthlyKey()
|
||||
case Period.LastMonth:
|
||||
return this.getMonthlyKey(this.getLastMonthDate())
|
||||
case Period.ThisYear:
|
||||
return this.getYearlyKey()
|
||||
default:
|
||||
throw new Error(`Unsuporrted period: ${period}`)
|
||||
}
|
||||
}
|
||||
|
||||
private getYearlyKey(date?: Date): string {
|
||||
date = date ?? new Date()
|
||||
|
||||
return this.getYear(date)
|
||||
}
|
||||
|
||||
private getMonthlyKey(date?: Date): string {
|
||||
date = date ?? new Date()
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ describe('RedisStatisticsStore', () => {
|
||||
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()
|
||||
|
||||
@@ -92,6 +93,13 @@ describe('RedisStatisticsStore', () => {
|
||||
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])
|
||||
|
||||
|
||||
@@ -8,6 +8,16 @@ import { StatisticsStoreInterface } from '../../Domain/Statistics/StatisticsStor
|
||||
export class RedisStatisticsStore implements StatisticsStoreInterface {
|
||||
constructor(private periodKeyGenerator: PeriodKeyGeneratorInterface, private redisClient: IORedis.Redis) {}
|
||||
|
||||
async setMeasure(measure: StatisticsMeasure, value: number, periods: Period[]): Promise<void> {
|
||||
const pipeline = this.redisClient.pipeline()
|
||||
|
||||
for (const period of periods) {
|
||||
pipeline.set(`count:measure:${measure}:timespan:${this.periodKeyGenerator.getPeriodKey(period)}`, value)
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
async getMeasureTotal(measure: StatisticsMeasure, period: Period): Promise<number> {
|
||||
const totalValue = await this.redisClient.get(
|
||||
`count:measure:${measure}:timespan:${this.periodKeyGenerator.getPeriodKey(period)}`,
|
||||
|
||||
@@ -3,6 +3,22 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.22.6](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.22.5...@standardnotes/api-gateway@1.22.6) (2022-09-30)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.22.5](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.22.4...@standardnotes/api-gateway@1.22.5) (2022-09-30)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.22.4](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.22.3...@standardnotes/api-gateway@1.22.4) (2022-09-30)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.22.3](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.22.2...@standardnotes/api-gateway@1.22.3) (2022-09-30)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.22.2](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.22.1...@standardnotes/api-gateway@1.22.2) (2022-09-28)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/api-gateway",
|
||||
"version": "1.22.2",
|
||||
"version": "1.22.6",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,54 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [1.35.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.34.1...@standardnotes/auth-server@1.35.0) (2022-09-30)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add tracking total customers count ([8a98f74](https://github.com/standardnotes/server/commit/8a98f746eb13c25f7940286aca594e2304232bdf))
|
||||
|
||||
## [1.34.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.34.0...@standardnotes/auth-server@1.34.1) (2022-09-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** fix calculating new and existing customers churn ([82bb851](https://github.com/standardnotes/server/commit/82bb85174d94a5e03f364604a1c07a9b1633920d))
|
||||
|
||||
# [1.34.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.33.0...@standardnotes/auth-server@1.34.0) (2022-09-30)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add measuring new customers ([b6118c1](https://github.com/standardnotes/server/commit/b6118c17e176ba0acc93b95a38e32748ac851410))
|
||||
|
||||
# [1.33.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.13...@standardnotes/auth-server@1.33.0) (2022-09-30)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add tracking churn activity ([39337c1](https://github.com/standardnotes/server/commit/39337c1c4f799f39672eeb8c9d050e7cbb19878a))
|
||||
|
||||
## [1.32.13](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.12...@standardnotes/auth-server@1.32.13) (2022-09-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** finding previous subscription setting for irreplacable subscription settings ([0a5b7e1](https://github.com/standardnotes/server/commit/0a5b7e13cd51ddbad40f67d629b0daf50b176fac))
|
||||
|
||||
## [1.32.12](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.11...@standardnotes/auth-server@1.32.12) (2022-09-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** reassign not replaceable subscription settings ([477f146](https://github.com/standardnotes/server/commit/477f146725c8e83b86a8224708046d0fd86bfa0b))
|
||||
|
||||
## [1.32.11](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.10...@standardnotes/auth-server@1.32.11) (2022-09-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** prevent replacing files bytes used subscription setting upon renewal ([40e6733](https://github.com/standardnotes/server/commit/40e673379bb84bd21bcc8dbcb1aa36caaa2adbf8))
|
||||
|
||||
## [1.32.10](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.9...@standardnotes/auth-server@1.32.10) (2022-09-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** exclude legacy 5 year plans from subscription length statistics ([c5a07a8](https://github.com/standardnotes/server/commit/c5a07a888aadc22f62a92a236977c266f8d8e1c0))
|
||||
|
||||
## [1.32.9](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.8...@standardnotes/auth-server@1.32.9) (2022-09-28)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/auth-server
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/auth-server",
|
||||
"version": "1.32.9",
|
||||
"version": "1.35.0",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -87,6 +87,20 @@ describe('SubscriptionCancelledEventHandler', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should not track statistics for subscriptions that are in a legacy 5 year plan', async () => {
|
||||
event.payload.timestamp = 1642395451516000
|
||||
|
||||
const userSubscription = {
|
||||
createdAt: 1642395451515000,
|
||||
endsAt: 1642395451515000 + 126_230_400_000_001,
|
||||
} as jest.Mocked<UserSubscription>
|
||||
userSubscriptionRepository.findBySubscriptionId = jest.fn().mockReturnValue([userSubscription])
|
||||
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(statisticsStore.incrementMeasure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update subscription cancelled - user not found', async () => {
|
||||
userRepository.findOneByEmail = jest.fn().mockReturnValue(null)
|
||||
|
||||
|
||||
@@ -35,31 +35,10 @@ export class SubscriptionCancelledEventHandler implements DomainEventHandlerInte
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
|
||||
const subscriptions = await this.userSubscriptionRepository.findBySubscriptionId(event.payload.subscriptionId)
|
||||
if (subscriptions.length !== 0) {
|
||||
const lastSubscription = subscriptions.shift() as UserSubscription
|
||||
const subscriptionLength = event.payload.timestamp - lastSubscription.createdAt
|
||||
await this.statisticsStore.incrementMeasure(StatisticsMeasure.SubscriptionLength, subscriptionLength, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
|
||||
const lastPurchaseTime = lastSubscription.renewedAt ?? lastSubscription.updatedAt
|
||||
const remainingSubscriptionTime = lastSubscription.endsAt - event.payload.timestamp
|
||||
const totalSubscriptionTime = lastSubscription.endsAt - lastPurchaseTime
|
||||
|
||||
const remainingSubscriptionPercentage = Math.floor((remainingSubscriptionTime / totalSubscriptionTime) * 100)
|
||||
|
||||
await this.statisticsStore.incrementMeasure(
|
||||
StatisticsMeasure.RemainingSubscriptionTimePercentage,
|
||||
remainingSubscriptionPercentage,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await this.trackSubscriptionStatistics(event)
|
||||
|
||||
if (event.payload.offline) {
|
||||
await this.updateOfflineSubscriptionCancelled(event.payload.subscriptionId, event.payload.timestamp)
|
||||
|
||||
@@ -76,4 +55,39 @@ export class SubscriptionCancelledEventHandler implements DomainEventHandlerInte
|
||||
private async updateOfflineSubscriptionCancelled(subscriptionId: number, timestamp: number): Promise<void> {
|
||||
await this.offlineUserSubscriptionRepository.updateCancelled(subscriptionId, true, timestamp)
|
||||
}
|
||||
|
||||
private async trackSubscriptionStatistics(event: SubscriptionCancelledEvent) {
|
||||
const subscriptions = await this.userSubscriptionRepository.findBySubscriptionId(event.payload.subscriptionId)
|
||||
if (subscriptions.length !== 0) {
|
||||
const lastSubscription = subscriptions.shift() as UserSubscription
|
||||
if (this.isLegacy5yearSubscriptionPlan(lastSubscription)) {
|
||||
return
|
||||
}
|
||||
|
||||
const subscriptionLength = event.payload.timestamp - lastSubscription.createdAt
|
||||
await this.statisticsStore.incrementMeasure(StatisticsMeasure.SubscriptionLength, subscriptionLength, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
|
||||
const lastPurchaseTime = lastSubscription.renewedAt ?? lastSubscription.updatedAt
|
||||
const remainingSubscriptionTime = lastSubscription.endsAt - event.payload.timestamp
|
||||
const totalSubscriptionTime = lastSubscription.endsAt - lastPurchaseTime
|
||||
|
||||
const remainingSubscriptionPercentage = Math.floor((remainingSubscriptionTime / totalSubscriptionTime) * 100)
|
||||
|
||||
await this.statisticsStore.incrementMeasure(
|
||||
StatisticsMeasure.RemainingSubscriptionTimePercentage,
|
||||
remainingSubscriptionPercentage,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private isLegacy5yearSubscriptionPlan(subscription: UserSubscription) {
|
||||
const fourYearsInMicroseconds = 126_230_400_000_000
|
||||
|
||||
return subscription.endsAt - subscription.createdAt > fourYearsInMicroseconds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscri
|
||||
import { RoleServiceInterface } from '../Role/RoleServiceInterface'
|
||||
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
|
||||
import { UserSubscription } from '../Subscription/UserSubscription'
|
||||
import { AnalyticsStoreInterface, StatisticsStoreInterface } from '@standardnotes/analytics'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
|
||||
describe('SubscriptionExpiredEventHandler', () => {
|
||||
let userRepository: UserRepositoryInterface
|
||||
@@ -23,6 +25,9 @@ describe('SubscriptionExpiredEventHandler', () => {
|
||||
let user: User
|
||||
let event: SubscriptionExpiredEvent
|
||||
let timestamp: number
|
||||
let getUserAnalyticsId: GetUserAnalyticsId
|
||||
let analyticsStore: AnalyticsStoreInterface
|
||||
let statisticsStore: StatisticsStoreInterface
|
||||
|
||||
const createHandler = () =>
|
||||
new SubscriptionExpiredEventHandler(
|
||||
@@ -30,6 +35,9 @@ describe('SubscriptionExpiredEventHandler', () => {
|
||||
userSubscriptionRepository,
|
||||
offlineUserSubscriptionRepository,
|
||||
roleService,
|
||||
getUserAnalyticsId,
|
||||
analyticsStore,
|
||||
statisticsStore,
|
||||
logger,
|
||||
)
|
||||
|
||||
@@ -50,6 +58,7 @@ describe('SubscriptionExpiredEventHandler', () => {
|
||||
|
||||
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
|
||||
userSubscriptionRepository.updateEndsAt = jest.fn()
|
||||
userSubscriptionRepository.countActiveSubscriptions = jest.fn().mockReturnValue(13)
|
||||
userSubscriptionRepository.findBySubscriptionId = jest
|
||||
.fn()
|
||||
.mockReturnValue([{ user: Promise.resolve(user) } as jest.Mocked<UserSubscription>])
|
||||
@@ -72,6 +81,15 @@ describe('SubscriptionExpiredEventHandler', () => {
|
||||
offline: false,
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
logger = {} as jest.Mocked<Logger>
|
||||
logger.info = jest.fn()
|
||||
logger.warn = jest.fn()
|
||||
|
||||
@@ -8,6 +8,14 @@ import { RoleServiceInterface } from '../Role/RoleServiceInterface'
|
||||
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
|
||||
import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
|
||||
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
|
||||
import {
|
||||
AnalyticsStoreInterface,
|
||||
AnalyticsActivity,
|
||||
Period,
|
||||
StatisticsMeasure,
|
||||
StatisticsStoreInterface,
|
||||
} from '@standardnotes/analytics'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
|
||||
@injectable()
|
||||
export class SubscriptionExpiredEventHandler implements DomainEventHandlerInterface {
|
||||
@@ -17,6 +25,9 @@ export class SubscriptionExpiredEventHandler implements DomainEventHandlerInterf
|
||||
@inject(TYPES.OfflineUserSubscriptionRepository)
|
||||
private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface,
|
||||
@inject(TYPES.RoleService) private roleService: RoleServiceInterface,
|
||||
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
|
||||
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
|
||||
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
|
||||
@inject(TYPES.Logger) private logger: Logger,
|
||||
) {}
|
||||
|
||||
@@ -36,6 +47,21 @@ export class SubscriptionExpiredEventHandler implements DomainEventHandlerInterf
|
||||
|
||||
await this.updateSubscriptionEndsAt(event.payload.subscriptionId, event.payload.timestamp)
|
||||
await this.removeRoleFromSubscriptionUsers(event.payload.subscriptionId, event.payload.subscriptionName)
|
||||
|
||||
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid })
|
||||
await this.analyticsStore.markActivity(
|
||||
[AnalyticsActivity.SubscriptionExpired, AnalyticsActivity.ExistingCustomersChurn],
|
||||
analyticsId,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
|
||||
const activeSubscriptions = await this.userSubscriptionRepository.countActiveSubscriptions()
|
||||
await this.statisticsStore.setMeasure(StatisticsMeasure.TotalCustomers, activeSubscriptions, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
Period.ThisYear,
|
||||
])
|
||||
}
|
||||
|
||||
private async removeRoleFromSubscriptionUsers(
|
||||
|
||||
@@ -73,12 +73,14 @@ describe('SubscriptionPurchasedEventHandler', () => {
|
||||
|
||||
statisticsStore = {} as jest.Mocked<StatisticsStoreInterface>
|
||||
statisticsStore.incrementMeasure = jest.fn()
|
||||
statisticsStore.setMeasure = jest.fn()
|
||||
|
||||
timer = {} as jest.Mocked<TimerInterface>
|
||||
timer.convertDateToMicroseconds = jest.fn().mockReturnValue(1)
|
||||
|
||||
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
|
||||
userSubscriptionRepository.countByUserUuid = jest.fn().mockReturnValue(0)
|
||||
userSubscriptionRepository.countActiveSubscriptions = jest.fn().mockReturnValue(13)
|
||||
userSubscriptionRepository.save = jest.fn().mockReturnValue(subscription)
|
||||
|
||||
offlineUserSubscription = {} as jest.Mocked<OfflineUserSubscription>
|
||||
@@ -113,6 +115,7 @@ describe('SubscriptionPurchasedEventHandler', () => {
|
||||
|
||||
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
|
||||
analyticsStore.markActivity = jest.fn()
|
||||
analyticsStore.unmarkActivity = jest.fn()
|
||||
|
||||
logger = {} as jest.Mocked<Logger>
|
||||
logger.info = jest.fn()
|
||||
@@ -132,6 +135,7 @@ describe('SubscriptionPurchasedEventHandler', () => {
|
||||
expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith(
|
||||
subscription,
|
||||
SubscriptionName.ProPlan,
|
||||
'123',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInte
|
||||
await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription,
|
||||
event.payload.subscriptionName,
|
||||
user.uuid,
|
||||
)
|
||||
|
||||
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid })
|
||||
@@ -84,6 +85,11 @@ export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInte
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
await this.analyticsStore.unmarkActivity(
|
||||
[AnalyticsActivity.ExistingCustomersChurn, AnalyticsActivity.NewCustomersChurn],
|
||||
analyticsId,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
|
||||
const limitedDiscountPurchased = ['limited-10', 'limited-20'].includes(event.payload.discountCode as string)
|
||||
if (limitedDiscountPurchased) {
|
||||
@@ -98,6 +104,19 @@ export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInte
|
||||
event.payload.timestamp - this.timer.convertDateToMicroseconds(user.createdAt),
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
await this.statisticsStore.incrementMeasure(StatisticsMeasure.NewCustomers, 1, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
Period.ThisYear,
|
||||
])
|
||||
const activeSubscriptions = await this.userSubscriptionRepository.countActiveSubscriptions()
|
||||
await this.statisticsStore.setMeasure(StatisticsMeasure.TotalCustomers, activeSubscriptions, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
Period.ThisYear,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ describe('SubscriptionReassignedEventHandler', () => {
|
||||
expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith(
|
||||
subscription,
|
||||
SubscriptionName.ProPlan,
|
||||
'123',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export class SubscriptionReassignedEventHandler implements DomainEventHandlerInt
|
||||
await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription,
|
||||
event.payload.subscriptionName,
|
||||
user.uuid,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { RoleServiceInterface } from '../Role/RoleServiceInterface'
|
||||
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
|
||||
import { UserSubscription } from '../Subscription/UserSubscription'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
import { AnalyticsStoreInterface } from '@standardnotes/analytics'
|
||||
import { AnalyticsActivity, AnalyticsStoreInterface, Period, StatisticsStoreInterface } from '@standardnotes/analytics'
|
||||
|
||||
describe('SubscriptionRefundedEventHandler', () => {
|
||||
let userRepository: UserRepositoryInterface
|
||||
@@ -27,6 +27,7 @@ describe('SubscriptionRefundedEventHandler', () => {
|
||||
let timestamp: number
|
||||
let getUserAnalyticsId: GetUserAnalyticsId
|
||||
let analyticsStore: AnalyticsStoreInterface
|
||||
let statisticsStore: StatisticsStoreInterface
|
||||
|
||||
const createHandler = () =>
|
||||
new SubscriptionRefundedEventHandler(
|
||||
@@ -36,6 +37,7 @@ describe('SubscriptionRefundedEventHandler', () => {
|
||||
roleService,
|
||||
getUserAnalyticsId,
|
||||
analyticsStore,
|
||||
statisticsStore,
|
||||
logger,
|
||||
)
|
||||
|
||||
@@ -56,6 +58,8 @@ describe('SubscriptionRefundedEventHandler', () => {
|
||||
|
||||
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
|
||||
userSubscriptionRepository.updateEndsAt = jest.fn()
|
||||
userSubscriptionRepository.countByUserUuid = jest.fn().mockReturnValue(1)
|
||||
userSubscriptionRepository.countActiveSubscriptions = jest.fn().mockReturnValue(13)
|
||||
userSubscriptionRepository.findBySubscriptionId = jest
|
||||
.fn()
|
||||
.mockReturnValue([{ user: Promise.resolve(user) } as jest.Mocked<UserSubscription>])
|
||||
@@ -83,6 +87,10 @@ describe('SubscriptionRefundedEventHandler', () => {
|
||||
|
||||
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
|
||||
analyticsStore.markActivity = jest.fn()
|
||||
analyticsStore.wasActivityDone = jest.fn().mockReturnValue(true)
|
||||
|
||||
statisticsStore = {} as jest.Mocked<StatisticsStoreInterface>
|
||||
statisticsStore.setMeasure = jest.fn()
|
||||
|
||||
logger = {} as jest.Mocked<Logger>
|
||||
logger.info = jest.fn()
|
||||
@@ -119,4 +127,33 @@ describe('SubscriptionRefundedEventHandler', () => {
|
||||
expect(roleService.removeUserRole).not.toHaveBeenCalled()
|
||||
expect(userSubscriptionRepository.updateEndsAt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should mark churn for new customer', async () => {
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(analyticsStore.markActivity).toHaveBeenCalledWith([AnalyticsActivity.NewCustomersChurn], 3, [
|
||||
Period.ThisMonth,
|
||||
])
|
||||
})
|
||||
|
||||
it('should mark churn for existing customer', async () => {
|
||||
userSubscriptionRepository.countByUserUuid = jest.fn().mockReturnValue(3)
|
||||
|
||||
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 () => {
|
||||
userSubscriptionRepository.countByUserUuid = jest.fn().mockReturnValue(3)
|
||||
analyticsStore.wasActivityDone = jest.fn().mockReturnValue(false)
|
||||
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(analyticsStore.markActivity).not.toHaveBeenCalledWith([AnalyticsActivity.ExistingCustomersChurn], 3, [
|
||||
Period.ThisMonth,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SubscriptionName } from '@standardnotes/common'
|
||||
import { SubscriptionName, Uuid } from '@standardnotes/common'
|
||||
import { DomainEventHandlerInterface, SubscriptionRefundedEvent } from '@standardnotes/domain-events'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import { Logger } from 'winston'
|
||||
@@ -8,7 +8,13 @@ import { RoleServiceInterface } from '../Role/RoleServiceInterface'
|
||||
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
|
||||
import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
|
||||
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
|
||||
import { AnalyticsActivity, AnalyticsStoreInterface, Period } from '@standardnotes/analytics'
|
||||
import {
|
||||
AnalyticsActivity,
|
||||
AnalyticsStoreInterface,
|
||||
Period,
|
||||
StatisticsMeasure,
|
||||
StatisticsStoreInterface,
|
||||
} from '@standardnotes/analytics'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
|
||||
@injectable()
|
||||
@@ -21,6 +27,7 @@ export class SubscriptionRefundedEventHandler implements DomainEventHandlerInter
|
||||
@inject(TYPES.RoleService) private roleService: RoleServiceInterface,
|
||||
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
|
||||
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
|
||||
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
|
||||
@inject(TYPES.Logger) private logger: Logger,
|
||||
) {}
|
||||
|
||||
@@ -47,6 +54,8 @@ export class SubscriptionRefundedEventHandler implements DomainEventHandlerInter
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
|
||||
await this.markChurnActivity(analyticsId, user.uuid)
|
||||
}
|
||||
|
||||
private async removeRoleFromSubscriptionUsers(
|
||||
@@ -66,4 +75,30 @@ export class SubscriptionRefundedEventHandler implements DomainEventHandlerInter
|
||||
private async updateOfflineSubscriptionEndsAt(subscriptionId: number, timestamp: number): Promise<void> {
|
||||
await this.offlineUserSubscriptionRepository.updateEndsAt(subscriptionId, timestamp, timestamp)
|
||||
}
|
||||
|
||||
private async markChurnActivity(analyticsId: number, userUuid: Uuid): Promise<void> {
|
||||
const existingSubscriptionsCount = await this.userSubscriptionRepository.countByUserUuid(userUuid)
|
||||
|
||||
const churnActivity =
|
||||
existingSubscriptionsCount > 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])
|
||||
}
|
||||
}
|
||||
|
||||
const activeSubscriptions = await this.userSubscriptionRepository.countActiveSubscriptions()
|
||||
await this.statisticsStore.setMeasure(StatisticsMeasure.TotalCustomers, activeSubscriptions, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
Period.ThisYear,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ describe('SubscriptionRenewedEventHandler', () => {
|
||||
|
||||
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
|
||||
analyticsStore.markActivity = jest.fn()
|
||||
analyticsStore.unmarkActivity = jest.fn()
|
||||
|
||||
logger = {} as jest.Mocked<Logger>
|
||||
logger.warn = jest.fn()
|
||||
|
||||
@@ -66,6 +66,11 @@ export class SubscriptionRenewedEventHandler implements DomainEventHandlerInterf
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
await this.analyticsStore.unmarkActivity(
|
||||
[AnalyticsActivity.ExistingCustomersChurn, AnalyticsActivity.NewCustomersChurn],
|
||||
analyticsId,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
}
|
||||
|
||||
private async addRoleToSubscriptionUsers(subscriptionId: number, subscriptionName: SubscriptionName): Promise<void> {
|
||||
|
||||
@@ -130,6 +130,7 @@ describe('SubscriptionSyncRequestedEventHandler', () => {
|
||||
expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith(
|
||||
subscription,
|
||||
SubscriptionName.ProPlan,
|
||||
'123',
|
||||
)
|
||||
|
||||
expect(settingService.createOrReplace).toHaveBeenCalledWith({
|
||||
|
||||
@@ -89,6 +89,7 @@ export class SubscriptionSyncRequestedEventHandler implements DomainEventHandler
|
||||
await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription,
|
||||
event.payload.subscriptionName,
|
||||
user.uuid,
|
||||
)
|
||||
|
||||
await this.settingService.createOrReplace({
|
||||
|
||||
@@ -4,4 +4,5 @@ export type SettingDescription = {
|
||||
value: string
|
||||
sensitive: boolean
|
||||
serverEncryptionVersion: EncryptionVersion
|
||||
replaceable: boolean
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
|
||||
sensitive: false,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
value: MuteSignInEmailsOption.NotMuted,
|
||||
replaceable: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -60,6 +61,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
|
||||
sensitive: false,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
value: MuteMarketingEmailsOption.NotMuted,
|
||||
replaceable: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -68,6 +70,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
|
||||
sensitive: false,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
value: LogSessionUserAgentOption.Enabled,
|
||||
replaceable: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
@@ -79,6 +82,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
|
||||
sensitive: false,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
value: LogSessionUserAgentOption.Disabled,
|
||||
replaceable: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SubscriptionName } from '@standardnotes/common'
|
||||
import { User } from '../User/User'
|
||||
import { SettingFactoryInterface } from './SettingFactoryInterface'
|
||||
import { SubscriptionSettingsAssociationServiceInterface } from './SubscriptionSettingsAssociationServiceInterface'
|
||||
import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
|
||||
|
||||
describe('SubscriptionSettingService', () => {
|
||||
let setting: SubscriptionSetting
|
||||
@@ -22,6 +23,7 @@ describe('SubscriptionSettingService', () => {
|
||||
let subscriptionSettingRepository: SubscriptionSettingRepositoryInterface
|
||||
let subscriptionSettingsAssociationService: SubscriptionSettingsAssociationServiceInterface
|
||||
let settingDecrypter: SettingDecrypterInterface
|
||||
let userSubscriptionRepository: UserSubscriptionRepositoryInterface
|
||||
let logger: Logger
|
||||
|
||||
const createService = () =>
|
||||
@@ -30,6 +32,7 @@ describe('SubscriptionSettingService', () => {
|
||||
subscriptionSettingRepository,
|
||||
subscriptionSettingsAssociationService,
|
||||
settingDecrypter,
|
||||
userSubscriptionRepository,
|
||||
logger,
|
||||
)
|
||||
|
||||
@@ -51,6 +54,16 @@ describe('SubscriptionSettingService', () => {
|
||||
subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid = jest.fn().mockReturnValue(null)
|
||||
subscriptionSettingRepository.save = jest.fn().mockImplementation((setting) => setting)
|
||||
|
||||
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
|
||||
userSubscriptionRepository.findByUserUuid = jest.fn().mockReturnValue([
|
||||
{
|
||||
uuid: 's-1-2-3',
|
||||
} as jest.Mocked<UserSubscription>,
|
||||
{
|
||||
uuid: 's-2-3-4',
|
||||
} as jest.Mocked<UserSubscription>,
|
||||
])
|
||||
|
||||
subscriptionSettingsAssociationService = {} as jest.Mocked<SubscriptionSettingsAssociationServiceInterface>
|
||||
subscriptionSettingsAssociationService.getDefaultSettingsAndValuesForSubscriptionName = jest.fn().mockReturnValue(
|
||||
new Map([
|
||||
@@ -60,6 +73,7 @@ describe('SubscriptionSettingService', () => {
|
||||
value: '0',
|
||||
sensitive: 0,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
replaceable: true,
|
||||
},
|
||||
],
|
||||
]),
|
||||
@@ -75,7 +89,91 @@ describe('SubscriptionSettingService', () => {
|
||||
})
|
||||
|
||||
it('should create default settings for a subscription', async () => {
|
||||
await createService().applyDefaultSubscriptionSettingsForSubscription(userSubscription, SubscriptionName.PlusPlan)
|
||||
await createService().applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription,
|
||||
SubscriptionName.PlusPlan,
|
||||
'1-2-3',
|
||||
)
|
||||
|
||||
expect(subscriptionSettingRepository.save).toHaveBeenCalledWith(setting)
|
||||
})
|
||||
|
||||
it('should reassign existing default settings for a subscription if it is not replaceable', async () => {
|
||||
subscriptionSettingsAssociationService.getDefaultSettingsAndValuesForSubscriptionName = jest.fn().mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
SubscriptionSettingName.FileUploadBytesUsed,
|
||||
{
|
||||
value: '0',
|
||||
sensitive: 0,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
replaceable: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
)
|
||||
subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid = jest.fn().mockReturnValue(setting)
|
||||
|
||||
await createService().applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription,
|
||||
SubscriptionName.PlusPlan,
|
||||
'1-2-3',
|
||||
)
|
||||
|
||||
expect(subscriptionSettingRepository.save).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should create default settings for a subscription if it is not replaceable and not existing', async () => {
|
||||
subscriptionSettingsAssociationService.getDefaultSettingsAndValuesForSubscriptionName = jest.fn().mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
SubscriptionSettingName.FileUploadBytesUsed,
|
||||
{
|
||||
value: '0',
|
||||
sensitive: 0,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
replaceable: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
)
|
||||
subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid = jest.fn().mockReturnValue(null)
|
||||
|
||||
await createService().applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription,
|
||||
SubscriptionName.PlusPlan,
|
||||
'1-2-3',
|
||||
)
|
||||
|
||||
expect(subscriptionSettingRepository.save).toHaveBeenCalledWith(setting)
|
||||
})
|
||||
|
||||
it('should create default settings for a subscription if it is not replaceable and no previous subscription existed', async () => {
|
||||
subscriptionSettingsAssociationService.getDefaultSettingsAndValuesForSubscriptionName = jest.fn().mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
SubscriptionSettingName.FileUploadBytesUsed,
|
||||
{
|
||||
value: '0',
|
||||
sensitive: 0,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
replaceable: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
)
|
||||
subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid = jest.fn().mockReturnValue(null)
|
||||
userSubscriptionRepository.findByUserUuid = jest.fn().mockReturnValue([
|
||||
{
|
||||
uuid: '1-2-3',
|
||||
} as jest.Mocked<UserSubscription>,
|
||||
])
|
||||
|
||||
await createService().applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription,
|
||||
SubscriptionName.PlusPlan,
|
||||
'1-2-3',
|
||||
)
|
||||
|
||||
expect(subscriptionSettingRepository.save).toHaveBeenCalledWith(setting)
|
||||
})
|
||||
@@ -85,7 +183,11 @@ describe('SubscriptionSettingService', () => {
|
||||
.fn()
|
||||
.mockReturnValue(undefined)
|
||||
|
||||
await createService().applyDefaultSubscriptionSettingsForSubscription(userSubscription, SubscriptionName.PlusPlan)
|
||||
await createService().applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription,
|
||||
SubscriptionName.PlusPlan,
|
||||
'1-2-3',
|
||||
)
|
||||
|
||||
expect(subscriptionSettingRepository.save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SubscriptionName } from '@standardnotes/common'
|
||||
import { SubscriptionName, Uuid } from '@standardnotes/common'
|
||||
import { SubscriptionSettingName } from '@standardnotes/settings'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import { Logger } from 'winston'
|
||||
@@ -16,6 +16,7 @@ import { FindSubscriptionSettingDTO } from './FindSubscriptionSettingDTO'
|
||||
import { SubscriptionSettingRepositoryInterface } from './SubscriptionSettingRepositoryInterface'
|
||||
import { SettingFactoryInterface } from './SettingFactoryInterface'
|
||||
import { SubscriptionSettingsAssociationServiceInterface } from './SubscriptionSettingsAssociationServiceInterface'
|
||||
import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
|
||||
|
||||
@injectable()
|
||||
export class SubscriptionSettingService implements SubscriptionSettingServiceInterface {
|
||||
@@ -26,12 +27,14 @@ export class SubscriptionSettingService implements SubscriptionSettingServiceInt
|
||||
@inject(TYPES.SubscriptionSettingsAssociationService)
|
||||
private subscriptionSettingAssociationService: SubscriptionSettingsAssociationServiceInterface,
|
||||
@inject(TYPES.SettingDecrypter) private settingDecrypter: SettingDecrypterInterface,
|
||||
@inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface,
|
||||
@inject(TYPES.Logger) private logger: Logger,
|
||||
) {}
|
||||
|
||||
async applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription: UserSubscription,
|
||||
subscriptionName: SubscriptionName,
|
||||
userUuid: Uuid,
|
||||
): Promise<void> {
|
||||
const defaultSettingsWithValues =
|
||||
await this.subscriptionSettingAssociationService.getDefaultSettingsAndValuesForSubscriptionName(subscriptionName)
|
||||
@@ -43,6 +46,15 @@ export class SubscriptionSettingService implements SubscriptionSettingServiceInt
|
||||
|
||||
for (const settingName of defaultSettingsWithValues.keys()) {
|
||||
const setting = defaultSettingsWithValues.get(settingName) as SettingDescription
|
||||
if (!setting.replaceable) {
|
||||
const existingSetting = await this.findPreviousSubscriptionSetting(settingName, userSubscription.uuid, userUuid)
|
||||
if (existingSetting !== null) {
|
||||
existingSetting.userSubscription = Promise.resolve(userSubscription)
|
||||
await this.subscriptionSettingRepository.save(existingSetting)
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
await this.createOrReplace({
|
||||
userSubscription,
|
||||
@@ -114,4 +126,22 @@ export class SubscriptionSettingService implements SubscriptionSettingServiceInt
|
||||
subscriptionSetting,
|
||||
}
|
||||
}
|
||||
|
||||
private async findPreviousSubscriptionSetting(
|
||||
settingName: SubscriptionSettingName,
|
||||
currentUserSubscriptionUuid: Uuid,
|
||||
userUuid: Uuid,
|
||||
): Promise<SubscriptionSetting | null> {
|
||||
const userSubscriptions = await this.userSubscriptionRepository.findByUserUuid(userUuid)
|
||||
const previousSubscriptions = userSubscriptions.filter(
|
||||
(subscription) => subscription.uuid !== currentUserSubscriptionUuid,
|
||||
)
|
||||
const lastSubscription = previousSubscriptions.shift()
|
||||
|
||||
if (!lastSubscription) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid(settingName, lastSubscription.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SubscriptionName } from '@standardnotes/common'
|
||||
import { SubscriptionName, Uuid } from '@standardnotes/common'
|
||||
import { UserSubscription } from '../Subscription/UserSubscription'
|
||||
|
||||
import { CreateOrReplaceSubscriptionSettingDTO } from './CreateOrReplaceSubscriptionSettingDTO'
|
||||
@@ -10,6 +10,7 @@ export interface SubscriptionSettingServiceInterface {
|
||||
applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription: UserSubscription,
|
||||
subscriptionName: SubscriptionName,
|
||||
userUuid: Uuid,
|
||||
): Promise<void>
|
||||
createOrReplace(dto: CreateOrReplaceSubscriptionSettingDTO): Promise<CreateOrReplaceSubscriptionSettingResponse>
|
||||
findSubscriptionSettingWithDecryptedValue(dto: FindSubscriptionSettingDTO): Promise<SubscriptionSetting | null>
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('SubscriptionSettingsAssociationService', () => {
|
||||
sensitive: false,
|
||||
serverEncryptionVersion: 0,
|
||||
value: '107374182400',
|
||||
replaceable: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,6 +89,7 @@ describe('SubscriptionSettingsAssociationService', () => {
|
||||
sensitive: false,
|
||||
serverEncryptionVersion: 0,
|
||||
value: '104857600',
|
||||
replaceable: true,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export class SubscriptionSettingsAssociationService implements SubscriptionSetti
|
||||
new Map([
|
||||
[
|
||||
SubscriptionSettingName.FileUploadBytesUsed,
|
||||
{ sensitive: false, serverEncryptionVersion: EncryptionVersion.Unencrypted, value: '0' },
|
||||
{ sensitive: false, serverEncryptionVersion: EncryptionVersion.Unencrypted, value: '0', replaceable: false },
|
||||
],
|
||||
]),
|
||||
],
|
||||
@@ -37,7 +37,7 @@ export class SubscriptionSettingsAssociationService implements SubscriptionSetti
|
||||
new Map([
|
||||
[
|
||||
SubscriptionSettingName.FileUploadBytesUsed,
|
||||
{ sensitive: false, serverEncryptionVersion: EncryptionVersion.Unencrypted, value: '0' },
|
||||
{ sensitive: false, serverEncryptionVersion: EncryptionVersion.Unencrypted, value: '0', replaceable: false },
|
||||
],
|
||||
]),
|
||||
],
|
||||
@@ -56,6 +56,7 @@ export class SubscriptionSettingsAssociationService implements SubscriptionSetti
|
||||
sensitive: false,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
value: (await this.getFileUploadLimit(subscriptionName)).toString(),
|
||||
replaceable: true,
|
||||
})
|
||||
|
||||
return defaultSettings
|
||||
|
||||
@@ -6,10 +6,12 @@ export interface UserSubscriptionRepositoryInterface {
|
||||
findOneByUuid(uuid: Uuid): Promise<UserSubscription | null>
|
||||
countByUserUuid(userUuid: Uuid): Promise<number>
|
||||
findOneByUserUuid(userUuid: Uuid): Promise<UserSubscription | null>
|
||||
findByUserUuid(userUuid: Uuid): Promise<UserSubscription[]>
|
||||
findOneByUserUuidAndSubscriptionId(userUuid: Uuid, subscriptionId: number): Promise<UserSubscription | null>
|
||||
findBySubscriptionIdAndType(subscriptionId: number, type: UserSubscriptionType): Promise<UserSubscription[]>
|
||||
findBySubscriptionId(subscriptionId: number): Promise<UserSubscription[]>
|
||||
updateEndsAt(subscriptionId: number, endsAt: number, updatedAt: number): Promise<void>
|
||||
updateCancelled(subscriptionId: number, cancelled: boolean, updatedAt: number): Promise<void>
|
||||
countActiveSubscriptions(): Promise<number>
|
||||
save(subscription: UserSubscription): Promise<UserSubscription>
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ describe('AcceptSharedSubscriptionInvitation', () => {
|
||||
expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith(
|
||||
inviteeSubscription,
|
||||
'PLUS_PLAN',
|
||||
'123',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ export class AcceptSharedSubscriptionInvitation implements UseCaseInterface {
|
||||
await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription(
|
||||
inviteeSubscription,
|
||||
inviteeSubscription.planName as SubscriptionName,
|
||||
invitee.uuid,
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,14 +7,16 @@ import { UserSubscription } from '../../Domain/Subscription/UserSubscription'
|
||||
|
||||
import { MySQLUserSubscriptionRepository } from './MySQLUserSubscriptionRepository'
|
||||
import { UserSubscriptionType } from '../../Domain/Subscription/UserSubscriptionType'
|
||||
import { TimerInterface } from '@standardnotes/time'
|
||||
|
||||
describe('MySQLUserSubscriptionRepository', () => {
|
||||
let ormRepository: Repository<UserSubscription>
|
||||
let selectQueryBuilder: SelectQueryBuilder<UserSubscription>
|
||||
let updateQueryBuilder: UpdateQueryBuilder<UserSubscription>
|
||||
let subscription: UserSubscription
|
||||
let timer: TimerInterface
|
||||
|
||||
const createRepository = () => new MySQLUserSubscriptionRepository(ormRepository)
|
||||
const createRepository = () => new MySQLUserSubscriptionRepository(ormRepository, timer)
|
||||
|
||||
beforeEach(() => {
|
||||
selectQueryBuilder = {} as jest.Mocked<SelectQueryBuilder<UserSubscription>>
|
||||
@@ -28,6 +30,9 @@ describe('MySQLUserSubscriptionRepository', () => {
|
||||
ormRepository = {} as jest.Mocked<Repository<UserSubscription>>
|
||||
ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder)
|
||||
ormRepository.save = jest.fn()
|
||||
|
||||
timer = {} as jest.Mocked<TimerInterface>
|
||||
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
|
||||
})
|
||||
|
||||
it('should save', async () => {
|
||||
@@ -36,6 +41,47 @@ describe('MySQLUserSubscriptionRepository', () => {
|
||||
expect(ormRepository.save).toHaveBeenCalledWith(subscription)
|
||||
})
|
||||
|
||||
it('should find all subscriptions by user uuid', async () => {
|
||||
const canceledSubscription = {
|
||||
planName: SubscriptionName.ProPlan,
|
||||
cancelled: true,
|
||||
} as jest.Mocked<UserSubscription>
|
||||
|
||||
ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder)
|
||||
|
||||
selectQueryBuilder.where = jest.fn().mockReturnThis()
|
||||
selectQueryBuilder.orderBy = jest.fn().mockReturnThis()
|
||||
selectQueryBuilder.getMany = jest.fn().mockReturnValue([canceledSubscription, subscription])
|
||||
|
||||
const result = await createRepository().findByUserUuid('123')
|
||||
|
||||
expect(selectQueryBuilder.where).toHaveBeenCalledWith('user_uuid = :user_uuid', {
|
||||
user_uuid: '123',
|
||||
})
|
||||
expect(selectQueryBuilder.orderBy).toHaveBeenCalledWith('ends_at', 'DESC')
|
||||
expect(selectQueryBuilder.getMany).toHaveBeenCalled()
|
||||
expect(result).toEqual([canceledSubscription, subscription])
|
||||
})
|
||||
|
||||
it('should count all active subscriptions', async () => {
|
||||
ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder)
|
||||
|
||||
selectQueryBuilder.select = jest.fn().mockReturnThis()
|
||||
selectQueryBuilder.distinct = jest.fn().mockReturnThis()
|
||||
selectQueryBuilder.where = jest.fn().mockReturnThis()
|
||||
selectQueryBuilder.getCount = jest.fn().mockReturnValue(2)
|
||||
|
||||
const result = await createRepository().countActiveSubscriptions()
|
||||
|
||||
expect(selectQueryBuilder.select).toHaveBeenCalledWith('user_uuid')
|
||||
expect(selectQueryBuilder.distinct).toHaveBeenCalled()
|
||||
expect(selectQueryBuilder.where).toHaveBeenCalledWith('ends_at > :timestamp', {
|
||||
timestamp: 123,
|
||||
})
|
||||
expect(selectQueryBuilder.getCount).toHaveBeenCalled()
|
||||
expect(result).toEqual(2)
|
||||
})
|
||||
|
||||
it('should find one longest lasting uncanceled subscription by user uuid if there are canceled ones', async () => {
|
||||
const canceledSubscription = {
|
||||
planName: SubscriptionName.ProPlan,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { TimerInterface } from '@standardnotes/time'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import { Repository } from 'typeorm'
|
||||
import TYPES from '../../Bootstrap/Types'
|
||||
@@ -12,8 +13,28 @@ export class MySQLUserSubscriptionRepository implements UserSubscriptionReposito
|
||||
constructor(
|
||||
@inject(TYPES.ORMUserSubscriptionRepository)
|
||||
private ormRepository: Repository<UserSubscription>,
|
||||
@inject(TYPES.Timer) private timer: TimerInterface,
|
||||
) {}
|
||||
|
||||
async countActiveSubscriptions(): Promise<number> {
|
||||
return await this.ormRepository
|
||||
.createQueryBuilder()
|
||||
.select('user_uuid')
|
||||
.distinct()
|
||||
.where('ends_at > :timestamp', { timestamp: this.timer.getTimestampInMicroseconds() })
|
||||
.getCount()
|
||||
}
|
||||
|
||||
async findByUserUuid(userUuid: string): Promise<UserSubscription[]> {
|
||||
return await this.ormRepository
|
||||
.createQueryBuilder()
|
||||
.where('user_uuid = :user_uuid', {
|
||||
user_uuid: userUuid,
|
||||
})
|
||||
.orderBy('ends_at', 'DESC')
|
||||
.getMany()
|
||||
}
|
||||
|
||||
async countByUserUuid(userUuid: Uuid): Promise<number> {
|
||||
return await this.ormRepository
|
||||
.createQueryBuilder()
|
||||
|
||||
@@ -3,6 +3,22 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.8.15](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.8.14...@standardnotes/syncing-server@1.8.15) (2022-09-30)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/syncing-server
|
||||
|
||||
## [1.8.14](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.8.13...@standardnotes/syncing-server@1.8.14) (2022-09-30)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/syncing-server
|
||||
|
||||
## [1.8.13](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.8.12...@standardnotes/syncing-server@1.8.13) (2022-09-30)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/syncing-server
|
||||
|
||||
## [1.8.12](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.8.11...@standardnotes/syncing-server@1.8.12) (2022-09-30)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/syncing-server
|
||||
|
||||
## [1.8.11](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.8.10...@standardnotes/syncing-server@1.8.11) (2022-09-28)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/syncing-server
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/syncing-server",
|
||||
"version": "1.8.11",
|
||||
"version": "1.8.15",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user