@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.
- package/Models/DatabaseModels/Host.ts +12 -4
- package/Models/DatabaseModels/KubernetesContainer.ts +5 -4
- package/Models/DatabaseModels/KubernetesResource.ts +8 -7
- package/Models/DatabaseModels/PodmanResource.ts +6 -6
- package/Models/DatabaseModels/ServiceLevelObjective.ts +91 -0
- package/Models/DatabaseModels/StatusPage.ts +95 -0
- package/Server/API/TeamMemberAPI.ts +85 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.ts +84 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.ts +40 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.ts +131 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.ts +25 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
- package/Server/Middleware/ProjectAuthorization.ts +66 -4
- package/Server/Services/BillingService.ts +600 -51
- package/Server/Services/CephClusterService.ts +47 -5
- package/Server/Services/CephResourceService.ts +43 -4
- package/Server/Services/CloudResourceService.ts +47 -5
- package/Server/Services/DatabaseService.ts +87 -6
- package/Server/Services/DockerHostService.ts +47 -5
- package/Server/Services/DockerResourceService.ts +62 -8
- package/Server/Services/DockerSwarmClusterService.ts +48 -5
- package/Server/Services/DockerSwarmResourceService.ts +53 -4
- package/Server/Services/HostService.ts +48 -5
- package/Server/Services/IoTDeviceService.ts +1 -17
- package/Server/Services/IoTFleetService.ts +47 -5
- package/Server/Services/KubernetesClusterService.ts +48 -5
- package/Server/Services/KubernetesContainerService.ts +50 -9
- package/Server/Services/KubernetesResourceService.ts +53 -7
- package/Server/Services/LabelService.ts +106 -1
- package/Server/Services/MonitorService.ts +57 -0
- package/Server/Services/PodmanHostService.ts +47 -5
- package/Server/Services/PodmanResourceService.ts +62 -8
- package/Server/Services/ProjectService.ts +165 -35
- package/Server/Services/ProxmoxClusterService.ts +47 -5
- package/Server/Services/ProxmoxResourceService.ts +43 -4
- package/Server/Services/RumApplicationService.ts +48 -5
- package/Server/Services/ServerlessFunctionService.ts +48 -5
- package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +426 -0
- package/Server/Services/ServiceLevelObjectiveService.ts +39 -0
- package/Server/Services/StatusPageService.ts +95 -36
- package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +18 -1
- package/Server/Services/StatusPageSubscriberService.ts +3 -0
- package/Server/Types/Database/JSONColumnQuery.ts +621 -0
- package/Server/Types/Database/Permissions/PublicPermission.ts +9 -2
- package/Server/Types/Database/QueryHelper.ts +17 -32
- package/Server/Utils/Database/TruncateColumnValue.ts +84 -0
- package/Tests/App/Dashboard/UsersTableGroupsByPerson.test.tsx +448 -0
- package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +704 -0
- package/Tests/Server/API/TeamMemberRemoveUserFromProjectAPI.test.ts +303 -0
- package/Tests/Server/Middleware/MasterAdminAuthorization.test.ts +350 -0
- package/Tests/Server/Middleware/ProjectAuthorizationApiKeyHeader.test.ts +362 -0
- package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +687 -0
- package/Tests/Server/Services/BillingService.test.ts +78 -3
- package/Tests/Server/Services/BillingServiceChangePlanCancel.test.ts +645 -0
- package/Tests/Server/Services/BillingServiceTrialPlanChange.test.ts +1005 -0
- package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +503 -0
- package/Tests/Server/Services/HostIpAddressesColumnWidth.test.ts +198 -0
- package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +510 -0
- package/Tests/Server/Services/InventoryResourceNameColumnWidth.test.ts +500 -0
- package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +586 -0
- package/Tests/Server/Services/InventoryUpsertTruncationGuard.test.ts +1242 -0
- package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +650 -0
- package/Tests/Server/Services/ProjectServiceExtendTrial.test.ts +1 -0
- package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +387 -0
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +770 -0
- package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +130 -0
- package/Tests/Server/Services/SloMonitorLabelRuleHooks.test.ts +352 -0
- package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +29 -13
- package/Tests/Server/TestingUtils/Services/BillingServiceHelper.ts +69 -8
- package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +593 -0
- package/Tests/Server/Types/Database/Permissions/PublicPermission.test.ts +495 -0
- package/Tests/Server/Types/Database/QueryUtil.test.ts +113 -0
- package/Tests/Server/Utils/Database/TruncateColumnValue.test.ts +179 -0
- package/Tests/Types/Events/Recurring.test.ts +157 -0
- package/Tests/UI/Components/ModelTable/ModelTableGroupsProjectUsers.test.tsx +375 -0
- package/Tests/UI/Components/ModelTable/useCustomFieldColumns.test.tsx +196 -0
- package/Tests/UI/Utils/ProjectUsersModelAPI.test.ts +739 -0
- package/Tests/UI/Utils/TeamMembersByUser.test.ts +633 -0
- package/Tests/Utils/StatusPage/ReportPeriod.test.ts +218 -0
- package/Tests/Utils/Telemetry/HostIpAddresses.test.ts +291 -0
- package/Types/CustomField/CustomFieldDefinition.ts +24 -0
- package/Types/Date.ts +112 -0
- package/Types/Events/Recurring.ts +99 -1
- package/Types/StatusPage/StatusPageReport.ts +11 -0
- package/Types/StatusPage/StatusPageReportPeriodType.ts +21 -0
- package/UI/Components/ModelTable/CustomFieldColumns.tsx +2 -6
- package/UI/Components/ModelTable/useCustomFieldColumns.ts +21 -2
- package/UI/Utils/ModelAPI/ProjectUsersModelAPI.ts +305 -0
- package/UI/Utils/TeamMembersByUser.ts +302 -0
- package/Utils/StatusPage/ReportPeriod.ts +236 -0
- package/Utils/Telemetry/HostIpAddresses.ts +98 -0
- package/build/dist/Models/DatabaseModels/Host.js +12 -4
- package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
- package/build/dist/Models/DatabaseModels/KubernetesContainer.js +4 -4
- package/build/dist/Models/DatabaseModels/KubernetesContainer.js.map +1 -1
- package/build/dist/Models/DatabaseModels/KubernetesResource.js +7 -7
- package/build/dist/Models/DatabaseModels/KubernetesResource.js.map +1 -1
- package/build/dist/Models/DatabaseModels/PodmanResource.js +6 -6
- package/build/dist/Models/DatabaseModels/PodmanResource.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +87 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPage.js +98 -0
- package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
- package/build/dist/Server/API/TeamMemberAPI.js +57 -2
- package/build/dist/Server/API/TeamMemberAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js +45 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js +31 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js +90 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js +14 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/ProjectAuthorization.js +59 -4
- package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
- package/build/dist/Server/Services/BillingService.js +466 -36
- package/build/dist/Server/Services/BillingService.js.map +1 -1
- package/build/dist/Server/Services/CephClusterService.js +42 -5
- package/build/dist/Server/Services/CephClusterService.js.map +1 -1
- package/build/dist/Server/Services/CephResourceService.js +28 -3
- package/build/dist/Server/Services/CephResourceService.js.map +1 -1
- package/build/dist/Server/Services/CloudResourceService.js +42 -5
- package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +69 -2
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/DockerHostService.js +42 -5
- package/build/dist/Server/Services/DockerHostService.js.map +1 -1
- package/build/dist/Server/Services/DockerResourceService.js +34 -4
- package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
- package/build/dist/Server/Services/DockerSwarmClusterService.js +43 -5
- package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
- package/build/dist/Server/Services/DockerSwarmResourceService.js +28 -3
- package/build/dist/Server/Services/DockerSwarmResourceService.js.map +1 -1
- package/build/dist/Server/Services/HostService.js +43 -5
- package/build/dist/Server/Services/HostService.js.map +1 -1
- package/build/dist/Server/Services/IoTDeviceService.js +1 -8
- package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
- package/build/dist/Server/Services/IoTFleetService.js +42 -5
- package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
- package/build/dist/Server/Services/KubernetesClusterService.js +43 -5
- package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
- package/build/dist/Server/Services/KubernetesContainerService.js +29 -3
- package/build/dist/Server/Services/KubernetesContainerService.js.map +1 -1
- package/build/dist/Server/Services/KubernetesResourceService.js +32 -4
- package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
- package/build/dist/Server/Services/LabelService.js +92 -0
- package/build/dist/Server/Services/LabelService.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +47 -0
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/PodmanHostService.js +42 -5
- package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
- package/build/dist/Server/Services/PodmanResourceService.js +34 -4
- package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
- package/build/dist/Server/Services/ProjectService.js +135 -21
- package/build/dist/Server/Services/ProjectService.js.map +1 -1
- package/build/dist/Server/Services/ProxmoxClusterService.js +42 -5
- package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
- package/build/dist/Server/Services/ProxmoxResourceService.js +28 -3
- package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
- package/build/dist/Server/Services/RumApplicationService.js +43 -5
- package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
- package/build/dist/Server/Services/ServerlessFunctionService.js +43 -5
- package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +347 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js +33 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageService.js +69 -46
- package/build/dist/Server/Services/StatusPageService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +17 -1
- package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageSubscriberService.js +3 -0
- package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
- package/build/dist/Server/Types/Database/JSONColumnQuery.js +402 -0
- package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -0
- package/build/dist/Server/Types/Database/Permissions/PublicPermission.js +9 -2
- package/build/dist/Server/Types/Database/Permissions/PublicPermission.js.map +1 -1
- package/build/dist/Server/Types/Database/QueryHelper.js +17 -27
- package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
- package/build/dist/Server/Utils/Database/TruncateColumnValue.js +32 -0
- package/build/dist/Server/Utils/Database/TruncateColumnValue.js.map +1 -0
- package/build/dist/Types/CustomField/CustomFieldDefinition.js +2 -0
- package/build/dist/Types/CustomField/CustomFieldDefinition.js.map +1 -0
- package/build/dist/Types/Date.js +68 -0
- package/build/dist/Types/Date.js.map +1 -1
- package/build/dist/Types/Events/Recurring.js +63 -0
- package/build/dist/Types/Events/Recurring.js.map +1 -1
- package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js +22 -0
- package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js.map +1 -0
- package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +18 -2
- package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -1
- package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js +213 -0
- package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js.map +1 -0
- package/build/dist/UI/Utils/TeamMembersByUser.js +195 -0
- package/build/dist/UI/Utils/TeamMembersByUser.js.map +1 -0
- package/build/dist/Utils/StatusPage/ReportPeriod.js +131 -0
- package/build/dist/Utils/StatusPage/ReportPeriod.js.map +1 -0
- package/build/dist/Utils/Telemetry/HostIpAddresses.js +82 -0
- package/build/dist/Utils/Telemetry/HostIpAddresses.js.map +1 -0
- package/jest.config.json +2 -0
- package/package.json +1 -1
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
668
|
-
logger.debug(data.meteredSubscriptionId);
|
|
669
|
-
await this.cancelSubscription(data.meteredSubscriptionId);
|
|
895
|
+
let endTrialAt: Date | undefined = data.endTrialAt;
|
|
670
896
|
|
|
671
|
-
if (
|
|
672
|
-
|
|
897
|
+
if (
|
|
898
|
+
trialEndOnCurrentSubscription &&
|
|
899
|
+
(!endTrialAt ||
|
|
900
|
+
OneUptimeDate.isAfter(trialEndOnCurrentSubscription, endTrialAt))
|
|
901
|
+
) {
|
|
902
|
+
endTrialAt = trialEndOnCurrentSubscription;
|
|
673
903
|
}
|
|
674
904
|
|
|
675
|
-
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
925
|
+
const subscriptionItemId: string | undefined =
|
|
926
|
+
subscription.items.data[0]?.id;
|
|
694
927
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
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()) {
|