@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,645 @@
1
+ import getJestMockFunction from "../../MockType";
2
+ import {
3
+ BillingService,
4
+ PaymentMethod,
5
+ } from "../../../Server/Services/BillingService";
6
+ import {
7
+ getStripeSubscription,
8
+ getSubscriptionPlanWithTrialPeriod,
9
+ mockIsBillingEnabled,
10
+ } from "../TestingUtils/Services/BillingServiceHelper";
11
+ import { Stripe, mockStripe } from "../TestingUtils/__mocks__/Stripe.mock";
12
+ import {
13
+ describe,
14
+ expect,
15
+ beforeAll,
16
+ beforeEach,
17
+ it,
18
+ jest,
19
+ } from "@jest/globals";
20
+ import SubscriptionPlan from "../../../Types/Billing/SubscriptionPlan";
21
+ import ObjectID from "../../../Types/ObjectID";
22
+
23
+ /*
24
+ * Changing plan must never leave the customer paying for a subscription that
25
+ * OneUptime has stopped pointing at.
26
+ *
27
+ * changePlan can give a project a brand new subscription, and the caller then
28
+ * writes the new id over the old one on the project row. The old subscription
29
+ * is cancelled just before that, leniently - cancelSubscription logs whatever
30
+ * the payment provider throws and returns as though the cancel had worked. A
31
+ * cancel lost to a rate limit, a dropped connection or a transient 5xx
32
+ * therefore left a subscription that no longer appeared anywhere in OneUptime.
33
+ *
34
+ * Two things keep that from being a double charge, and both are asserted here:
35
+ *
36
+ * 1. Nothing that still bills is replaced. The flat-fee and the metered
37
+ * subscription are decided separately, so a project billing happily on
38
+ * one of them does not have it cancelled because the other died.
39
+ * 2. A subscription cannot be mistaken for dead. Only the payment provider
40
+ * saying it does not have the subscription counts; a transient error
41
+ * fails the plan change instead of routing it onto the replace path.
42
+ *
43
+ * What is left - cancelling a subscription that was already not billing - is
44
+ * retried, and whatever is still there afterwards is reported to the caller so
45
+ * it can be raised rather than logged and forgotten.
46
+ */
47
+ describe("BillingService.changePlan - replacing and cancelling subscriptions", () => {
48
+ let billingService: BillingService;
49
+
50
+ const PROJECT_ID: ObjectID = new ObjectID(
51
+ "11111111-1111-4111-8111-111111111111",
52
+ );
53
+ const CUSTOMER_ID: string = "cus_cancel_123";
54
+ const SUBSCRIPTION_ID: string = "sub_main_123";
55
+ const METERED_SUBSCRIPTION_ID: string = "sub_metered_456";
56
+ const SUBSCRIPTION_ITEM_ID: string = "si_flat_fee_123";
57
+
58
+ const scalePlan: SubscriptionPlan = getSubscriptionPlanWithTrialPeriod(0, {
59
+ name: "Scale",
60
+ monthlyPlanId: "price_monthly_scale",
61
+ yearlyPlanId: "price_yearly_scale",
62
+ });
63
+
64
+ /*
65
+ * Payment provider errors as the caller actually sees them: plain objects
66
+ * carrying the fields the stripe library sets. They are not built with
67
+ * `new Stripe.errors.X` because the stripe module is mocked wholesale here,
68
+ * which is the same reason the service matches them by shape.
69
+ */
70
+ type ProviderErrorFunction = (fields: {
71
+ type: string;
72
+ rawType: string;
73
+ code?: string | undefined;
74
+ }) => Error;
75
+
76
+ const providerError: ProviderErrorFunction = (fields: {
77
+ type: string;
78
+ rawType: string;
79
+ code?: string | undefined;
80
+ }): Error => {
81
+ return Object.assign(new Error(fields.type), fields);
82
+ };
83
+
84
+ type ErrorFunction = () => Error;
85
+
86
+ const resourceMissingError: ErrorFunction = (): Error => {
87
+ return providerError({
88
+ type: "StripeInvalidRequestError",
89
+ rawType: "invalid_request_error",
90
+ code: "resource_missing",
91
+ });
92
+ };
93
+
94
+ const rateLimitError: ErrorFunction = (): Error => {
95
+ return providerError({
96
+ type: "StripeRateLimitError",
97
+ rawType: "rate_limit_error",
98
+ });
99
+ };
100
+
101
+ const connectionError: ErrorFunction = (): Error => {
102
+ return providerError({
103
+ type: "StripeConnectionError",
104
+ rawType: "api_connection_error",
105
+ });
106
+ };
107
+
108
+ interface SetupOptions {
109
+ status?: Stripe.Subscription.Status | undefined;
110
+ meteredStatus?: Stripe.Subscription.Status | undefined;
111
+
112
+ // Thrown instead of returning the metered subscription, when given.
113
+ meteredRetrieveError?: Error | undefined;
114
+ }
115
+
116
+ type SetupFunction = (options?: SetupOptions) => void;
117
+
118
+ const setup: SetupFunction = (options?: SetupOptions): void => {
119
+ mockStripe.subscriptions.retrieve =
120
+ getJestMockFunction().mockImplementation(
121
+ (subscriptionId: string): Promise<Stripe.Subscription> => {
122
+ if (subscriptionId === METERED_SUBSCRIPTION_ID) {
123
+ if (options?.meteredRetrieveError) {
124
+ return Promise.reject(options.meteredRetrieveError);
125
+ }
126
+
127
+ return Promise.resolve(
128
+ getStripeSubscription({
129
+ id: METERED_SUBSCRIPTION_ID,
130
+ status: options?.meteredStatus || options?.status || "active",
131
+ customer: CUSTOMER_ID,
132
+ }),
133
+ );
134
+ }
135
+
136
+ return Promise.resolve(
137
+ getStripeSubscription({
138
+ id: SUBSCRIPTION_ID,
139
+ status: options?.status || "active",
140
+ customer: CUSTOMER_ID,
141
+ itemId: SUBSCRIPTION_ITEM_ID,
142
+ }),
143
+ );
144
+ },
145
+ );
146
+
147
+ mockStripe.subscriptions.update = getJestMockFunction().mockResolvedValue(
148
+ {},
149
+ );
150
+
151
+ mockStripe.subscriptions.del = getJestMockFunction().mockResolvedValue({});
152
+
153
+ mockStripe.subscriptions.create = getJestMockFunction()
154
+ .mockResolvedValueOnce(getStripeSubscription({ id: "sub_main_new" }))
155
+ .mockResolvedValueOnce(getStripeSubscription({ id: "sub_metered_new" }));
156
+
157
+ const paymentMethods: Array<PaymentMethod> = [
158
+ {
159
+ id: "pm_123",
160
+ type: "card",
161
+ last4Digits: "4242",
162
+ isDefault: true,
163
+ },
164
+ ];
165
+
166
+ billingService.getPaymentMethods =
167
+ getJestMockFunction().mockResolvedValue(paymentMethods);
168
+ };
169
+
170
+ type ChangePlanFunction = () => Promise<{
171
+ subscriptionId: string;
172
+ meteredSubscriptionId: string;
173
+ trialEndsAt?: Date | undefined;
174
+ subscriptionIdsPendingCancellation: Array<string>;
175
+ }>;
176
+
177
+ const changePlan: ChangePlanFunction = (): Promise<{
178
+ subscriptionId: string;
179
+ meteredSubscriptionId: string;
180
+ trialEndsAt?: Date | undefined;
181
+ subscriptionIdsPendingCancellation: Array<string>;
182
+ }> => {
183
+ return billingService.changePlan({
184
+ projectId: PROJECT_ID,
185
+ subscriptionId: SUBSCRIPTION_ID,
186
+ meteredSubscriptionId: METERED_SUBSCRIPTION_ID,
187
+ serverMeteredPlans: [],
188
+ newPlan: scalePlan,
189
+ quantity: 3,
190
+ isYearly: false,
191
+ endTrialAt: undefined,
192
+ });
193
+ };
194
+
195
+ type CancelledIdsFunction = () => Array<string>;
196
+
197
+ const cancelledIds: CancelledIdsFunction = (): Array<string> => {
198
+ return (
199
+ mockStripe.subscriptions.del as unknown as {
200
+ mock: { calls: Array<Array<unknown>> };
201
+ }
202
+ ).mock.calls.map((call: Array<unknown>) => {
203
+ return call[0] as string;
204
+ });
205
+ };
206
+
207
+ type CreateCallParamsFunction = (
208
+ nthCall: number,
209
+ ) => Stripe.SubscriptionCreateParams | undefined;
210
+
211
+ const createCallParams: CreateCallParamsFunction = (
212
+ nthCall: number,
213
+ ): Stripe.SubscriptionCreateParams | undefined => {
214
+ const call: Array<unknown> | undefined = (
215
+ mockStripe.subscriptions.create as unknown as {
216
+ mock: { calls: Array<Array<unknown>> };
217
+ }
218
+ ).mock.calls[nthCall - 1];
219
+
220
+ return call?.[0] as Stripe.SubscriptionCreateParams | undefined;
221
+ };
222
+
223
+ beforeAll(async () => {
224
+ billingService = await mockIsBillingEnabled(true);
225
+ }, 30 * 1000);
226
+
227
+ beforeEach(() => {
228
+ jest.clearAllMocks();
229
+ });
230
+
231
+ /*
232
+ * The scenario the fix exists for. The flat-fee subscription is active and
233
+ * billing; only the metered one has died. Replacing the pair cancelled the
234
+ * live one to make way for a duplicate of itself, and the customer paid for
235
+ * both from the moment the cancel was lost.
236
+ */
237
+ describe("a subscription that still bills is never replaced", () => {
238
+ /*
239
+ * "unpaid" is the status used throughout for a dead subscription that
240
+ * still has to be cancelled: it bills nothing and an update cannot revive
241
+ * it, but it is still open at the payment provider. A "canceled" one is
242
+ * already over and is never sent back to be cancelled again - that case
243
+ * has its own tests below.
244
+ */
245
+ it("should keep an active flat-fee subscription when the metered one is dead", async () => {
246
+ setup({ status: "active", meteredStatus: "unpaid" });
247
+
248
+ const result: {
249
+ subscriptionId: string;
250
+ meteredSubscriptionId: string;
251
+ } = await changePlan();
252
+
253
+ expect(cancelledIds()).toEqual([METERED_SUBSCRIPTION_ID]);
254
+ expect(cancelledIds()).not.toContain(SUBSCRIPTION_ID);
255
+ expect(result.subscriptionId).toBe(SUBSCRIPTION_ID);
256
+ });
257
+
258
+ it("should keep an active metered subscription when the flat-fee one is dead", async () => {
259
+ /*
260
+ * The same bug the other way round, and worse in one respect: the
261
+ * metered subscription carries the usage already reported for the
262
+ * period, which a replacement starts over from zero.
263
+ */
264
+ setup({ status: "unpaid", meteredStatus: "active" });
265
+
266
+ const result: {
267
+ subscriptionId: string;
268
+ meteredSubscriptionId: string;
269
+ } = await changePlan();
270
+
271
+ expect(cancelledIds()).toEqual([SUBSCRIPTION_ID]);
272
+ expect(cancelledIds()).not.toContain(METERED_SUBSCRIPTION_ID);
273
+ expect(result.meteredSubscriptionId).toBe(METERED_SUBSCRIPTION_ID);
274
+ expect(result.subscriptionId).toBe("sub_main_new");
275
+ });
276
+
277
+ it("should cancel nothing at all when both subscriptions still bill", async () => {
278
+ setup({ status: "active" });
279
+
280
+ await changePlan();
281
+
282
+ expect(mockStripe.subscriptions.del).not.toHaveBeenCalled();
283
+ expect(mockStripe.subscriptions.create).not.toHaveBeenCalled();
284
+ });
285
+
286
+ it("should replace both when neither subscription bills", async () => {
287
+ setup({ status: "unpaid" });
288
+
289
+ await changePlan();
290
+
291
+ expect(cancelledIds()).toEqual([
292
+ SUBSCRIPTION_ID,
293
+ METERED_SUBSCRIPTION_ID,
294
+ ]);
295
+ });
296
+
297
+ /*
298
+ * Stripe is still collecting on a past_due subscription and the customer
299
+ * still owes on it, so it is not dead - and a price swap applies to it
300
+ * perfectly well. The rest of the product already counts past_due as
301
+ * active; routing agrees with it.
302
+ */
303
+ it("should swap the price onto a past_due subscription rather than replace it", async () => {
304
+ setup({ status: "past_due" });
305
+
306
+ const result: {
307
+ subscriptionId: string;
308
+ meteredSubscriptionId: string;
309
+ } = await changePlan();
310
+
311
+ expect(mockStripe.subscriptions.update).toHaveBeenCalledWith(
312
+ SUBSCRIPTION_ID,
313
+ expect.objectContaining({
314
+ items: [
315
+ {
316
+ id: SUBSCRIPTION_ITEM_ID,
317
+ price: "price_monthly_scale",
318
+ quantity: 3,
319
+ },
320
+ ],
321
+ }),
322
+ );
323
+ expect(mockStripe.subscriptions.del).not.toHaveBeenCalled();
324
+ expect(result.subscriptionId).toBe(SUBSCRIPTION_ID);
325
+ });
326
+
327
+ it("should not abandon a past_due metered subscription", async () => {
328
+ setup({ status: "active", meteredStatus: "past_due" });
329
+
330
+ const result: {
331
+ meteredSubscriptionId: string;
332
+ } = await changePlan();
333
+
334
+ expect(mockStripe.subscriptions.del).not.toHaveBeenCalled();
335
+ expect(result.meteredSubscriptionId).toBe(METERED_SUBSCRIPTION_ID);
336
+ });
337
+
338
+ /*
339
+ * Every status an update cannot revive is replaced. Whether the old
340
+ * subscription is then cancelled is a separate question, answered by
341
+ * whether it is still open - see "subscriptions that are already over".
342
+ */
343
+ it.each([
344
+ ["unpaid"],
345
+ ["incomplete"],
346
+ ["incomplete_expired"],
347
+ ["paused"],
348
+ ["canceled"],
349
+ ] as Array<[Stripe.Subscription.Status]>)(
350
+ "should replace a %s subscription, which an update cannot revive",
351
+ async (status: Stripe.Subscription.Status) => {
352
+ setup({ status: status });
353
+
354
+ const result: {
355
+ subscriptionId: string;
356
+ } = await changePlan();
357
+
358
+ expect(mockStripe.subscriptions.create).toHaveBeenCalled();
359
+ expect(result.subscriptionId).toBe("sub_main_new");
360
+ expect(mockStripe.subscriptions.update).not.toHaveBeenCalled();
361
+ },
362
+ );
363
+ });
364
+
365
+ /*
366
+ * Reading the metered subscription is how changePlan decides whether to
367
+ * replace it. Treating every failure of that read as "it is not there" meant
368
+ * a rate limit was enough to cancel and recreate the subscriptions of a
369
+ * project that had nothing wrong with it.
370
+ */
371
+ describe("telling a missing subscription from an unreadable one", () => {
372
+ it.each([
373
+ ["a rate limit", rateLimitError],
374
+ ["a connection error", connectionError],
375
+ ])(
376
+ "should fail the plan change on %s rather than replace anything",
377
+ async (_name: string, buildError: ErrorFunction) => {
378
+ setup({ status: "active", meteredRetrieveError: buildError() });
379
+
380
+ await expect(changePlan()).rejects.toThrow();
381
+
382
+ expect(mockStripe.subscriptions.create).not.toHaveBeenCalled();
383
+ expect(mockStripe.subscriptions.del).not.toHaveBeenCalled();
384
+ expect(mockStripe.subscriptions.update).not.toHaveBeenCalled();
385
+ },
386
+ );
387
+
388
+ it("should replace the metered subscription when the provider really does not have it", async () => {
389
+ setup({ status: "active", meteredRetrieveError: resourceMissingError() });
390
+
391
+ const result: {
392
+ subscriptionId: string;
393
+ meteredSubscriptionId: string;
394
+ } = await changePlan();
395
+
396
+ expect(mockStripe.subscriptions.create).toHaveBeenCalledTimes(1);
397
+ expect(result.subscriptionId).toBe(SUBSCRIPTION_ID);
398
+ expect(result.meteredSubscriptionId).toBe("sub_main_new");
399
+
400
+ // Nothing to cancel: the provider does not have it in the first place.
401
+ expect(mockStripe.subscriptions.del).not.toHaveBeenCalled();
402
+ });
403
+ });
404
+
405
+ describe("cancelling what has been replaced", () => {
406
+ it("should create the replacements before cancelling anything", async () => {
407
+ /*
408
+ * The two are not atomic. Creating first means a failure in between
409
+ * leaves the project on the subscriptions it had; cancelling first left
410
+ * it with none at all.
411
+ */
412
+ setup({ status: "unpaid" });
413
+
414
+ await changePlan();
415
+
416
+ const firstCreate: number = (
417
+ mockStripe.subscriptions.create as unknown as {
418
+ mock: { invocationCallOrder: Array<number> };
419
+ }
420
+ ).mock.invocationCallOrder[0]!;
421
+
422
+ const firstDelete: number = (
423
+ mockStripe.subscriptions.del as unknown as {
424
+ mock: { invocationCallOrder: Array<number> };
425
+ }
426
+ ).mock.invocationCallOrder[0]!;
427
+
428
+ expect(firstCreate).toBeLessThan(firstDelete);
429
+ });
430
+
431
+ it(
432
+ "should retry a cancel that fails and report nothing when it lands",
433
+ async () => {
434
+ setup({ status: "unpaid", meteredStatus: "active" });
435
+
436
+ mockStripe.subscriptions.del = getJestMockFunction()
437
+ .mockRejectedValueOnce(rateLimitError())
438
+ .mockResolvedValueOnce({});
439
+
440
+ const result: {
441
+ subscriptionIdsPendingCancellation: Array<string>;
442
+ } = await changePlan();
443
+
444
+ expect(mockStripe.subscriptions.del).toHaveBeenCalledTimes(2);
445
+ expect(result.subscriptionIdsPendingCancellation).toEqual([]);
446
+ },
447
+ 30 * 1000,
448
+ );
449
+
450
+ it(
451
+ "should report a cancel that fails every time",
452
+ async () => {
453
+ setup({ status: "unpaid", meteredStatus: "active" });
454
+
455
+ mockStripe.subscriptions.del =
456
+ getJestMockFunction().mockRejectedValue(rateLimitError());
457
+
458
+ const result: {
459
+ subscriptionId: string;
460
+ subscriptionIdsPendingCancellation: Array<string>;
461
+ } = await changePlan();
462
+
463
+ expect(result.subscriptionIdsPendingCancellation).toEqual([
464
+ SUBSCRIPTION_ID,
465
+ ]);
466
+
467
+ /*
468
+ * And the replacement is still reported. Throwing here would leave the
469
+ * caller never recording the new subscription, which makes the LIVE
470
+ * replacement the abandoned one - strictly worse than the dead
471
+ * subscription this is complaining about.
472
+ */
473
+ expect(result.subscriptionId).toBe("sub_main_new");
474
+ },
475
+ 30 * 1000,
476
+ );
477
+
478
+ it("should treat a subscription the provider no longer has as cancelled", async () => {
479
+ setup({ status: "unpaid", meteredStatus: "active" });
480
+
481
+ mockStripe.subscriptions.del = getJestMockFunction().mockRejectedValue(
482
+ resourceMissingError(),
483
+ );
484
+
485
+ const result: {
486
+ subscriptionIdsPendingCancellation: Array<string>;
487
+ } = await changePlan();
488
+
489
+ // Gone is the state being asked for, so it is not retried or reported.
490
+ expect(mockStripe.subscriptions.del).toHaveBeenCalledTimes(1);
491
+ expect(result.subscriptionIdsPendingCancellation).toEqual([]);
492
+ });
493
+
494
+ it(
495
+ "should report both subscriptions when neither cancel lands",
496
+ async () => {
497
+ setup({ status: "unpaid" });
498
+
499
+ mockStripe.subscriptions.del =
500
+ getJestMockFunction().mockRejectedValue(connectionError());
501
+
502
+ const result: {
503
+ subscriptionIdsPendingCancellation: Array<string>;
504
+ } = await changePlan();
505
+
506
+ expect(result.subscriptionIdsPendingCancellation).toEqual([
507
+ SUBSCRIPTION_ID,
508
+ METERED_SUBSCRIPTION_ID,
509
+ ]);
510
+ },
511
+ 30 * 1000,
512
+ );
513
+
514
+ it("should report nothing when there was nothing to replace", async () => {
515
+ setup({ status: "active" });
516
+
517
+ const result: {
518
+ subscriptionIdsPendingCancellation: Array<string>;
519
+ } = await changePlan();
520
+
521
+ expect(result.subscriptionIdsPendingCancellation).toEqual([]);
522
+ });
523
+ });
524
+
525
+ /*
526
+ * A subscription that has already finished is replaced but not cancelled.
527
+ * Asking the payment provider to cancel one again is rejected, and to the
528
+ * retry loop that rejection is indistinguishable from a cancel that did not
529
+ * land: it would burn seconds retrying and then raise an alert asking an
530
+ * operator to cancel by hand something that is already cancelled. This is
531
+ * the common case - it is how reactivation reaches this path at all.
532
+ */
533
+ describe("subscriptions that are already over", () => {
534
+ it.each([["canceled"], ["incomplete_expired"]] as Array<
535
+ [Stripe.Subscription.Status]
536
+ >)(
537
+ "should replace a %s subscription without asking for it to be cancelled",
538
+ async (status: Stripe.Subscription.Status) => {
539
+ setup({ status: status, meteredStatus: "active" });
540
+
541
+ const result: {
542
+ subscriptionId: string;
543
+ subscriptionIdsPendingCancellation: Array<string>;
544
+ } = await changePlan();
545
+
546
+ // Replaced...
547
+ expect(result.subscriptionId).toBe("sub_main_new");
548
+
549
+ // ...but never sent to be cancelled, and so never reported.
550
+ expect(mockStripe.subscriptions.del).not.toHaveBeenCalled();
551
+ expect(result.subscriptionIdsPendingCancellation).toEqual([]);
552
+ },
553
+ );
554
+
555
+ it("should not raise a false alert when the provider refuses to cancel an already cancelled subscription", async () => {
556
+ setup({ status: "canceled" });
557
+
558
+ /*
559
+ * What a provider does with a cancel for a subscription that is already
560
+ * cancelled. It is not resource_missing - the subscription is still
561
+ * there, it is just finished - so nothing downstream can tell it apart
562
+ * from a cancel that failed.
563
+ */
564
+ mockStripe.subscriptions.del = getJestMockFunction().mockRejectedValue(
565
+ providerError({
566
+ type: "StripeInvalidRequestError",
567
+ rawType: "invalid_request_error",
568
+ }),
569
+ );
570
+
571
+ const result: {
572
+ subscriptionIdsPendingCancellation: Array<string>;
573
+ } = await changePlan();
574
+
575
+ expect(mockStripe.subscriptions.del).not.toHaveBeenCalled();
576
+ expect(result.subscriptionIdsPendingCancellation).toEqual([]);
577
+ });
578
+
579
+ it("should not treat a subscription the provider does not have as one to cancel", async () => {
580
+ setup({ status: "active", meteredRetrieveError: resourceMissingError() });
581
+
582
+ const result: {
583
+ subscriptionIdsPendingCancellation: Array<string>;
584
+ } = await changePlan();
585
+
586
+ expect(mockStripe.subscriptions.create).toHaveBeenCalledTimes(1);
587
+ expect(mockStripe.subscriptions.del).not.toHaveBeenCalled();
588
+ expect(result.subscriptionIdsPendingCancellation).toEqual([]);
589
+ });
590
+
591
+ it("should still cancel a subscription that bills nothing but is not over", async () => {
592
+ // The counterpart: unpaid, incomplete and paused are all still open.
593
+ setup({ status: "unpaid", meteredStatus: "incomplete" });
594
+
595
+ await changePlan();
596
+
597
+ expect(cancelledIds()).toEqual([
598
+ SUBSCRIPTION_ID,
599
+ METERED_SUBSCRIPTION_ID,
600
+ ]);
601
+ });
602
+ });
603
+
604
+ /*
605
+ * The last line of defence. Whatever happens to the cancel, the replacement
606
+ * carries the id it replaced and the project it belongs to, recorded at the
607
+ * payment provider before anything was cancelled or overwritten.
608
+ */
609
+ describe("the breadcrumb left on a replacement", () => {
610
+ it("should record the project and the subscription each replacement replaces", async () => {
611
+ setup({ status: "unpaid" });
612
+
613
+ await changePlan();
614
+
615
+ expect(createCallParams(1)?.metadata).toEqual({
616
+ projectId: PROJECT_ID.toString(),
617
+ replacedSubscriptionId: SUBSCRIPTION_ID,
618
+ });
619
+
620
+ expect(createCallParams(2)?.metadata).toEqual({
621
+ projectId: PROJECT_ID.toString(),
622
+ replacedSubscriptionId: METERED_SUBSCRIPTION_ID,
623
+ });
624
+ });
625
+
626
+ it(
627
+ "should record it before the subscription it replaces is cancelled",
628
+ async () => {
629
+ setup({ status: "unpaid", meteredStatus: "active" });
630
+
631
+ mockStripe.subscriptions.del =
632
+ getJestMockFunction().mockRejectedValue(rateLimitError());
633
+
634
+ await changePlan();
635
+
636
+ // A cancel that never lands still leaves the trail behind.
637
+ expect(createCallParams(1)?.metadata).toEqual({
638
+ projectId: PROJECT_ID.toString(),
639
+ replacedSubscriptionId: SUBSCRIPTION_ID,
640
+ });
641
+ },
642
+ 30 * 1000,
643
+ );
644
+ });
645
+ });