@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
@@ -22,6 +22,7 @@ import APIException from "../../Types/Exception/ApiException";
22
22
  import BadDataException from "../../Types/Exception/BadDataException";
23
23
  import ProductType from "../../Types/MeteredPlan/ProductType";
24
24
  import ObjectID from "../../Types/ObjectID";
25
+ import Sleep from "../../Types/Sleep";
25
26
  import Stripe from "stripe";
26
27
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
27
28
  /*
@@ -30,6 +31,15 @@ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
30
31
  * instead of a raw payment-provider failure.
31
32
  */
32
33
  export const MAX_TRIAL_LENGTH_IN_DAYS = 730;
34
+ /*
35
+ * How long to wait before trying a cancel again, per retry.
36
+ *
37
+ * The errors that lose a cancel - a rate limit, a dropped connection, a
38
+ * transient 5xx - are the ones a second attempt fixes, and a cancel that never
39
+ * lands leaves an open subscription nothing in OneUptime points at any more.
40
+ * Kept short and finite because this runs inside the plan change request.
41
+ */
42
+ const CANCEL_RETRY_DELAYS_IN_MS = [1000, 3000];
33
43
  export class BillingService extends BaseService {
34
44
  constructor() {
35
45
  super();
@@ -203,6 +213,9 @@ export class BillingService extends BaseService {
203
213
  meteredPlanSubscriptionParams.default_payment_method =
204
214
  data.defaultPaymentMethodId;
205
215
  }
216
+ if (data.metadata) {
217
+ meteredPlanSubscriptionParams.metadata = data.metadata;
218
+ }
206
219
  // Create metered subscriptions
207
220
  const meteredSubscription = await this.stripe.subscriptions.create(meteredPlanSubscriptionParams);
208
221
  for (const serverMeteredPlan of data.serverMeteredPlans) {
@@ -245,6 +258,64 @@ export class BillingService extends BaseService {
245
258
  else if (data.trial instanceof Date) {
246
259
  trialDate = data.trial;
247
260
  }
261
+ /*
262
+ * A trial is live when its end date is still ahead of us - nothing else
263
+ * decides it.
264
+ *
265
+ * The new plan's configured trial length is an input to how long a *fresh*
266
+ * trial runs (the boolean branch above). It must not decide whether an
267
+ * already-running trial handed over by changePlan survives: gating on it
268
+ * meant a project that changed plan mid-trial onto a plan configured with
269
+ * 0 trial days got trial_end "now", and Stripe invoiced the full plan on
270
+ * the spot. That is the upgrade-during-trial charge customers reported.
271
+ *
272
+ * Same predicate as subscribeToMeteredPlan, so the flat-fee and the
273
+ * metered subscription always end their trial on the same date.
274
+ */
275
+ const isTrialing = Boolean(trialDate && OneUptimeDate.isInTheFuture(trialDate));
276
+ const subscriptionId = await this.createFlatFeeSubscription({
277
+ customerId: data.customerId,
278
+ plan: data.plan,
279
+ quantity: data.quantity,
280
+ isYearly: data.isYearly,
281
+ trialDate: isTrialing ? trialDate : null,
282
+ defaultPaymentMethodId: data.defaultPaymentMethodId,
283
+ promoCode: data.promoCode,
284
+ });
285
+ // Create metered subscriptions
286
+ const meteredSubscription = await this.subscribeToMeteredPlan(Object.assign(Object.assign({}, data), {
287
+ /*
288
+ * The resolved trial, not the raw one: both subscriptions have to answer
289
+ * "is this project trialing" the same way, or usage starts being invoiced
290
+ * while the flat fee is still waived.
291
+ */
292
+ trialDate: isTrialing ? trialDate : null }));
293
+ return {
294
+ subscriptionId: subscriptionId,
295
+ meteredSubscriptionId: meteredSubscription.meteredSubscriptionId,
296
+ /*
297
+ * Reported with the same predicate that built trial_end. Callers persist
298
+ * this onto the project row, so a mismatch would leave the project
299
+ * showing no trial while Stripe has the subscription trialing (or the
300
+ * reverse) - and the project row is what gates the in-app trial banner.
301
+ */
302
+ trialEndsAt: isTrialing ? trialDate : null,
303
+ };
304
+ }
305
+ /*
306
+ * Creates the flat-fee subscription that carries the plan's price.
307
+ *
308
+ * Split out of subscribeToPlan so a plan change can rebuild the flat-fee
309
+ * subscription on its own. The metered subscription is not part of a plan
310
+ * change - it carries the usage already reported for the current period -
311
+ * and is only ever rebuilt when it is itself dead.
312
+ *
313
+ * The trial is passed in already resolved. Whether one is running is the
314
+ * caller's decision, and it has to be the same decision the metered
315
+ * subscription is created with, or usage starts being invoiced while the
316
+ * flat fee is still waived.
317
+ */
318
+ async createFlatFeeSubscription(data) {
248
319
  const subscriptionParams = {
249
320
  customer: data.customerId,
250
321
  items: [
@@ -256,8 +327,9 @@ export class BillingService extends BaseService {
256
327
  },
257
328
  ],
258
329
  proration_behavior: "always_invoice",
259
- trial_end: trialDate && data.plan.getTrialPeriod() > 0
260
- ? OneUptimeDate.toUnixTimestamp(trialDate)
330
+ // Same predicate as subscribeToMeteredPlan, so the pair trials together.
331
+ trial_end: data.trialDate && OneUptimeDate.isInTheFuture(data.trialDate)
332
+ ? OneUptimeDate.toUnixTimestamp(data.trialDate)
261
333
  : "now",
262
334
  };
263
335
  if (data.promoCode) {
@@ -266,14 +338,11 @@ export class BillingService extends BaseService {
266
338
  if (data.defaultPaymentMethodId) {
267
339
  subscriptionParams.default_payment_method = data.defaultPaymentMethodId;
268
340
  }
341
+ if (data.metadata) {
342
+ subscriptionParams.metadata = data.metadata;
343
+ }
269
344
  const subscription = await this.stripe.subscriptions.create(subscriptionParams);
270
- // Create metered subscriptions
271
- const meteredSubscription = await this.subscribeToMeteredPlan(Object.assign(Object.assign({}, data), { trialDate }));
272
- return {
273
- subscriptionId: subscription.id,
274
- meteredSubscriptionId: meteredSubscription.meteredSubscriptionId,
275
- trialEndsAt: trialDate && data.plan.getTrialPeriod() > 0 ? trialDate : null,
276
- };
345
+ return subscription.id;
277
346
  }
278
347
  async changeQuantity(subscriptionId, quantity) {
279
348
  var _a;
@@ -396,8 +465,108 @@ export class BillingService extends BaseService {
396
465
  }
397
466
  return subscription.items.data;
398
467
  }
468
+ /*
469
+ * The trial a subscription itself carries, as a Date. Null when it carries
470
+ * none - which is also what a subscription that no longer exists reports.
471
+ */
472
+ getTrialEndOnSubscription(subscription) {
473
+ return (subscription === null || subscription === void 0 ? void 0 : subscription.trial_end)
474
+ ? OneUptimeDate.fromUnixTimestamp(subscription.trial_end)
475
+ : null;
476
+ }
477
+ /*
478
+ * Whether a subscription is one the project has to keep.
479
+ *
480
+ * A subscription that still bills the customer - active, trialing, or
481
+ * past_due, the same three the rest of the product counts as active - is
482
+ * kept, and a plan change is applied to it in place. Cancelling one of
483
+ * these and creating a replacement bills the customer for both for as long
484
+ * as the cancel does not land, and Stripe is still collecting on a past_due
485
+ * subscription, so an abandoned one is not harmless either.
486
+ *
487
+ * The rest - cancelled, unpaid, incomplete, incomplete_expired, paused -
488
+ * bill nothing and cannot be revived by an update: swapping a price onto
489
+ * one would leave the project on a subscription that still bills nothing.
490
+ * Those are replaced.
491
+ */
492
+ isSubscriptionStillBilling(subscription) {
493
+ if (!(subscription === null || subscription === void 0 ? void 0 : subscription.status)) {
494
+ return false;
495
+ }
496
+ /*
497
+ * isSubscriptionActive answers "yes" to an absent status - it is written
498
+ * for project rows, where no status means a project that predates the
499
+ * column. A subscription always has one, and the guard above has already
500
+ * dealt with a subscription that is not there at all.
501
+ */
502
+ return SubscriptionStatusUtil.isSubscriptionActive(subscription.status);
503
+ }
504
+ /*
505
+ * Whether a subscription has already finished, and so has nothing left to
506
+ * cancel.
507
+ *
508
+ * Not the negation of isSubscriptionStillBilling: between the two sit the
509
+ * statuses that bill nothing but are not over either - unpaid, incomplete,
510
+ * paused - which are replaced AND still have to be cancelled, because the
511
+ * subscription is still open at the payment provider.
512
+ *
513
+ * A subscription that is already cancelled or expired is not sent to the
514
+ * provider to be cancelled again. The provider rejects that, and the reject
515
+ * is indistinguishable to the retry loop from a cancel that genuinely did
516
+ * not land: it would spend seconds retrying and then raise an alert asking
517
+ * an operator to cancel by hand something that is already cancelled. Null
518
+ * is the same case - findSubscription has already established the provider
519
+ * does not have it.
520
+ */
521
+ isSubscriptionAlreadyFinished(subscription) {
522
+ return (!subscription ||
523
+ subscription.status === "canceled" ||
524
+ subscription.status === "incomplete_expired");
525
+ }
526
+ /*
527
+ * Retrieves a subscription, returning null only when the payment provider
528
+ * says it does not have it.
529
+ *
530
+ * A project row can point at an id the provider has since dropped, and that
531
+ * is a reason to give the project a new subscription rather than to fail its
532
+ * plan change outright.
533
+ *
534
+ * Every other error is thrown. A rate limit, a dropped connection or a
535
+ * transient 5xx says nothing about whether the subscription is live, and
536
+ * reading one as "it is gone" is how a perfectly healthy project got routed
537
+ * onto the path that cancels and recreates its subscriptions. Failing the
538
+ * plan change leaves the project exactly as it was, which is the safe answer
539
+ * when we cannot tell.
540
+ */
541
+ async findSubscription(subscriptionId) {
542
+ try {
543
+ return (await this.stripe.subscriptions.retrieve(subscriptionId)) || null;
544
+ }
545
+ catch (err) {
546
+ if (this.isResourceMissingError(err)) {
547
+ logger.debug(`Payment provider has no subscription ${subscriptionId}. It will be replaced.`);
548
+ return null;
549
+ }
550
+ logger.error(err, { subscriptionId });
551
+ throw err;
552
+ }
553
+ }
554
+ /*
555
+ * A payment provider error saying the object is not there.
556
+ *
557
+ * Matched on the error's shape rather than with instanceof: the error can be
558
+ * raised by a different copy of the stripe package than the one imported
559
+ * here, and instanceof would then quietly say no - turning a subscription
560
+ * that is simply gone into a failed plan change.
561
+ */
562
+ isResourceMissingError(error) {
563
+ const providerError = (error || {});
564
+ return (providerError.code === "resource_missing" &&
565
+ (providerError.type === "StripeInvalidRequestError" ||
566
+ providerError.rawType === "invalid_request_error"));
567
+ }
399
568
  async changePlan(data) {
400
- var _a;
569
+ var _a, _b, _c;
401
570
  logger.debug("Changing plan");
402
571
  logger.debug(data);
403
572
  if (!this.isBillingEnabled()) {
@@ -420,34 +589,285 @@ export class BillingService extends BaseService {
420
589
  logger.debug("No payment methods");
421
590
  throw new BadDataException(Errors.BillingService.NO_PAYMENTS_METHODS);
422
591
  }
423
- logger.debug("Cancelling subscriptions");
592
+ /*
593
+ * The trial the customer is really on lives on the subscription being
594
+ * changed; endTrialAt is only the caller's copy of it. Resolve both while
595
+ * the old subscription is still readable, and keep whichever runs longer.
596
+ *
597
+ * Changing plan must never shorten a trial. A caller holding a stale
598
+ * project row - or one that does not pass endTrialAt at all - would
599
+ * otherwise be able to end a trial the customer was promised, and Stripe
600
+ * would invoice the full plan on the spot.
601
+ */
602
+ const trialEndOnCurrentSubscription = this.getTrialEndOnSubscription(subscription);
603
+ let endTrialAt = data.endTrialAt;
604
+ if (trialEndOnCurrentSubscription &&
605
+ (!endTrialAt ||
606
+ OneUptimeDate.isAfter(trialEndOnCurrentSubscription, endTrialAt))) {
607
+ endTrialAt = trialEndOnCurrentSubscription;
608
+ }
609
+ /*
610
+ * A trial that has already lapsed is not revived by a plan change - the
611
+ * customer is billed from now, which is what changing plan off a finished
612
+ * trial is meant to do.
613
+ */
614
+ if (endTrialAt && !OneUptimeDate.isInTheFuture(endTrialAt)) {
615
+ endTrialAt = undefined;
616
+ }
617
+ /*
618
+ * The metered subscription is read but never rebuilt while it is alive.
619
+ * Its items come from the metered plans' own price ids, which have nothing
620
+ * to do with the plan being changed - recreating it would throw away the
621
+ * usage already reported against it for the current period. It is fetched
622
+ * here to find out whether it is one of the two halves that has to be
623
+ * replaced.
624
+ */
625
+ const meteredSubscription = await this.findSubscription(data.meteredSubscriptionId);
626
+ const subscriptionItemId = (_a = subscription.items.data[0]) === null || _a === void 0 ? void 0 : _a.id;
627
+ /*
628
+ * A plan change is a price swap, so do exactly that whenever the project's
629
+ * subscription can take one. Cancelling and recreating it means every
630
+ * piece of subscription state - the trial, the billing cycle anchor,
631
+ * discounts, billing anchor history, reported usage - has to be rebuilt by
632
+ * hand, and the window between the create and the cancel is one the
633
+ * customer pays for twice.
634
+ *
635
+ * The two subscriptions are decided separately, because they fail
636
+ * separately: a project can be billing quite happily on its flat-fee
637
+ * subscription while its metered one is dead, and the reverse. Replacing
638
+ * the pair whenever either half was dead is what cancelled a live, billing
639
+ * subscription and left the customer paying for it alongside its
640
+ * replacement. Nothing that still bills is cancelled here.
641
+ *
642
+ * A subscription with no item has no handle to swap a price onto, so it is
643
+ * replaced whatever its status says. It charges nothing either way - the
644
+ * items are what carry the prices - so replacing it bills nobody twice.
645
+ */
646
+ const canSwapPriceOnSubscription = Boolean(subscriptionItemId) &&
647
+ this.isSubscriptionStillBilling(subscription);
648
+ const canKeepMeteredSubscription = this.isSubscriptionStillBilling(meteredSubscription);
649
+ const customerId = subscription.customer.toString();
650
+ /*
651
+ * Order matters, and it is: update what is being kept, create what is
652
+ * replacing what is not, cancel what has been replaced.
653
+ *
654
+ * The updates come first because they are the only steps that can fail
655
+ * without leaving anything behind - the project is left on the
656
+ * subscriptions it already had. The creates come before the cancels so
657
+ * that a failure between them leaves the project on the subscriptions it
658
+ * had rather than on none at all.
659
+ */
660
+ const subscriptionIdsToCancel = [];
661
+ let newSubscriptionId = data.subscriptionId;
662
+ let newMeteredSubscriptionId = data.meteredSubscriptionId;
663
+ if (canSwapPriceOnSubscription) {
664
+ await this.swapPlanPriceOnSubscription({
665
+ subscriptionId: data.subscriptionId,
666
+ subscriptionItemId: subscriptionItemId,
667
+ priceId: data.isYearly
668
+ ? data.newPlan.getYearlyPlanId()
669
+ : data.newPlan.getMonthlyPlanId(),
670
+ quantity: data.quantity,
671
+ endTrialAt: endTrialAt,
672
+ trialEndOnSubscription: trialEndOnCurrentSubscription,
673
+ });
674
+ }
675
+ if (canKeepMeteredSubscription) {
676
+ await this.moveTrialOnMeteredSubscription({
677
+ meteredSubscriptionId: data.meteredSubscriptionId,
678
+ meteredSubscription: meteredSubscription,
679
+ endTrialAt: endTrialAt,
680
+ });
681
+ }
682
+ if (!canSwapPriceOnSubscription) {
683
+ logger.debug("Replacing the flat-fee subscription");
684
+ newSubscriptionId = await this.createFlatFeeSubscription({
685
+ customerId: customerId,
686
+ plan: data.newPlan,
687
+ quantity: data.quantity,
688
+ isYearly: data.isYearly,
689
+ trialDate: endTrialAt || null,
690
+ defaultPaymentMethodId: (_b = paymentMethods[0]) === null || _b === void 0 ? void 0 : _b.id,
691
+ metadata: this.getReplacementSubscriptionMetadata({
692
+ projectId: data.projectId,
693
+ replacedSubscriptionId: data.subscriptionId,
694
+ }),
695
+ });
696
+ if (!this.isSubscriptionAlreadyFinished(subscription)) {
697
+ subscriptionIdsToCancel.push(data.subscriptionId);
698
+ }
699
+ }
700
+ if (!canKeepMeteredSubscription) {
701
+ logger.debug("Replacing the metered subscription");
702
+ const replacementMeteredSubscription = await this.subscribeToMeteredPlan({
703
+ projectId: data.projectId,
704
+ customerId: customerId,
705
+ serverMeteredPlans: data.serverMeteredPlans,
706
+ trialDate: endTrialAt || null,
707
+ defaultPaymentMethodId: (_c = paymentMethods[0]) === null || _c === void 0 ? void 0 : _c.id,
708
+ metadata: this.getReplacementSubscriptionMetadata({
709
+ projectId: data.projectId,
710
+ replacedSubscriptionId: data.meteredSubscriptionId,
711
+ }),
712
+ });
713
+ newMeteredSubscriptionId =
714
+ replacementMeteredSubscription.meteredSubscriptionId;
715
+ if (!this.isSubscriptionAlreadyFinished(meteredSubscription)) {
716
+ subscriptionIdsToCancel.push(data.meteredSubscriptionId);
717
+ }
718
+ }
719
+ const subscriptionIdsPendingCancellation = await this.cancelReplacedSubscriptions(subscriptionIdsToCancel);
720
+ return {
721
+ subscriptionId: newSubscriptionId,
722
+ meteredSubscriptionId: newMeteredSubscriptionId,
723
+ /*
724
+ * endTrialAt is the resolved trial: the longer of the caller's and the
725
+ * one the old subscription carried, and undefined once it has lapsed.
726
+ * It is what was sent to the payment provider on both halves, and the
727
+ * caller writes it onto the project row.
728
+ */
729
+ trialEndsAt: endTrialAt,
730
+ subscriptionIdsPendingCancellation: subscriptionIdsPendingCancellation,
731
+ };
732
+ }
733
+ /*
734
+ * The breadcrumb a replacement subscription carries at the payment provider.
735
+ *
736
+ * It is set when the replacement is created, which is before anything is
737
+ * cancelled and before the caller writes the new ids over the old ones. That
738
+ * ordering is the point: if the cancel is lost, or the plan change dies
739
+ * between the two creates, this is what is left to find the abandoned
740
+ * subscription - and the project it belongs to - with.
741
+ */
742
+ getReplacementSubscriptionMetadata(data) {
743
+ return {
744
+ projectId: data.projectId.toString(),
745
+ replacedSubscriptionId: data.replacedSubscriptionId,
746
+ };
747
+ }
748
+ /*
749
+ * Puts the new plan's price on the subscription the project already has.
750
+ *
751
+ * The subscription survives, so everything it carries survives with it.
752
+ */
753
+ async swapPlanPriceOnSubscription(data) {
754
+ logger.debug("Swapping plan price on subscription");
424
755
  logger.debug(data.subscriptionId);
425
- await this.cancelSubscription(data.subscriptionId);
426
- logger.debug("Cancelling metered subscriptions");
427
- logger.debug(data.meteredSubscriptionId);
428
- await this.cancelSubscription(data.meteredSubscriptionId);
429
- if (data.endTrialAt && !OneUptimeDate.isInTheFuture(data.endTrialAt)) {
430
- data.endTrialAt = undefined;
431
- }
432
- logger.debug("Subscribing to plan");
433
- const subscribeToPlan = await this.subscribeToPlan({
434
- projectId: data.projectId,
435
- customerId: subscription.customer.toString(),
436
- serverMeteredPlans: data.serverMeteredPlans,
437
- plan: data.newPlan,
438
- quantity: data.quantity,
439
- isYearly: data.isYearly,
440
- trial: data.endTrialAt,
441
- defaultPaymentMethodId: (_a = paymentMethods[0]) === null || _a === void 0 ? void 0 : _a.id,
442
- promoCode: undefined,
443
- });
444
- logger.debug("Subscribed to plan");
445
- const value = {
446
- subscriptionId: subscribeToPlan.subscriptionId,
447
- meteredSubscriptionId: subscribeToPlan.meteredSubscriptionId,
448
- trialEndsAt: subscribeToPlan.trialEndsAt || undefined,
756
+ const isTrialing = Boolean(data.endTrialAt);
757
+ const updateParams = {
758
+ items: [
759
+ {
760
+ /*
761
+ * The id of the item being replaced. It is not optional: without it
762
+ * Stripe ADDS the new price alongside the old one instead of
763
+ * swapping it, and the customer is billed for both plans.
764
+ */
765
+ id: data.subscriptionItemId,
766
+ price: data.priceId,
767
+ quantity: data.quantity,
768
+ },
769
+ ],
770
+ /*
771
+ * Prorations are recorded and settle on the next invoice. Never
772
+ * "always_invoice" - that raises an invoice on the spot, which is the
773
+ * surprise charge a plan change must not produce. A trialing
774
+ * subscription has nothing to prorate, so it asks for nothing.
775
+ */
776
+ proration_behavior: isTrialing ? "none" : "create_prorations",
449
777
  };
450
- return value;
778
+ /*
779
+ * trial_end is deliberately absent unless the trial has to move forward.
780
+ * Omitting it is what leaves the subscription trialing on the trial it
781
+ * already has - sending a value re-anchors the billing cycle to it, and
782
+ * sending "now" ends the trial and invoices the plan immediately.
783
+ *
784
+ * It is sent only when the resolved trial outlasts the one the
785
+ * subscription carries: an extension recorded on the project but not yet
786
+ * pushed to the payment provider is still a trial the customer was
787
+ * promised, and the date returned below is written onto the project row,
788
+ * so the two have to agree.
789
+ */
790
+ if (data.endTrialAt &&
791
+ (!data.trialEndOnSubscription ||
792
+ OneUptimeDate.isAfter(data.endTrialAt, data.trialEndOnSubscription))) {
793
+ updateParams.trial_end = OneUptimeDate.toUnixTimestamp(data.endTrialAt);
794
+ }
795
+ await this.stripe.subscriptions.update(data.subscriptionId, updateParams);
796
+ logger.debug("Swapped plan price on subscription");
797
+ }
798
+ /*
799
+ * Carries a trial that has moved forward onto the metered subscription.
800
+ *
801
+ * Both subscriptions bill the same customer for the same project, so they
802
+ * have to end their trial on the same date - see subscribeToPlan. The
803
+ * metered one is touched only for that; its plan is not part of a plan
804
+ * change, and its items are the metered plans' own price ids.
805
+ */
806
+ async moveTrialOnMeteredSubscription(data) {
807
+ const trialEndOnMeteredSubscription = this.getTrialEndOnSubscription(data.meteredSubscription);
808
+ if (data.endTrialAt &&
809
+ (!trialEndOnMeteredSubscription ||
810
+ OneUptimeDate.isAfter(data.endTrialAt, trialEndOnMeteredSubscription))) {
811
+ await this.stripe.subscriptions.update(data.meteredSubscriptionId, {
812
+ trial_end: OneUptimeDate.toUnixTimestamp(data.endTrialAt),
813
+ proration_behavior: "none",
814
+ });
815
+ }
816
+ }
817
+ /*
818
+ * Cancels the subscriptions a plan change has replaced, and reports back the
819
+ * ones that are still there.
820
+ *
821
+ * Every id handed here belongs to a subscription that was not billing when
822
+ * the plan change read it - changePlan keeps anything that was - so a cancel
823
+ * that does not land is a loose end rather than a second charge. It still
824
+ * must not pass silently: the caller is about to write the replacement ids
825
+ * over these, and after that nothing in OneUptime points at them.
826
+ *
827
+ * The cancel is not allowed to throw, because by this point the replacements
828
+ * already exist. Failing here would mean the caller never records them, and
829
+ * the abandoned subscription would be the new, live pair instead of the dead
830
+ * one - strictly worse than the problem being solved.
831
+ */
832
+ async cancelReplacedSubscriptions(subscriptionIds) {
833
+ const subscriptionIdsPendingCancellation = [];
834
+ for (const subscriptionId of subscriptionIds) {
835
+ if (!subscriptionId) {
836
+ continue;
837
+ }
838
+ logger.debug(`Cancelling replaced subscription ${subscriptionId}`);
839
+ const isCancelled = await this.cancelReplacedSubscription(subscriptionId);
840
+ if (!isCancelled) {
841
+ subscriptionIdsPendingCancellation.push(subscriptionId);
842
+ }
843
+ }
844
+ return subscriptionIdsPendingCancellation;
845
+ }
846
+ // Cancels one replaced subscription, retrying. True when it is gone.
847
+ async cancelReplacedSubscription(subscriptionId) {
848
+ for (let attempt = 0; attempt <= CANCEL_RETRY_DELAYS_IN_MS.length; attempt++) {
849
+ try {
850
+ await this.stripe.subscriptions.del(subscriptionId);
851
+ return true;
852
+ }
853
+ catch (err) {
854
+ /*
855
+ * Already gone - cancelled by a webhook, by another request, or by an
856
+ * earlier attempt of this one whose response was lost. Gone is the
857
+ * state being asked for.
858
+ */
859
+ if (this.isResourceMissingError(err)) {
860
+ return true;
861
+ }
862
+ logger.error(err, { subscriptionId });
863
+ const delayInMs = CANCEL_RETRY_DELAYS_IN_MS[attempt];
864
+ if (delayInMs === undefined) {
865
+ return false;
866
+ }
867
+ await Sleep.sleep(delayInMs);
868
+ }
869
+ }
870
+ return false;
451
871
  }
452
872
  async deletePaymentMethod(customerId, paymentMethodId) {
453
873
  if (!this.isBillingEnabled()) {
@@ -574,6 +994,16 @@ export class BillingService extends BaseService {
574
994
  // Balance is in cents, convert to dollars
575
995
  return (customer.balance || 0) / 100;
576
996
  }
997
+ /*
998
+ * Cancels a subscription, and carries on if it could not be cancelled.
999
+ *
1000
+ * The leniency is for deleting a project: the project row goes away either
1001
+ * way, and a failed cancel there is a billing loose end to chase, not a
1002
+ * reason to leave the project half-deleted. A caller that needs to know the
1003
+ * subscription really stopped billing must not use this - a plan change goes
1004
+ * through cancelReplacedSubscriptions, which retries and reports what is
1005
+ * left.
1006
+ */
577
1007
  async cancelSubscription(subscriptionId) {
578
1008
  if (!this.isBillingEnabled()) {
579
1009
  throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);