@oneuptime/common 12.0.0 → 12.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/Models/DatabaseModels/Host.ts +12 -4
  2. package/Models/DatabaseModels/KubernetesContainer.ts +5 -4
  3. package/Models/DatabaseModels/KubernetesResource.ts +8 -7
  4. package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.ts +12 -2
  5. package/Models/DatabaseModels/PodmanResource.ts +6 -6
  6. package/Models/DatabaseModels/ServiceLevelObjective.ts +91 -0
  7. package/Models/DatabaseModels/StatusPage.ts +95 -0
  8. package/Server/API/TeamMemberAPI.ts +85 -0
  9. package/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.ts +12 -2
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.ts +84 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.ts +40 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.ts +131 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.ts +25 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  15. package/Server/Middleware/ProjectAuthorization.ts +66 -4
  16. package/Server/Services/BillingService.ts +600 -51
  17. package/Server/Services/CephClusterService.ts +47 -5
  18. package/Server/Services/CephResourceService.ts +43 -4
  19. package/Server/Services/CloudResourceService.ts +47 -5
  20. package/Server/Services/DatabaseService.ts +87 -6
  21. package/Server/Services/DockerHostService.ts +47 -5
  22. package/Server/Services/DockerResourceService.ts +62 -8
  23. package/Server/Services/DockerSwarmClusterService.ts +48 -5
  24. package/Server/Services/DockerSwarmResourceService.ts +53 -4
  25. package/Server/Services/HostService.ts +48 -5
  26. package/Server/Services/IoTDeviceService.ts +1 -17
  27. package/Server/Services/IoTFleetService.ts +47 -5
  28. package/Server/Services/KubernetesClusterService.ts +48 -5
  29. package/Server/Services/KubernetesContainerService.ts +50 -9
  30. package/Server/Services/KubernetesResourceService.ts +53 -7
  31. package/Server/Services/LabelService.ts +106 -1
  32. package/Server/Services/LlmProviderService.ts +69 -9
  33. package/Server/Services/MonitorService.ts +57 -0
  34. package/Server/Services/PodmanHostService.ts +47 -5
  35. package/Server/Services/PodmanResourceService.ts +62 -8
  36. package/Server/Services/ProjectService.ts +165 -35
  37. package/Server/Services/ProxmoxClusterService.ts +47 -5
  38. package/Server/Services/ProxmoxResourceService.ts +43 -4
  39. package/Server/Services/RumApplicationService.ts +48 -5
  40. package/Server/Services/RunnerService.ts +1 -7
  41. package/Server/Services/ServerlessFunctionService.ts +48 -5
  42. package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +426 -0
  43. package/Server/Services/ServiceLevelObjectiveService.ts +39 -0
  44. package/Server/Services/StatusPageService.ts +95 -36
  45. package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +18 -1
  46. package/Server/Services/StatusPageSubscriberService.ts +3 -0
  47. package/Server/Types/Database/JSONColumnQuery.ts +621 -0
  48. package/Server/Types/Database/Permissions/PublicPermission.ts +9 -2
  49. package/Server/Types/Database/QueryHelper.ts +17 -32
  50. package/Server/Utils/Database/TruncateColumnValue.ts +84 -0
  51. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +12 -0
  52. package/Tests/App/Dashboard/RunnerInstallInstructions.test.tsx +119 -0
  53. package/Tests/App/Dashboard/RunnerStatus.test.tsx +274 -0
  54. package/Tests/App/Dashboard/UsersTableGroupsByPerson.test.tsx +448 -0
  55. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +704 -0
  56. package/Tests/Server/API/TeamMemberRemoveUserFromProjectAPI.test.ts +303 -0
  57. package/Tests/Server/Middleware/MasterAdminAuthorization.test.ts +350 -0
  58. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyHeader.test.ts +362 -0
  59. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +687 -0
  60. package/Tests/Server/Services/BillingService.test.ts +78 -3
  61. package/Tests/Server/Services/BillingServiceChangePlanCancel.test.ts +645 -0
  62. package/Tests/Server/Services/BillingServiceTrialPlanChange.test.ts +1005 -0
  63. package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +503 -0
  64. package/Tests/Server/Services/HostIpAddressesColumnWidth.test.ts +198 -0
  65. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +510 -0
  66. package/Tests/Server/Services/InventoryResourceNameColumnWidth.test.ts +500 -0
  67. package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +586 -0
  68. package/Tests/Server/Services/InventoryUpsertTruncationGuard.test.ts +1242 -0
  69. package/Tests/Server/Services/LlmProviderUsableByProject.test.ts +293 -0
  70. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +650 -0
  71. package/Tests/Server/Services/ProjectServiceExtendTrial.test.ts +1 -0
  72. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +387 -0
  73. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +770 -0
  74. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +130 -0
  75. package/Tests/Server/Services/SloMonitorLabelRuleHooks.test.ts +352 -0
  76. package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +29 -13
  77. package/Tests/Server/TestingUtils/Services/BillingServiceHelper.ts +69 -8
  78. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +593 -0
  79. package/Tests/Server/Types/Database/Permissions/PublicPermission.test.ts +495 -0
  80. package/Tests/Server/Types/Database/QueryUtil.test.ts +113 -0
  81. package/Tests/Server/Utils/AI/ToolArgsExtractors.test.ts +141 -0
  82. package/Tests/Server/Utils/AI/Toolbox/WidgetBuilder.test.ts +205 -0
  83. package/Tests/Server/Utils/Database/TruncateColumnValue.test.ts +179 -0
  84. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +897 -0
  85. package/Tests/Types/Events/Recurring.test.ts +157 -0
  86. package/Tests/Types/Runner/RunnerLiveStatus.test.ts +320 -0
  87. package/Tests/UI/Components/AiInvestigationSettingsCard.test.tsx +285 -0
  88. package/Tests/UI/Components/Detail/EntityFields.test.tsx +166 -0
  89. package/Tests/UI/Components/ModelTable/ModelTableGroupsProjectUsers.test.tsx +375 -0
  90. package/Tests/UI/Components/ModelTable/useCustomFieldColumns.test.tsx +196 -0
  91. package/Tests/UI/Utils/ProjectUsersModelAPI.test.ts +739 -0
  92. package/Tests/UI/Utils/TeamMembersByUser.test.ts +633 -0
  93. package/Tests/Utils/StatusPage/ReportPeriod.test.ts +218 -0
  94. package/Tests/Utils/Telemetry/HostIpAddresses.test.ts +291 -0
  95. package/Types/CustomField/CustomFieldDefinition.ts +24 -0
  96. package/Types/Date.ts +112 -0
  97. package/Types/Events/Recurring.ts +99 -1
  98. package/Types/Runbook/RunbookStep.ts +15 -0
  99. package/Types/Runner/RunnerLiveStatus.ts +114 -0
  100. package/Types/StatusPage/StatusPageReport.ts +11 -0
  101. package/Types/StatusPage/StatusPageReportPeriodType.ts +21 -0
  102. package/UI/Components/Detail/Detail.tsx +155 -0
  103. package/UI/Components/ModelTable/CustomFieldColumns.tsx +2 -6
  104. package/UI/Components/ModelTable/useCustomFieldColumns.ts +21 -2
  105. package/UI/Utils/ModelAPI/ProjectUsersModelAPI.ts +305 -0
  106. package/UI/Utils/TeamMembersByUser.ts +302 -0
  107. package/Utils/StatusPage/ReportPeriod.ts +236 -0
  108. package/Utils/Telemetry/HostIpAddresses.ts +98 -0
  109. package/build/dist/Models/DatabaseModels/Host.js +12 -4
  110. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  111. package/build/dist/Models/DatabaseModels/KubernetesContainer.js +4 -4
  112. package/build/dist/Models/DatabaseModels/KubernetesContainer.js.map +1 -1
  113. package/build/dist/Models/DatabaseModels/KubernetesResource.js +7 -7
  114. package/build/dist/Models/DatabaseModels/KubernetesResource.js.map +1 -1
  115. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js +16 -4
  116. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js.map +1 -1
  117. package/build/dist/Models/DatabaseModels/PodmanResource.js +6 -6
  118. package/build/dist/Models/DatabaseModels/PodmanResource.js.map +1 -1
  119. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +87 -0
  120. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
  121. package/build/dist/Models/DatabaseModels/StatusPage.js +98 -0
  122. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  123. package/build/dist/Server/API/TeamMemberAPI.js +57 -2
  124. package/build/dist/Server/API/TeamMemberAPI.js.map +1 -1
  125. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js +11 -1
  126. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js.map +1 -1
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js +45 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js.map +1 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js +31 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js.map +1 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js +90 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js.map +1 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js +14 -0
  134. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js.map +1 -0
  135. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  136. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  137. package/build/dist/Server/Middleware/ProjectAuthorization.js +59 -4
  138. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  139. package/build/dist/Server/Services/BillingService.js +466 -36
  140. package/build/dist/Server/Services/BillingService.js.map +1 -1
  141. package/build/dist/Server/Services/CephClusterService.js +42 -5
  142. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  143. package/build/dist/Server/Services/CephResourceService.js +28 -3
  144. package/build/dist/Server/Services/CephResourceService.js.map +1 -1
  145. package/build/dist/Server/Services/CloudResourceService.js +42 -5
  146. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  147. package/build/dist/Server/Services/DatabaseService.js +69 -2
  148. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  149. package/build/dist/Server/Services/DockerHostService.js +42 -5
  150. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  151. package/build/dist/Server/Services/DockerResourceService.js +34 -4
  152. package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
  153. package/build/dist/Server/Services/DockerSwarmClusterService.js +43 -5
  154. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  155. package/build/dist/Server/Services/DockerSwarmResourceService.js +28 -3
  156. package/build/dist/Server/Services/DockerSwarmResourceService.js.map +1 -1
  157. package/build/dist/Server/Services/HostService.js +43 -5
  158. package/build/dist/Server/Services/HostService.js.map +1 -1
  159. package/build/dist/Server/Services/IoTDeviceService.js +1 -8
  160. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  161. package/build/dist/Server/Services/IoTFleetService.js +42 -5
  162. package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
  163. package/build/dist/Server/Services/KubernetesClusterService.js +43 -5
  164. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  165. package/build/dist/Server/Services/KubernetesContainerService.js +29 -3
  166. package/build/dist/Server/Services/KubernetesContainerService.js.map +1 -1
  167. package/build/dist/Server/Services/KubernetesResourceService.js +32 -4
  168. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  169. package/build/dist/Server/Services/LabelService.js +92 -0
  170. package/build/dist/Server/Services/LabelService.js.map +1 -1
  171. package/build/dist/Server/Services/LlmProviderService.js +61 -8
  172. package/build/dist/Server/Services/LlmProviderService.js.map +1 -1
  173. package/build/dist/Server/Services/MonitorService.js +47 -0
  174. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  175. package/build/dist/Server/Services/PodmanHostService.js +42 -5
  176. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  177. package/build/dist/Server/Services/PodmanResourceService.js +34 -4
  178. package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
  179. package/build/dist/Server/Services/ProjectService.js +135 -21
  180. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  181. package/build/dist/Server/Services/ProxmoxClusterService.js +42 -5
  182. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  183. package/build/dist/Server/Services/ProxmoxResourceService.js +28 -3
  184. package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
  185. package/build/dist/Server/Services/RumApplicationService.js +43 -5
  186. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  187. package/build/dist/Server/Services/RunnerService.js +1 -6
  188. package/build/dist/Server/Services/RunnerService.js.map +1 -1
  189. package/build/dist/Server/Services/ServerlessFunctionService.js +43 -5
  190. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  191. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +347 -0
  192. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -0
  193. package/build/dist/Server/Services/ServiceLevelObjectiveService.js +33 -1
  194. package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
  195. package/build/dist/Server/Services/StatusPageService.js +69 -46
  196. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  197. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +17 -1
  198. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
  199. package/build/dist/Server/Services/StatusPageSubscriberService.js +3 -0
  200. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  201. package/build/dist/Server/Types/Database/JSONColumnQuery.js +402 -0
  202. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -0
  203. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js +9 -2
  204. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js.map +1 -1
  205. package/build/dist/Server/Types/Database/QueryHelper.js +17 -27
  206. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  207. package/build/dist/Server/Utils/Database/TruncateColumnValue.js +32 -0
  208. package/build/dist/Server/Utils/Database/TruncateColumnValue.js.map +1 -0
  209. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +11 -0
  210. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  211. package/build/dist/Types/CustomField/CustomFieldDefinition.js +2 -0
  212. package/build/dist/Types/CustomField/CustomFieldDefinition.js.map +1 -0
  213. package/build/dist/Types/Date.js +68 -0
  214. package/build/dist/Types/Date.js.map +1 -1
  215. package/build/dist/Types/Events/Recurring.js +63 -0
  216. package/build/dist/Types/Events/Recurring.js.map +1 -1
  217. package/build/dist/Types/Runbook/RunbookStep.js.map +1 -1
  218. package/build/dist/Types/Runner/RunnerLiveStatus.js +69 -0
  219. package/build/dist/Types/Runner/RunnerLiveStatus.js.map +1 -0
  220. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js +22 -0
  221. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js.map +1 -0
  222. package/build/dist/UI/Components/Detail/Detail.js +97 -0
  223. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  224. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  225. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +18 -2
  226. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -1
  227. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js +213 -0
  228. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js.map +1 -0
  229. package/build/dist/UI/Utils/TeamMembersByUser.js +195 -0
  230. package/build/dist/UI/Utils/TeamMembersByUser.js.map +1 -0
  231. package/build/dist/Utils/StatusPage/ReportPeriod.js +131 -0
  232. package/build/dist/Utils/StatusPage/ReportPeriod.js.map +1 -0
  233. package/build/dist/Utils/Telemetry/HostIpAddresses.js +82 -0
  234. package/build/dist/Utils/Telemetry/HostIpAddresses.js.map +1 -0
  235. package/jest.config.json +2 -0
  236. package/package.json +1 -1
  237. package/tsconfig.json +12 -1
