@oneuptime/common 12.0.1 → 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 (205) 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/PodmanResource.ts +6 -6
  5. package/Models/DatabaseModels/ServiceLevelObjective.ts +91 -0
  6. package/Models/DatabaseModels/StatusPage.ts +95 -0
  7. package/Server/API/TeamMemberAPI.ts +85 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.ts +84 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.ts +40 -0
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.ts +131 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.ts +25 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  13. package/Server/Middleware/ProjectAuthorization.ts +66 -4
  14. package/Server/Services/BillingService.ts +600 -51
  15. package/Server/Services/CephClusterService.ts +47 -5
  16. package/Server/Services/CephResourceService.ts +43 -4
  17. package/Server/Services/CloudResourceService.ts +47 -5
  18. package/Server/Services/DatabaseService.ts +87 -6
  19. package/Server/Services/DockerHostService.ts +47 -5
  20. package/Server/Services/DockerResourceService.ts +62 -8
  21. package/Server/Services/DockerSwarmClusterService.ts +48 -5
  22. package/Server/Services/DockerSwarmResourceService.ts +53 -4
  23. package/Server/Services/HostService.ts +48 -5
  24. package/Server/Services/IoTDeviceService.ts +1 -17
  25. package/Server/Services/IoTFleetService.ts +47 -5
  26. package/Server/Services/KubernetesClusterService.ts +48 -5
  27. package/Server/Services/KubernetesContainerService.ts +50 -9
  28. package/Server/Services/KubernetesResourceService.ts +53 -7
  29. package/Server/Services/LabelService.ts +106 -1
  30. package/Server/Services/MonitorService.ts +57 -0
  31. package/Server/Services/PodmanHostService.ts +47 -5
  32. package/Server/Services/PodmanResourceService.ts +62 -8
  33. package/Server/Services/ProjectService.ts +165 -35
  34. package/Server/Services/ProxmoxClusterService.ts +47 -5
  35. package/Server/Services/ProxmoxResourceService.ts +43 -4
  36. package/Server/Services/RumApplicationService.ts +48 -5
  37. package/Server/Services/ServerlessFunctionService.ts +48 -5
  38. package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +426 -0
  39. package/Server/Services/ServiceLevelObjectiveService.ts +39 -0
  40. package/Server/Services/StatusPageService.ts +95 -36
  41. package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +18 -1
  42. package/Server/Services/StatusPageSubscriberService.ts +3 -0
  43. package/Server/Types/Database/JSONColumnQuery.ts +621 -0
  44. package/Server/Types/Database/Permissions/PublicPermission.ts +9 -2
  45. package/Server/Types/Database/QueryHelper.ts +17 -32
  46. package/Server/Utils/Database/TruncateColumnValue.ts +84 -0
  47. package/Tests/App/Dashboard/UsersTableGroupsByPerson.test.tsx +448 -0
  48. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +704 -0
  49. package/Tests/Server/API/TeamMemberRemoveUserFromProjectAPI.test.ts +303 -0
  50. package/Tests/Server/Middleware/MasterAdminAuthorization.test.ts +350 -0
  51. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyHeader.test.ts +362 -0
  52. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +687 -0
  53. package/Tests/Server/Services/BillingService.test.ts +78 -3
  54. package/Tests/Server/Services/BillingServiceChangePlanCancel.test.ts +645 -0
  55. package/Tests/Server/Services/BillingServiceTrialPlanChange.test.ts +1005 -0
  56. package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +503 -0
  57. package/Tests/Server/Services/HostIpAddressesColumnWidth.test.ts +198 -0
  58. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +510 -0
  59. package/Tests/Server/Services/InventoryResourceNameColumnWidth.test.ts +500 -0
  60. package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +586 -0
  61. package/Tests/Server/Services/InventoryUpsertTruncationGuard.test.ts +1242 -0
  62. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +650 -0
  63. package/Tests/Server/Services/ProjectServiceExtendTrial.test.ts +1 -0
  64. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +387 -0
  65. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +770 -0
  66. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +130 -0
  67. package/Tests/Server/Services/SloMonitorLabelRuleHooks.test.ts +352 -0
  68. package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +29 -13
  69. package/Tests/Server/TestingUtils/Services/BillingServiceHelper.ts +69 -8
  70. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +593 -0
  71. package/Tests/Server/Types/Database/Permissions/PublicPermission.test.ts +495 -0
  72. package/Tests/Server/Types/Database/QueryUtil.test.ts +113 -0
  73. package/Tests/Server/Utils/Database/TruncateColumnValue.test.ts +179 -0
  74. package/Tests/Types/Events/Recurring.test.ts +157 -0
  75. package/Tests/UI/Components/ModelTable/ModelTableGroupsProjectUsers.test.tsx +375 -0
  76. package/Tests/UI/Components/ModelTable/useCustomFieldColumns.test.tsx +196 -0
  77. package/Tests/UI/Utils/ProjectUsersModelAPI.test.ts +739 -0
  78. package/Tests/UI/Utils/TeamMembersByUser.test.ts +633 -0
  79. package/Tests/Utils/StatusPage/ReportPeriod.test.ts +218 -0
  80. package/Tests/Utils/Telemetry/HostIpAddresses.test.ts +291 -0
  81. package/Types/CustomField/CustomFieldDefinition.ts +24 -0
  82. package/Types/Date.ts +112 -0
  83. package/Types/Events/Recurring.ts +99 -1
  84. package/Types/StatusPage/StatusPageReport.ts +11 -0
  85. package/Types/StatusPage/StatusPageReportPeriodType.ts +21 -0
  86. package/UI/Components/ModelTable/CustomFieldColumns.tsx +2 -6
  87. package/UI/Components/ModelTable/useCustomFieldColumns.ts +21 -2
  88. package/UI/Utils/ModelAPI/ProjectUsersModelAPI.ts +305 -0
  89. package/UI/Utils/TeamMembersByUser.ts +302 -0
  90. package/Utils/StatusPage/ReportPeriod.ts +236 -0
  91. package/Utils/Telemetry/HostIpAddresses.ts +98 -0
  92. package/build/dist/Models/DatabaseModels/Host.js +12 -4
  93. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/KubernetesContainer.js +4 -4
  95. package/build/dist/Models/DatabaseModels/KubernetesContainer.js.map +1 -1
  96. package/build/dist/Models/DatabaseModels/KubernetesResource.js +7 -7
  97. package/build/dist/Models/DatabaseModels/KubernetesResource.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/PodmanResource.js +6 -6
  99. package/build/dist/Models/DatabaseModels/PodmanResource.js.map +1 -1
  100. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +87 -0
  101. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
  102. package/build/dist/Models/DatabaseModels/StatusPage.js +98 -0
  103. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  104. package/build/dist/Server/API/TeamMemberAPI.js +57 -2
  105. package/build/dist/Server/API/TeamMemberAPI.js.map +1 -1
  106. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js +45 -0
  107. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js.map +1 -0
  108. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js +31 -0
  109. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js.map +1 -0
  110. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js +90 -0
  111. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js.map +1 -0
  112. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js +14 -0
  113. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js.map +1 -0
  114. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  115. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  116. package/build/dist/Server/Middleware/ProjectAuthorization.js +59 -4
  117. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  118. package/build/dist/Server/Services/BillingService.js +466 -36
  119. package/build/dist/Server/Services/BillingService.js.map +1 -1
  120. package/build/dist/Server/Services/CephClusterService.js +42 -5
  121. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  122. package/build/dist/Server/Services/CephResourceService.js +28 -3
  123. package/build/dist/Server/Services/CephResourceService.js.map +1 -1
  124. package/build/dist/Server/Services/CloudResourceService.js +42 -5
  125. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  126. package/build/dist/Server/Services/DatabaseService.js +69 -2
  127. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  128. package/build/dist/Server/Services/DockerHostService.js +42 -5
  129. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  130. package/build/dist/Server/Services/DockerResourceService.js +34 -4
  131. package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
  132. package/build/dist/Server/Services/DockerSwarmClusterService.js +43 -5
  133. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  134. package/build/dist/Server/Services/DockerSwarmResourceService.js +28 -3
  135. package/build/dist/Server/Services/DockerSwarmResourceService.js.map +1 -1
  136. package/build/dist/Server/Services/HostService.js +43 -5
  137. package/build/dist/Server/Services/HostService.js.map +1 -1
  138. package/build/dist/Server/Services/IoTDeviceService.js +1 -8
  139. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  140. package/build/dist/Server/Services/IoTFleetService.js +42 -5
  141. package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
  142. package/build/dist/Server/Services/KubernetesClusterService.js +43 -5
  143. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  144. package/build/dist/Server/Services/KubernetesContainerService.js +29 -3
  145. package/build/dist/Server/Services/KubernetesContainerService.js.map +1 -1
  146. package/build/dist/Server/Services/KubernetesResourceService.js +32 -4
  147. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  148. package/build/dist/Server/Services/LabelService.js +92 -0
  149. package/build/dist/Server/Services/LabelService.js.map +1 -1
  150. package/build/dist/Server/Services/MonitorService.js +47 -0
  151. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  152. package/build/dist/Server/Services/PodmanHostService.js +42 -5
  153. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  154. package/build/dist/Server/Services/PodmanResourceService.js +34 -4
  155. package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
  156. package/build/dist/Server/Services/ProjectService.js +135 -21
  157. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  158. package/build/dist/Server/Services/ProxmoxClusterService.js +42 -5
  159. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  160. package/build/dist/Server/Services/ProxmoxResourceService.js +28 -3
  161. package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
  162. package/build/dist/Server/Services/RumApplicationService.js +43 -5
  163. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  164. package/build/dist/Server/Services/ServerlessFunctionService.js +43 -5
  165. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  166. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +347 -0
  167. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -0
  168. package/build/dist/Server/Services/ServiceLevelObjectiveService.js +33 -1
  169. package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
  170. package/build/dist/Server/Services/StatusPageService.js +69 -46
  171. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  172. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +17 -1
  173. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
  174. package/build/dist/Server/Services/StatusPageSubscriberService.js +3 -0
  175. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  176. package/build/dist/Server/Types/Database/JSONColumnQuery.js +402 -0
  177. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -0
  178. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js +9 -2
  179. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js.map +1 -1
  180. package/build/dist/Server/Types/Database/QueryHelper.js +17 -27
  181. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  182. package/build/dist/Server/Utils/Database/TruncateColumnValue.js +32 -0
  183. package/build/dist/Server/Utils/Database/TruncateColumnValue.js.map +1 -0
  184. package/build/dist/Types/CustomField/CustomFieldDefinition.js +2 -0
  185. package/build/dist/Types/CustomField/CustomFieldDefinition.js.map +1 -0
  186. package/build/dist/Types/Date.js +68 -0
  187. package/build/dist/Types/Date.js.map +1 -1
  188. package/build/dist/Types/Events/Recurring.js +63 -0
  189. package/build/dist/Types/Events/Recurring.js.map +1 -1
  190. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js +22 -0
  191. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js.map +1 -0
  192. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  193. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +18 -2
  194. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -1
  195. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js +213 -0
  196. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js.map +1 -0
  197. package/build/dist/UI/Utils/TeamMembersByUser.js +195 -0
  198. package/build/dist/UI/Utils/TeamMembersByUser.js.map +1 -0
  199. package/build/dist/Utils/StatusPage/ReportPeriod.js +131 -0
  200. package/build/dist/Utils/StatusPage/ReportPeriod.js.map +1 -0
  201. package/build/dist/Utils/Telemetry/HostIpAddresses.js +82 -0
  202. package/build/dist/Utils/Telemetry/HostIpAddresses.js.map +1 -0
  203. package/jest.config.json +2 -0
  204. package/package.json +1 -1
  205. package/tsconfig.json +12 -1
