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