@@ -0,0 +1,650 @@
1
+ import ProjectService from "../../../Server/Services/ProjectService";
2
+ import BillingService from "../../../Server/Services/BillingService";
3
+ import TeamMemberService from "../../../Server/Services/TeamMemberService";
4
+ import Project from "../../../Models/DatabaseModels/Project";
5
+ import SubscriptionPlan from "../../../Types/Billing/SubscriptionPlan";
6
+ import SubscriptionStatus from "../../../Types/Billing/SubscriptionStatus";
7
+ import OneUptimeDate from "../../../Types/Date";
8
+ import ObjectID from "../../../Types/ObjectID";
9
+ import logger from "../../../Server/Utils/Logger";
10
+ import { describe, expect, it, afterEach } from "@jest/globals";
11
+
12
+ /*
13
+ * ProjectService.changePlan is the hop that hands a project's running trial to
14
+ * the payment provider. If the trial does not make it across, the customer is
15
+ * billed the full plan the moment they pick a different one - which is what
16
+ * upgrading mid-trial used to do.
17
+ *
18
+ * Everything below the service boundary is spied: no database, no Stripe.
19
+ */
20
+
21
+ jest.mock("../../../Server/EnvironmentConfig", () => {
22
+ return {
23
+ ...jest.requireActual("../../../Server/EnvironmentConfig"),
24
+ IsBillingEnabled: true,
25
+
26
+ /*
27
+ * config.env carries a real incoming webhook URL, and changePlan announces
28
+ * every plan change on it. Blanked so the suite cannot post to Slack.
29
+ */
30
+ NotificationSlackWebhookOnSubscriptionUpdate: "",
31
+ };
32
+ });
33
+
34
+ const PROJECT_ID: ObjectID = new ObjectID(
35
+ "11111111-1111-4111-8111-111111111111",
36
+ );
37
+ const CUSTOMER_ID: string = "cus_789";
38
+ const SUBSCRIPTION_ID: string = "sub_main_123";
39
+ const METERED_SUBSCRIPTION_ID: string = "sub_metered_456";
40
+ const NEW_SUBSCRIPTION_ID: string = "sub_main_new_789";
41
+ const NEW_METERED_SUBSCRIPTION_ID: string = "sub_metered_new_012";
42
+ const NEW_PLAN_ID: string = "price_monthly_scale";
43
+
44
+ function fakeProject(overrides?: Record<string, unknown>): Project {
45
+ return {
46
+ id: PROJECT_ID,
47
+ _id: PROJECT_ID.toString(),
48
+ trialEndsAt: OneUptimeDate.getSomeDaysAfter(9),
49
+ paymentProviderCustomerId: CUSTOMER_ID,
50
+ paymentProviderSubscriptionId: SUBSCRIPTION_ID,
51
+ paymentProviderMeteredSubscriptionId: METERED_SUBSCRIPTION_ID,
52
+ paymentProviderSubscriptionSeats: 4,
53
+ paymentProviderPlanId: "price_monthly_growth",
54
+
55
+ /*
56
+ * Left unset on purpose: capturePlanChangeAnalytics returns early without
57
+ * an owner email, so the suite never reaches the analytics client.
58
+ */
59
+ ...overrides,
60
+ } as unknown as Project;
61
+ }
62
+
63
+ describe("ProjectService.changePlan", () => {
64
+ afterEach(() => {
65
+ jest.restoreAllMocks();
66
+ });
67
+
68
+ interface ChangePlanSpies {
69
+ findOneById: jest.SpyInstance;
70
+ changePlan: jest.SpyInstance;
71
+ hasPaymentMethods: jest.SpyInstance;
72
+ getSubscriptionStatus: jest.SpyInstance;
73
+ updateOneById: jest.SpyInstance;
74
+ getUniqueTeamMemberCountInProject: jest.SpyInstance;
75
+ }
76
+
77
+ function setup(data?: {
78
+ project?: Project | null;
79
+
80
+ /*
81
+ * The trial the payment provider reports on the subscriptions it created.
82
+ * Omitted means it reported none.
83
+ */
84
+ billingTrialEndsAt?: Date | undefined;
85
+
86
+ // Trial period configured on the plan being moved to. 0 for Scale/Basic.
87
+ newPlanTrialPeriodInDays?: number;
88
+
89
+ /*
90
+ * Subscriptions changePlan replaced but could not cancel. They are about
91
+ * to disappear from the project row, so the plan change has to raise them.
92
+ */
93
+ subscriptionIdsPendingCancellation?: Array<string> | undefined;
94
+ }): ChangePlanSpies {
95
+ const findOneById: jest.SpyInstance = jest
96
+ .spyOn(ProjectService, "findOneById")
97
+ .mockResolvedValue(
98
+ data && data.project !== undefined ? data.project : fakeProject(),
99
+ );
100
+
101
+ jest
102
+ .spyOn(SubscriptionPlan, "getSubscriptionPlanById")
103
+ .mockReturnValue(
104
+ new SubscriptionPlan(
105
+ NEW_PLAN_ID,
106
+ "price_yearly_scale",
107
+ "Scale",
108
+ 99,
109
+ 84,
110
+ 3,
111
+ data?.newPlanTrialPeriodInDays ?? 0,
112
+ ),
113
+ );
114
+
115
+ const hasPaymentMethods: jest.SpyInstance = jest
116
+ .spyOn(BillingService, "hasPaymentMethods")
117
+ .mockResolvedValue(true as never);
118
+
119
+ const changePlan: jest.SpyInstance = jest
120
+ .spyOn(BillingService, "changePlan")
121
+ .mockResolvedValue({
122
+ subscriptionId: NEW_SUBSCRIPTION_ID,
123
+ meteredSubscriptionId: NEW_METERED_SUBSCRIPTION_ID,
124
+ trialEndsAt: data?.billingTrialEndsAt,
125
+ subscriptionIdsPendingCancellation:
126
+ data?.subscriptionIdsPendingCancellation || [],
127
+ } as never);
128
+
129
+ const getSubscriptionStatus: jest.SpyInstance = jest
130
+ .spyOn(BillingService, "getSubscriptionStatus")
131
+ .mockResolvedValue(SubscriptionStatus.Trialing as never);
132
+
133
+ const updateOneById: jest.SpyInstance = jest
134
+ .spyOn(ProjectService, "updateOneById")
135
+ .mockResolvedValue(undefined as never);
136
+
137
+ const getUniqueTeamMemberCountInProject: jest.SpyInstance = jest
138
+ .spyOn(TeamMemberService, "getUniqueTeamMemberCountInProject")
139
+ .mockResolvedValue(7 as never);
140
+
141
+ return {
142
+ findOneById,
143
+ changePlan,
144
+ hasPaymentMethods,
145
+ getSubscriptionStatus,
146
+ updateOneById,
147
+ getUniqueTeamMemberCountInProject,
148
+ };
149
+ }
150
+
151
+ function getUpdatedData(spies: ChangePlanSpies): Record<string, unknown> {
152
+ return (
153
+ spies.updateOneById.mock.calls[0]![0] as {
154
+ data: Record<string, unknown>;
155
+ }
156
+ ).data;
157
+ }
158
+
159
+ describe("reading the project", () => {
160
+ it("should select the trial date", async () => {
161
+ /*
162
+ * Without this column in the select the trial reads back undefined, the
163
+ * plan change is made with no trial, and the customer is charged. Same
164
+ * failure mode the extend-trial suite guards.
165
+ */
166
+ const spies: ChangePlanSpies = setup();
167
+
168
+ await ProjectService.changePlan({
169
+ projectId: PROJECT_ID,
170
+ paymentProviderPlanId: NEW_PLAN_ID,
171
+ });
172
+
173
+ expect(spies.findOneById).toHaveBeenCalledWith(
174
+ expect.objectContaining({
175
+ select: expect.objectContaining({ trialEndsAt: true }),
176
+ }),
177
+ );
178
+ });
179
+ });
180
+
181
+ describe("carrying the running trial to the payment provider", () => {
182
+ it("should hand the project's trial over when it is still running", async () => {
183
+ /*
184
+ * This assertion is the feature. The trial is what stops Stripe raising
185
+ * an invoice for the new plan on the spot.
186
+ */
187
+ const runningTrialEndsAt: Date = OneUptimeDate.getSomeDaysAfter(9);
188
+ const spies: ChangePlanSpies = setup({
189
+ project: fakeProject({ trialEndsAt: runningTrialEndsAt }),
190
+ });
191
+
192
+ await ProjectService.changePlan({
193
+ projectId: PROJECT_ID,
194
+ paymentProviderPlanId: NEW_PLAN_ID,
195
+ });
196
+
197
+ expect(spies.changePlan).toHaveBeenCalledWith(
198
+ expect.objectContaining({ endTrialAt: runningTrialEndsAt }),
199
+ );
200
+ });
201
+
202
+ it("should hand the trial over even when the plan moved to has no trial period of its own", async () => {
203
+ /*
204
+ * Scale and Basic are configured with 0 trial days. That says how long a
205
+ * NEW trial on them runs - it must not shorten a trial the customer is
206
+ * already in the middle of.
207
+ */
208
+ const runningTrialEndsAt: Date = OneUptimeDate.getSomeDaysAfter(9);
209
+ const spies: ChangePlanSpies = setup({
210
+ project: fakeProject({ trialEndsAt: runningTrialEndsAt }),
211
+ newPlanTrialPeriodInDays: 0,
212
+ });
213
+
214
+ await ProjectService.changePlan({
215
+ projectId: PROJECT_ID,
216
+ paymentProviderPlanId: NEW_PLAN_ID,
217
+ });
218
+
219
+ expect(spies.changePlan).toHaveBeenCalledWith(
220
+ expect.objectContaining({ endTrialAt: runningTrialEndsAt }),
221
+ );
222
+ });
223
+
224
+ it("should let an explicitly supplied trial date win over the project's", async () => {
225
+ const explicitTrialEndsAt: Date = OneUptimeDate.getSomeDaysAfter(30);
226
+ const spies: ChangePlanSpies = setup({
227
+ project: fakeProject({
228
+ trialEndsAt: OneUptimeDate.getSomeDaysAfter(2),
229
+ }),
230
+ });
231
+
232
+ await ProjectService.changePlan({
233
+ projectId: PROJECT_ID,
234
+ paymentProviderPlanId: NEW_PLAN_ID,
235
+ endTrialAt: explicitTrialEndsAt,
236
+ });
237
+
238
+ expect(spies.changePlan).toHaveBeenCalledWith(
239
+ expect.objectContaining({ endTrialAt: explicitTrialEndsAt }),
240
+ );
241
+ });
242
+
243
+ it("should end the trial when null is explicitly supplied", async () => {
244
+ /*
245
+ * null is the caller saying "no trial", as distinct from undefined,
246
+ * which means "whatever the project has".
247
+ */
248
+ const spies: ChangePlanSpies = setup({
249
+ project: fakeProject({
250
+ trialEndsAt: OneUptimeDate.getSomeDaysAfter(9),
251
+ }),
252
+ });
253
+
254
+ await ProjectService.changePlan({
255
+ projectId: PROJECT_ID,
256
+ paymentProviderPlanId: NEW_PLAN_ID,
257
+ endTrialAt: null,
258
+ });
259
+
260
+ expect(spies.changePlan).toHaveBeenCalledWith(
261
+ expect.objectContaining({ endTrialAt: undefined }),
262
+ );
263
+ });
264
+
265
+ it("should hand over no trial when the project has none", async () => {
266
+ const spies: ChangePlanSpies = setup({
267
+ project: fakeProject({ trialEndsAt: null }),
268
+ });
269
+
270
+ await ProjectService.changePlan({
271
+ projectId: PROJECT_ID,
272
+ paymentProviderPlanId: NEW_PLAN_ID,
273
+ });
274
+
275
+ expect(spies.changePlan).toHaveBeenCalledWith(
276
+ expect.objectContaining({ endTrialAt: undefined }),
277
+ );
278
+ });
279
+
280
+ it("should forward a lapsed trial date and leave the payment provider to drop it", async () => {
281
+ /*
282
+ * A single place decides whether a trial is still live, and it is the one
283
+ * next to the payment provider call. Filtering here as well would mean
284
+ * two answers to the same question.
285
+ */
286
+ const lapsed: Date = OneUptimeDate.addRemoveDays(
287
+ OneUptimeDate.getCurrentDate(),
288
+ -5,
289
+ );
290
+ const spies: ChangePlanSpies = setup({
291
+ project: fakeProject({ trialEndsAt: lapsed }),
292
+ });
293
+
294
+ await ProjectService.changePlan({
295
+ projectId: PROJECT_ID,
296
+ paymentProviderPlanId: NEW_PLAN_ID,
297
+ });
298
+
299
+ expect(spies.changePlan).toHaveBeenCalledWith(
300
+ expect.objectContaining({ endTrialAt: lapsed }),
301
+ );
302
+ });
303
+ });
304
+
305
+ describe("writing the result back to the project", () => {
306
+ it("should record the trial the payment provider reports", async () => {
307
+ const trialFromBilling: Date = OneUptimeDate.getSomeDaysAfter(9);
308
+ const spies: ChangePlanSpies = setup({
309
+ billingTrialEndsAt: trialFromBilling,
310
+ });
311
+
312
+ await ProjectService.changePlan({
313
+ projectId: PROJECT_ID,
314
+ paymentProviderPlanId: NEW_PLAN_ID,
315
+ });
316
+
317
+ expect(getUpdatedData(spies)["trialEndsAt"]).toEqual(trialFromBilling);
318
+ });
319
+
320
+ it("should prefer the payment provider's trial over the one it sent", async () => {
321
+ /*
322
+ * The payment provider is the source of truth for what the customer is
323
+ * actually trialing on, so a date it reports back wins over the request.
324
+ */
325
+ const trialFromBilling: Date = OneUptimeDate.getSomeDaysAfter(20);
326
+ const spies: ChangePlanSpies = setup({
327
+ project: fakeProject({
328
+ trialEndsAt: OneUptimeDate.getSomeDaysAfter(9),
329
+ }),
330
+ billingTrialEndsAt: trialFromBilling,
331
+ });
332
+
333
+ await ProjectService.changePlan({
334
+ projectId: PROJECT_ID,
335
+ paymentProviderPlanId: NEW_PLAN_ID,
336
+ });
337
+
338
+ expect(getUpdatedData(spies)["trialEndsAt"]).toEqual(trialFromBilling);
339
+ });
340
+
341
+ it("should keep the requested trial when the payment provider reports none", async () => {
342
+ const runningTrialEndsAt: Date = OneUptimeDate.getSomeDaysAfter(9);
343
+ const spies: ChangePlanSpies = setup({
344
+ project: fakeProject({ trialEndsAt: runningTrialEndsAt }),
345
+ billingTrialEndsAt: undefined,
346
+ });
347
+
348
+ await ProjectService.changePlan({
349
+ projectId: PROJECT_ID,
350
+ paymentProviderPlanId: NEW_PLAN_ID,
351
+ });
352
+
353
+ expect(getUpdatedData(spies)["trialEndsAt"]).toEqual(runningTrialEndsAt);
354
+ });
355
+
356
+ it("should mark the trial as over when there is none on either side", async () => {
357
+ const spies: ChangePlanSpies = setup({
358
+ project: fakeProject({ trialEndsAt: null }),
359
+ billingTrialEndsAt: undefined,
360
+ });
361
+
362
+ await ProjectService.changePlan({
363
+ projectId: PROJECT_ID,
364
+ paymentProviderPlanId: NEW_PLAN_ID,
365
+ });
366
+
367
+ const written: Date = getUpdatedData(spies)["trialEndsAt"] as Date;
368
+
369
+ expect(written).toBeInstanceOf(Date);
370
+ expect(OneUptimeDate.isInTheFuture(written)).toBe(false);
371
+ });
372
+
373
+ it("should record the new subscriptions, plan and seat count", async () => {
374
+ const spies: ChangePlanSpies = setup();
375
+
376
+ await ProjectService.changePlan({
377
+ projectId: PROJECT_ID,
378
+ paymentProviderPlanId: NEW_PLAN_ID,
379
+ });
380
+
381
+ expect(getUpdatedData(spies)).toEqual(
382
+ expect.objectContaining({
383
+ paymentProviderPlanId: NEW_PLAN_ID,
384
+ paymentProviderSubscriptionId: NEW_SUBSCRIPTION_ID,
385
+ paymentProviderMeteredSubscriptionId: NEW_METERED_SUBSCRIPTION_ID,
386
+ paymentProviderSubscriptionSeats: 4,
387
+ planName: "Scale",
388
+ paymentProviderSubscriptionStatus: SubscriptionStatus.Trialing,
389
+ paymentProviderMeteredSubscriptionStatus: SubscriptionStatus.Trialing,
390
+ }),
391
+ );
392
+ });
393
+
394
+ it("should write the project row only after the payment provider has accepted the change", async () => {
395
+ /*
396
+ * The payment provider decides what the customer is charged, so it goes
397
+ * first: a rejected change must leave the project on its old plan and its
398
+ * old trial rather than showing a plan the customer is not on.
399
+ */
400
+ const spies: ChangePlanSpies = setup();
401
+ spies.changePlan.mockRejectedValue(new Error("card declined") as never);
402
+
403
+ await expect(
404
+ ProjectService.changePlan({
405
+ projectId: PROJECT_ID,
406
+ paymentProviderPlanId: NEW_PLAN_ID,
407
+ }),
408
+ ).rejects.toThrow("card declined");
409
+
410
+ expect(spies.updateOneById).not.toHaveBeenCalled();
411
+ });
412
+ });
413
+
414
+ describe("seats", () => {
415
+ it("should reuse the stored seat count", async () => {
416
+ const spies: ChangePlanSpies = setup();
417
+
418
+ await ProjectService.changePlan({
419
+ projectId: PROJECT_ID,
420
+ paymentProviderPlanId: NEW_PLAN_ID,
421
+ });
422
+
423
+ expect(spies.getUniqueTeamMemberCountInProject).not.toHaveBeenCalled();
424
+ expect(spies.changePlan).toHaveBeenCalledWith(
425
+ expect.objectContaining({ quantity: 4 }),
426
+ );
427
+ });
428
+
429
+ it("should count team members when no seat count is stored", async () => {
430
+ const spies: ChangePlanSpies = setup({
431
+ project: fakeProject({ paymentProviderSubscriptionSeats: 0 }),
432
+ });
433
+
434
+ await ProjectService.changePlan({
435
+ projectId: PROJECT_ID,
436
+ paymentProviderPlanId: NEW_PLAN_ID,
437
+ });
438
+
439
+ expect(spies.getUniqueTeamMemberCountInProject).toHaveBeenCalledWith(
440
+ PROJECT_ID,
441
+ );
442
+ expect(spies.changePlan).toHaveBeenCalledWith(
443
+ expect.objectContaining({ quantity: 7 }),
444
+ );
445
+ });
446
+ });
447
+
448
+ describe("refusing to change plan", () => {
449
+ it("should throw when the project does not exist", async () => {
450
+ setup({ project: null });
451
+
452
+ await expect(
453
+ ProjectService.changePlan({
454
+ projectId: PROJECT_ID,
455
+ paymentProviderPlanId: NEW_PLAN_ID,
456
+ }),
457
+ ).rejects.toThrow("Project not found");
458
+ });
459
+
460
+ it("should throw when the customer has no payment method", async () => {
461
+ const spies: ChangePlanSpies = setup();
462
+ spies.hasPaymentMethods.mockResolvedValue(false as never);
463
+
464
+ await expect(
465
+ ProjectService.changePlan({
466
+ projectId: PROJECT_ID,
467
+ paymentProviderPlanId: NEW_PLAN_ID,
468
+ }),
469
+ ).rejects.toThrow();
470
+
471
+ expect(spies.changePlan).not.toHaveBeenCalled();
472
+ });
473
+
474
+ it("should throw when the plan id is not a known plan", async () => {
475
+ const spies: ChangePlanSpies = setup();
476
+ (
477
+ SubscriptionPlan.getSubscriptionPlanById as unknown as jest.SpyInstance
478
+ ).mockReturnValue(undefined);
479
+
480
+ await expect(
481
+ ProjectService.changePlan({
482
+ projectId: PROJECT_ID,
483
+ paymentProviderPlanId: "not_a_plan",
484
+ }),
485
+ ).rejects.toThrow("Invalid plan");
486
+
487
+ expect(spies.changePlan).not.toHaveBeenCalled();
488
+ });
489
+
490
+ it("should throw when the project has no subscription to change", async () => {
491
+ const spies: ChangePlanSpies = setup({
492
+ project: fakeProject({ paymentProviderSubscriptionId: null }),
493
+ });
494
+
495
+ await expect(
496
+ ProjectService.changePlan({
497
+ projectId: PROJECT_ID,
498
+ paymentProviderPlanId: NEW_PLAN_ID,
499
+ }),
500
+ ).rejects.toThrow("Payment Provider subscription not found");
501
+
502
+ expect(spies.changePlan).not.toHaveBeenCalled();
503
+ });
504
+
505
+ it("should throw when the project has no metered subscription to change", async () => {
506
+ const spies: ChangePlanSpies = setup({
507
+ project: fakeProject({
508
+ paymentProviderMeteredSubscriptionId: null,
509
+ }),
510
+ });
511
+
512
+ await expect(
513
+ ProjectService.changePlan({
514
+ projectId: PROJECT_ID,
515
+ paymentProviderPlanId: NEW_PLAN_ID,
516
+ }),
517
+ ).rejects.toThrow("Payment Provider metered subscription not found");
518
+
519
+ expect(spies.changePlan).not.toHaveBeenCalled();
520
+ });
521
+ });
522
+
523
+ /*
524
+ * A plan change can replace a project's subscriptions, and the row written
525
+ * above is what makes the old ids unreachable - it now carries the new ones.
526
+ * A replaced subscription that could not be cancelled therefore has to be
527
+ * raised here, with its id, or nobody ever finds it again.
528
+ */
529
+ describe("subscriptions that could not be cancelled", () => {
530
+ it("should raise the ids of subscriptions left open at the payment provider", async () => {
531
+ const spies: ChangePlanSpies = setup({
532
+ subscriptionIdsPendingCancellation: [
533
+ SUBSCRIPTION_ID,
534
+ METERED_SUBSCRIPTION_ID,
535
+ ],
536
+ });
537
+
538
+ const error: jest.SpyInstance = jest
539
+ .spyOn(logger, "error")
540
+ .mockReturnValue(undefined);
541
+
542
+ await ProjectService.changePlan({
543
+ projectId: PROJECT_ID,
544
+ paymentProviderPlanId: NEW_PLAN_ID,
545
+ });
546
+
547
+ const raised: string = error.mock.calls
548
+ .map((call: Array<unknown>) => {
549
+ return String(call[0]);
550
+ })
551
+ .join("\n");
552
+
553
+ expect(raised).toContain(SUBSCRIPTION_ID);
554
+ expect(raised).toContain(METERED_SUBSCRIPTION_ID);
555
+
556
+ // And the plan change itself still went through.
557
+ expect(getUpdatedData(spies)).toMatchObject({
558
+ paymentProviderSubscriptionId: NEW_SUBSCRIPTION_ID,
559
+ paymentProviderMeteredSubscriptionId: NEW_METERED_SUBSCRIPTION_ID,
560
+ });
561
+ });
562
+
563
+ it("should stay quiet when every replaced subscription was cancelled", async () => {
564
+ setup({ subscriptionIdsPendingCancellation: [] });
565
+
566
+ const error: jest.SpyInstance = jest
567
+ .spyOn(logger, "error")
568
+ .mockReturnValue(undefined);
569
+
570
+ await ProjectService.changePlan({
571
+ projectId: PROJECT_ID,
572
+ paymentProviderPlanId: NEW_PLAN_ID,
573
+ });
574
+
575
+ expect(error).not.toHaveBeenCalled();
576
+ });
577
+ });
578
+
579
+ /*
580
+ * The subscription ids changePlan returns are the ONLY record of the
581
+ * subscriptions it created: the replacement already exists at the payment
582
+ * provider and is already billing, and nothing else in OneUptime points at
583
+ * it. Reading a status back sits between the two, and a rate limit there
584
+ * must not be what stops the ids being written down - reactivation is
585
+ * retried from BillingInvoiceService, so a row left pointing at the old id
586
+ * mints another live subscription every time it runs.
587
+ */
588
+ describe("reading the new subscription statuses back", () => {
589
+ it("should still record the new subscription ids when the status cannot be read", async () => {
590
+ const spies: ChangePlanSpies = setup();
591
+
592
+ spies.getSubscriptionStatus.mockRejectedValue(
593
+ new Error("Request rate limit exceeded") as never,
594
+ );
595
+
596
+ jest.spyOn(logger, "error").mockReturnValue(undefined);
597
+
598
+ await ProjectService.changePlan({
599
+ projectId: PROJECT_ID,
600
+ paymentProviderPlanId: NEW_PLAN_ID,
601
+ });
602
+
603
+ expect(spies.updateOneById).toHaveBeenCalled();
604
+ expect(getUpdatedData(spies)).toMatchObject({
605
+ paymentProviderSubscriptionId: NEW_SUBSCRIPTION_ID,
606
+ paymentProviderMeteredSubscriptionId: NEW_METERED_SUBSCRIPTION_ID,
607
+ });
608
+ });
609
+
610
+ it("should leave the status columns alone rather than guess at them", async () => {
611
+ const spies: ChangePlanSpies = setup();
612
+
613
+ spies.getSubscriptionStatus.mockRejectedValue(
614
+ new Error("Request rate limit exceeded") as never,
615
+ );
616
+
617
+ jest.spyOn(logger, "error").mockReturnValue(undefined);
618
+
619
+ await ProjectService.changePlan({
620
+ projectId: PROJECT_ID,
621
+ paymentProviderPlanId: NEW_PLAN_ID,
622
+ });
623
+
624
+ /*
625
+ * Left for PaymentProvider:CheckSubscriptionStatus to fill in. Writing a
626
+ * guess would drive the paywall off a status nobody read.
627
+ */
628
+ expect(getUpdatedData(spies)).not.toHaveProperty(
629
+ "paymentProviderSubscriptionStatus",
630
+ );
631
+ expect(getUpdatedData(spies)).not.toHaveProperty(
632
+ "paymentProviderMeteredSubscriptionStatus",
633
+ );
634
+ });
635
+
636
+ it("should record the statuses when they can be read", async () => {
637
+ const spies: ChangePlanSpies = setup();
638
+
639
+ await ProjectService.changePlan({
640
+ projectId: PROJECT_ID,
641
+ paymentProviderPlanId: NEW_PLAN_ID,
642
+ });
643
+
644
+ expect(getUpdatedData(spies)).toMatchObject({
645
+ paymentProviderSubscriptionStatus: SubscriptionStatus.Trialing,
646
+ paymentProviderMeteredSubscriptionStatus: SubscriptionStatus.Trialing,
647
+ });
648
+ });
649
+ });
650
+ });
@@ -404,6 +404,7 @@ describe("ProjectService.reactiveSubscription", () => {
404
404
  subscriptionId: REACTIVATED_SUBSCRIPTION_ID,
405
405
  meteredSubscriptionId: REACTIVATED_METERED_SUBSCRIPTION_ID,
406
406
  trialEndsAt: data?.billingTrialEndsAt,
407
+ subscriptionIdsPendingCancellation: [],
407
408
  } as never);
408
409
 
409
410
  const getSubscriptionStatus: jest.SpyInstance = jest