@@ -0,0 +1,1005 @@
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 { describe, expect, beforeAll, beforeEach, jest } from "@jest/globals";
13
+ import OneUptimeDate from "../../../Types/Date";
14
+ import SubscriptionPlan from "../../../Types/Billing/SubscriptionPlan";
15
+ import ObjectID from "../../../Types/ObjectID";
16
+
17
+ /*
18
+ * Changing plan while a trial is still running must never charge the customer.
19
+ *
20
+ * The bug this suite exists for: subscribeToPlan decided whether to send a
21
+ * trial_end by asking the NEW plan how long its trial is. A project trialing on
22
+ * Growth (14 trial days) that moved to Scale or Basic - both configured with 0
23
+ * trial days - therefore got trial_end "now", and Stripe invoiced the full plan
24
+ * immediately. The customer had days of trial left and was charged anyway.
25
+ *
26
+ * Everything below asserts on what is handed to Stripe, because that is the
27
+ * only thing that decides whether an invoice is raised.
28
+ */
29
+ describe("BillingService - plan changes during trial", () => {
30
+ let billingService: BillingService;
31
+
32
+ const PROJECT_ID: ObjectID = new ObjectID(
33
+ "11111111-1111-4111-8111-111111111111",
34
+ );
35
+ const CUSTOMER_ID: string = "cus_trial_123";
36
+ const SUBSCRIPTION_ID: string = "sub_main_123";
37
+ const METERED_SUBSCRIPTION_ID: string = "sub_metered_456";
38
+
39
+ // Plans as they are actually configured: Growth trials, Scale/Basic do not.
40
+ const growthPlan: SubscriptionPlan = getSubscriptionPlanWithTrialPeriod(14, {
41
+ name: "Growth",
42
+ monthlyPlanId: "price_monthly_growth",
43
+ yearlyPlanId: "price_yearly_growth",
44
+ });
45
+
46
+ const scalePlan: SubscriptionPlan = getSubscriptionPlanWithTrialPeriod(0, {
47
+ name: "Scale",
48
+ monthlyPlanId: "price_monthly_scale",
49
+ yearlyPlanId: "price_yearly_scale",
50
+ });
51
+
52
+ type PastDateFunction = (daysAgo: number) => Date;
53
+
54
+ // getSomeDaysAfter goes through PositiveNumber, so it cannot count backwards.
55
+ const pastDate: PastDateFunction = (daysAgo: number): Date => {
56
+ return OneUptimeDate.addRemoveDays(
57
+ OneUptimeDate.getCurrentDate(),
58
+ -daysAgo,
59
+ );
60
+ };
61
+
62
+ type CreateCallParamsFunction = (
63
+ nthCall: number,
64
+ ) => Stripe.SubscriptionCreateParams;
65
+
66
+ /*
67
+ * subscribeToPlan creates the flat-fee subscription first and the metered one
68
+ * second. Both have to carry the same trial, so tests read them by position.
69
+ */
70
+ const createCallParams: CreateCallParamsFunction = (
71
+ nthCall: number,
72
+ ): Stripe.SubscriptionCreateParams => {
73
+ const call: Array<unknown> | undefined = (
74
+ mockStripe.subscriptions.create as unknown as {
75
+ mock: { calls: Array<Array<unknown>> };
76
+ }
77
+ ).mock.calls[nthCall - 1];
78
+
79
+ if (!call) {
80
+ throw new Error(
81
+ `stripe.subscriptions.create was not called ${nthCall} time(s)`,
82
+ );
83
+ }
84
+
85
+ return call[0] as Stripe.SubscriptionCreateParams;
86
+ };
87
+
88
+ type FirstCallOrderFunction = (mockFunction: unknown) => number;
89
+
90
+ // Where a mock's first call sits in the global order of all mock calls.
91
+ const firstCallOrder: FirstCallOrderFunction = (
92
+ mockFunction: unknown,
93
+ ): number => {
94
+ const order: number | undefined = (
95
+ mockFunction as { mock: { invocationCallOrder: Array<number> } }
96
+ ).mock.invocationCallOrder[0];
97
+
98
+ if (order === undefined) {
99
+ throw new Error("mock was never called");
100
+ }
101
+
102
+ return order;
103
+ };
104
+
105
+ /*
106
+ * Built once: mockIsBillingEnabled resets the module registry and re-imports
107
+ * the whole service graph, which costs seconds. Nothing here toggles billing
108
+ * off, so one instance serves every test - the per-test mocks are reset in
109
+ * beforeEach instead.
110
+ */
111
+ beforeAll(
112
+ async () => {
113
+ billingService = await mockIsBillingEnabled(true);
114
+ },
115
+ 30 * 1000, // 30 second timeout because setting up the module graph is slow
116
+ );
117
+
118
+ beforeEach(() => {
119
+ jest.clearAllMocks();
120
+
121
+ mockStripe.subscriptions.create = getJestMockFunction()
122
+ .mockResolvedValueOnce(
123
+ getStripeSubscription({ id: "sub_main_new", status: "trialing" }),
124
+ )
125
+ .mockResolvedValueOnce(
126
+ getStripeSubscription({
127
+ id: "sub_metered_new",
128
+ status: "trialing",
129
+ }),
130
+ );
131
+ });
132
+
133
+ describe("subscribeToPlan - resolving the trial", () => {
134
+ type SubscribeFunction = (data: {
135
+ plan: SubscriptionPlan;
136
+ trial: boolean | Date | undefined;
137
+ }) => Promise<{
138
+ subscriptionId: string;
139
+ meteredSubscriptionId: string;
140
+ trialEndsAt: Date | null;
141
+ }>;
142
+
143
+ const subscribe: SubscribeFunction = (data: {
144
+ plan: SubscriptionPlan;
145
+ trial: boolean | Date | undefined;
146
+ }): Promise<{
147
+ subscriptionId: string;
148
+ meteredSubscriptionId: string;
149
+ trialEndsAt: Date | null;
150
+ }> => {
151
+ return billingService.subscribeToPlan({
152
+ projectId: PROJECT_ID,
153
+ customerId: CUSTOMER_ID,
154
+ serverMeteredPlans: [],
155
+ plan: data.plan,
156
+ quantity: 3,
157
+ isYearly: false,
158
+ trial: data.trial,
159
+ });
160
+ };
161
+
162
+ it("should keep a running trial when moving to a plan that has no trial period of its own", async () => {
163
+ /*
164
+ * This assertion is the fix. A project nine days into its Growth trial
165
+ * upgrades to Scale, which is configured with 0 trial days. The trial the
166
+ * customer already has must survive the move - sending "now" here is what
167
+ * charged them for the full plan on the spot.
168
+ */
169
+ const runningTrialEndsAt: Date = OneUptimeDate.getSomeDaysAfter(9);
170
+
171
+ const result: {
172
+ subscriptionId: string;
173
+ meteredSubscriptionId: string;
174
+ trialEndsAt: Date | null;
175
+ } = await subscribe({ plan: scalePlan, trial: runningTrialEndsAt });
176
+
177
+ expect(createCallParams(1).trial_end).toBe(
178
+ OneUptimeDate.toUnixTimestamp(runningTrialEndsAt),
179
+ );
180
+ expect(createCallParams(1).trial_end).not.toBe("now");
181
+ expect(result.trialEndsAt).toEqual(runningTrialEndsAt);
182
+ });
183
+
184
+ it("should keep a running trial when moving to a plan that does have a trial period", async () => {
185
+ const runningTrialEndsAt: Date = OneUptimeDate.getSomeDaysAfter(9);
186
+
187
+ await subscribe({ plan: growthPlan, trial: runningTrialEndsAt });
188
+
189
+ expect(createCallParams(1).trial_end).toBe(
190
+ OneUptimeDate.toUnixTimestamp(runningTrialEndsAt),
191
+ );
192
+ });
193
+
194
+ it("should give the metered subscription the same trial as the flat-fee one", async () => {
195
+ /*
196
+ * The two subscriptions bill the same customer for the same project. If
197
+ * only one of them trials, the other starts invoicing usage while the
198
+ * customer still believes they are on trial.
199
+ */
200
+ const runningTrialEndsAt: Date = OneUptimeDate.getSomeDaysAfter(9);
201
+
202
+ await subscribe({ plan: scalePlan, trial: runningTrialEndsAt });
203
+
204
+ expect(createCallParams(2).trial_end).toBe(
205
+ OneUptimeDate.toUnixTimestamp(runningTrialEndsAt),
206
+ );
207
+ expect(createCallParams(2).trial_end).toBe(createCallParams(1).trial_end);
208
+ });
209
+
210
+ it("should carry a trial that is only minutes away from ending", async () => {
211
+ // A trial is live right up to its last second; it is not rounded away.
212
+ const almostOver: Date = OneUptimeDate.addRemoveMinutes(
213
+ OneUptimeDate.getCurrentDate(),
214
+ 5,
215
+ );
216
+
217
+ await subscribe({ plan: scalePlan, trial: almostOver });
218
+
219
+ expect(createCallParams(1).trial_end).toBe(
220
+ OneUptimeDate.toUnixTimestamp(almostOver),
221
+ );
222
+ });
223
+
224
+ it("should not revive a trial that has already ended", async () => {
225
+ /*
226
+ * A lapsed trial means the customer is a paying customer. Changing plan
227
+ * bills them from now - that is the correct charge, not the bug.
228
+ */
229
+ const result: {
230
+ subscriptionId: string;
231
+ meteredSubscriptionId: string;
232
+ trialEndsAt: Date | null;
233
+ } = await subscribe({ plan: growthPlan, trial: pastDate(3) });
234
+
235
+ expect(createCallParams(1).trial_end).toBe("now");
236
+ expect(createCallParams(2).trial_end).toBe("now");
237
+ expect(result.trialEndsAt).toBeNull();
238
+ });
239
+
240
+ it("should start a fresh trial of the plan's own length when asked for one", async () => {
241
+ const result: {
242
+ subscriptionId: string;
243
+ meteredSubscriptionId: string;
244
+ trialEndsAt: Date | null;
245
+ } = await subscribe({ plan: growthPlan, trial: true });
246
+
247
+ expect(createCallParams(1).trial_end).not.toBe("now");
248
+ expect(result.trialEndsAt).not.toBeNull();
249
+
250
+ /*
251
+ * 14 days out. The trial is measured from the moment subscribeToPlan ran,
252
+ * so it lands a little before a date measured now - a minute of slack
253
+ * covers the test's own runtime without letting a wrong plan's length
254
+ * through.
255
+ */
256
+ const fourteenDaysOut: number = OneUptimeDate.toUnixTimestamp(
257
+ OneUptimeDate.getSomeDaysAfter(14),
258
+ );
259
+
260
+ expect(createCallParams(1).trial_end).toBeLessThanOrEqual(
261
+ fourteenDaysOut,
262
+ );
263
+ expect(createCallParams(1).trial_end).toBeGreaterThan(
264
+ fourteenDaysOut - 60,
265
+ );
266
+ });
267
+
268
+ it("should not start a trial on a plan configured with no trial period", async () => {
269
+ /*
270
+ * The counterpart to the fix: getTrialPeriod() still decides how long a
271
+ * NEW trial runs. Asking for a trial on a 0-day plan is asking for
272
+ * nothing, and the customer is billed immediately - as before.
273
+ */
274
+ const result: {
275
+ subscriptionId: string;
276
+ meteredSubscriptionId: string;
277
+ trialEndsAt: Date | null;
278
+ } = await subscribe({ plan: scalePlan, trial: true });
279
+
280
+ expect(createCallParams(1).trial_end).toBe("now");
281
+ expect(result.trialEndsAt).toBeNull();
282
+ });
283
+
284
+ it("should not start a trial when one is explicitly declined", async () => {
285
+ const result: {
286
+ subscriptionId: string;
287
+ meteredSubscriptionId: string;
288
+ trialEndsAt: Date | null;
289
+ } = await subscribe({ plan: growthPlan, trial: false });
290
+
291
+ expect(createCallParams(1).trial_end).toBe("now");
292
+ expect(createCallParams(2).trial_end).toBe("now");
293
+ expect(result.trialEndsAt).toBeNull();
294
+ });
295
+
296
+ it("should not start a trial when none is asked for at all", async () => {
297
+ const result: {
298
+ subscriptionId: string;
299
+ meteredSubscriptionId: string;
300
+ trialEndsAt: Date | null;
301
+ } = await subscribe({ plan: growthPlan, trial: undefined });
302
+
303
+ expect(createCallParams(1).trial_end).toBe("now");
304
+ expect(result.trialEndsAt).toBeNull();
305
+ });
306
+
307
+ it("should report a trial end date that matches what it sent to the payment provider", async () => {
308
+ /*
309
+ * Callers persist the returned date onto the project row, and that row
310
+ * drives the in-app trial banner and the next plan change. Reporting a
311
+ * trial the payment provider was never given (or dropping one it was)
312
+ * puts the two permanently out of step.
313
+ */
314
+ const cases: Array<{ plan: SubscriptionPlan; trial: boolean | Date }> = [
315
+ { plan: scalePlan, trial: OneUptimeDate.getSomeDaysAfter(9) },
316
+ { plan: growthPlan, trial: OneUptimeDate.getSomeDaysAfter(2) },
317
+ { plan: growthPlan, trial: pastDate(1) },
318
+ { plan: scalePlan, trial: true },
319
+ { plan: growthPlan, trial: false },
320
+ ];
321
+
322
+ for (const testCase of cases) {
323
+ jest.clearAllMocks();
324
+ mockStripe.subscriptions.create = getJestMockFunction()
325
+ .mockResolvedValueOnce(getStripeSubscription({ id: "sub_main_new" }))
326
+ .mockResolvedValueOnce(
327
+ getStripeSubscription({ id: "sub_metered_new" }),
328
+ );
329
+
330
+ const result: {
331
+ subscriptionId: string;
332
+ meteredSubscriptionId: string;
333
+ trialEndsAt: Date | null;
334
+ } = await subscribe(testCase);
335
+
336
+ const sentTrialEnd: unknown = createCallParams(1).trial_end;
337
+
338
+ if (result.trialEndsAt === null) {
339
+ expect(sentTrialEnd).toBe("now");
340
+ } else {
341
+ expect(sentTrialEnd).toBe(
342
+ OneUptimeDate.toUnixTimestamp(result.trialEndsAt),
343
+ );
344
+ }
345
+ }
346
+ });
347
+ });
348
+
349
+ /*
350
+ * A plan change is a price swap on the subscription the project already has.
351
+ * Cancelling it and building a replacement meant every piece of subscription
352
+ * state - the trial above all - had to be reconstructed by hand, which is how
353
+ * the mid-trial charge got in. Keeping the subscription keeps its trial for
354
+ * free: the assertions below are mostly about what changePlan does NOT send.
355
+ */
356
+ describe("changePlan - swapping the price on the subscription the project has", () => {
357
+ const SUBSCRIPTION_ITEM_ID: string = "si_flat_fee_123";
358
+
359
+ /*
360
+ * Stripe reports trial_end as whole seconds, so a date that has been
361
+ * through it has no milliseconds. Building the expected date the same way
362
+ * keeps the assertions exact instead of off by a fraction of a second.
363
+ */
364
+ type StripeTrialFunction = (daysFromNow: number) => Date;
365
+
366
+ const stripeTrial: StripeTrialFunction = (daysFromNow: number): Date => {
367
+ return OneUptimeDate.fromUnixTimestamp(
368
+ OneUptimeDate.toUnixTimestamp(
369
+ OneUptimeDate.getSomeDaysAfter(daysFromNow),
370
+ ),
371
+ );
372
+ };
373
+
374
+ interface SetupOptions {
375
+ trialEndOnStripe?: number | null | undefined;
376
+ status?: Stripe.Subscription.Status | undefined;
377
+
378
+ /*
379
+ * The metered subscription defaults to the same shape as the flat-fee
380
+ * one, which is how a real project's pair looks - they are created
381
+ * together and their trials move together.
382
+ */
383
+ meteredStatus?: Stripe.Subscription.Status | undefined;
384
+ meteredTrialEndOnStripe?: number | null | undefined;
385
+
386
+ // An empty array stands for a subscription with nothing to swap a price onto.
387
+ subscriptionItems?: Array<{ id: string }> | undefined;
388
+ }
389
+
390
+ type SetupFunction = (options?: SetupOptions) => void;
391
+
392
+ const setup: SetupFunction = (options?: SetupOptions): void => {
393
+ mockStripe.subscriptions.retrieve =
394
+ getJestMockFunction().mockImplementation(
395
+ (subscriptionId: string): Promise<Stripe.Subscription> => {
396
+ if (subscriptionId === METERED_SUBSCRIPTION_ID) {
397
+ return Promise.resolve(
398
+ getStripeSubscription({
399
+ id: METERED_SUBSCRIPTION_ID,
400
+ status:
401
+ options?.meteredStatus || options?.status || "trialing",
402
+ trialEnd:
403
+ options?.meteredTrialEndOnStripe ??
404
+ options?.trialEndOnStripe ??
405
+ null,
406
+ customer: CUSTOMER_ID,
407
+ }),
408
+ );
409
+ }
410
+
411
+ return Promise.resolve(
412
+ getStripeSubscription({
413
+ id: SUBSCRIPTION_ID,
414
+ status: options?.status || "trialing",
415
+ trialEnd: options?.trialEndOnStripe ?? null,
416
+ customer: CUSTOMER_ID,
417
+ itemId: SUBSCRIPTION_ITEM_ID,
418
+ items: options?.subscriptionItems,
419
+ }),
420
+ );
421
+ },
422
+ );
423
+
424
+ mockStripe.subscriptions.update = getJestMockFunction().mockResolvedValue(
425
+ {},
426
+ );
427
+
428
+ mockStripe.subscriptions.del = getJestMockFunction().mockResolvedValue(
429
+ {},
430
+ );
431
+
432
+ const paymentMethods: Array<PaymentMethod> = [
433
+ {
434
+ id: "pm_123",
435
+ type: "card",
436
+ last4Digits: "4242",
437
+ isDefault: true,
438
+ },
439
+ ];
440
+
441
+ billingService.getPaymentMethods =
442
+ getJestMockFunction().mockResolvedValue(paymentMethods);
443
+ };
444
+
445
+ type UpdateCallParamsFunction = (
446
+ subscriptionId: string,
447
+ ) => Stripe.SubscriptionUpdateParams | undefined;
448
+
449
+ // What was sent to update a given subscription, or undefined if it was left alone.
450
+ const updateCallParams: UpdateCallParamsFunction = (
451
+ subscriptionId: string,
452
+ ): Stripe.SubscriptionUpdateParams | undefined => {
453
+ const call: Array<unknown> | undefined = (
454
+ mockStripe.subscriptions.update as unknown as {
455
+ mock: { calls: Array<Array<unknown>> };
456
+ }
457
+ ).mock.calls.find((call: Array<unknown>) => {
458
+ return call[0] === subscriptionId;
459
+ });
460
+
461
+ return call?.[1] as Stripe.SubscriptionUpdateParams | undefined;
462
+ };
463
+
464
+ type ChangePlanFunction = (data?: {
465
+ endTrialAt?: Date | undefined;
466
+ newPlan?: SubscriptionPlan | undefined;
467
+ }) => Promise<{
468
+ subscriptionId: string;
469
+ meteredSubscriptionId: string;
470
+ trialEndsAt?: Date | undefined;
471
+ }>;
472
+
473
+ const changePlan: ChangePlanFunction = (data?: {
474
+ endTrialAt?: Date | undefined;
475
+ newPlan?: SubscriptionPlan | undefined;
476
+ }): Promise<{
477
+ subscriptionId: string;
478
+ meteredSubscriptionId: string;
479
+ trialEndsAt?: Date | undefined;
480
+ }> => {
481
+ return billingService.changePlan({
482
+ projectId: PROJECT_ID,
483
+ subscriptionId: SUBSCRIPTION_ID,
484
+ meteredSubscriptionId: METERED_SUBSCRIPTION_ID,
485
+ serverMeteredPlans: [],
486
+ newPlan: data?.newPlan || scalePlan,
487
+ quantity: 3,
488
+ isYearly: false,
489
+ endTrialAt: data?.endTrialAt,
490
+ });
491
+ };
492
+
493
+ describe("changing the plan", () => {
494
+ it("should put the new plan's price on the subscription the project already has", async () => {
495
+ setup({ status: "active" });
496
+
497
+ const result: {
498
+ subscriptionId: string;
499
+ meteredSubscriptionId: string;
500
+ trialEndsAt?: Date | undefined;
501
+ } = await changePlan();
502
+
503
+ expect(updateCallParams(SUBSCRIPTION_ID)?.items).toEqual([
504
+ {
505
+ id: SUBSCRIPTION_ITEM_ID,
506
+ price: "price_monthly_scale",
507
+ quantity: 3,
508
+ },
509
+ ]);
510
+
511
+ // The same subscription, so the project's ids do not move.
512
+ expect(result.subscriptionId).toBe(SUBSCRIPTION_ID);
513
+ expect(result.meteredSubscriptionId).toBe(METERED_SUBSCRIPTION_ID);
514
+ });
515
+
516
+ it("should send the id of the item it is replacing", async () => {
517
+ /*
518
+ * Without the existing item's id Stripe ADDS the new price alongside
519
+ * the old one instead of swapping it, and the customer is billed for
520
+ * both plans at once.
521
+ */
522
+ setup({ status: "active" });
523
+
524
+ await changePlan();
525
+
526
+ const items: Stripe.SubscriptionUpdateParams["items"] =
527
+ updateCallParams(SUBSCRIPTION_ID)?.items;
528
+
529
+ expect(items).toHaveLength(1);
530
+ expect(items?.[0]?.id).toBe(SUBSCRIPTION_ITEM_ID);
531
+ });
532
+
533
+ it("should downgrade the same way it upgrades", async () => {
534
+ const basicPlan: SubscriptionPlan = getSubscriptionPlanWithTrialPeriod(
535
+ 0,
536
+ {
537
+ name: "Free",
538
+ monthlyPlanId: "price_monthly_basic",
539
+ yearlyPlanId: "price_yearly_basic",
540
+ },
541
+ );
542
+ setup({ status: "active" });
543
+
544
+ await changePlan({ newPlan: basicPlan });
545
+
546
+ expect(updateCallParams(SUBSCRIPTION_ID)?.items).toEqual([
547
+ {
548
+ id: SUBSCRIPTION_ITEM_ID,
549
+ price: "price_monthly_basic",
550
+ quantity: 3,
551
+ },
552
+ ]);
553
+ });
554
+
555
+ it("should not cancel or recreate anything", async () => {
556
+ /*
557
+ * Cancel-then-create is not atomic - a failure between the two leaves
558
+ * the project with no subscription at all - and it throws away the
559
+ * billing cycle anchor, discounts and reported usage on the way.
560
+ */
561
+ setup({ status: "active" });
562
+
563
+ await changePlan();
564
+
565
+ expect(mockStripe.subscriptions.del).not.toHaveBeenCalled();
566
+ expect(mockStripe.subscriptions.create).not.toHaveBeenCalled();
567
+ });
568
+
569
+ it("should leave the metered subscription completely alone", async () => {
570
+ /*
571
+ * Its items come from the metered plans' own price ids, which have
572
+ * nothing to do with the plan being changed. Rebuilding it would throw
573
+ * away the usage already reported against it for the period.
574
+ */
575
+ const trialOnStripe: Date = stripeTrial(9);
576
+ setup({
577
+ trialEndOnStripe: OneUptimeDate.toUnixTimestamp(trialOnStripe),
578
+ });
579
+
580
+ await changePlan({ endTrialAt: trialOnStripe });
581
+
582
+ expect(updateCallParams(METERED_SUBSCRIPTION_ID)).toBeUndefined();
583
+ });
584
+
585
+ it("should never raise an invoice on the spot", async () => {
586
+ /*
587
+ * "always_invoice" bills the change immediately. Prorations are
588
+ * recorded and settle on the customer's next invoice instead.
589
+ */
590
+ setup({ status: "active" });
591
+
592
+ await changePlan();
593
+
594
+ expect(updateCallParams(SUBSCRIPTION_ID)?.proration_behavior).toBe(
595
+ "create_prorations",
596
+ );
597
+ });
598
+ });
599
+
600
+ describe("keeping a running trial", () => {
601
+ it("should not charge a project that upgrades mid-trial to a plan with no trial period", async () => {
602
+ /*
603
+ * The exact scenario from the bug report: trialing on Growth with nine
604
+ * days left, owner picks Scale, which is configured with 0 trial days.
605
+ *
606
+ * Leaving trial_end out of the update is what keeps the subscription
607
+ * trialing. Sending one - even the same date - re-anchors the billing
608
+ * cycle to it, and sending "now" ends the trial and invoices the full
609
+ * plan on the spot, which is the charge customers reported.
610
+ */
611
+ const trialOnStripe: Date = stripeTrial(9);
612
+ setup({
613
+ trialEndOnStripe: OneUptimeDate.toUnixTimestamp(trialOnStripe),
614
+ });
615
+
616
+ const result: {
617
+ subscriptionId: string;
618
+ meteredSubscriptionId: string;
619
+ trialEndsAt?: Date | undefined;
620
+ } = await changePlan({ endTrialAt: trialOnStripe });
621
+
622
+ const params: Stripe.SubscriptionUpdateParams | undefined =
623
+ updateCallParams(SUBSCRIPTION_ID);
624
+
625
+ expect(params).toBeDefined();
626
+ expect(params).not.toHaveProperty("trial_end");
627
+ expect(params).not.toHaveProperty("billing_cycle_anchor");
628
+ expect(result.trialEndsAt).toEqual(trialOnStripe);
629
+ });
630
+
631
+ it("should not prorate a change made during a trial", async () => {
632
+ // There is nothing to prorate while the plan is not being billed.
633
+ const trialOnStripe: Date = stripeTrial(9);
634
+ setup({
635
+ trialEndOnStripe: OneUptimeDate.toUnixTimestamp(trialOnStripe),
636
+ });
637
+
638
+ await changePlan({ endTrialAt: trialOnStripe });
639
+
640
+ expect(updateCallParams(SUBSCRIPTION_ID)?.proration_behavior).toBe(
641
+ "none",
642
+ );
643
+ });
644
+
645
+ it("should report the trial the payment provider reports when the caller passes none", async () => {
646
+ /*
647
+ * The payment provider is the source of truth for what the customer is
648
+ * actually trialing on. A caller that does not pass endTrialAt - or one
649
+ * holding a project row written before an admin extended the trial -
650
+ * must not be able to cut that trial short.
651
+ */
652
+ const trialOnStripe: Date = stripeTrial(6);
653
+ setup({
654
+ trialEndOnStripe: OneUptimeDate.toUnixTimestamp(trialOnStripe),
655
+ });
656
+
657
+ const result: {
658
+ subscriptionId: string;
659
+ meteredSubscriptionId: string;
660
+ trialEndsAt?: Date | undefined;
661
+ } = await changePlan({ endTrialAt: undefined });
662
+
663
+ expect(updateCallParams(SUBSCRIPTION_ID)).not.toHaveProperty(
664
+ "trial_end",
665
+ );
666
+ expect(result.trialEndsAt).toEqual(trialOnStripe);
667
+ });
668
+
669
+ it("should keep the trial the payment provider reports when the caller is behind it", async () => {
670
+ const trialOnStripe: Date = stripeTrial(20);
671
+ const staleTrialFromCaller: Date = OneUptimeDate.getSomeDaysAfter(3);
672
+ setup({
673
+ trialEndOnStripe: OneUptimeDate.toUnixTimestamp(trialOnStripe),
674
+ });
675
+
676
+ const result: {
677
+ subscriptionId: string;
678
+ meteredSubscriptionId: string;
679
+ trialEndsAt?: Date | undefined;
680
+ } = await changePlan({ endTrialAt: staleTrialFromCaller });
681
+
682
+ expect(updateCallParams(SUBSCRIPTION_ID)).not.toHaveProperty(
683
+ "trial_end",
684
+ );
685
+ expect(result.trialEndsAt).toEqual(trialOnStripe);
686
+ });
687
+
688
+ it("should push a trial forward when the caller is ahead of the payment provider", async () => {
689
+ /*
690
+ * The other direction, and the one case where trial_end is sent: an
691
+ * extension recorded on the project but not yet pushed to the payment
692
+ * provider is still a trial the customer was promised. The date
693
+ * returned here is written back onto the project row, so the row and
694
+ * the payment provider have to end up agreeing.
695
+ */
696
+ const trialOnStripe: Date = stripeTrial(3);
697
+ const extendedTrial: Date = OneUptimeDate.getSomeDaysAfter(20);
698
+ setup({
699
+ trialEndOnStripe: OneUptimeDate.toUnixTimestamp(trialOnStripe),
700
+ });
701
+
702
+ const result: {
703
+ subscriptionId: string;
704
+ meteredSubscriptionId: string;
705
+ trialEndsAt?: Date | undefined;
706
+ } = await changePlan({ endTrialAt: extendedTrial });
707
+
708
+ expect(updateCallParams(SUBSCRIPTION_ID)?.trial_end).toBe(
709
+ OneUptimeDate.toUnixTimestamp(extendedTrial),
710
+ );
711
+ expect(result.trialEndsAt).toEqual(extendedTrial);
712
+ });
713
+
714
+ it("should push the same trial onto the metered subscription", async () => {
715
+ /*
716
+ * The two subscriptions bill the same customer for the same project.
717
+ * If only one of them trials, the other starts invoicing usage while
718
+ * the customer still believes they are on trial.
719
+ */
720
+ const trialOnStripe: Date = stripeTrial(3);
721
+ const extendedTrial: Date = OneUptimeDate.getSomeDaysAfter(20);
722
+ setup({
723
+ trialEndOnStripe: OneUptimeDate.toUnixTimestamp(trialOnStripe),
724
+ });
725
+
726
+ await changePlan({ endTrialAt: extendedTrial });
727
+
728
+ expect(updateCallParams(METERED_SUBSCRIPTION_ID)).toEqual({
729
+ trial_end: OneUptimeDate.toUnixTimestamp(extendedTrial),
730
+ proration_behavior: "none",
731
+ });
732
+ });
733
+
734
+ it("should carry a trial that is only minutes away from ending", async () => {
735
+ // A trial is live right up to its last second; it is not rounded away.
736
+ const almostOver: Date = OneUptimeDate.addRemoveMinutes(
737
+ OneUptimeDate.getCurrentDate(),
738
+ 5,
739
+ );
740
+ setup({
741
+ trialEndOnStripe: OneUptimeDate.toUnixTimestamp(almostOver),
742
+ });
743
+
744
+ const result: {
745
+ subscriptionId: string;
746
+ meteredSubscriptionId: string;
747
+ trialEndsAt?: Date | undefined;
748
+ } = await changePlan({ endTrialAt: undefined });
749
+
750
+ expect(updateCallParams(SUBSCRIPTION_ID)?.proration_behavior).toBe(
751
+ "none",
752
+ );
753
+ expect(result.trialEndsAt).toBeDefined();
754
+ });
755
+ });
756
+
757
+ describe("changing plan off a trial that is over", () => {
758
+ it("should not revive a trial the payment provider reports as lapsed", async () => {
759
+ setup({
760
+ status: "active",
761
+ trialEndOnStripe: OneUptimeDate.toUnixTimestamp(pastDate(5)),
762
+ });
763
+
764
+ const result: {
765
+ subscriptionId: string;
766
+ meteredSubscriptionId: string;
767
+ trialEndsAt?: Date | undefined;
768
+ } = await changePlan({ endTrialAt: undefined });
769
+
770
+ expect(result.trialEndsAt).toBeUndefined();
771
+ expect(updateCallParams(METERED_SUBSCRIPTION_ID)).toBeUndefined();
772
+ });
773
+
774
+ it("should not revive a trial the caller reports as lapsed", async () => {
775
+ setup({ status: "active" });
776
+
777
+ const result: {
778
+ subscriptionId: string;
779
+ meteredSubscriptionId: string;
780
+ trialEndsAt?: Date | undefined;
781
+ } = await changePlan({ endTrialAt: pastDate(5) });
782
+
783
+ expect(result.trialEndsAt).toBeUndefined();
784
+ });
785
+
786
+ it("should never end a trial by sending trial_end now", async () => {
787
+ /*
788
+ * A lapsed trial is Stripe's to close, and it already has. Sending
789
+ * "now" here would re-anchor the billing cycle and invoice the plan
790
+ * immediately - the surprise charge, arriving by another route.
791
+ */
792
+ setup({
793
+ status: "active",
794
+ trialEndOnStripe: OneUptimeDate.toUnixTimestamp(pastDate(5)),
795
+ });
796
+
797
+ await changePlan({ endTrialAt: pastDate(1) });
798
+
799
+ expect(updateCallParams(SUBSCRIPTION_ID)).not.toHaveProperty(
800
+ "trial_end",
801
+ );
802
+ });
803
+
804
+ it("should charge from now when there is no trial anywhere", async () => {
805
+ setup({ status: "active" });
806
+
807
+ const result: {
808
+ subscriptionId: string;
809
+ meteredSubscriptionId: string;
810
+ trialEndsAt?: Date | undefined;
811
+ } = await changePlan({ endTrialAt: undefined });
812
+
813
+ expect(updateCallParams(SUBSCRIPTION_ID)?.proration_behavior).toBe(
814
+ "create_prorations",
815
+ );
816
+ expect(result.trialEndsAt).toBeUndefined();
817
+ });
818
+ });
819
+
820
+ it("should not mutate the caller's arguments while resolving the trial", async () => {
821
+ /*
822
+ * changePlan used to overwrite data.endTrialAt in place. Callers reuse
823
+ * these objects (and read endTrialAt back to decide what to persist), so
824
+ * the resolution has to stay local.
825
+ */
826
+ const trialOnStripe: Date = stripeTrial(20);
827
+ setup({ trialEndOnStripe: OneUptimeDate.toUnixTimestamp(trialOnStripe) });
828
+
829
+ const data: {
830
+ projectId: ObjectID;
831
+ subscriptionId: string;
832
+ meteredSubscriptionId: string;
833
+ serverMeteredPlans: [];
834
+ newPlan: SubscriptionPlan;
835
+ quantity: number;
836
+ isYearly: boolean;
837
+ endTrialAt: Date;
838
+ } = {
839
+ projectId: PROJECT_ID,
840
+ subscriptionId: SUBSCRIPTION_ID,
841
+ meteredSubscriptionId: METERED_SUBSCRIPTION_ID,
842
+ serverMeteredPlans: [],
843
+ newPlan: scalePlan,
844
+ quantity: 3,
845
+ isYearly: false,
846
+ endTrialAt: pastDate(2),
847
+ };
848
+
849
+ const originalEndTrialAt: Date = data.endTrialAt;
850
+
851
+ await billingService.changePlan(data);
852
+
853
+ expect(data.endTrialAt).toBe(originalEndTrialAt);
854
+ });
855
+
856
+ /*
857
+ * A subscription an update cannot fix still has to be replaced. This is
858
+ * how reactiveSubscription puts a project back on its plan once payment
859
+ * recovers, and it is the only caller that lands here.
860
+ *
861
+ * The two subscriptions are replaced independently. Replacing the pair
862
+ * whenever either half was dead meant a live, billing subscription got
863
+ * cancelled to make way for a duplicate of itself, and the customer paid
864
+ * for both until the cancel landed.
865
+ */
866
+ describe("subscriptions that cannot be updated", () => {
867
+ it("should build replacements when the project's subscription is cancelled", async () => {
868
+ setup({ status: "canceled" });
869
+
870
+ const result: {
871
+ subscriptionId: string;
872
+ meteredSubscriptionId: string;
873
+ trialEndsAt?: Date | undefined;
874
+ } = await changePlan();
875
+
876
+ expect(createCallParams(1).items).toEqual([
877
+ { price: "price_monthly_scale", quantity: 3 },
878
+ ]);
879
+ expect(result.subscriptionId).toBe("sub_main_new");
880
+ expect(result.meteredSubscriptionId).toBe("sub_metered_new");
881
+ expect(mockStripe.subscriptions.update).not.toHaveBeenCalled();
882
+ });
883
+
884
+ it("should replace only the metered subscription when only it is dead", async () => {
885
+ /*
886
+ * The half that is dead is the half that is replaced. Leaving a
887
+ * cancelled metered subscription in place would put the project back
888
+ * on its plan while its usage silently stops being billed - but
889
+ * cancelling the flat-fee subscription alongside it is what billed
890
+ * the customer twice: it is active, so it is swapped in place.
891
+ */
892
+ setup({ status: "active", meteredStatus: "canceled" });
893
+
894
+ // The only subscription created here is the metered replacement.
895
+ mockStripe.subscriptions.create =
896
+ getJestMockFunction().mockResolvedValue(
897
+ getStripeSubscription({ id: "sub_metered_new" }),
898
+ );
899
+
900
+ const result: {
901
+ subscriptionId: string;
902
+ meteredSubscriptionId: string;
903
+ trialEndsAt?: Date | undefined;
904
+ } = await changePlan();
905
+
906
+ expect(mockStripe.subscriptions.create).toHaveBeenCalledTimes(1);
907
+ expect(updateCallParams(SUBSCRIPTION_ID)?.items).toEqual([
908
+ {
909
+ id: SUBSCRIPTION_ITEM_ID,
910
+ price: "price_monthly_scale",
911
+ quantity: 3,
912
+ },
913
+ ]);
914
+
915
+ // The flat-fee subscription is kept; only the metered id moves.
916
+ expect(result.subscriptionId).toBe(SUBSCRIPTION_ID);
917
+ expect(result.meteredSubscriptionId).toBe("sub_metered_new");
918
+ });
919
+
920
+ it("should build replacements when there is no item to swap a price onto", async () => {
921
+ setup({ status: "active", subscriptionItems: [] });
922
+
923
+ await changePlan();
924
+
925
+ expect(mockStripe.subscriptions.create).toHaveBeenCalled();
926
+ expect(mockStripe.subscriptions.update).not.toHaveBeenCalled();
927
+ });
928
+
929
+ it("should carry a running trial onto the replacements", async () => {
930
+ const runningTrialEndsAt: Date = OneUptimeDate.getSomeDaysAfter(9);
931
+ setup({ status: "canceled" });
932
+
933
+ const result: {
934
+ subscriptionId: string;
935
+ meteredSubscriptionId: string;
936
+ trialEndsAt?: Date | undefined;
937
+ } = await changePlan({ endTrialAt: runningTrialEndsAt });
938
+
939
+ expect(createCallParams(1).trial_end).toBe(
940
+ OneUptimeDate.toUnixTimestamp(runningTrialEndsAt),
941
+ );
942
+ expect(createCallParams(2).trial_end).toBe(
943
+ OneUptimeDate.toUnixTimestamp(runningTrialEndsAt),
944
+ );
945
+ expect(result.trialEndsAt).toEqual(runningTrialEndsAt);
946
+ });
947
+
948
+ /*
949
+ * "unpaid" rather than "canceled" below: it is the status that both has
950
+ * to be replaced AND still has to be cancelled. A cancelled
951
+ * subscription is already over and is never sent back to the payment
952
+ * provider to be cancelled again.
953
+ */
954
+ it("should create the replacements before cancelling the old subscriptions", async () => {
955
+ /*
956
+ * The two are not atomic. Creating first means a failure in between
957
+ * leaves the project on the subscriptions it had; cancelling first
958
+ * left it with none at all.
959
+ */
960
+ setup({ status: "unpaid" });
961
+
962
+ await changePlan();
963
+
964
+ expect(firstCallOrder(mockStripe.subscriptions.create)).toBeLessThan(
965
+ firstCallOrder(mockStripe.subscriptions.del),
966
+ );
967
+ });
968
+
969
+ it("should still cancel both of the old subscriptions", async () => {
970
+ setup({ status: "unpaid" });
971
+
972
+ await changePlan();
973
+
974
+ expect(mockStripe.subscriptions.del).toHaveBeenCalledWith(
975
+ SUBSCRIPTION_ID,
976
+ );
977
+ expect(mockStripe.subscriptions.del).toHaveBeenCalledWith(
978
+ METERED_SUBSCRIPTION_ID,
979
+ );
980
+ });
981
+
982
+ it("should read the trial off the old subscription before cancelling it", async () => {
983
+ /*
984
+ * Ordering matters: a deleted subscription cannot be asked what trial
985
+ * it had. Retrieve has to happen first or the fallback silently reads
986
+ * nothing.
987
+ */
988
+ const trialOnStripe: Date = stripeTrial(6);
989
+ setup({
990
+ status: "unpaid",
991
+ trialEndOnStripe: OneUptimeDate.toUnixTimestamp(trialOnStripe),
992
+ });
993
+
994
+ await changePlan({ endTrialAt: undefined });
995
+
996
+ expect(firstCallOrder(mockStripe.subscriptions.retrieve)).toBeLessThan(
997
+ firstCallOrder(mockStripe.subscriptions.del),
998
+ );
999
+ expect(createCallParams(1).trial_end).toBe(
1000
+ OneUptimeDate.toUnixTimestamp(trialOnStripe),
1001
+ );
1002
+ });
1003
+ });
1004
+ });
1005
+ });