@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
@@ -23,6 +23,7 @@ import APIException from "../../Types/Exception/ApiException";
23
23
  import BadDataException from "../../Types/Exception/BadDataException";
24
24
  import ProductType from "../../Types/MeteredPlan/ProductType";
25
25
  import ObjectID from "../../Types/ObjectID";
26
+ import Sleep from "../../Types/Sleep";
26
27
  import Stripe from "stripe";
27
28
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
28
29
 
@@ -44,6 +45,16 @@ export interface PaymentMethod {
44
45
  */
45
46
  export const MAX_TRIAL_LENGTH_IN_DAYS: number = 730;
46
47
 
48
+ /*
49
+ * How long to wait before trying a cancel again, per retry.
50
+ *
51
+ * The errors that lose a cancel - a rate limit, a dropped connection, a
52
+ * transient 5xx - are the ones a second attempt fixes, and a cancel that never
53
+ * lands leaves an open subscription nothing in OneUptime points at any more.
54
+ * Kept short and finite because this runs inside the plan change request.
55
+ */
56
+ const CANCEL_RETRY_DELAYS_IN_MS: Array<number> = [1000, 3000];
57
+
47
58
  export interface Invoice {
48
59
  id: string;
49
60
  amount: number;
@@ -257,6 +268,7 @@ export class BillingService extends BaseService {
257
268
  trialDate: Date | null;
258
269
  defaultPaymentMethodId?: string | undefined;
259
270
  promoCode?: string | undefined;
271
+ metadata?: Dictionary<string> | undefined;
260
272
  }): Promise<{
261
273
  meteredSubscriptionId: string;
262
274
  trialEndsAt: Date | null;
@@ -298,6 +310,10 @@ export class BillingService extends BaseService {
298
310
  data.defaultPaymentMethodId;
299
311
  }
300
312
 
313
+ if (data.metadata) {
314
+ meteredPlanSubscriptionParams.metadata = data.metadata;
315
+ }
316
+
301
317
  // Create metered subscriptions
302
318
  const meteredSubscription: Stripe.Response<Stripe.Subscription> =
303
319
  await this.stripe.subscriptions.create(meteredPlanSubscriptionParams);
@@ -370,6 +386,86 @@ export class BillingService extends BaseService {
370
386
  trialDate = data.trial;
371
387
  }
372
388
 
389
+ /*
390
+ * A trial is live when its end date is still ahead of us - nothing else
391
+ * decides it.
392
+ *
393
+ * The new plan's configured trial length is an input to how long a *fresh*
394
+ * trial runs (the boolean branch above). It must not decide whether an
395
+ * already-running trial handed over by changePlan survives: gating on it
396
+ * meant a project that changed plan mid-trial onto a plan configured with
397
+ * 0 trial days got trial_end "now", and Stripe invoiced the full plan on
398
+ * the spot. That is the upgrade-during-trial charge customers reported.
399
+ *
400
+ * Same predicate as subscribeToMeteredPlan, so the flat-fee and the
401
+ * metered subscription always end their trial on the same date.
402
+ */
403
+ const isTrialing: boolean = Boolean(
404
+ trialDate && OneUptimeDate.isInTheFuture(trialDate),
405
+ );
406
+
407
+ const subscriptionId: string = await this.createFlatFeeSubscription({
408
+ customerId: data.customerId,
409
+ plan: data.plan,
410
+ quantity: data.quantity,
411
+ isYearly: data.isYearly,
412
+ trialDate: isTrialing ? trialDate : null,
413
+ defaultPaymentMethodId: data.defaultPaymentMethodId,
414
+ promoCode: data.promoCode,
415
+ });
416
+
417
+ // Create metered subscriptions
418
+ const meteredSubscription: {
419
+ meteredSubscriptionId: string;
420
+ trialEndsAt: Date | null;
421
+ } = await this.subscribeToMeteredPlan({
422
+ ...data,
423
+
424
+ /*
425
+ * The resolved trial, not the raw one: both subscriptions have to answer
426
+ * "is this project trialing" the same way, or usage starts being invoiced
427
+ * while the flat fee is still waived.
428
+ */
429
+ trialDate: isTrialing ? trialDate : null,
430
+ });
431
+
432
+ return {
433
+ subscriptionId: subscriptionId,
434
+ meteredSubscriptionId: meteredSubscription.meteredSubscriptionId,
435
+
436
+ /*
437
+ * Reported with the same predicate that built trial_end. Callers persist
438
+ * this onto the project row, so a mismatch would leave the project
439
+ * showing no trial while Stripe has the subscription trialing (or the
440
+ * reverse) - and the project row is what gates the in-app trial banner.
441
+ */
442
+ trialEndsAt: isTrialing ? trialDate : null,
443
+ };
444
+ }
445
+
446
+ /*
447
+ * Creates the flat-fee subscription that carries the plan's price.
448
+ *
449
+ * Split out of subscribeToPlan so a plan change can rebuild the flat-fee
450
+ * subscription on its own. The metered subscription is not part of a plan
451
+ * change - it carries the usage already reported for the current period -
452
+ * and is only ever rebuilt when it is itself dead.
453
+ *
454
+ * The trial is passed in already resolved. Whether one is running is the
455
+ * caller's decision, and it has to be the same decision the metered
456
+ * subscription is created with, or usage starts being invoiced while the
457
+ * flat fee is still waived.
458
+ */
459
+ private async createFlatFeeSubscription(data: {
460
+ customerId: string;
461
+ plan: SubscriptionPlan;
462
+ quantity: number;
463
+ isYearly: boolean;
464
+ trialDate: Date | null;
465
+ defaultPaymentMethodId?: string | undefined;
466
+ promoCode?: string | undefined;
467
+ metadata?: Dictionary<string> | undefined;
468
+ }): Promise<string> {
373
469
  const subscriptionParams: Stripe.SubscriptionCreateParams = {
374
470
  customer: data.customerId,
375
471
 
@@ -384,9 +480,10 @@ export class BillingService extends BaseService {
384
480
 
385
481
  proration_behavior: "always_invoice",
386
482
 
483
+ // Same predicate as subscribeToMeteredPlan, so the pair trials together.
387
484
  trial_end:
388
- trialDate && data.plan.getTrialPeriod() > 0
389
- ? OneUptimeDate.toUnixTimestamp(trialDate)
485
+ data.trialDate && OneUptimeDate.isInTheFuture(data.trialDate)
486
+ ? OneUptimeDate.toUnixTimestamp(data.trialDate)
390
487
  : "now",
391
488
  };
392
489
 
@@ -398,24 +495,14 @@ export class BillingService extends BaseService {
398
495
  subscriptionParams.default_payment_method = data.defaultPaymentMethodId;
399
496
  }
400
497
 
498
+ if (data.metadata) {
499
+ subscriptionParams.metadata = data.metadata;
500
+ }
501
+
401
502
  const subscription: Stripe.Response<Stripe.Subscription> =
402
503
  await this.stripe.subscriptions.create(subscriptionParams);
403
504
 
404
- // Create metered subscriptions
405
- const meteredSubscription: {
406
- meteredSubscriptionId: string;
407
- trialEndsAt: Date | null;
408
- } = await this.subscribeToMeteredPlan({
409
- ...data,
410
- trialDate,
411
- });
412
-
413
- return {
414
- subscriptionId: subscription.id,
415
- meteredSubscriptionId: meteredSubscription.meteredSubscriptionId,
416
- trialEndsAt:
417
- trialDate && data.plan.getTrialPeriod() > 0 ? trialDate : null,
418
- };
505
+ return subscription.id;
419
506
  }
420
507
 
421
508
  @CaptureSpan()
@@ -609,6 +696,137 @@ export class BillingService extends BaseService {
609
696
  return subscription.items.data;
610
697
  }
611
698
 
699
+ /*
700
+ * The trial a subscription itself carries, as a Date. Null when it carries
701
+ * none - which is also what a subscription that no longer exists reports.
702
+ */
703
+ private getTrialEndOnSubscription(
704
+ subscription: Stripe.Subscription | null,
705
+ ): Date | null {
706
+ return subscription?.trial_end
707
+ ? OneUptimeDate.fromUnixTimestamp(subscription.trial_end)
708
+ : null;
709
+ }
710
+
711
+ /*
712
+ * Whether a subscription is one the project has to keep.
713
+ *
714
+ * A subscription that still bills the customer - active, trialing, or
715
+ * past_due, the same three the rest of the product counts as active - is
716
+ * kept, and a plan change is applied to it in place. Cancelling one of
717
+ * these and creating a replacement bills the customer for both for as long
718
+ * as the cancel does not land, and Stripe is still collecting on a past_due
719
+ * subscription, so an abandoned one is not harmless either.
720
+ *
721
+ * The rest - cancelled, unpaid, incomplete, incomplete_expired, paused -
722
+ * bill nothing and cannot be revived by an update: swapping a price onto
723
+ * one would leave the project on a subscription that still bills nothing.
724
+ * Those are replaced.
725
+ */
726
+ private isSubscriptionStillBilling(
727
+ subscription: Stripe.Subscription | null,
728
+ ): boolean {
729
+ if (!subscription?.status) {
730
+ return false;
731
+ }
732
+
733
+ /*
734
+ * isSubscriptionActive answers "yes" to an absent status - it is written
735
+ * for project rows, where no status means a project that predates the
736
+ * column. A subscription always has one, and the guard above has already
737
+ * dealt with a subscription that is not there at all.
738
+ */
739
+ return SubscriptionStatusUtil.isSubscriptionActive(
740
+ subscription.status as SubscriptionStatus,
741
+ );
742
+ }
743
+
744
+ /*
745
+ * Whether a subscription has already finished, and so has nothing left to
746
+ * cancel.
747
+ *
748
+ * Not the negation of isSubscriptionStillBilling: between the two sit the
749
+ * statuses that bill nothing but are not over either - unpaid, incomplete,
750
+ * paused - which are replaced AND still have to be cancelled, because the
751
+ * subscription is still open at the payment provider.
752
+ *
753
+ * A subscription that is already cancelled or expired is not sent to the
754
+ * provider to be cancelled again. The provider rejects that, and the reject
755
+ * is indistinguishable to the retry loop from a cancel that genuinely did
756
+ * not land: it would spend seconds retrying and then raise an alert asking
757
+ * an operator to cancel by hand something that is already cancelled. Null
758
+ * is the same case - findSubscription has already established the provider
759
+ * does not have it.
760
+ */
761
+ private isSubscriptionAlreadyFinished(
762
+ subscription: Stripe.Subscription | null,
763
+ ): boolean {
764
+ return (
765
+ !subscription ||
766
+ subscription.status === "canceled" ||
767
+ subscription.status === "incomplete_expired"
768
+ );
769
+ }
770
+
771
+ /*
772
+ * Retrieves a subscription, returning null only when the payment provider
773
+ * says it does not have it.
774
+ *
775
+ * A project row can point at an id the provider has since dropped, and that
776
+ * is a reason to give the project a new subscription rather than to fail its
777
+ * plan change outright.
778
+ *
779
+ * Every other error is thrown. A rate limit, a dropped connection or a
780
+ * transient 5xx says nothing about whether the subscription is live, and
781
+ * reading one as "it is gone" is how a perfectly healthy project got routed
782
+ * onto the path that cancels and recreates its subscriptions. Failing the
783
+ * plan change leaves the project exactly as it was, which is the safe answer
784
+ * when we cannot tell.
785
+ */
786
+ private async findSubscription(
787
+ subscriptionId: string,
788
+ ): Promise<Stripe.Subscription | null> {
789
+ try {
790
+ return (await this.stripe.subscriptions.retrieve(subscriptionId)) || null;
791
+ } catch (err) {
792
+ if (this.isResourceMissingError(err)) {
793
+ logger.debug(
794
+ `Payment provider has no subscription ${subscriptionId}. It will be replaced.`,
795
+ );
796
+ return null;
797
+ }
798
+
799
+ logger.error(err, { subscriptionId } as LogAttributes);
800
+ throw err;
801
+ }
802
+ }
803
+
804
+ /*
805
+ * A payment provider error saying the object is not there.
806
+ *
807
+ * Matched on the error's shape rather than with instanceof: the error can be
808
+ * raised by a different copy of the stripe package than the one imported
809
+ * here, and instanceof would then quietly say no - turning a subscription
810
+ * that is simply gone into a failed plan change.
811
+ */
812
+ private isResourceMissingError(error: unknown): boolean {
813
+ const providerError: {
814
+ type?: string | undefined;
815
+ rawType?: string | undefined;
816
+ code?: string | undefined;
817
+ } = (error || {}) as {
818
+ type?: string | undefined;
819
+ rawType?: string | undefined;
820
+ code?: string | undefined;
821
+ };
822
+
823
+ return (
824
+ providerError.code === "resource_missing" &&
825
+ (providerError.type === "StripeInvalidRequestError" ||
826
+ providerError.rawType === "invalid_request_error")
827
+ );
828
+ }
829
+
612
830
  @CaptureSpan()
613
831
  public async changePlan(data: {
614
832
  projectId: ObjectID;
@@ -623,6 +841,7 @@ export class BillingService extends BaseService {
623
841
  subscriptionId: string;
624
842
  meteredSubscriptionId: string;
625
843
  trialEndsAt?: Date | undefined;
844
+ subscriptionIdsPendingCancellation: Array<string>;
626
845
  }> {
627
846
  logger.debug("Changing plan");
628
847
  logger.debug(data);
@@ -660,49 +879,369 @@ export class BillingService extends BaseService {
660
879
  throw new BadDataException(Errors.BillingService.NO_PAYMENTS_METHODS);
661
880
  }
662
881
 
663
- logger.debug("Cancelling subscriptions");
664
- logger.debug(data.subscriptionId);
665
- await this.cancelSubscription(data.subscriptionId);
882
+ /*
883
+ * The trial the customer is really on lives on the subscription being
884
+ * changed; endTrialAt is only the caller's copy of it. Resolve both while
885
+ * the old subscription is still readable, and keep whichever runs longer.
886
+ *
887
+ * Changing plan must never shorten a trial. A caller holding a stale
888
+ * project row - or one that does not pass endTrialAt at all - would
889
+ * otherwise be able to end a trial the customer was promised, and Stripe
890
+ * would invoice the full plan on the spot.
891
+ */
892
+ const trialEndOnCurrentSubscription: Date | null =
893
+ this.getTrialEndOnSubscription(subscription);
666
894
 
667
- logger.debug("Cancelling metered subscriptions");
668
- logger.debug(data.meteredSubscriptionId);
669
- await this.cancelSubscription(data.meteredSubscriptionId);
895
+ let endTrialAt: Date | undefined = data.endTrialAt;
670
896
 
671
- if (data.endTrialAt && !OneUptimeDate.isInTheFuture(data.endTrialAt)) {
672
- data.endTrialAt = undefined;
897
+ if (
898
+ trialEndOnCurrentSubscription &&
899
+ (!endTrialAt ||
900
+ OneUptimeDate.isAfter(trialEndOnCurrentSubscription, endTrialAt))
901
+ ) {
902
+ endTrialAt = trialEndOnCurrentSubscription;
673
903
  }
674
904
 
675
- logger.debug("Subscribing to plan");
905
+ /*
906
+ * A trial that has already lapsed is not revived by a plan change - the
907
+ * customer is billed from now, which is what changing plan off a finished
908
+ * trial is meant to do.
909
+ */
910
+ if (endTrialAt && !OneUptimeDate.isInTheFuture(endTrialAt)) {
911
+ endTrialAt = undefined;
912
+ }
676
913
 
677
- const subscribeToPlan: {
678
- subscriptionId: string;
679
- meteredSubscriptionId: string;
680
- trialEndsAt: Date | null;
681
- } = await this.subscribeToPlan({
682
- projectId: data.projectId,
683
- customerId: subscription.customer.toString(),
684
- serverMeteredPlans: data.serverMeteredPlans,
685
- plan: data.newPlan,
686
- quantity: data.quantity,
687
- isYearly: data.isYearly,
688
- trial: data.endTrialAt,
689
- defaultPaymentMethodId: paymentMethods[0]?.id,
690
- promoCode: undefined,
691
- });
914
+ /*
915
+ * The metered subscription is read but never rebuilt while it is alive.
916
+ * Its items come from the metered plans' own price ids, which have nothing
917
+ * to do with the plan being changed - recreating it would throw away the
918
+ * usage already reported against it for the current period. It is fetched
919
+ * here to find out whether it is one of the two halves that has to be
920
+ * replaced.
921
+ */
922
+ const meteredSubscription: Stripe.Subscription | null =
923
+ await this.findSubscription(data.meteredSubscriptionId);
692
924
 
693
- logger.debug("Subscribed to plan");
925
+ const subscriptionItemId: string | undefined =
926
+ subscription.items.data[0]?.id;
694
927
 
695
- const value: {
696
- subscriptionId: string;
697
- meteredSubscriptionId: string;
698
- trialEndsAt?: Date | undefined;
699
- } = {
700
- subscriptionId: subscribeToPlan.subscriptionId,
701
- meteredSubscriptionId: subscribeToPlan.meteredSubscriptionId,
702
- trialEndsAt: subscribeToPlan.trialEndsAt || undefined,
928
+ /*
929
+ * A plan change is a price swap, so do exactly that whenever the project's
930
+ * subscription can take one. Cancelling and recreating it means every
931
+ * piece of subscription state - the trial, the billing cycle anchor,
932
+ * discounts, billing anchor history, reported usage - has to be rebuilt by
933
+ * hand, and the window between the create and the cancel is one the
934
+ * customer pays for twice.
935
+ *
936
+ * The two subscriptions are decided separately, because they fail
937
+ * separately: a project can be billing quite happily on its flat-fee
938
+ * subscription while its metered one is dead, and the reverse. Replacing
939
+ * the pair whenever either half was dead is what cancelled a live, billing
940
+ * subscription and left the customer paying for it alongside its
941
+ * replacement. Nothing that still bills is cancelled here.
942
+ *
943
+ * A subscription with no item has no handle to swap a price onto, so it is
944
+ * replaced whatever its status says. It charges nothing either way - the
945
+ * items are what carry the prices - so replacing it bills nobody twice.
946
+ */
947
+ const canSwapPriceOnSubscription: boolean =
948
+ Boolean(subscriptionItemId) &&
949
+ this.isSubscriptionStillBilling(subscription);
950
+
951
+ const canKeepMeteredSubscription: boolean =
952
+ this.isSubscriptionStillBilling(meteredSubscription);
953
+
954
+ const customerId: string = subscription.customer.toString();
955
+
956
+ /*
957
+ * Order matters, and it is: update what is being kept, create what is
958
+ * replacing what is not, cancel what has been replaced.
959
+ *
960
+ * The updates come first because they are the only steps that can fail
961
+ * without leaving anything behind - the project is left on the
962
+ * subscriptions it already had. The creates come before the cancels so
963
+ * that a failure between them leaves the project on the subscriptions it
964
+ * had rather than on none at all.
965
+ */
966
+ const subscriptionIdsToCancel: Array<string> = [];
967
+
968
+ let newSubscriptionId: string = data.subscriptionId;
969
+ let newMeteredSubscriptionId: string = data.meteredSubscriptionId;
970
+
971
+ if (canSwapPriceOnSubscription) {
972
+ await this.swapPlanPriceOnSubscription({
973
+ subscriptionId: data.subscriptionId,
974
+ subscriptionItemId: subscriptionItemId!,
975
+ priceId: data.isYearly
976
+ ? data.newPlan.getYearlyPlanId()
977
+ : data.newPlan.getMonthlyPlanId(),
978
+ quantity: data.quantity,
979
+ endTrialAt: endTrialAt,
980
+ trialEndOnSubscription: trialEndOnCurrentSubscription,
981
+ });
982
+ }
983
+
984
+ if (canKeepMeteredSubscription) {
985
+ await this.moveTrialOnMeteredSubscription({
986
+ meteredSubscriptionId: data.meteredSubscriptionId,
987
+ meteredSubscription: meteredSubscription,
988
+ endTrialAt: endTrialAt,
989
+ });
990
+ }
991
+
992
+ if (!canSwapPriceOnSubscription) {
993
+ logger.debug("Replacing the flat-fee subscription");
994
+
995
+ newSubscriptionId = await this.createFlatFeeSubscription({
996
+ customerId: customerId,
997
+ plan: data.newPlan,
998
+ quantity: data.quantity,
999
+ isYearly: data.isYearly,
1000
+ trialDate: endTrialAt || null,
1001
+ defaultPaymentMethodId: paymentMethods[0]?.id,
1002
+ metadata: this.getReplacementSubscriptionMetadata({
1003
+ projectId: data.projectId,
1004
+ replacedSubscriptionId: data.subscriptionId,
1005
+ }),
1006
+ });
1007
+
1008
+ if (!this.isSubscriptionAlreadyFinished(subscription)) {
1009
+ subscriptionIdsToCancel.push(data.subscriptionId);
1010
+ }
1011
+ }
1012
+
1013
+ if (!canKeepMeteredSubscription) {
1014
+ logger.debug("Replacing the metered subscription");
1015
+
1016
+ const replacementMeteredSubscription: {
1017
+ meteredSubscriptionId: string;
1018
+ trialEndsAt: Date | null;
1019
+ } = await this.subscribeToMeteredPlan({
1020
+ projectId: data.projectId,
1021
+ customerId: customerId,
1022
+ serverMeteredPlans: data.serverMeteredPlans,
1023
+ trialDate: endTrialAt || null,
1024
+ defaultPaymentMethodId: paymentMethods[0]?.id,
1025
+ metadata: this.getReplacementSubscriptionMetadata({
1026
+ projectId: data.projectId,
1027
+ replacedSubscriptionId: data.meteredSubscriptionId,
1028
+ }),
1029
+ });
1030
+
1031
+ newMeteredSubscriptionId =
1032
+ replacementMeteredSubscription.meteredSubscriptionId;
1033
+
1034
+ if (!this.isSubscriptionAlreadyFinished(meteredSubscription)) {
1035
+ subscriptionIdsToCancel.push(data.meteredSubscriptionId);
1036
+ }
1037
+ }
1038
+
1039
+ const subscriptionIdsPendingCancellation: Array<string> =
1040
+ await this.cancelReplacedSubscriptions(subscriptionIdsToCancel);
1041
+
1042
+ return {
1043
+ subscriptionId: newSubscriptionId,
1044
+ meteredSubscriptionId: newMeteredSubscriptionId,
1045
+
1046
+ /*
1047
+ * endTrialAt is the resolved trial: the longer of the caller's and the
1048
+ * one the old subscription carried, and undefined once it has lapsed.
1049
+ * It is what was sent to the payment provider on both halves, and the
1050
+ * caller writes it onto the project row.
1051
+ */
1052
+ trialEndsAt: endTrialAt,
1053
+ subscriptionIdsPendingCancellation: subscriptionIdsPendingCancellation,
1054
+ };
1055
+ }
1056
+
1057
+ /*
1058
+ * The breadcrumb a replacement subscription carries at the payment provider.
1059
+ *
1060
+ * It is set when the replacement is created, which is before anything is
1061
+ * cancelled and before the caller writes the new ids over the old ones. That
1062
+ * ordering is the point: if the cancel is lost, or the plan change dies
1063
+ * between the two creates, this is what is left to find the abandoned
1064
+ * subscription - and the project it belongs to - with.
1065
+ */
1066
+ private getReplacementSubscriptionMetadata(data: {
1067
+ projectId: ObjectID;
1068
+ replacedSubscriptionId: string;
1069
+ }): Dictionary<string> {
1070
+ return {
1071
+ projectId: data.projectId.toString(),
1072
+ replacedSubscriptionId: data.replacedSubscriptionId,
1073
+ };
1074
+ }
1075
+
1076
+ /*
1077
+ * Puts the new plan's price on the subscription the project already has.
1078
+ *
1079
+ * The subscription survives, so everything it carries survives with it.
1080
+ */
1081
+ private async swapPlanPriceOnSubscription(data: {
1082
+ subscriptionId: string;
1083
+ subscriptionItemId: string;
1084
+ priceId: string;
1085
+ quantity: number;
1086
+ endTrialAt: Date | undefined;
1087
+ trialEndOnSubscription: Date | null;
1088
+ }): Promise<void> {
1089
+ logger.debug("Swapping plan price on subscription");
1090
+ logger.debug(data.subscriptionId);
1091
+
1092
+ const isTrialing: boolean = Boolean(data.endTrialAt);
1093
+
1094
+ const updateParams: Stripe.SubscriptionUpdateParams = {
1095
+ items: [
1096
+ {
1097
+ /*
1098
+ * The id of the item being replaced. It is not optional: without it
1099
+ * Stripe ADDS the new price alongside the old one instead of
1100
+ * swapping it, and the customer is billed for both plans.
1101
+ */
1102
+ id: data.subscriptionItemId,
1103
+ price: data.priceId,
1104
+ quantity: data.quantity,
1105
+ },
1106
+ ],
1107
+
1108
+ /*
1109
+ * Prorations are recorded and settle on the next invoice. Never
1110
+ * "always_invoice" - that raises an invoice on the spot, which is the
1111
+ * surprise charge a plan change must not produce. A trialing
1112
+ * subscription has nothing to prorate, so it asks for nothing.
1113
+ */
1114
+ proration_behavior: isTrialing ? "none" : "create_prorations",
703
1115
  };
704
1116
 
705
- return value;
1117
+ /*
1118
+ * trial_end is deliberately absent unless the trial has to move forward.
1119
+ * Omitting it is what leaves the subscription trialing on the trial it
1120
+ * already has - sending a value re-anchors the billing cycle to it, and
1121
+ * sending "now" ends the trial and invoices the plan immediately.
1122
+ *
1123
+ * It is sent only when the resolved trial outlasts the one the
1124
+ * subscription carries: an extension recorded on the project but not yet
1125
+ * pushed to the payment provider is still a trial the customer was
1126
+ * promised, and the date returned below is written onto the project row,
1127
+ * so the two have to agree.
1128
+ */
1129
+ if (
1130
+ data.endTrialAt &&
1131
+ (!data.trialEndOnSubscription ||
1132
+ OneUptimeDate.isAfter(data.endTrialAt, data.trialEndOnSubscription))
1133
+ ) {
1134
+ updateParams.trial_end = OneUptimeDate.toUnixTimestamp(data.endTrialAt);
1135
+ }
1136
+
1137
+ await this.stripe.subscriptions.update(data.subscriptionId, updateParams);
1138
+
1139
+ logger.debug("Swapped plan price on subscription");
1140
+ }
1141
+
1142
+ /*
1143
+ * Carries a trial that has moved forward onto the metered subscription.
1144
+ *
1145
+ * Both subscriptions bill the same customer for the same project, so they
1146
+ * have to end their trial on the same date - see subscribeToPlan. The
1147
+ * metered one is touched only for that; its plan is not part of a plan
1148
+ * change, and its items are the metered plans' own price ids.
1149
+ */
1150
+ private async moveTrialOnMeteredSubscription(data: {
1151
+ meteredSubscriptionId: string;
1152
+ meteredSubscription: Stripe.Subscription | null;
1153
+ endTrialAt: Date | undefined;
1154
+ }): Promise<void> {
1155
+ const trialEndOnMeteredSubscription: Date | null =
1156
+ this.getTrialEndOnSubscription(data.meteredSubscription);
1157
+
1158
+ if (
1159
+ data.endTrialAt &&
1160
+ (!trialEndOnMeteredSubscription ||
1161
+ OneUptimeDate.isAfter(data.endTrialAt, trialEndOnMeteredSubscription))
1162
+ ) {
1163
+ await this.stripe.subscriptions.update(data.meteredSubscriptionId, {
1164
+ trial_end: OneUptimeDate.toUnixTimestamp(data.endTrialAt),
1165
+ proration_behavior: "none",
1166
+ });
1167
+ }
1168
+ }
1169
+
1170
+ /*
1171
+ * Cancels the subscriptions a plan change has replaced, and reports back the
1172
+ * ones that are still there.
1173
+ *
1174
+ * Every id handed here belongs to a subscription that was not billing when
1175
+ * the plan change read it - changePlan keeps anything that was - so a cancel
1176
+ * that does not land is a loose end rather than a second charge. It still
1177
+ * must not pass silently: the caller is about to write the replacement ids
1178
+ * over these, and after that nothing in OneUptime points at them.
1179
+ *
1180
+ * The cancel is not allowed to throw, because by this point the replacements
1181
+ * already exist. Failing here would mean the caller never records them, and
1182
+ * the abandoned subscription would be the new, live pair instead of the dead
1183
+ * one - strictly worse than the problem being solved.
1184
+ */
1185
+ private async cancelReplacedSubscriptions(
1186
+ subscriptionIds: Array<string>,
1187
+ ): Promise<Array<string>> {
1188
+ const subscriptionIdsPendingCancellation: Array<string> = [];
1189
+
1190
+ for (const subscriptionId of subscriptionIds) {
1191
+ if (!subscriptionId) {
1192
+ continue;
1193
+ }
1194
+
1195
+ logger.debug(`Cancelling replaced subscription ${subscriptionId}`);
1196
+
1197
+ const isCancelled: boolean =
1198
+ await this.cancelReplacedSubscription(subscriptionId);
1199
+
1200
+ if (!isCancelled) {
1201
+ subscriptionIdsPendingCancellation.push(subscriptionId);
1202
+ }
1203
+ }
1204
+
1205
+ return subscriptionIdsPendingCancellation;
1206
+ }
1207
+
1208
+ // Cancels one replaced subscription, retrying. True when it is gone.
1209
+ private async cancelReplacedSubscription(
1210
+ subscriptionId: string,
1211
+ ): Promise<boolean> {
1212
+ for (
1213
+ let attempt: number = 0;
1214
+ attempt <= CANCEL_RETRY_DELAYS_IN_MS.length;
1215
+ attempt++
1216
+ ) {
1217
+ try {
1218
+ await this.stripe.subscriptions.del(subscriptionId);
1219
+
1220
+ return true;
1221
+ } catch (err) {
1222
+ /*
1223
+ * Already gone - cancelled by a webhook, by another request, or by an
1224
+ * earlier attempt of this one whose response was lost. Gone is the
1225
+ * state being asked for.
1226
+ */
1227
+ if (this.isResourceMissingError(err)) {
1228
+ return true;
1229
+ }
1230
+
1231
+ logger.error(err, { subscriptionId } as LogAttributes);
1232
+
1233
+ const delayInMs: number | undefined =
1234
+ CANCEL_RETRY_DELAYS_IN_MS[attempt];
1235
+
1236
+ if (delayInMs === undefined) {
1237
+ return false;
1238
+ }
1239
+
1240
+ await Sleep.sleep(delayInMs);
1241
+ }
1242
+ }
1243
+
1244
+ return false;
706
1245
  }
707
1246
 
708
1247
  @CaptureSpan()
@@ -886,6 +1425,16 @@ export class BillingService extends BaseService {
886
1425
  return ((customer as Stripe.Customer).balance || 0) / 100;
887
1426
  }
888
1427
 
1428
+ /*
1429
+ * Cancels a subscription, and carries on if it could not be cancelled.
1430
+ *
1431
+ * The leniency is for deleting a project: the project row goes away either
1432
+ * way, and a failed cancel there is a billing loose end to chase, not a
1433
+ * reason to leave the project half-deleted. A caller that needs to know the
1434
+ * subscription really stopped billing must not use this - a plan change goes
1435
+ * through cancelReplacedSubscriptions, which retries and reports what is
1436
+ * left.
1437
+ */
889
1438
  @CaptureSpan()
890
1439
  public async cancelSubscription(subscriptionId: string): Promise<void> {
891
1440
  if (!this.isBillingEnabled()) {