Skip to content

Commit 9eb3ca1

Browse files
author
serverpod_cloud
committed
feat: 417700911290c4167a2272b9c3d40edf81fb11ce
1 parent a0a5840 commit 9eb3ca1

9 files changed

Lines changed: 103 additions & 62 deletions

File tree

ground_control_client/lib/src/protocol/client.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,17 @@ class EndpointPlans extends _i1.EndpointRef {
650650
{},
651651
);
652652

653+
/// Checks if a plan is available for procurement.
654+
///
655+
/// - Throws [NotFoundException] if the product is not found.
656+
/// - Throws [ProcurementDeniedException] if the product is not available.
657+
_i2.Future<void> checkPlanAvailability({required String planName}) =>
658+
caller.callServerEndpoint<void>(
659+
'plans',
660+
'checkPlanAvailability',
661+
{'planName': planName},
662+
);
663+
653664
/// Fetches the names of the available subscription plans.
654665
_i2.Future<List<String>> listPlanNames() =>
655666
caller.callServerEndpoint<List<String>>(

ground_control_client/lib/src/protocol/protocol.dart

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ import 'features/secret_manager/models/secret_type.dart' as _i42;
5959
import 'shared/exceptions/models/duplicate_entry_exception.dart' as _i43;
6060
import 'shared/exceptions/models/invalid_value_exception.dart' as _i44;
6161
import 'shared/exceptions/models/not_found_exception.dart' as _i45;
62-
import 'shared/exceptions/models/resource_denied_exception.dart' as _i46;
62+
import 'shared/exceptions/models/procurement_denied_exception.dart' as _i46;
6363
import 'shared/exceptions/models/unauthenticated_exception.dart' as _i47;
6464
import 'shared/exceptions/models/unauthorized_exception.dart' as _i48;
6565
import 'shared/models/serverpod_region.dart' as _i49;
@@ -136,7 +136,7 @@ export 'features/secret_manager/models/secret_type.dart';
136136
export 'shared/exceptions/models/duplicate_entry_exception.dart';
137137
export 'shared/exceptions/models/invalid_value_exception.dart';
138138
export 'shared/exceptions/models/not_found_exception.dart';
139-
export 'shared/exceptions/models/resource_denied_exception.dart';
139+
export 'shared/exceptions/models/procurement_denied_exception.dart';
140140
export 'shared/exceptions/models/unauthenticated_exception.dart';
141141
export 'shared/exceptions/models/unauthorized_exception.dart';
142142
export 'shared/models/serverpod_region.dart';
@@ -288,8 +288,8 @@ class Protocol extends _i1.SerializationManager {
288288
if (t == _i45.NotFoundException) {
289289
return _i45.NotFoundException.fromJson(data) as T;
290290
}
291-
if (t == _i46.ResourceDeniedException) {
292-
return _i46.ResourceDeniedException.fromJson(data) as T;
291+
if (t == _i46.ProcurementDeniedException) {
292+
return _i46.ProcurementDeniedException.fromJson(data) as T;
293293
}
294294
if (t == _i47.UnauthenticatedException) {
295295
return _i47.UnauthenticatedException.fromJson(data) as T;
@@ -453,9 +453,10 @@ class Protocol extends _i1.SerializationManager {
453453
if (t == _i1.getType<_i45.NotFoundException?>()) {
454454
return (data != null ? _i45.NotFoundException.fromJson(data) : null) as T;
455455
}
456-
if (t == _i1.getType<_i46.ResourceDeniedException?>()) {
457-
return (data != null ? _i46.ResourceDeniedException.fromJson(data) : null)
458-
as T;
456+
if (t == _i1.getType<_i46.ProcurementDeniedException?>()) {
457+
return (data != null
458+
? _i46.ProcurementDeniedException.fromJson(data)
459+
: null) as T;
459460
}
460461
if (t == _i1.getType<_i47.UnauthenticatedException?>()) {
461462
return (data != null
@@ -709,8 +710,8 @@ class Protocol extends _i1.SerializationManager {
709710
return 'InvalidValueException';
710711
case _i45.NotFoundException():
711712
return 'NotFoundException';
712-
case _i46.ResourceDeniedException():
713-
return 'ResourceDeniedException';
713+
case _i46.ProcurementDeniedException():
714+
return 'ProcurementDeniedException';
714715
case _i47.UnauthenticatedException():
715716
return 'UnauthenticatedException';
716717
case _i48.UnauthorizedException():
@@ -882,8 +883,8 @@ class Protocol extends _i1.SerializationManager {
882883
if (dataClassName == 'NotFoundException') {
883884
return deserialize<_i45.NotFoundException>(data['data']);
884885
}
885-
if (dataClassName == 'ResourceDeniedException') {
886-
return deserialize<_i46.ResourceDeniedException>(data['data']);
886+
if (dataClassName == 'ProcurementDeniedException') {
887+
return deserialize<_i46.ProcurementDeniedException>(data['data']);
887888
}
888889
if (dataClassName == 'UnauthenticatedException') {
889890
return deserialize<_i47.UnauthenticatedException>(data['data']);

ground_control_client/lib/src/protocol/shared/exceptions/models/resource_denied_exception.dart renamed to ground_control_client/lib/src/protocol/shared/exceptions/models/procurement_denied_exception.dart

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,29 @@
1212
// ignore_for_file: no_leading_underscores_for_library_prefixes
1313
import 'package:serverpod_client/serverpod_client.dart' as _i1;
1414

15-
/// Exception thrown when a resource is denied to the user / organization
16-
/// due to insufficient quota, allowance, or other subscription limit.
15+
/// Exception thrown when a procurement is denied to the user / organization
16+
/// due to insufficient allowance or other subscription limits.
1717
///
18-
/// This is distinct from access authorization.
19-
abstract class ResourceDeniedException
18+
/// This is distinct from access authorization, and from quota limits.
19+
abstract class ProcurementDeniedException
2020
implements _i1.SerializableException, _i1.SerializableModel {
21-
ResourceDeniedException._({required this.message});
21+
ProcurementDeniedException._({required this.message});
2222

23-
factory ResourceDeniedException({required String message}) =
24-
_ResourceDeniedExceptionImpl;
23+
factory ProcurementDeniedException({required String message}) =
24+
_ProcurementDeniedExceptionImpl;
2525

26-
factory ResourceDeniedException.fromJson(
26+
factory ProcurementDeniedException.fromJson(
2727
Map<String, dynamic> jsonSerialization) {
28-
return ResourceDeniedException(
28+
return ProcurementDeniedException(
2929
message: jsonSerialization['message'] as String);
3030
}
3131

3232
String message;
3333

34-
/// Returns a shallow copy of this [ResourceDeniedException]
34+
/// Returns a shallow copy of this [ProcurementDeniedException]
3535
/// with some or all fields replaced by the given arguments.
3636
@_i1.useResult
37-
ResourceDeniedException copyWith({String? message});
37+
ProcurementDeniedException copyWith({String? message});
3838
@override
3939
Map<String, dynamic> toJson() {
4040
return {'message': message};
@@ -46,15 +46,15 @@ abstract class ResourceDeniedException
4646
}
4747
}
4848

49-
class _ResourceDeniedExceptionImpl extends ResourceDeniedException {
50-
_ResourceDeniedExceptionImpl({required String message})
49+
class _ProcurementDeniedExceptionImpl extends ProcurementDeniedException {
50+
_ProcurementDeniedExceptionImpl({required String message})
5151
: super._(message: message);
5252

53-
/// Returns a shallow copy of this [ResourceDeniedException]
53+
/// Returns a shallow copy of this [ProcurementDeniedException]
5454
/// with some or all fields replaced by the given arguments.
5555
@_i1.useResult
5656
@override
57-
ResourceDeniedException copyWith({String? message}) {
58-
return ResourceDeniedException(message: message ?? this.message);
57+
ProcurementDeniedException copyWith({String? message}) {
58+
return ProcurementDeniedException(message: message ?? this.message);
5959
}
6060
}

serverpod_cloud_cli/lib/commands/launch/launch.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ abstract class Launch {
2727
required final bool? enableDb,
2828
required final bool? performDeploy,
2929
}) async {
30+
await ProjectCommands.checkPlanAvailability(
31+
cloudApiClient,
32+
logger: logger,
33+
);
34+
3035
logger.init('Launching new Serverpod Cloud project.\n');
3136

3237
final projectSetup = ProjectLaunch(

serverpod_cloud_cli/lib/commands/project/project.dart

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,26 @@ import 'package:serverpod_cloud_cli/util/scloud_config/scloud_config_file.dart';
1313
import 'package:serverpod_cloud_cli/util/scloudignore.dart';
1414

1515
abstract class ProjectCommands {
16+
static const defaultPlanName = 'early-access';
17+
18+
/// Subcommand to check if the user is subscribed to a plan,
19+
/// and if not whether a plan can be procured.
20+
/// If [planName] is not provided, the default plan will be assumed.
21+
/// Throws an exception if there is no subscription and the plan cannot be
22+
/// procured.
23+
static Future<void> checkPlanAvailability(
24+
final Client cloudApiClient, {
25+
required final CommandLogger logger,
26+
final String? planName,
27+
}) async {
28+
final planNames = await cloudApiClient.plans.listProcuredPlanNames();
29+
if (planNames.isEmpty) {
30+
await cloudApiClient.plans.checkPlanAvailability(
31+
planName: planName ?? defaultPlanName,
32+
);
33+
}
34+
}
35+
1636
/// Subcommand to create a new tenant project.
1737
static Future<void> createProject(
1838
final Client cloudApiClient, {
@@ -26,16 +46,7 @@ abstract class ProjectCommands {
2646
// This behavior will be changed in the future.
2747
final planNames = await cloudApiClient.plans.listProcuredPlanNames();
2848
if (planNames.isEmpty) {
29-
const defaultPlanName = 'early-access';
30-
try {
31-
await cloudApiClient.plans.procurePlan(planName: defaultPlanName);
32-
} on ResourceDeniedException catch (e) {
33-
final setupUrl = _getConsoleSetupAccountUrl();
34-
throw FailureException(
35-
error:
36-
"Couldn't procure the plan '$defaultPlanName':\n${e.message}",
37-
hint: 'Visit $setupUrl to set up your account.');
38-
}
49+
await cloudApiClient.plans.procurePlan(planName: defaultPlanName);
3950
logger.init('Creating Serverpod Cloud project "$projectId".');
4051
logger.info('On plan: $defaultPlanName');
4152
} else {
@@ -383,14 +394,4 @@ abstract class ProjectCommands {
383394
gitIgnoreFile.writeAsStringSync('$content$scloudIgnoreTemplate');
384395
return true;
385396
}
386-
387-
static String _getConsoleSetupAccountUrl() {
388-
const prodConsoleHost = 'https://console.serverpod.cloud';
389-
const setupLandingPath = '/projects/create';
390-
391-
final hostFromEnv =
392-
Platform.environment['SERVERPOD_CLOUD_CONSOLE_SERVER_URL'];
393-
final consoleHost = hostFromEnv ?? prodConsoleHost;
394-
return '$consoleHost$setupLandingPath';
395-
}
396397
}

serverpod_cloud_cli/lib/shared/helpers/common_exceptions_handler.dart

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:serverpod_cloud_cli/command_logger/command_logger.dart';
22
import 'package:serverpod_cloud_cli/shared/exceptions/exit_exceptions.dart';
33
import 'package:ground_control_client/ground_control_client.dart';
4+
import 'package:serverpod_cloud_cli/shared/helpers/console_urls.dart';
45

56
/// If the exception is a common client exception, process it by displaying
67
/// relevant messages to the user and throwing an [ErrorExitException].
@@ -42,17 +43,24 @@ void processCommonClientExceptions(
4243
stackTrace,
4344
);
4445

45-
case ResourceDeniedException():
46-
logger.error(
47-
'The resource was not allowed.',
48-
hint: e.message,
49-
);
50-
51-
throw ErrorExitException(
52-
'The action was not allowed.',
53-
e,
54-
stackTrace,
55-
);
46+
case ProcurementDeniedException():
47+
final baseUrl = getConsoleBaseUrl();
48+
if (e.message.contains('no valid payment method')) {
49+
final setupUrl = '$baseUrl/projects/create';
50+
logger.error(
51+
"You need a payment method!",
52+
hint: 'To set up your account, visit: $setupUrl\n',
53+
newParagraph: true,
54+
);
55+
} else {
56+
final projectsUrl = '$baseUrl/projects';
57+
logger.error(
58+
e.message,
59+
hint: 'To see your account, visit: $projectsUrl\n',
60+
newParagraph: true,
61+
);
62+
}
63+
throw ErrorExitException('The procurement was not allowed.');
5664

5765
case NotFoundException():
5866
logger.error(
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import 'dart:io' show Platform;
2+
3+
String getConsoleBaseUrl() {
4+
const prodConsoleHost = 'https://console.serverpod.cloud';
5+
6+
final hostFromEnv =
7+
Platform.environment['SERVERPOD_CLOUD_CONSOLE_SERVER_URL'];
8+
return hostFromEnv ?? prodConsoleHost;
9+
}

serverpod_cloud_cli/test/common_exceptions_handler_test.dart

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,13 @@ void main() {
6262
});
6363

6464
test(
65-
'Given a ResourceDeniedException '
65+
'Given a ProcurementDeniedException '
6666
'when calling processCommonClientExceptions '
6767
'then should throw ExitErrorException and log error message', () {
6868
expect(
6969
() => processCommonClientExceptions(
7070
logger,
71-
ResourceDeniedException(
71+
ProcurementDeniedException(
7272
message:
7373
'The maximum number of projects that can be created has been reached (5).',
7474
),
@@ -79,9 +79,11 @@ void main() {
7979
expect(
8080
logger.errorCalls.last,
8181
equalsErrorCall(
82-
message: 'The resource was not allowed.',
83-
hint:
82+
message:
8483
'The maximum number of projects that can be created has been reached (5).',
84+
hint:
85+
'To see your account, visit: https://console.serverpod.cloud/projects\n',
86+
newParagraph: true,
8587
),
8688
);
8789
});

serverpod_cloud_cli/test_integration/commands/launch_command_test.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ void main() {
162162

163163
when(() => client.plans.procurePlan(planName: any(named: 'planName')))
164164
.thenAnswer((final invocation) async => Future.value());
165+
166+
when(() => client.plans
167+
.checkPlanAvailability(planName: any(named: 'planName')))
168+
.thenAnswer((final invocation) async => Future.value());
165169
});
166170

167171
tearDownAll(() async {

0 commit comments

Comments
 (0)