@saasicat/nest 0.18.1 → 0.21.0
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/dist/_entries.cjs +1341 -590
- package/dist/admin/index.js +2 -1
- package/dist/billing/index.d.cts +10 -6
- package/dist/billing/index.d.ts +10 -6
- package/dist/billing/index.js +7 -6
- package/dist/catalog/index.js +4 -4
- package/dist/checkout-offer/index.js +2 -2
- package/dist/{chunk-RZVSKKXX.js → chunk-3DRZ5GCP.js} +56 -105
- package/dist/{chunk-GLLCB4BP.js → chunk-4O6R6ARQ.js} +23 -13
- package/dist/{chunk-7RRWI5LH.js → chunk-55QGAZRQ.js} +10 -10
- package/dist/{chunk-Z3FWN3HJ.js → chunk-5ZXBXYXU.js} +3 -3
- package/dist/{chunk-V34AA7C7.js → chunk-6WL6VQ7Z.js} +1 -1
- package/dist/{chunk-ABB2CRAS.js → chunk-74SCJ4HF.js} +1 -1
- package/dist/{chunk-EXOLOJFZ.js → chunk-AGOG5B77.js} +36 -9
- package/dist/{chunk-32EMXPU2.js → chunk-B7NRY2LV.js} +48 -17
- package/dist/{chunk-MDIZUVIK.js → chunk-BSK6YBLI.js} +1 -1
- package/dist/{chunk-R2QOSVAA.js → chunk-DQKCK7DX.js} +1 -1
- package/dist/chunk-EBJJNTW6.js +22 -0
- package/dist/{chunk-PLJEAUFU.js → chunk-FYWLPYBK.js} +79 -39
- package/dist/{chunk-P3DP6GJE.js → chunk-GC5AKN44.js} +487 -160
- package/dist/{chunk-5KH2O4SF.js → chunk-NZLAYCEX.js} +286 -110
- package/dist/{chunk-E7AJ42AB.js → chunk-VQQWF7MW.js} +172 -28
- package/dist/{chunk-GJBXOCRZ.js → chunk-XTVAFJ3P.js} +46 -17
- package/dist/{chunk-BOADBIGB.js → chunk-YYUXKLXK.js} +2 -2
- package/dist/{chunk-YITTE646.js → chunk-ZH5PYEMI.js} +41 -23
- package/dist/{define-saasicat-Rc0rXrYG.d.ts → define-saasicat-B20Ms8UC.d.ts} +1 -1
- package/dist/{define-saasicat-CeDBX9ji.d.cts → define-saasicat-Cf9DpEsY.d.cts} +1 -1
- package/dist/discovery/index.d.cts +2 -2
- package/dist/discovery/index.d.ts +2 -2
- package/dist/discovery/index.js +4 -4
- package/dist/{enforce-quota.interceptor-CmbWX39L.d.ts → enforce-quota.interceptor-CUyfR4cQ.d.ts} +1 -1
- package/dist/{enforce-quota.interceptor-DMw-bPyk.d.cts → enforce-quota.interceptor-Dtg5WdFj.d.cts} +1 -1
- package/dist/entitlement/index.js +3 -2
- package/dist/index.d.cts +27 -8
- package/dist/index.d.ts +27 -8
- package/dist/index.js +21 -17
- package/dist/{module-DgYJur6f.d.ts → module-BE17XFLg.d.ts} +1 -1
- package/dist/{module-CSN8j8Tp.d.cts → module-CHMIyWlf.d.cts} +1 -1
- package/dist/platform/index.d.cts +8 -8
- package/dist/platform/index.d.ts +8 -8
- package/dist/platform/index.js +14 -13
- package/dist/promo/index.d.cts +3 -3
- package/dist/promo/index.d.ts +3 -3
- package/dist/promo/index.js +4 -3
- package/dist/registration/index.js +2 -1
- package/dist/{saas-platform.module-D3rUTiho.d.cts → saas-platform.module-BfaYfL-G.d.cts} +1 -1
- package/dist/{saas-platform.module-DLhoqKNA.d.ts → saas-platform.module-DChhDrR-.d.ts} +1 -1
- package/dist/{service-DX8KbGXl.d.cts → service-D48clObR.d.cts} +3 -3
- package/dist/{service-DX8KbGXl.d.ts → service-D48clObR.d.ts} +3 -3
- package/dist/subscription-contract/index.js +2 -2
- package/dist/{tenant-billing.controller-CG4CaFUP.d.cts → tenant-billing.controller-BIF-mstX.d.cts} +2 -2
- package/dist/{tenant-billing.controller-BwZRnJ3q.d.ts → tenant-billing.controller-DT5UrnDX.d.ts} +2 -2
- package/dist/testing/index.d.cts +5 -5
- package/dist/testing/index.d.ts +5 -5
- package/dist/testing/index.js +34 -33
- package/package.json +3 -3
package/dist/_entries.cjs
CHANGED
|
@@ -87,6 +87,7 @@ var RLS_BYPASS_PORT_TOKEN = /* @__PURE__ */ Symbol.for("saas-platform/RlsBypassP
|
|
|
87
87
|
|
|
88
88
|
// src/admin/super-admin.guard.ts
|
|
89
89
|
var import_common = require("@nestjs/common");
|
|
90
|
+
var import_types = require("@saasicat/types");
|
|
90
91
|
function _ts_decorate(decorators, target, key, desc) {
|
|
91
92
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
92
93
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -100,10 +101,18 @@ var SuperAdminGuard = class {
|
|
|
100
101
|
}
|
|
101
102
|
canActivate(context) {
|
|
102
103
|
const request = context.switchToHttp().getRequest();
|
|
103
|
-
if (!request.user)
|
|
104
|
+
if (!request.user) {
|
|
105
|
+
throw new import_common.ForbiddenException({
|
|
106
|
+
code: import_types.AUTH_ERROR_CODES.NOT_AUTHENTICATED,
|
|
107
|
+
message: "Not authenticated"
|
|
108
|
+
});
|
|
109
|
+
}
|
|
104
110
|
const role = request.user.platformRole ?? request.user.role;
|
|
105
111
|
if (role !== "SUPER_ADMIN") {
|
|
106
|
-
throw new import_common.ForbiddenException(
|
|
112
|
+
throw new import_common.ForbiddenException({
|
|
113
|
+
code: import_types.AUTH_ERROR_CODES.SUPER_ADMIN_REQUIRED,
|
|
114
|
+
message: "Only the SUPER_ADMIN role is allowed"
|
|
115
|
+
});
|
|
107
116
|
}
|
|
108
117
|
return true;
|
|
109
118
|
}
|
|
@@ -115,6 +124,22 @@ SuperAdminGuard = _ts_decorate([
|
|
|
115
124
|
// src/admin/mfa.guard.ts
|
|
116
125
|
var import_common3 = require("@nestjs/common");
|
|
117
126
|
var import_core = require("@nestjs/core");
|
|
127
|
+
var import_types3 = require("@saasicat/types");
|
|
128
|
+
|
|
129
|
+
// src/errors/coded-error.ts
|
|
130
|
+
var import_types2 = require("@saasicat/types");
|
|
131
|
+
function codedError(code, params) {
|
|
132
|
+
const message = (0, import_types2.formatErrorMessage)(import_types2.ERROR_MESSAGES_EN[code], params ?? {});
|
|
133
|
+
return params ? {
|
|
134
|
+
code,
|
|
135
|
+
message,
|
|
136
|
+
params
|
|
137
|
+
} : {
|
|
138
|
+
code,
|
|
139
|
+
message
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
__name(codedError, "codedError");
|
|
118
143
|
|
|
119
144
|
// src/admin/mfa.ts
|
|
120
145
|
var import_common2 = require("@nestjs/common");
|
|
@@ -194,7 +219,7 @@ var MfaService = class _MfaService {
|
|
|
194
219
|
return false;
|
|
195
220
|
}
|
|
196
221
|
const message = error instanceof Error ? error.message : String(error);
|
|
197
|
-
this.logger.warn(`TOTP
|
|
222
|
+
this.logger.warn(`TOTP verification for user ${input.userId} failed unexpectedly: ${message}`);
|
|
198
223
|
return false;
|
|
199
224
|
}
|
|
200
225
|
}
|
|
@@ -260,22 +285,30 @@ var MfaGuard = class _MfaGuard {
|
|
|
260
285
|
}
|
|
261
286
|
const request = context.switchToHttp().getRequest();
|
|
262
287
|
const user = request.user;
|
|
263
|
-
if (!user)
|
|
264
|
-
|
|
265
|
-
|
|
288
|
+
if (!user) {
|
|
289
|
+
throw new import_common3.UnauthorizedException({
|
|
290
|
+
...codedError(import_types3.AUTH_ERROR_CODES.NOT_AUTHENTICATED),
|
|
291
|
+
// `reason` is superseded by `code` and will be removed.
|
|
292
|
+
reason: "NOT_AUTHENTICATED"
|
|
293
|
+
});
|
|
294
|
+
}
|
|
266
295
|
const enabled = await this.mfaService.isEnabled(user.id);
|
|
267
296
|
if (!enabled) {
|
|
268
297
|
throw new import_common3.UnauthorizedException({
|
|
298
|
+
code: import_types3.AUTH_ERROR_CODES.MFA_NOT_SET_UP,
|
|
299
|
+
// `reason` is superseded by `code` and will be removed.
|
|
269
300
|
reason: "MFA_NOT_SET_UP",
|
|
270
|
-
message: "
|
|
301
|
+
message: "Run the MFA setup via the CLI first."
|
|
271
302
|
});
|
|
272
303
|
}
|
|
273
304
|
const headerVal = request.headers?.["x-mfa-code"];
|
|
274
305
|
const code = Array.isArray(headerVal) ? headerVal[0] : headerVal;
|
|
275
306
|
if (!code) {
|
|
276
307
|
throw new import_common3.UnauthorizedException({
|
|
308
|
+
code: import_types3.AUTH_ERROR_CODES.MFA_REQUIRED,
|
|
309
|
+
// `reason` is superseded by `code` and will be removed.
|
|
277
310
|
reason: "MFA_REQUIRED",
|
|
278
|
-
message: "TOTP
|
|
311
|
+
message: "TOTP code required in the X-Mfa-Code header."
|
|
279
312
|
});
|
|
280
313
|
}
|
|
281
314
|
const valid = await this.mfaService.verify({
|
|
@@ -284,8 +317,10 @@ var MfaGuard = class _MfaGuard {
|
|
|
284
317
|
});
|
|
285
318
|
if (!valid) {
|
|
286
319
|
throw new import_common3.UnauthorizedException({
|
|
320
|
+
code: import_types3.AUTH_ERROR_CODES.MFA_FAILED,
|
|
321
|
+
// `reason` is superseded by `code` and will be removed.
|
|
287
322
|
reason: "MFA_FAILED",
|
|
288
|
-
message: "TOTP
|
|
323
|
+
message: "Invalid TOTP code."
|
|
289
324
|
});
|
|
290
325
|
}
|
|
291
326
|
return true;
|
|
@@ -799,7 +834,7 @@ var PLATFORM_CORE_MANIFEST_CONTRIBUTION = {
|
|
|
799
834
|
actions: [
|
|
800
835
|
{
|
|
801
836
|
id: "platform.tenants.suspend",
|
|
802
|
-
label: "
|
|
837
|
+
label: "Suspend tenant",
|
|
803
838
|
actionKey: "tenants.suspend",
|
|
804
839
|
requiredCapability: "tenants.suspend",
|
|
805
840
|
requiresMfa: true,
|
|
@@ -807,7 +842,7 @@ var PLATFORM_CORE_MANIFEST_CONTRIBUTION = {
|
|
|
807
842
|
},
|
|
808
843
|
{
|
|
809
844
|
id: "platform.tenants.reactivate",
|
|
810
|
-
label: "
|
|
845
|
+
label: "Reactivate tenant",
|
|
811
846
|
actionKey: "tenants.reactivate",
|
|
812
847
|
requiredCapability: "tenants.reactivate",
|
|
813
848
|
requiresMfa: true,
|
|
@@ -823,7 +858,7 @@ var PLATFORM_CORE_MANIFEST_CONTRIBUTION = {
|
|
|
823
858
|
},
|
|
824
859
|
{
|
|
825
860
|
id: "platform.tenants.export",
|
|
826
|
-
label: "
|
|
861
|
+
label: "GDPR export",
|
|
827
862
|
actionKey: "tenants.export",
|
|
828
863
|
requiredCapability: "tenants.export",
|
|
829
864
|
requiresMfa: true,
|
|
@@ -831,7 +866,7 @@ var PLATFORM_CORE_MANIFEST_CONTRIBUTION = {
|
|
|
831
866
|
},
|
|
832
867
|
{
|
|
833
868
|
id: "platform.subscriptions.cancel",
|
|
834
|
-
label: "
|
|
869
|
+
label: "Cancel subscription",
|
|
835
870
|
actionKey: "subscriptions.cancel",
|
|
836
871
|
requiredCapability: "subscriptions.cancel",
|
|
837
872
|
requiresMfa: true,
|
|
@@ -839,7 +874,7 @@ var PLATFORM_CORE_MANIFEST_CONTRIBUTION = {
|
|
|
839
874
|
},
|
|
840
875
|
{
|
|
841
876
|
id: "platform.pilots.grant",
|
|
842
|
-
label: "
|
|
877
|
+
label: "Set pilot status",
|
|
843
878
|
actionKey: "pilots.grant",
|
|
844
879
|
requiredCapability: "pilots.grant",
|
|
845
880
|
requiresMfa: true,
|
|
@@ -847,7 +882,7 @@ var PLATFORM_CORE_MANIFEST_CONTRIBUTION = {
|
|
|
847
882
|
},
|
|
848
883
|
{
|
|
849
884
|
id: "platform.pilots.revoke",
|
|
850
|
-
label: "
|
|
885
|
+
label: "Revoke pilot status",
|
|
851
886
|
actionKey: "pilots.revoke",
|
|
852
887
|
requiredCapability: "pilots.revoke",
|
|
853
888
|
requiresMfa: true,
|
|
@@ -855,7 +890,7 @@ var PLATFORM_CORE_MANIFEST_CONTRIBUTION = {
|
|
|
855
890
|
},
|
|
856
891
|
{
|
|
857
892
|
id: "platform.pilots.extend",
|
|
858
|
-
label: "
|
|
893
|
+
label: "Extend pilot",
|
|
859
894
|
actionKey: "pilots.extend",
|
|
860
895
|
requiredCapability: "pilots.extend",
|
|
861
896
|
requiresMfa: true,
|
|
@@ -867,102 +902,102 @@ var PLATFORM_CORE_MANIFEST_CONTRIBUTION = {
|
|
|
867
902
|
actions: [
|
|
868
903
|
{
|
|
869
904
|
key: "TENANT_SUSPEND",
|
|
870
|
-
label: "
|
|
905
|
+
label: "Tenant suspended",
|
|
871
906
|
severity: "high"
|
|
872
907
|
},
|
|
873
908
|
{
|
|
874
909
|
key: "TENANT_REACTIVATE",
|
|
875
|
-
label: "
|
|
910
|
+
label: "Tenant reactivated",
|
|
876
911
|
severity: "medium"
|
|
877
912
|
},
|
|
878
913
|
{
|
|
879
914
|
key: "TENANT_IMPERSONATE",
|
|
880
|
-
label: "Impersonation
|
|
915
|
+
label: "Impersonation started",
|
|
881
916
|
severity: "high"
|
|
882
917
|
},
|
|
883
918
|
{
|
|
884
919
|
key: "TENANT_EXPORT",
|
|
885
|
-
label: "
|
|
920
|
+
label: "GDPR export triggered",
|
|
886
921
|
severity: "medium"
|
|
887
922
|
},
|
|
888
923
|
{
|
|
889
924
|
key: "PILOT_CREATE",
|
|
890
|
-
label: "Pilot
|
|
925
|
+
label: "Pilot tenant created",
|
|
891
926
|
severity: "medium"
|
|
892
927
|
},
|
|
893
928
|
{
|
|
894
929
|
key: "PILOT_GRANT",
|
|
895
|
-
label: "Pilot
|
|
930
|
+
label: "Pilot status set",
|
|
896
931
|
severity: "medium"
|
|
897
932
|
},
|
|
898
933
|
{
|
|
899
934
|
key: "PILOT_REVOKE",
|
|
900
|
-
label: "Pilot
|
|
935
|
+
label: "Pilot status revoked",
|
|
901
936
|
severity: "medium"
|
|
902
937
|
},
|
|
903
938
|
{
|
|
904
939
|
key: "PILOT_EXTEND",
|
|
905
|
-
label: "Pilot
|
|
940
|
+
label: "Pilot extended",
|
|
906
941
|
severity: "low"
|
|
907
942
|
},
|
|
908
943
|
{
|
|
909
944
|
key: "PLAN_VERSION_PUBLISH",
|
|
910
|
-
label: "Plan
|
|
945
|
+
label: "Plan version approved",
|
|
911
946
|
severity: "high"
|
|
912
947
|
},
|
|
913
948
|
{
|
|
914
949
|
key: "PLAN_VERSION_TERMINATE",
|
|
915
|
-
label: "Plan
|
|
950
|
+
label: "Plan version terminated",
|
|
916
951
|
severity: "high"
|
|
917
952
|
},
|
|
918
953
|
{
|
|
919
954
|
key: "SUBSCRIPTION_CANCEL",
|
|
920
|
-
label: "Subscription
|
|
955
|
+
label: "Subscription cancelled",
|
|
921
956
|
severity: "high"
|
|
922
957
|
},
|
|
923
958
|
{
|
|
924
959
|
key: "USER_RESET_PASSWORD",
|
|
925
|
-
label: "
|
|
960
|
+
label: "Password reset triggered",
|
|
926
961
|
severity: "medium"
|
|
927
962
|
},
|
|
928
963
|
{
|
|
929
964
|
key: "USER_DEACTIVATE",
|
|
930
|
-
label: "User
|
|
965
|
+
label: "User deactivated",
|
|
931
966
|
severity: "high"
|
|
932
967
|
},
|
|
933
968
|
{
|
|
934
969
|
key: "USER_REASSIGN_ADMIN",
|
|
935
|
-
label: "Admin
|
|
970
|
+
label: "Admin role transferred",
|
|
936
971
|
severity: "high"
|
|
937
972
|
},
|
|
938
973
|
{
|
|
939
974
|
key: "PLATFORM_EMAIL_PROVIDER_CREATE",
|
|
940
|
-
label: "
|
|
975
|
+
label: "Platform sender created",
|
|
941
976
|
severity: "medium"
|
|
942
977
|
},
|
|
943
978
|
{
|
|
944
979
|
key: "PLATFORM_EMAIL_PROVIDER_UPDATE",
|
|
945
|
-
label: "
|
|
980
|
+
label: "Platform sender changed",
|
|
946
981
|
severity: "medium"
|
|
947
982
|
},
|
|
948
983
|
{
|
|
949
984
|
key: "PLATFORM_EMAIL_PROVIDER_DELETE",
|
|
950
|
-
label: "
|
|
985
|
+
label: "Platform sender deleted",
|
|
951
986
|
severity: "medium"
|
|
952
987
|
},
|
|
953
988
|
{
|
|
954
989
|
key: "PLATFORM_EMAIL_PROVIDER_TEST",
|
|
955
|
-
label: "
|
|
990
|
+
label: "Platform sender test send",
|
|
956
991
|
severity: "low"
|
|
957
992
|
},
|
|
958
993
|
{
|
|
959
994
|
key: "EMAIL_HISTORY_DELETE",
|
|
960
|
-
label: "
|
|
995
|
+
label: "Platform email removed from history",
|
|
961
996
|
severity: "medium"
|
|
962
997
|
},
|
|
963
998
|
{
|
|
964
999
|
key: "EMAIL_HISTORY_RESEND",
|
|
965
|
-
label: "
|
|
1000
|
+
label: "Platform email resent",
|
|
966
1001
|
severity: "low"
|
|
967
1002
|
}
|
|
968
1003
|
]
|
|
@@ -1081,7 +1116,7 @@ var AdminManifestModule = class _AdminManifestModule {
|
|
|
1081
1116
|
];
|
|
1082
1117
|
if (includeManifestController) {
|
|
1083
1118
|
if (options.guards === void 0) {
|
|
1084
|
-
throw new Error("AdminManifestModule.forRoot: `guards`
|
|
1119
|
+
throw new Error("AdminManifestModule.forRoot: `guards` is required when `includeManifestController` is enabled (default `true`). Set `guards: [JwtAuthGuard, SuperAdminGuard]` (or equivalent) \u2014 or `guards: []` to deliberately leave the endpoint unauthenticated. Alternative: `includeManifestController: false` plus registering your own wrapping controller.");
|
|
1085
1120
|
}
|
|
1086
1121
|
controllers.push(buildManifestController(options.guards, options.reloadGuards ?? []));
|
|
1087
1122
|
}
|
|
@@ -1318,6 +1353,7 @@ AdminStatsModule = _ts_decorate12([
|
|
|
1318
1353
|
// src/admin/admin-resources.module.ts
|
|
1319
1354
|
var import_common14 = require("@nestjs/common");
|
|
1320
1355
|
var import_class_validator = require("class-validator");
|
|
1356
|
+
var import_types4 = require("@saasicat/types");
|
|
1321
1357
|
function _ts_decorate13(decorators, target, key, desc) {
|
|
1322
1358
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
1323
1359
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1363,7 +1399,15 @@ var AdminResourcesService = class {
|
|
|
1363
1399
|
}
|
|
1364
1400
|
async getTenantDetail(slug) {
|
|
1365
1401
|
const tenant = await this.resources.getTenantDetail(slug);
|
|
1366
|
-
if (!tenant)
|
|
1402
|
+
if (!tenant) {
|
|
1403
|
+
throw new import_common14.NotFoundException({
|
|
1404
|
+
code: import_types4.BILLING_ERROR_CODES.TENANT_NOT_FOUND,
|
|
1405
|
+
message: `Tenant ${slug} not found`,
|
|
1406
|
+
params: {
|
|
1407
|
+
slug
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1367
1411
|
return tenant;
|
|
1368
1412
|
}
|
|
1369
1413
|
listUsers(filter) {
|
|
@@ -1389,7 +1433,15 @@ var AdminResourcesService = class {
|
|
|
1389
1433
|
}
|
|
1390
1434
|
async setTenantActive(slug, active, subscriptionStatus, actor, audit) {
|
|
1391
1435
|
const result = await this.resources.setTenantActive(slug, active, subscriptionStatus);
|
|
1392
|
-
if (!result)
|
|
1436
|
+
if (!result) {
|
|
1437
|
+
throw new import_common14.NotFoundException({
|
|
1438
|
+
code: import_types4.BILLING_ERROR_CODES.TENANT_NOT_FOUND,
|
|
1439
|
+
message: `Tenant ${slug} not found`,
|
|
1440
|
+
params: {
|
|
1441
|
+
slug
|
|
1442
|
+
}
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1393
1445
|
await this.audit.log({
|
|
1394
1446
|
actor,
|
|
1395
1447
|
entity: "Tenant",
|
|
@@ -1631,8 +1683,8 @@ __export(billing_exports, {
|
|
|
1631
1683
|
buildPlanCatalogFromSnapshot: () => buildPlanCatalogFromSnapshot,
|
|
1632
1684
|
buildPlanCatalogImporterController: () => buildPlanCatalogImporterController,
|
|
1633
1685
|
buildTenantSubscriptionBundlesController: () => buildTenantSubscriptionBundlesController,
|
|
1634
|
-
classifyBundleVersionDiff: () =>
|
|
1635
|
-
classifyPlanDiff: () =>
|
|
1686
|
+
classifyBundleVersionDiff: () => import_types5.classifyBundleVersionDiff,
|
|
1687
|
+
classifyPlanDiff: () => import_types5.classifyPlanDiff,
|
|
1636
1688
|
clearPendingPlanVersionFields: () => clearPendingPlanVersionFields,
|
|
1637
1689
|
computeCarriedTrialEndsAt: () => computeCarriedTrialEndsAt,
|
|
1638
1690
|
computeNextPeriod: () => computeNextPeriod,
|
|
@@ -1693,7 +1745,7 @@ function periodEndWithMinLead(startedAt, cycle, now, minLeadDays = 42) {
|
|
|
1693
1745
|
__name(periodEndWithMinLead, "periodEndWithMinLead");
|
|
1694
1746
|
|
|
1695
1747
|
// src/billing/version-diff.ts
|
|
1696
|
-
var
|
|
1748
|
+
var import_types5 = require("@saasicat/types");
|
|
1697
1749
|
|
|
1698
1750
|
// src/billing/version-publish.ts
|
|
1699
1751
|
var PublishValidationError = class extends Error {
|
|
@@ -1709,35 +1761,35 @@ var PublishValidationError = class extends Error {
|
|
|
1709
1761
|
function assertChangeNote(changeNote) {
|
|
1710
1762
|
const trimmed = (changeNote ?? "").trim();
|
|
1711
1763
|
if (trimmed.length === 0) {
|
|
1712
|
-
throw new PublishValidationError("CHANGE_NOTE_REQUIRED", "changeNote
|
|
1764
|
+
throw new PublishValidationError("CHANGE_NOTE_REQUIRED", "changeNote is required when publishing a version.");
|
|
1713
1765
|
}
|
|
1714
1766
|
return trimmed;
|
|
1715
1767
|
}
|
|
1716
1768
|
__name(assertChangeNote, "assertChangeNote");
|
|
1717
1769
|
function assertDraftPublishable(draft, draftId) {
|
|
1718
1770
|
if (!draft) {
|
|
1719
|
-
throw new PublishValidationError("NOT_FOUND", `PlanVersion ${draftId}
|
|
1771
|
+
throw new PublishValidationError("NOT_FOUND", `PlanVersion ${draftId} not found`);
|
|
1720
1772
|
}
|
|
1721
1773
|
if (draft.publishedAt !== null) {
|
|
1722
|
-
throw new PublishValidationError("ALREADY_PUBLISHED", `PlanVersion ${draftId}
|
|
1774
|
+
throw new PublishValidationError("ALREADY_PUBLISHED", `PlanVersion ${draftId} has already been published.`);
|
|
1723
1775
|
}
|
|
1724
1776
|
if (!draft.baseVersionId) {
|
|
1725
|
-
throw new PublishValidationError("NO_BASE_VERSION", `Draft ${draftId}
|
|
1777
|
+
throw new PublishValidationError("NO_BASE_VERSION", `Draft ${draftId} has no baseVersion \u2014 probably a manually created v1 that may only be published via a migration.`);
|
|
1726
1778
|
}
|
|
1727
1779
|
}
|
|
1728
1780
|
__name(assertDraftPublishable, "assertDraftPublishable");
|
|
1729
1781
|
function assertBaseVersionFresh(base, baseId, planContext) {
|
|
1730
1782
|
if (!base) {
|
|
1731
|
-
throw new PublishValidationError("BASE_NOT_FOUND", `baseVersion ${baseId}
|
|
1783
|
+
throw new PublishValidationError("BASE_NOT_FOUND", `baseVersion ${baseId} no longer exists.`);
|
|
1732
1784
|
}
|
|
1733
1785
|
if (base.supersededAt !== null) {
|
|
1734
|
-
throw new PublishValidationError("BASE_SUPERSEDED", `
|
|
1786
|
+
throw new PublishValidationError("BASE_SUPERSEDED", `The draft is based on a superseded version (${planContext}). Rebase the draft onto the current live version, or create a new one.`);
|
|
1735
1787
|
}
|
|
1736
1788
|
}
|
|
1737
1789
|
__name(assertBaseVersionFresh, "assertBaseVersionFresh");
|
|
1738
1790
|
function assertOptimisticLockHeld(updateCount, draftId) {
|
|
1739
1791
|
if (updateCount !== 1) {
|
|
1740
|
-
throw new PublishValidationError("OPTIMISTIC_LOCK_CONFLICT", `Draft ${draftId}
|
|
1792
|
+
throw new PublishValidationError("OPTIMISTIC_LOCK_CONFLICT", `Draft ${draftId} has meanwhile been published by another admin.`);
|
|
1741
1793
|
}
|
|
1742
1794
|
}
|
|
1743
1795
|
__name(assertOptimisticLockHeld, "assertOptimisticLockHeld");
|
|
@@ -1788,10 +1840,11 @@ var FEATURE_GUARD_CONFIG_TOKEN = /* @__PURE__ */ Symbol.for("saas-platform/Featu
|
|
|
1788
1840
|
// src/billing/feature.guard.ts
|
|
1789
1841
|
var import_common18 = require("@nestjs/common");
|
|
1790
1842
|
var import_core2 = require("@nestjs/core");
|
|
1791
|
-
var
|
|
1843
|
+
var import_types7 = require("@saasicat/types");
|
|
1792
1844
|
|
|
1793
1845
|
// src/entitlement/service.ts
|
|
1794
1846
|
var import_common17 = require("@nestjs/common");
|
|
1847
|
+
var import_types6 = require("@saasicat/types");
|
|
1795
1848
|
|
|
1796
1849
|
// src/catalog/tokens.ts
|
|
1797
1850
|
var PLAN_REPOSITORY_TOKEN = /* @__PURE__ */ Symbol.for("saas-platform/PlanRepository");
|
|
@@ -1966,7 +2019,7 @@ __name(findPlan, "findPlan");
|
|
|
1966
2019
|
function getPlanOrThrow(catalog, planId) {
|
|
1967
2020
|
const plan = findPlan(catalog, planId);
|
|
1968
2021
|
if (!plan) {
|
|
1969
|
-
throw new Error(`Plan "${planId}"
|
|
2022
|
+
throw new Error(`Plan "${planId}" not found in the catalog (${catalog.projectKey})`);
|
|
1970
2023
|
}
|
|
1971
2024
|
return plan;
|
|
1972
2025
|
}
|
|
@@ -2236,7 +2289,7 @@ function assertNoReplacesCycle(index) {
|
|
|
2236
2289
|
...path.slice(cycleStart),
|
|
2237
2290
|
key
|
|
2238
2291
|
].join(" \u2192 ");
|
|
2239
|
-
throw new Error(`replaces
|
|
2292
|
+
throw new Error(`replaces cycle in the discovery metadata: ${cycle}. Feature replacements must be acyclic \u2014 fix the replaces declarations in code (#39).`);
|
|
2240
2293
|
}
|
|
2241
2294
|
state.set(key, IN_PROGRESS);
|
|
2242
2295
|
for (const successor of index.get(key) ?? []) {
|
|
@@ -2262,7 +2315,7 @@ var LimitExceededError = class extends Error {
|
|
|
2262
2315
|
used;
|
|
2263
2316
|
code = LIMIT_EXCEEDED;
|
|
2264
2317
|
constructor(dimension, max, used) {
|
|
2265
|
-
super(`Limit
|
|
2318
|
+
super(`Limit for ${dimension} reached: ${used}/${max}.`), this.dimension = dimension, this.max = max, this.used = used;
|
|
2266
2319
|
this.name = "LimitExceededError";
|
|
2267
2320
|
}
|
|
2268
2321
|
};
|
|
@@ -2357,7 +2410,13 @@ var EntitlementService = class {
|
|
|
2357
2410
|
if (cached) return cached;
|
|
2358
2411
|
const sub = await this.subscriptions.findByTenantId(tenantId);
|
|
2359
2412
|
if (!sub) {
|
|
2360
|
-
throw new import_common17.NotFoundException(
|
|
2413
|
+
throw new import_common17.NotFoundException({
|
|
2414
|
+
code: import_types6.BILLING_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
2415
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
2416
|
+
params: {
|
|
2417
|
+
tenantId
|
|
2418
|
+
}
|
|
2419
|
+
});
|
|
2361
2420
|
}
|
|
2362
2421
|
const limits = await this.deriveLimits(sub, now);
|
|
2363
2422
|
this.writeCache(tenantId, limits, now.getTime());
|
|
@@ -2430,7 +2489,7 @@ var EntitlementService = class {
|
|
|
2430
2489
|
return Promise.all(active.map(async (booking) => {
|
|
2431
2490
|
const bv = await this.bundles.findVersionById(booking.bundleVersionId, tx);
|
|
2432
2491
|
if (!bv) {
|
|
2433
|
-
throw new Error(`BundleVersion '${booking.bundleVersionId}'
|
|
2492
|
+
throw new Error(`BundleVersion '${booking.bundleVersionId}' from an active subscription bundle not found`);
|
|
2434
2493
|
}
|
|
2435
2494
|
return {
|
|
2436
2495
|
bundleKey: bv.bundleKey,
|
|
@@ -2452,12 +2511,21 @@ var EntitlementService = class {
|
|
|
2452
2511
|
return this.tx.run(async (tx) => {
|
|
2453
2512
|
const sub = await this.subscriptions.findByTenantIdLocked(input.tenantId, tx);
|
|
2454
2513
|
if (!sub) {
|
|
2455
|
-
throw new import_common17.NotFoundException(
|
|
2514
|
+
throw new import_common17.NotFoundException({
|
|
2515
|
+
code: import_types6.BILLING_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
2516
|
+
message: `No subscription for tenant ${input.tenantId}`,
|
|
2517
|
+
params: {
|
|
2518
|
+
tenantId: input.tenantId
|
|
2519
|
+
}
|
|
2520
|
+
});
|
|
2456
2521
|
}
|
|
2457
2522
|
const limits = await this.deriveLimits(sub, now, tx);
|
|
2458
2523
|
const max = limits.quotas[input.dimension];
|
|
2459
2524
|
if (max === void 0) {
|
|
2460
|
-
throw new
|
|
2525
|
+
throw new import_common17.InternalServerErrorException(codedError(import_types6.BILLING_ERROR_CODES.QUOTA_DIMENSION_UNKNOWN, {
|
|
2526
|
+
dimension: input.dimension,
|
|
2527
|
+
projectKey: this.catalog.projectKey
|
|
2528
|
+
}));
|
|
2461
2529
|
}
|
|
2462
2530
|
const used = await input.currentUsage(tx, input.tenantId);
|
|
2463
2531
|
if (max !== -1 && used + delta > max) {
|
|
@@ -2478,7 +2546,15 @@ var EntitlementService = class {
|
|
|
2478
2546
|
async findActivePlanVersionOrFallback(planId, asOf, tx) {
|
|
2479
2547
|
const v = this.planVersions.findActive ? await this.planVersions.findActive(planId, asOf, tx) : await this.planVersions.findLatestLive(planId, tx);
|
|
2480
2548
|
if (!v) {
|
|
2481
|
-
|
|
2549
|
+
const asOfDate = asOf.toISOString().slice(0, 10);
|
|
2550
|
+
throw new import_common17.NotFoundException({
|
|
2551
|
+
code: import_types6.BILLING_ERROR_CODES.NO_ACTIVE_PLAN_VERSION,
|
|
2552
|
+
message: `No plan version for ${planId} active as of ${asOfDate} \u2014 neither the validFrom window is satisfied nor is a latest-live version available.`,
|
|
2553
|
+
params: {
|
|
2554
|
+
planId,
|
|
2555
|
+
asOf: asOfDate
|
|
2556
|
+
}
|
|
2557
|
+
});
|
|
2482
2558
|
}
|
|
2483
2559
|
return v;
|
|
2484
2560
|
}
|
|
@@ -2581,11 +2657,21 @@ var FeatureGuard = class _FeatureGuard {
|
|
|
2581
2657
|
if (!required || required.length === 0) return true;
|
|
2582
2658
|
const request = context.switchToHttp().getRequest();
|
|
2583
2659
|
const user = request.user;
|
|
2584
|
-
if (!user)
|
|
2660
|
+
if (!user) {
|
|
2661
|
+
throw new import_common18.ForbiddenException({
|
|
2662
|
+
code: import_types7.AUTH_ERROR_CODES.NOT_AUTHENTICATED,
|
|
2663
|
+
message: "Not authenticated"
|
|
2664
|
+
});
|
|
2665
|
+
}
|
|
2585
2666
|
const role = this.config?.userRoleResolver ? this.config.userRoleResolver(user) : user.role ?? user.platformRole;
|
|
2586
2667
|
if (role === "SUPER_ADMIN") return true;
|
|
2587
2668
|
const tenantId = this.config?.tenantIdResolver ? this.config.tenantIdResolver(request) : request.tenantId ?? user.tenantId;
|
|
2588
|
-
if (!tenantId)
|
|
2669
|
+
if (!tenantId) {
|
|
2670
|
+
throw new import_common18.ForbiddenException({
|
|
2671
|
+
code: import_types7.AUTH_ERROR_CODES.NO_TENANT_ASSIGNED,
|
|
2672
|
+
message: "No tenant assigned"
|
|
2673
|
+
});
|
|
2674
|
+
}
|
|
2589
2675
|
const compute = /* @__PURE__ */ __name(() => this.entitlements.computeLimits(tenantId), "compute");
|
|
2590
2676
|
const limits = this.config?.tenantContextRunner ? await this.config.tenantContextRunner(tenantId, compute) : await compute();
|
|
2591
2677
|
const allowed = required.some((f) => limits.features.has(f));
|
|
@@ -2595,25 +2681,27 @@ var FeatureGuard = class _FeatureGuard {
|
|
|
2595
2681
|
return true;
|
|
2596
2682
|
}
|
|
2597
2683
|
/**
|
|
2598
|
-
* Upsell response (#36):
|
|
2599
|
-
*
|
|
2600
|
-
*
|
|
2601
|
-
*
|
|
2684
|
+
* Upsell response (#36): the 403 body is always the full
|
|
2685
|
+
* `FeatureNotLicensedBody`, so a consumer that matches on `code` can read
|
|
2686
|
+
* `featureKey`/`offers` unconditionally. Without a registered
|
|
2687
|
+
* `UpsellOfferResolver` there is simply nothing to offer — `offers` is
|
|
2688
|
+
* empty, the same degradation a failing resolver produces.
|
|
2602
2689
|
*
|
|
2603
2690
|
* Deliberately 403 + `code` field instead of 402 — rationale in
|
|
2604
2691
|
* `@saasicat/types` upsell.types.ts (402 is reserved / inconsistently
|
|
2605
2692
|
* supported; SPA interceptors must not treat the 403 as an auth error, the
|
|
2606
2693
|
* distinction runs through `code`).
|
|
2694
|
+
*
|
|
2695
|
+
* The message must keep starting with the word `Feature` — consumers
|
|
2696
|
+
* classify the response by that prefix.
|
|
2607
2697
|
*/
|
|
2608
2698
|
async buildNotLicensedException(required, tenantId) {
|
|
2609
|
-
const message = `Feature ${required.join(" / ")} nicht im aktuellen Paket enthalten.`;
|
|
2610
|
-
if (!this.upsellResolver) return new import_common18.ForbiddenException(message);
|
|
2611
2699
|
const body = {
|
|
2612
|
-
code:
|
|
2700
|
+
code: import_types7.FEATURE_NOT_LICENSED,
|
|
2613
2701
|
featureKey: required[0],
|
|
2614
2702
|
featureKeys: required,
|
|
2615
|
-
offers: await this.resolveOffersSafe(required, tenantId),
|
|
2616
|
-
message
|
|
2703
|
+
offers: this.upsellResolver ? await this.resolveOffersSafe(required, tenantId) : [],
|
|
2704
|
+
message: `Feature ${required.join(" / ")} is not included in the current plan.`
|
|
2617
2705
|
};
|
|
2618
2706
|
return new import_common18.ForbiddenException(body);
|
|
2619
2707
|
}
|
|
@@ -2625,7 +2713,7 @@ var FeatureGuard = class _FeatureGuard {
|
|
|
2625
2713
|
try {
|
|
2626
2714
|
return await this.upsellResolver.resolveOffers(required, tenantId);
|
|
2627
2715
|
} catch (error) {
|
|
2628
|
-
this.logger.warn(`UpsellOfferResolver
|
|
2716
|
+
this.logger.warn(`UpsellOfferResolver failed for [${required.join(", ")}]: ${String(error)}`);
|
|
2629
2717
|
return [];
|
|
2630
2718
|
}
|
|
2631
2719
|
}
|
|
@@ -2649,7 +2737,7 @@ FeatureGuard = _ts_decorate16([
|
|
|
2649
2737
|
|
|
2650
2738
|
// src/billing/catalog-bundle-upsell-resolver.ts
|
|
2651
2739
|
var import_common19 = require("@nestjs/common");
|
|
2652
|
-
var
|
|
2740
|
+
var import_types8 = require("@saasicat/types");
|
|
2653
2741
|
|
|
2654
2742
|
// src/billing/public-catalog.tokens.ts
|
|
2655
2743
|
var PUBLIC_CATALOG_PROJECT_KEY_TOKEN = /* @__PURE__ */ Symbol.for("saas-platform/PublicCatalogProjectKey");
|
|
@@ -2719,7 +2807,7 @@ var CatalogBundleUpsellResolver = class {
|
|
|
2719
2807
|
const rows = await this.catalogEntryRepo.listFeatures({
|
|
2720
2808
|
projectKey: this.projectKey
|
|
2721
2809
|
});
|
|
2722
|
-
return (0,
|
|
2810
|
+
return (0, import_types8.collectUnsatisfiedRequires)(featureKeys, (0, import_types8.buildFeatureRequiresIndex)(rows));
|
|
2723
2811
|
}
|
|
2724
2812
|
/** Only published-and-live AND marketed — non-marketed bundles are not an offer. */
|
|
2725
2813
|
async listLiveMarketedBundles() {
|
|
@@ -2778,6 +2866,7 @@ __name(compareRankedOffers, "compareRankedOffers");
|
|
|
2778
2866
|
// src/billing/limit-exceeded.filter.ts
|
|
2779
2867
|
var import_common20 = require("@nestjs/common");
|
|
2780
2868
|
var import_core3 = require("@nestjs/core");
|
|
2869
|
+
var import_types9 = require("@saasicat/types");
|
|
2781
2870
|
function _ts_decorate18(decorators, target, key, desc) {
|
|
2782
2871
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
2783
2872
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -2798,11 +2887,14 @@ var LimitExceededFilter = class _LimitExceededFilter extends import_core3.BaseEx
|
|
|
2798
2887
|
const ctx = host.switchToHttp();
|
|
2799
2888
|
const response = ctx.getResponse();
|
|
2800
2889
|
const request = ctx.getRequest();
|
|
2801
|
-
this.logger.warn(`Limit ${exception.dimension}
|
|
2890
|
+
this.logger.warn(`Limit ${exception.dimension} exceeded: ${exception.used}/${exception.max} on ${request.method ?? "?"} ${request.url ?? ""}`);
|
|
2802
2891
|
response.status(import_common20.HttpStatus.PAYMENT_REQUIRED).send({
|
|
2803
2892
|
statusCode: import_common20.HttpStatus.PAYMENT_REQUIRED,
|
|
2804
2893
|
error: "PaymentRequired",
|
|
2805
|
-
|
|
2894
|
+
code: import_types9.BILLING_ERROR_CODES.LIMIT_EXCEEDED,
|
|
2895
|
+
// Deprecated: superseded by `code`, kept until consumers have
|
|
2896
|
+
// migrated. Same value, only under the older field name.
|
|
2897
|
+
reason: import_types9.BILLING_ERROR_CODES.LIMIT_EXCEEDED,
|
|
2806
2898
|
dimension: exception.dimension,
|
|
2807
2899
|
used: exception.used,
|
|
2808
2900
|
max: exception.max,
|
|
@@ -2828,8 +2920,8 @@ var PlanCatalogValidationError = class extends Error {
|
|
|
2828
2920
|
source;
|
|
2829
2921
|
errors;
|
|
2830
2922
|
constructor(source, errors) {
|
|
2831
|
-
const messages = errors.map((e) => `${e.instancePath || "/"}: ${e.message ?? "
|
|
2832
|
-
super(`Plan
|
|
2923
|
+
const messages = errors.map((e) => `${e.instancePath || "/"}: ${e.message ?? "unknown"}`).join("\n ");
|
|
2924
|
+
super(`Plan catalog validation failed for ${source}:
|
|
2833
2925
|
${messages}`), this.source = source, this.errors = errors;
|
|
2834
2926
|
this.name = "PlanCatalogValidationError";
|
|
2835
2927
|
}
|
|
@@ -2846,7 +2938,7 @@ __name(loadPlanCatalogFromFile, "loadPlanCatalogFromFile");
|
|
|
2846
2938
|
function loadPlanCatalogFromString(yamlContent, opts) {
|
|
2847
2939
|
const parsed = import_js_yaml.default.load(yamlContent);
|
|
2848
2940
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2849
|
-
throw new Error(`YAML
|
|
2941
|
+
throw new Error(`The YAML content of ${opts.source} is not an object`);
|
|
2850
2942
|
}
|
|
2851
2943
|
const ajv = new import__.default({
|
|
2852
2944
|
strict: false,
|
|
@@ -2870,19 +2962,19 @@ function validateConsistency(catalog, source) {
|
|
|
2870
2962
|
const planIds = /* @__PURE__ */ new Set();
|
|
2871
2963
|
for (const plan of catalog.plans ?? []) {
|
|
2872
2964
|
if (planIds.has(plan.id)) {
|
|
2873
|
-
errors.push(`plans[].id:
|
|
2965
|
+
errors.push(`plans[].id: duplicate plan id "${plan.id}"`);
|
|
2874
2966
|
}
|
|
2875
2967
|
planIds.add(plan.id);
|
|
2876
2968
|
if (catalog.features) {
|
|
2877
2969
|
for (const fk of plan.features) {
|
|
2878
2970
|
if (!declaredFeatureKeys.has(fk)) {
|
|
2879
|
-
errors.push(`plans[id=${plan.id}].features:
|
|
2971
|
+
errors.push(`plans[id=${plan.id}].features: Unknown featureKey "${fk}" \u2014 not declared in catalog.features`);
|
|
2880
2972
|
}
|
|
2881
2973
|
}
|
|
2882
2974
|
}
|
|
2883
2975
|
}
|
|
2884
2976
|
if (errors.length > 0) {
|
|
2885
|
-
const err = new Error(`Plan
|
|
2977
|
+
const err = new Error(`Plan catalog consistency error in ${source}:
|
|
2886
2978
|
${errors.join("\n ")}`);
|
|
2887
2979
|
err.name = "PlanCatalogConsistencyError";
|
|
2888
2980
|
throw err;
|
|
@@ -2965,7 +3057,7 @@ var PlanCatalogImporterService = class {
|
|
|
2965
3057
|
if (planResult.created) report.plansCreated++;
|
|
2966
3058
|
else report.plansSkipped++;
|
|
2967
3059
|
if (plan.monthlyNet === null || plan.monthlyNet === void 0) {
|
|
2968
|
-
report.warnings.push(`Plan '${plan.id}'
|
|
3060
|
+
report.warnings.push(`Plan '${plan.id}' has no monthlyNet \u2014 skipped (on-request plans need a manual plan version).`);
|
|
2969
3061
|
continue;
|
|
2970
3062
|
}
|
|
2971
3063
|
const versionResult = await this.sink.upsertPlanVersion({
|
|
@@ -3114,7 +3206,7 @@ var FEATURE_UI_REGISTRY_TOKEN2 = /* @__PURE__ */ Symbol.for("saas-platform/Featu
|
|
|
3114
3206
|
|
|
3115
3207
|
// src/billing/public-catalog.controller.ts
|
|
3116
3208
|
var import_common24 = require("@nestjs/common");
|
|
3117
|
-
var
|
|
3209
|
+
var import_types10 = require("@saasicat/types");
|
|
3118
3210
|
function _ts_decorate22(decorators, target, key, desc) {
|
|
3119
3211
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3120
3212
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -3234,7 +3326,7 @@ var PublicCatalogController = class {
|
|
|
3234
3326
|
const rows = await this.catalogEntryRepo.listFeatures({
|
|
3235
3327
|
projectKey: this.projectKey
|
|
3236
3328
|
});
|
|
3237
|
-
return (0,
|
|
3329
|
+
return (0, import_types10.buildFeatureRequiresIndex)(rows);
|
|
3238
3330
|
} catch {
|
|
3239
3331
|
return /* @__PURE__ */ new Map();
|
|
3240
3332
|
}
|
|
@@ -3274,7 +3366,7 @@ var PublicCatalogController = class {
|
|
|
3274
3366
|
quotas: live.quotas,
|
|
3275
3367
|
monthlyNet: live.monthlyNet,
|
|
3276
3368
|
yearlyNet: live.yearlyNet,
|
|
3277
|
-
requiresFeatures: (0,
|
|
3369
|
+
requiresFeatures: (0, import_types10.collectUnsatisfiedRequires)(features, requiresIndex),
|
|
3278
3370
|
marketing
|
|
3279
3371
|
};
|
|
3280
3372
|
}
|
|
@@ -3398,6 +3490,7 @@ var PENDING_PLAN_QUERY_PORT_TOKEN = /* @__PURE__ */ Symbol.for("saas-platform/Pe
|
|
|
3398
3490
|
|
|
3399
3491
|
// src/billing/composed-tenant-auth.guard.ts
|
|
3400
3492
|
var import_common26 = require("@nestjs/common");
|
|
3493
|
+
var import_types11 = require("@saasicat/types");
|
|
3401
3494
|
function _ts_decorate24(decorators, target, key, desc) {
|
|
3402
3495
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3403
3496
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -3425,7 +3518,10 @@ var ComposedTenantAuthGuard = class {
|
|
|
3425
3518
|
}
|
|
3426
3519
|
async canActivate(context) {
|
|
3427
3520
|
if (!this.guards || this.guards.length === 0) {
|
|
3428
|
-
throw new import_common26.ForbiddenException(
|
|
3521
|
+
throw new import_common26.ForbiddenException({
|
|
3522
|
+
code: import_types11.AUTH_ERROR_CODES.AUTH_GUARDS_NOT_CONFIGURED,
|
|
3523
|
+
message: "TenantBillingModule.forRoot.authGuards is not configured."
|
|
3524
|
+
});
|
|
3429
3525
|
}
|
|
3430
3526
|
for (const guard of this.guards) {
|
|
3431
3527
|
const result = await Promise.resolve(guard.canActivate(context));
|
|
@@ -3446,6 +3542,7 @@ ComposedTenantAuthGuard = _ts_decorate24([
|
|
|
3446
3542
|
|
|
3447
3543
|
// src/billing/tenant-admin.guard.ts
|
|
3448
3544
|
var import_common27 = require("@nestjs/common");
|
|
3545
|
+
var import_types12 = require("@saasicat/types");
|
|
3449
3546
|
function _ts_decorate25(decorators, target, key, desc) {
|
|
3450
3547
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3451
3548
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -3464,11 +3561,17 @@ var TenantAdminGuard = class {
|
|
|
3464
3561
|
canActivate(context) {
|
|
3465
3562
|
const request = context.switchToHttp().getRequest();
|
|
3466
3563
|
if (!request.user) {
|
|
3467
|
-
throw new import_common27.ForbiddenException(
|
|
3564
|
+
throw new import_common27.ForbiddenException({
|
|
3565
|
+
code: import_types12.AUTH_ERROR_CODES.NOT_AUTHENTICATED,
|
|
3566
|
+
message: "Not authenticated"
|
|
3567
|
+
});
|
|
3468
3568
|
}
|
|
3469
3569
|
const role = request.user.platformRole ?? request.user.role;
|
|
3470
3570
|
if (!role || !ADMIN_ROLES.has(role)) {
|
|
3471
|
-
throw new import_common27.ForbiddenException(
|
|
3571
|
+
throw new import_common27.ForbiddenException({
|
|
3572
|
+
code: import_types12.AUTH_ERROR_CODES.TENANT_ADMIN_REQUIRED,
|
|
3573
|
+
message: "This action requires the TENANT_ADMIN role."
|
|
3574
|
+
});
|
|
3472
3575
|
}
|
|
3473
3576
|
return true;
|
|
3474
3577
|
}
|
|
@@ -3506,6 +3609,7 @@ __name(round22, "round2");
|
|
|
3506
3609
|
|
|
3507
3610
|
// src/billing/plan-change-preview.service.ts
|
|
3508
3611
|
var import_common29 = require("@nestjs/common");
|
|
3612
|
+
var import_types13 = require("@saasicat/types");
|
|
3509
3613
|
|
|
3510
3614
|
// src/entitlement/index.ts
|
|
3511
3615
|
var entitlement_exports = {};
|
|
@@ -3634,11 +3738,24 @@ var PlanChangePreviewService = class {
|
|
|
3634
3738
|
async preview(tenantId, targetPlan, targetCycle, now = /* @__PURE__ */ new Date()) {
|
|
3635
3739
|
const sub = await this.subscriptions.findForTenant(tenantId);
|
|
3636
3740
|
if (!sub) {
|
|
3637
|
-
throw new import_common29.NotFoundException(
|
|
3741
|
+
throw new import_common29.NotFoundException({
|
|
3742
|
+
code: import_types13.BILLING_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
3743
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
3744
|
+
params: {
|
|
3745
|
+
tenantId
|
|
3746
|
+
}
|
|
3747
|
+
});
|
|
3638
3748
|
}
|
|
3639
3749
|
const targetPlanDef = findPlan(this.catalog, targetPlan);
|
|
3640
3750
|
if (!targetPlanDef) {
|
|
3641
|
-
throw new import_common29.NotFoundException(
|
|
3751
|
+
throw new import_common29.NotFoundException({
|
|
3752
|
+
code: import_types13.BILLING_ERROR_CODES.PLAN_NOT_IN_CATALOG,
|
|
3753
|
+
message: `Plan "${targetPlan}" is not in the catalog (${this.catalog.projectKey})`,
|
|
3754
|
+
params: {
|
|
3755
|
+
planKey: targetPlan,
|
|
3756
|
+
projectKey: this.catalog.projectKey
|
|
3757
|
+
}
|
|
3758
|
+
});
|
|
3642
3759
|
}
|
|
3643
3760
|
const ctx = {
|
|
3644
3761
|
currentPeriodStart: sub.currentPeriodStart,
|
|
@@ -3703,13 +3820,13 @@ var PlanChangePreviewService = class {
|
|
|
3703
3820
|
if (blockedTargets.includes(targetPlan)) {
|
|
3704
3821
|
blockers.push({
|
|
3705
3822
|
code: `${targetPlan}_NOT_SELF_SERVICE`,
|
|
3706
|
-
message: `${targetSnap.name}
|
|
3823
|
+
message: `${targetSnap.name} is only activated via a special contract. Please contact the contract manager.`
|
|
3707
3824
|
});
|
|
3708
3825
|
}
|
|
3709
3826
|
if (blockedSources.includes(sub.plan)) {
|
|
3710
3827
|
blockers.push({
|
|
3711
3828
|
code: `${sub.plan}_LOCKED`,
|
|
3712
|
-
message: `
|
|
3829
|
+
message: `Active ${currentSnap.name} special contract \u2014 please contact the contract manager to change plans.`
|
|
3713
3830
|
});
|
|
3714
3831
|
}
|
|
3715
3832
|
for (const [key, row] of Object.entries(limitsCheck)) {
|
|
@@ -3717,19 +3834,19 @@ var PlanChangePreviewService = class {
|
|
|
3717
3834
|
const usedDisplay = isFloatQuota(key) ? row.used.toFixed(1) : row.used.toString();
|
|
3718
3835
|
blockers.push({
|
|
3719
3836
|
code: `${key.toUpperCase()}_OVER_TARGET`,
|
|
3720
|
-
message: `
|
|
3837
|
+
message: `Current usage ${usedDisplay} exceeds the target limit ${row.targetMax} (${key}) in the ${targetSnap.name} plan. Please reduce usage first.`
|
|
3721
3838
|
});
|
|
3722
3839
|
}
|
|
3723
3840
|
if (featuresLost.length > 0) {
|
|
3724
3841
|
warnings.push({
|
|
3725
3842
|
code: "FEATURES_LOST",
|
|
3726
|
-
message: `
|
|
3843
|
+
message: `Switching means losing access to ${featuresLost.length} feature${featuresLost.length === 1 ? "" : "s"}. Existing data is retained and never deleted \u2014 upgrading again unlocks it.`
|
|
3727
3844
|
});
|
|
3728
3845
|
}
|
|
3729
3846
|
if (changeType === "NOOP") {
|
|
3730
3847
|
warnings.push({
|
|
3731
3848
|
code: "NO_CHANGE",
|
|
3732
|
-
message: "
|
|
3849
|
+
message: "Target plan and billing cycle already match the current state."
|
|
3733
3850
|
});
|
|
3734
3851
|
}
|
|
3735
3852
|
const projectedTrialEndsAt = this.trialProjection && sub.status === "TRIAL" ? await this.trialProjection.projectTrialEndsAt({
|
|
@@ -3887,13 +4004,13 @@ var PendingPlanMaterializationService = class _PendingPlanMaterializationService
|
|
|
3887
4004
|
this.entitlements.invalidateTenant(change.tenantId);
|
|
3888
4005
|
applied += 1;
|
|
3889
4006
|
} catch (err) {
|
|
3890
|
-
this.logger.error(`Pending
|
|
4007
|
+
this.logger.error(`Pending plan materialisation failed (tenant ${change.tenantId}): ${String(err)}`);
|
|
3891
4008
|
continue;
|
|
3892
4009
|
}
|
|
3893
4010
|
await this.tryFreeze(change.tenantId, change.pendingPlan, cycle, now);
|
|
3894
4011
|
}
|
|
3895
4012
|
if (applied > 0) {
|
|
3896
|
-
this.logger.log(`Pending
|
|
4013
|
+
this.logger.log(`Pending plan materialisation: applied ${applied} scheduled plan change(s).`);
|
|
3897
4014
|
}
|
|
3898
4015
|
return {
|
|
3899
4016
|
applied
|
|
@@ -3904,7 +4021,7 @@ var PendingPlanMaterializationService = class _PendingPlanMaterializationService
|
|
|
3904
4021
|
try {
|
|
3905
4022
|
await this.contractFreeze.freezeOnPlanChange(tenantId, plan, cycle, now);
|
|
3906
4023
|
} catch (err) {
|
|
3907
|
-
this.logger.error(`Contract
|
|
4024
|
+
this.logger.error(`Contract freeze after materialisation failed (tenant ${tenantId}): ${String(err)}`);
|
|
3908
4025
|
}
|
|
3909
4026
|
}
|
|
3910
4027
|
};
|
|
@@ -3978,7 +4095,7 @@ function createDiscountLineItem(input, discountNet) {
|
|
|
3978
4095
|
kind: "discount",
|
|
3979
4096
|
sourceKey: promoCode?.code ?? firstPromotion?.id ?? "price-discount",
|
|
3980
4097
|
sourceVersionId: null,
|
|
3981
|
-
titleSnapshot: promoCode?.label ?? firstPromotion?.label ?? "
|
|
4098
|
+
titleSnapshot: promoCode?.label ?? firstPromotion?.label ?? "Discount",
|
|
3982
4099
|
descriptionSnapshot: promoCode ? `Promo-Code ${promoCode.code}` : null,
|
|
3983
4100
|
quantity: 1,
|
|
3984
4101
|
unit: null,
|
|
@@ -4021,6 +4138,7 @@ function cloneJsonValue(value) {
|
|
|
4021
4138
|
__name(cloneJsonValue, "cloneJsonValue");
|
|
4022
4139
|
|
|
4023
4140
|
// src/subscription-contract/subscription-contract.service.ts
|
|
4141
|
+
var import_types14 = require("@saasicat/types");
|
|
4024
4142
|
function _ts_decorate29(decorators, target, key, desc) {
|
|
4025
4143
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4026
4144
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -4052,7 +4170,13 @@ var SubscriptionContractService = class {
|
|
|
4052
4170
|
async getById(contractId) {
|
|
4053
4171
|
const row = await this.repo.findById(contractId);
|
|
4054
4172
|
if (!row) {
|
|
4055
|
-
throw new import_common31.NotFoundException(
|
|
4173
|
+
throw new import_common31.NotFoundException({
|
|
4174
|
+
code: import_types14.CONTRACT_ERROR_CODES.SUBSCRIPTION_CONTRACT_NOT_FOUND,
|
|
4175
|
+
message: `SubscriptionContract '${contractId}' not found`,
|
|
4176
|
+
params: {
|
|
4177
|
+
contractId
|
|
4178
|
+
}
|
|
4179
|
+
});
|
|
4056
4180
|
}
|
|
4057
4181
|
return row;
|
|
4058
4182
|
}
|
|
@@ -4062,7 +4186,14 @@ var SubscriptionContractService = class {
|
|
|
4062
4186
|
async getActiveInvoiceSnapshotForTenant(tenantId, asOf = /* @__PURE__ */ new Date()) {
|
|
4063
4187
|
const contract = await this.repo.findActiveByTenantId(tenantId, asOf);
|
|
4064
4188
|
if (!contract) {
|
|
4065
|
-
throw new import_common31.NotFoundException(
|
|
4189
|
+
throw new import_common31.NotFoundException({
|
|
4190
|
+
code: import_types14.CONTRACT_ERROR_CODES.NO_ACTIVE_SUBSCRIPTION_CONTRACT,
|
|
4191
|
+
message: `No active subscription contract for tenant ${tenantId}`,
|
|
4192
|
+
params: {
|
|
4193
|
+
tenantId,
|
|
4194
|
+
asOf: asOf.toISOString()
|
|
4195
|
+
}
|
|
4196
|
+
});
|
|
4066
4197
|
}
|
|
4067
4198
|
return subscriptionContractToInvoiceSnapshot(contract);
|
|
4068
4199
|
}
|
|
@@ -4095,7 +4226,14 @@ var SubscriptionContractService = class {
|
|
|
4095
4226
|
}
|
|
4096
4227
|
createDataFromOffer(offer, options) {
|
|
4097
4228
|
if (offer.status !== "consumed") {
|
|
4098
|
-
throw new import_common31.ConflictException(
|
|
4229
|
+
throw new import_common31.ConflictException({
|
|
4230
|
+
code: import_types14.CONTRACT_ERROR_CODES.CHECKOUT_OFFER_NOT_CONSUMED,
|
|
4231
|
+
message: `CheckoutOffer '${offer.id}' must be consumed before the contract is created`,
|
|
4232
|
+
params: {
|
|
4233
|
+
offerId: offer.id,
|
|
4234
|
+
status: offer.status
|
|
4235
|
+
}
|
|
4236
|
+
});
|
|
4099
4237
|
}
|
|
4100
4238
|
const lineItems = this.lineItemsFromOffer(offer);
|
|
4101
4239
|
return {
|
|
@@ -4128,8 +4266,8 @@ var SubscriptionContractService = class {
|
|
|
4128
4266
|
const source = offer.lineItems ?? [];
|
|
4129
4267
|
if (source.length === 0) {
|
|
4130
4268
|
throw new import_common31.UnprocessableEntityException({
|
|
4131
|
-
code:
|
|
4132
|
-
message: "
|
|
4269
|
+
code: import_types14.CONTRACT_ERROR_CODES.CHECKOUT_OFFER_LINE_ITEMS_REQUIRED,
|
|
4270
|
+
message: "A checkout offer can yield only one contract, and only once its line items are frozen."
|
|
4133
4271
|
});
|
|
4134
4272
|
}
|
|
4135
4273
|
return appendImplicitDiscountLineItem({
|
|
@@ -4167,8 +4305,11 @@ var SubscriptionContractService = class {
|
|
|
4167
4305
|
const date = value instanceof Date ? new Date(value) : new Date(value);
|
|
4168
4306
|
if (Number.isNaN(date.getTime())) {
|
|
4169
4307
|
throw new import_common31.UnprocessableEntityException({
|
|
4170
|
-
code:
|
|
4171
|
-
message: `${field}
|
|
4308
|
+
code: import_types14.CONTRACT_ERROR_CODES.SUBSCRIPTION_CONTRACT_INVALID_DATE,
|
|
4309
|
+
message: `${field} must be a valid date.`,
|
|
4310
|
+
params: {
|
|
4311
|
+
field
|
|
4312
|
+
}
|
|
4172
4313
|
});
|
|
4173
4314
|
}
|
|
4174
4315
|
return date;
|
|
@@ -4188,32 +4329,39 @@ var SubscriptionContractService = class {
|
|
|
4188
4329
|
assertCreateData(data) {
|
|
4189
4330
|
if (data.lineItems.length === 0) {
|
|
4190
4331
|
throw new import_common31.UnprocessableEntityException({
|
|
4191
|
-
code:
|
|
4192
|
-
message: "
|
|
4332
|
+
code: import_types14.CONTRACT_ERROR_CODES.SUBSCRIPTION_CONTRACT_LINE_ITEMS_REQUIRED,
|
|
4333
|
+
message: "A subscription contract requires at least one line item."
|
|
4193
4334
|
});
|
|
4194
4335
|
}
|
|
4195
4336
|
if (data.effectiveUntil && data.effectiveUntil <= data.effectiveFrom) {
|
|
4196
4337
|
throw new import_common31.UnprocessableEntityException({
|
|
4197
|
-
code:
|
|
4198
|
-
message: "effectiveUntil
|
|
4338
|
+
code: import_types14.CONTRACT_ERROR_CODES.SUBSCRIPTION_CONTRACT_INVALID_WINDOW,
|
|
4339
|
+
message: "effectiveUntil must be after effectiveFrom."
|
|
4199
4340
|
});
|
|
4200
4341
|
}
|
|
4201
4342
|
const planLineItems = data.lineItems.filter((item) => item.kind === "plan");
|
|
4202
4343
|
if (planLineItems.length !== 1) {
|
|
4203
4344
|
throw new import_common31.UnprocessableEntityException({
|
|
4204
|
-
code:
|
|
4205
|
-
message: "
|
|
4345
|
+
code: import_types14.CONTRACT_ERROR_CODES.SUBSCRIPTION_CONTRACT_PLAN_LINE_ITEM_REQUIRED,
|
|
4346
|
+
message: "A subscription contract requires exactly one plan base item."
|
|
4206
4347
|
});
|
|
4207
4348
|
}
|
|
4208
4349
|
}
|
|
4209
4350
|
assertTerminable(existing, data) {
|
|
4210
4351
|
if (existing.status === "terminated" || existing.status === "superseded") {
|
|
4211
|
-
throw new import_common31.ConflictException(
|
|
4352
|
+
throw new import_common31.ConflictException({
|
|
4353
|
+
code: import_types14.CONTRACT_ERROR_CODES.SUBSCRIPTION_CONTRACT_ALREADY_CLOSED,
|
|
4354
|
+
message: `SubscriptionContract '${existing.id}' is already closed`,
|
|
4355
|
+
params: {
|
|
4356
|
+
contractId: existing.id,
|
|
4357
|
+
status: existing.status
|
|
4358
|
+
}
|
|
4359
|
+
});
|
|
4212
4360
|
}
|
|
4213
4361
|
if (data.effectiveUntil <= existing.effectiveFrom) {
|
|
4214
4362
|
throw new import_common31.UnprocessableEntityException({
|
|
4215
|
-
code:
|
|
4216
|
-
message: "effectiveUntil
|
|
4363
|
+
code: import_types14.CONTRACT_ERROR_CODES.SUBSCRIPTION_CONTRACT_TERMINATION_BEFORE_START,
|
|
4364
|
+
message: "effectiveUntil must be after the effectiveFrom of the contract."
|
|
4217
4365
|
});
|
|
4218
4366
|
}
|
|
4219
4367
|
}
|
|
@@ -4505,14 +4653,14 @@ var PreviewPlanChangeDto = class {
|
|
|
4505
4653
|
_ts_decorate32([
|
|
4506
4654
|
(0, import_class_validator3.IsString)(),
|
|
4507
4655
|
(0, import_class_validator3.Matches)(PLAN_OR_CYCLE_PATTERN, {
|
|
4508
|
-
message: "plan
|
|
4656
|
+
message: "plan must be SCREAMING_SNAKE_CASE"
|
|
4509
4657
|
}),
|
|
4510
4658
|
_ts_metadata22("design:type", String)
|
|
4511
4659
|
], PreviewPlanChangeDto.prototype, "plan", void 0);
|
|
4512
4660
|
_ts_decorate32([
|
|
4513
4661
|
(0, import_class_validator3.IsString)(),
|
|
4514
4662
|
(0, import_class_validator3.Matches)(PLAN_OR_CYCLE_PATTERN, {
|
|
4515
|
-
message: "billingCycle
|
|
4663
|
+
message: "billingCycle must be SCREAMING_SNAKE_CASE (e.g. MONTHLY, YEARLY)"
|
|
4516
4664
|
}),
|
|
4517
4665
|
_ts_metadata22("design:type", String)
|
|
4518
4666
|
], PreviewPlanChangeDto.prototype, "billingCycle", void 0);
|
|
@@ -4576,14 +4724,14 @@ var CompleteOnboardingSubscriptionDto = class {
|
|
|
4576
4724
|
_ts_decorate33([
|
|
4577
4725
|
(0, import_class_validator4.IsString)(),
|
|
4578
4726
|
(0, import_class_validator4.Matches)(PLAN_OR_CYCLE_PATTERN2, {
|
|
4579
|
-
message: "plan
|
|
4727
|
+
message: "plan must be SCREAMING_SNAKE_CASE"
|
|
4580
4728
|
}),
|
|
4581
4729
|
_ts_metadata23("design:type", String)
|
|
4582
4730
|
], CompleteOnboardingSubscriptionDto.prototype, "plan", void 0);
|
|
4583
4731
|
_ts_decorate33([
|
|
4584
4732
|
(0, import_class_validator4.IsString)(),
|
|
4585
4733
|
(0, import_class_validator4.Matches)(PLAN_OR_CYCLE_PATTERN2, {
|
|
4586
|
-
message: "billingCycle
|
|
4734
|
+
message: "billingCycle must be SCREAMING_SNAKE_CASE (e.g. MONTHLY, YEARLY)"
|
|
4587
4735
|
}),
|
|
4588
4736
|
_ts_metadata23("design:type", String)
|
|
4589
4737
|
], CompleteOnboardingSubscriptionDto.prototype, "billingCycle", void 0);
|
|
@@ -4591,7 +4739,7 @@ _ts_decorate33([
|
|
|
4591
4739
|
(0, import_class_validator4.IsOptional)(),
|
|
4592
4740
|
(0, import_class_validator4.IsString)(),
|
|
4593
4741
|
(0, import_class_validator4.Matches)(PROMO_CODE_PATTERN, {
|
|
4594
|
-
message: 'promoCode
|
|
4742
|
+
message: 'promoCode must consist of A\u2013Z, 0\u20139, "-", "_" (4\u201332 characters)'
|
|
4595
4743
|
}),
|
|
4596
4744
|
_ts_metadata23("design:type", String)
|
|
4597
4745
|
], CompleteOnboardingSubscriptionDto.prototype, "promoCode", void 0);
|
|
@@ -4607,9 +4755,11 @@ _ts_decorate33([
|
|
|
4607
4755
|
|
|
4608
4756
|
// src/billing/tenant-billing.controller.ts
|
|
4609
4757
|
var import_common36 = require("@nestjs/common");
|
|
4758
|
+
var import_types17 = require("@saasicat/types");
|
|
4610
4759
|
|
|
4611
4760
|
// src/promo/service.ts
|
|
4612
4761
|
var import_common34 = require("@nestjs/common");
|
|
4762
|
+
var import_types15 = require("@saasicat/types");
|
|
4613
4763
|
|
|
4614
4764
|
// src/promo/tokens.ts
|
|
4615
4765
|
var PROMO_CODE_REPOSITORY_TOKEN = /* @__PURE__ */ Symbol.for("saas-platform/PromoCodeRepository");
|
|
@@ -4663,15 +4813,15 @@ function buildLabel(code, cycle) {
|
|
|
4663
4813
|
maximumFractionDigits: 2
|
|
4664
4814
|
})} \u20AC`;
|
|
4665
4815
|
if (code.durationType === "ONCE") {
|
|
4666
|
-
return `${valueStr}
|
|
4816
|
+
return `${valueStr} once`;
|
|
4667
4817
|
}
|
|
4668
4818
|
if (code.durationType === "MONTHS") {
|
|
4669
4819
|
const m = code.durationValue ?? 0;
|
|
4670
|
-
return m === 1 ? `${valueStr}
|
|
4820
|
+
return m === 1 ? `${valueStr} for the first month` : `${valueStr} for ${m} months`;
|
|
4671
4821
|
}
|
|
4672
4822
|
const n = code.durationValue ?? 0;
|
|
4673
|
-
if (n === 1) return cycle === "YEARLY" ? `${valueStr}
|
|
4674
|
-
return cycle === "YEARLY" ? `${valueStr}
|
|
4823
|
+
if (n === 1) return cycle === "YEARLY" ? `${valueStr} for the first year` : `${valueStr} for the first month`;
|
|
4824
|
+
return cycle === "YEARLY" ? `${valueStr} for the first ${n} years` : `${valueStr} for the first ${n} months`;
|
|
4675
4825
|
}
|
|
4676
4826
|
__name(buildLabel, "buildLabel");
|
|
4677
4827
|
|
|
@@ -4693,7 +4843,23 @@ function _ts_param22(paramIndex, decorator) {
|
|
|
4693
4843
|
};
|
|
4694
4844
|
}
|
|
4695
4845
|
__name(_ts_param22, "_ts_param");
|
|
4696
|
-
var
|
|
4846
|
+
var CODE_MIN_LENGTH = 4;
|
|
4847
|
+
var CODE_MAX_LENGTH = 32;
|
|
4848
|
+
var CODE_PATTERN = new RegExp(`^[A-Z0-9_-]{${CODE_MIN_LENGTH},${CODE_MAX_LENGTH}}$`);
|
|
4849
|
+
var PERCENT_MIN_EXCLUSIVE = 0;
|
|
4850
|
+
var PERCENT_MAX = 100;
|
|
4851
|
+
var DURATION_MIN = 1;
|
|
4852
|
+
var DURATION_MAX = 24;
|
|
4853
|
+
function notRedeemable(reason) {
|
|
4854
|
+
return new import_common34.BadRequestException({
|
|
4855
|
+
code: import_types15.PROMO_ERROR_CODES.PROMO_CODE_NOT_REDEEMABLE,
|
|
4856
|
+
message: `Code cannot be redeemed: ${reason}`,
|
|
4857
|
+
params: {
|
|
4858
|
+
reason
|
|
4859
|
+
}
|
|
4860
|
+
});
|
|
4861
|
+
}
|
|
4862
|
+
__name(notRedeemable, "notRedeemable");
|
|
4697
4863
|
var PromoCodesService = class {
|
|
4698
4864
|
static {
|
|
4699
4865
|
__name(this, "PromoCodesService");
|
|
@@ -4722,46 +4888,118 @@ var PromoCodesService = class {
|
|
|
4722
4888
|
async create(input) {
|
|
4723
4889
|
const code = input.code.trim().toUpperCase();
|
|
4724
4890
|
if (!CODE_PATTERN.test(code)) {
|
|
4725
|
-
throw new import_common34.BadRequestException(
|
|
4891
|
+
throw new import_common34.BadRequestException({
|
|
4892
|
+
code: import_types15.PROMO_ERROR_CODES.PROMO_CODE_FORMAT_INVALID,
|
|
4893
|
+
message: 'The code may only contain upper-case letters, digits, "-" and "_" (4\u201332 characters).',
|
|
4894
|
+
params: {
|
|
4895
|
+
promoCode: code,
|
|
4896
|
+
minLength: CODE_MIN_LENGTH,
|
|
4897
|
+
maxLength: CODE_MAX_LENGTH
|
|
4898
|
+
}
|
|
4899
|
+
});
|
|
4726
4900
|
}
|
|
4727
4901
|
if (input.valueType === "PERCENT") {
|
|
4728
|
-
if (!(input.value >
|
|
4729
|
-
throw new import_common34.BadRequestException(
|
|
4902
|
+
if (!(input.value > PERCENT_MIN_EXCLUSIVE && input.value <= PERCENT_MAX)) {
|
|
4903
|
+
throw new import_common34.BadRequestException({
|
|
4904
|
+
code: import_types15.PROMO_ERROR_CODES.PROMO_PERCENT_OUT_OF_RANGE,
|
|
4905
|
+
message: "The percentage must be between 0 and 100.",
|
|
4906
|
+
params: {
|
|
4907
|
+
value: input.value,
|
|
4908
|
+
min: PERCENT_MIN_EXCLUSIVE,
|
|
4909
|
+
max: PERCENT_MAX
|
|
4910
|
+
}
|
|
4911
|
+
});
|
|
4730
4912
|
}
|
|
4731
4913
|
} else if (!(input.value > 0)) {
|
|
4732
|
-
throw new import_common34.BadRequestException(
|
|
4914
|
+
throw new import_common34.BadRequestException({
|
|
4915
|
+
code: import_types15.PROMO_ERROR_CODES.PROMO_AMOUNT_NOT_POSITIVE,
|
|
4916
|
+
message: "The amount must be positive.",
|
|
4917
|
+
params: {
|
|
4918
|
+
value: input.value,
|
|
4919
|
+
min: 0
|
|
4920
|
+
}
|
|
4921
|
+
});
|
|
4733
4922
|
}
|
|
4734
4923
|
if (input.durationType === "ONCE") {
|
|
4735
4924
|
if (input.durationValue != null) {
|
|
4736
|
-
throw new import_common34.BadRequestException(
|
|
4925
|
+
throw new import_common34.BadRequestException({
|
|
4926
|
+
code: import_types15.PROMO_ERROR_CODES.PROMO_ONE_OFF_WITH_DURATION,
|
|
4927
|
+
message: "A one-off discount must not set a duration.",
|
|
4928
|
+
params: {
|
|
4929
|
+
durationValue: input.durationValue
|
|
4930
|
+
}
|
|
4931
|
+
});
|
|
4737
4932
|
}
|
|
4738
4933
|
} else {
|
|
4739
4934
|
const v = input.durationValue;
|
|
4740
|
-
if (v == null || v <
|
|
4741
|
-
throw new import_common34.BadRequestException(
|
|
4935
|
+
if (v == null || v < DURATION_MIN || v > DURATION_MAX) {
|
|
4936
|
+
throw new import_common34.BadRequestException({
|
|
4937
|
+
code: import_types15.PROMO_ERROR_CODES.PROMO_DURATION_INVALID,
|
|
4938
|
+
message: "Invalid duration (at most 24 months or billing periods).",
|
|
4939
|
+
params: {
|
|
4940
|
+
durationValue: v ?? null,
|
|
4941
|
+
min: DURATION_MIN,
|
|
4942
|
+
max: DURATION_MAX
|
|
4943
|
+
}
|
|
4944
|
+
});
|
|
4742
4945
|
}
|
|
4743
4946
|
}
|
|
4744
4947
|
if (input.validFrom && input.validUntil && input.validFrom >= input.validUntil) {
|
|
4745
|
-
throw new import_common34.BadRequestException(
|
|
4948
|
+
throw new import_common34.BadRequestException({
|
|
4949
|
+
code: import_types15.PROMO_ERROR_CODES.PROMO_VALIDITY_WINDOW_INVALID,
|
|
4950
|
+
message: "Invalid validity window.",
|
|
4951
|
+
params: {
|
|
4952
|
+
validFrom: input.validFrom.toISOString(),
|
|
4953
|
+
validUntil: input.validUntil.toISOString()
|
|
4954
|
+
}
|
|
4955
|
+
});
|
|
4746
4956
|
}
|
|
4747
4957
|
const plans = input.appliesToPlans ?? [];
|
|
4748
4958
|
const blocked = this.config.nonRedeemablePlans ?? [];
|
|
4749
4959
|
for (const p of plans) {
|
|
4750
4960
|
if (blocked.includes(p)) {
|
|
4751
|
-
throw new import_common34.BadRequestException(
|
|
4961
|
+
throw new import_common34.BadRequestException({
|
|
4962
|
+
code: import_types15.PROMO_ERROR_CODES.PROMO_PLAN_NOT_DISCOUNTABLE,
|
|
4963
|
+
message: `${p}-Plan cannot be discounted.`,
|
|
4964
|
+
params: {
|
|
4965
|
+
plan: p
|
|
4966
|
+
}
|
|
4967
|
+
});
|
|
4752
4968
|
}
|
|
4753
4969
|
}
|
|
4754
4970
|
if (input.minimumPlanAmountGross != null && input.minimumPlanAmountGross <= 0) {
|
|
4755
|
-
throw new import_common34.BadRequestException(
|
|
4971
|
+
throw new import_common34.BadRequestException({
|
|
4972
|
+
code: import_types15.PROMO_ERROR_CODES.PROMO_MIN_AMOUNT_NOT_POSITIVE,
|
|
4973
|
+
message: "The minimum plan gross amount must be positive.",
|
|
4974
|
+
params: {
|
|
4975
|
+
value: input.minimumPlanAmountGross,
|
|
4976
|
+
min: 0
|
|
4977
|
+
}
|
|
4978
|
+
});
|
|
4756
4979
|
}
|
|
4757
4980
|
if (input.valueType === "ABSOLUTE" && !input.allowZeroInvoice) {
|
|
4758
4981
|
const lowestApplicableGross = this.lowestApplicablePlanGross(plans);
|
|
4759
4982
|
if (lowestApplicableGross != null && input.value >= lowestApplicableGross) {
|
|
4760
|
-
throw new import_common34.BadRequestException(
|
|
4983
|
+
throw new import_common34.BadRequestException({
|
|
4984
|
+
code: import_types15.PROMO_ERROR_CODES.PROMO_WOULD_PRODUCE_ZERO_INVOICE,
|
|
4985
|
+
message: "For absolute amounts the discount must stay below the lowest applicable plan price, or allowZeroInvoice must be enabled.",
|
|
4986
|
+
params: {
|
|
4987
|
+
value: input.value,
|
|
4988
|
+
lowestApplicablePlanGross: lowestApplicableGross
|
|
4989
|
+
}
|
|
4990
|
+
});
|
|
4761
4991
|
}
|
|
4762
4992
|
}
|
|
4763
4993
|
const exists = await this.promoRepo.findByCode(code);
|
|
4764
|
-
if (exists)
|
|
4994
|
+
if (exists) {
|
|
4995
|
+
throw new import_common34.BadRequestException({
|
|
4996
|
+
code: import_types15.PROMO_ERROR_CODES.PROMO_CODE_ALREADY_EXISTS,
|
|
4997
|
+
message: "The code already exists.",
|
|
4998
|
+
params: {
|
|
4999
|
+
promoCode: code
|
|
5000
|
+
}
|
|
5001
|
+
});
|
|
5002
|
+
}
|
|
4765
5003
|
return this.promoRepo.create({
|
|
4766
5004
|
...input,
|
|
4767
5005
|
code
|
|
@@ -4769,10 +5007,26 @@ var PromoCodesService = class {
|
|
|
4769
5007
|
}
|
|
4770
5008
|
async update(id, input) {
|
|
4771
5009
|
const existing = await this.promoRepo.findById(id);
|
|
4772
|
-
if (!existing)
|
|
5010
|
+
if (!existing) {
|
|
5011
|
+
throw new import_common34.NotFoundException({
|
|
5012
|
+
code: import_types15.PROMO_ERROR_CODES.PROMO_CODE_NOT_FOUND,
|
|
5013
|
+
message: "Code not found",
|
|
5014
|
+
params: {
|
|
5015
|
+
promoCodeId: id,
|
|
5016
|
+
promoCode: null
|
|
5017
|
+
}
|
|
5018
|
+
});
|
|
5019
|
+
}
|
|
4773
5020
|
if (input.maxRedemptions != null && existing.maxRedemptions != null) {
|
|
4774
5021
|
if (input.maxRedemptions < existing.maxRedemptions) {
|
|
4775
|
-
throw new import_common34.BadRequestException(
|
|
5022
|
+
throw new import_common34.BadRequestException({
|
|
5023
|
+
code: import_types15.PROMO_ERROR_CODES.PROMO_MAX_REDEMPTIONS_LOWERED,
|
|
5024
|
+
message: "maxRedemptions cannot be lowered.",
|
|
5025
|
+
params: {
|
|
5026
|
+
current: existing.maxRedemptions,
|
|
5027
|
+
requested: input.maxRedemptions
|
|
5028
|
+
}
|
|
5029
|
+
});
|
|
4776
5030
|
}
|
|
4777
5031
|
}
|
|
4778
5032
|
return this.promoRepo.update(id, input);
|
|
@@ -4780,7 +5034,14 @@ var PromoCodesService = class {
|
|
|
4780
5034
|
async softDelete(id) {
|
|
4781
5035
|
const redemptions = await this.redemptionRepo.countByPromoCode(id);
|
|
4782
5036
|
if (redemptions > 0) {
|
|
4783
|
-
throw new import_common34.BadRequestException(
|
|
5037
|
+
throw new import_common34.BadRequestException({
|
|
5038
|
+
code: import_types15.PROMO_ERROR_CODES.PROMO_CODE_HAS_REDEMPTIONS,
|
|
5039
|
+
message: "The code already has redemptions \u2014 it cannot be soft-deleted. Pause it instead.",
|
|
5040
|
+
params: {
|
|
5041
|
+
promoCodeId: id,
|
|
5042
|
+
redemptions
|
|
5043
|
+
}
|
|
5044
|
+
});
|
|
4784
5045
|
}
|
|
4785
5046
|
await this.promoRepo.softDelete(id);
|
|
4786
5047
|
}
|
|
@@ -4791,7 +5052,16 @@ var PromoCodesService = class {
|
|
|
4791
5052
|
async findOne(id) {
|
|
4792
5053
|
await this.lazyExpire();
|
|
4793
5054
|
const code = await this.promoRepo.findById(id);
|
|
4794
|
-
if (!code || code.deletedAt)
|
|
5055
|
+
if (!code || code.deletedAt) {
|
|
5056
|
+
throw new import_common34.NotFoundException({
|
|
5057
|
+
code: import_types15.PROMO_ERROR_CODES.PROMO_CODE_NOT_FOUND,
|
|
5058
|
+
message: "Code not found",
|
|
5059
|
+
params: {
|
|
5060
|
+
promoCodeId: id,
|
|
5061
|
+
promoCode: null
|
|
5062
|
+
}
|
|
5063
|
+
});
|
|
5064
|
+
}
|
|
4795
5065
|
return code;
|
|
4796
5066
|
}
|
|
4797
5067
|
async stats(id) {
|
|
@@ -4919,28 +5189,51 @@ var PromoCodesService = class {
|
|
|
4919
5189
|
const code = input.code.trim().toUpperCase();
|
|
4920
5190
|
const promo = await this.promoRepo.findByCode(code, tx);
|
|
4921
5191
|
if (!promo || promo.deletedAt) {
|
|
4922
|
-
throw new import_common34.BadRequestException(
|
|
5192
|
+
throw new import_common34.BadRequestException({
|
|
5193
|
+
code: import_types15.PROMO_ERROR_CODES.PROMO_CODE_NOT_FOUND,
|
|
5194
|
+
// The lookup runs on the code string, so there is no id to report.
|
|
5195
|
+
message: "Code not found",
|
|
5196
|
+
params: {
|
|
5197
|
+
promoCodeId: null,
|
|
5198
|
+
promoCode: code
|
|
5199
|
+
}
|
|
5200
|
+
});
|
|
4923
5201
|
}
|
|
4924
5202
|
const sub = await this.subscriptionLookup.findById(input.subscriptionId, tx);
|
|
4925
|
-
if (!sub)
|
|
5203
|
+
if (!sub) {
|
|
5204
|
+
throw new import_common34.NotFoundException({
|
|
5205
|
+
code: import_types15.BILLING_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
5206
|
+
message: "Subscription not found",
|
|
5207
|
+
params: {
|
|
5208
|
+
tenantId: input.tenantId
|
|
5209
|
+
}
|
|
5210
|
+
});
|
|
5211
|
+
}
|
|
4926
5212
|
if (sub.tenantId !== input.tenantId) {
|
|
4927
|
-
throw new import_common34.BadRequestException(
|
|
5213
|
+
throw new import_common34.BadRequestException({
|
|
5214
|
+
code: import_types15.BILLING_ERROR_CODES.SUBSCRIPTION_TENANT_MISMATCH,
|
|
5215
|
+
message: "Subscription does not belong to the tenant",
|
|
5216
|
+
params: {
|
|
5217
|
+
subscriptionId: sub.id,
|
|
5218
|
+
tenantId: input.tenantId
|
|
5219
|
+
}
|
|
5220
|
+
});
|
|
4928
5221
|
}
|
|
4929
5222
|
const reason = this.checkEligibility(promo, {
|
|
4930
5223
|
code,
|
|
4931
5224
|
planId: sub.plan,
|
|
4932
5225
|
billingCycle: sub.billingCycle
|
|
4933
5226
|
});
|
|
4934
|
-
if (reason) throw
|
|
5227
|
+
if (reason) throw notRedeemable(reason);
|
|
4935
5228
|
const firstTimeReason = await this.checkFirstTimeCustomer(promo, input.email, {
|
|
4936
5229
|
requireEmail: true
|
|
4937
5230
|
});
|
|
4938
5231
|
if (firstTimeReason) {
|
|
4939
|
-
throw
|
|
5232
|
+
throw notRedeemable(firstTimeReason);
|
|
4940
5233
|
}
|
|
4941
5234
|
const claimed = await this.promoRepo.claimSlot(promo.id, tx);
|
|
4942
5235
|
if (!claimed) {
|
|
4943
|
-
throw
|
|
5236
|
+
throw notRedeemable("EXHAUSTED");
|
|
4944
5237
|
}
|
|
4945
5238
|
await this.promoRepo.markExhaustedIfFull(promo.id, tx);
|
|
4946
5239
|
const startsAt = sub.startedAt ?? /* @__PURE__ */ new Date();
|
|
@@ -5051,6 +5344,7 @@ PromoCodesService = _ts_decorate34([
|
|
|
5051
5344
|
|
|
5052
5345
|
// src/billing/subscription-bundles.service.ts
|
|
5053
5346
|
var import_common35 = require("@nestjs/common");
|
|
5347
|
+
var import_types16 = require("@saasicat/types");
|
|
5054
5348
|
function _ts_decorate35(decorators, target, key, desc) {
|
|
5055
5349
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
5056
5350
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -5098,38 +5392,61 @@ var SubscriptionBundlesService = class {
|
|
|
5098
5392
|
async addBundleToSubscription(input) {
|
|
5099
5393
|
const bundleVersion = await this.bundles.findVersionById(input.bundleVersionId);
|
|
5100
5394
|
if (!bundleVersion) {
|
|
5101
|
-
throw new import_common35.NotFoundException(
|
|
5395
|
+
throw new import_common35.NotFoundException({
|
|
5396
|
+
code: import_types16.CATALOG_ERROR_CODES.BUNDLE_VERSION_NOT_FOUND,
|
|
5397
|
+
message: `BundleVersion '${input.bundleVersionId}' not found`,
|
|
5398
|
+
params: {
|
|
5399
|
+
bundleVersionId: input.bundleVersionId
|
|
5400
|
+
}
|
|
5401
|
+
});
|
|
5102
5402
|
}
|
|
5103
5403
|
if (bundleVersion.publishedAt === null) {
|
|
5104
5404
|
throw new import_common35.UnprocessableEntityException({
|
|
5105
|
-
code:
|
|
5106
|
-
message: `BundleVersion '${input.bundleVersionId}'
|
|
5405
|
+
code: import_types16.CATALOG_ERROR_CODES.BUNDLE_VERSION_NOT_PUBLISHED,
|
|
5406
|
+
message: `BundleVersion '${input.bundleVersionId}' is not published and cannot be booked.`,
|
|
5407
|
+
params: {
|
|
5408
|
+
bundleVersionId: input.bundleVersionId
|
|
5409
|
+
}
|
|
5107
5410
|
});
|
|
5108
5411
|
}
|
|
5109
5412
|
if (bundleVersion.supersededAt !== null) {
|
|
5110
5413
|
throw new import_common35.UnprocessableEntityException({
|
|
5111
|
-
code:
|
|
5112
|
-
message: `BundleVersion '${input.bundleVersionId}'
|
|
5414
|
+
code: import_types16.CATALOG_ERROR_CODES.BUNDLE_VERSION_SUPERSEDED,
|
|
5415
|
+
message: `BundleVersion '${input.bundleVersionId}' has been superseded by a newer version.`,
|
|
5416
|
+
params: {
|
|
5417
|
+
bundleVersionId: input.bundleVersionId
|
|
5418
|
+
}
|
|
5113
5419
|
});
|
|
5114
5420
|
}
|
|
5115
5421
|
if (this.blockedBundles?.bundleKeys?.includes(bundleVersion.bundleKey)) {
|
|
5116
5422
|
throw new import_common35.UnprocessableEntityException({
|
|
5117
|
-
code:
|
|
5118
|
-
message: `Bundle '${bundleVersion.bundleKey}'
|
|
5423
|
+
code: import_types16.BILLING_ERROR_CODES.BUNDLE_NOT_SELF_SERVICE,
|
|
5424
|
+
message: `Bundle '${bundleVersion.bundleKey}' is only activated via a special contract. Please contact the contract manager.`,
|
|
5425
|
+
params: {
|
|
5426
|
+
bundleKey: bundleVersion.bundleKey
|
|
5427
|
+
}
|
|
5119
5428
|
});
|
|
5120
5429
|
}
|
|
5121
5430
|
const planIds = bundleVersion.compatibility?.planIds ?? [];
|
|
5122
5431
|
if (planIds.length > 0 && !planIds.includes(input.currentPlanKey)) {
|
|
5123
5432
|
throw new import_common35.UnprocessableEntityException({
|
|
5124
|
-
code:
|
|
5125
|
-
message: `BundleVersion '${input.bundleVersionId}'
|
|
5433
|
+
code: import_types16.BILLING_ERROR_CODES.BUNDLE_INCOMPATIBLE_WITH_PLAN,
|
|
5434
|
+
message: `BundleVersion '${input.bundleVersionId}' is not compatible with plan '${input.currentPlanKey}'. Allowed: [${planIds.join(", ")}].`,
|
|
5435
|
+
params: {
|
|
5436
|
+
bundleVersionId: input.bundleVersionId,
|
|
5437
|
+
planKey: input.currentPlanKey,
|
|
5438
|
+
allowedPlanKeys: planIds
|
|
5439
|
+
}
|
|
5126
5440
|
});
|
|
5127
5441
|
}
|
|
5128
5442
|
const active = await this.repo.listActiveBySubscription(input.subscriptionId);
|
|
5129
5443
|
if (active.some((b) => b.bundleVersionId === input.bundleVersionId)) {
|
|
5130
5444
|
throw new import_common35.UnprocessableEntityException({
|
|
5131
|
-
code:
|
|
5132
|
-
message: `Subscription '${input.subscriptionId}'
|
|
5445
|
+
code: import_types16.BILLING_ERROR_CODES.BUNDLE_ALREADY_SUBSCRIBED,
|
|
5446
|
+
message: `Subscription '${input.subscriptionId}' has already actively booked this bundle.`,
|
|
5447
|
+
params: {
|
|
5448
|
+
subscriptionId: input.subscriptionId
|
|
5449
|
+
}
|
|
5133
5450
|
});
|
|
5134
5451
|
}
|
|
5135
5452
|
const startedAt = input.startedAt ?? /* @__PURE__ */ new Date();
|
|
@@ -5145,12 +5462,21 @@ var SubscriptionBundlesService = class {
|
|
|
5145
5462
|
async cancelBundleFromSubscription(input) {
|
|
5146
5463
|
const existing = await this.repo.findById(input.subscriptionBundleId);
|
|
5147
5464
|
if (!existing) {
|
|
5148
|
-
throw new import_common35.NotFoundException(
|
|
5465
|
+
throw new import_common35.NotFoundException({
|
|
5466
|
+
code: import_types16.BILLING_ERROR_CODES.SUBSCRIPTION_BUNDLE_NOT_FOUND,
|
|
5467
|
+
message: `SubscriptionBundle '${input.subscriptionBundleId}' not found`,
|
|
5468
|
+
params: {
|
|
5469
|
+
subscriptionBundleId: input.subscriptionBundleId
|
|
5470
|
+
}
|
|
5471
|
+
});
|
|
5149
5472
|
}
|
|
5150
5473
|
if (existing.canceledAt !== null) {
|
|
5151
5474
|
throw new import_common35.UnprocessableEntityException({
|
|
5152
|
-
code:
|
|
5153
|
-
message: `SubscriptionBundle '${input.subscriptionBundleId}'
|
|
5475
|
+
code: import_types16.BILLING_ERROR_CODES.SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED,
|
|
5476
|
+
message: `SubscriptionBundle '${input.subscriptionBundleId}' is already cancelled.`,
|
|
5477
|
+
params: {
|
|
5478
|
+
subscriptionBundleId: input.subscriptionBundleId
|
|
5479
|
+
}
|
|
5154
5480
|
});
|
|
5155
5481
|
}
|
|
5156
5482
|
const canceledAt = input.canceledAt ?? /* @__PURE__ */ new Date();
|
|
@@ -5171,18 +5497,27 @@ var SubscriptionBundlesService = class {
|
|
|
5171
5497
|
async reactivateBundle(subscriptionBundleId) {
|
|
5172
5498
|
const existing = await this.repo.findById(subscriptionBundleId);
|
|
5173
5499
|
if (!existing) {
|
|
5174
|
-
throw new import_common35.NotFoundException(
|
|
5500
|
+
throw new import_common35.NotFoundException({
|
|
5501
|
+
code: import_types16.BILLING_ERROR_CODES.SUBSCRIPTION_BUNDLE_NOT_FOUND,
|
|
5502
|
+
message: `SubscriptionBundle '${subscriptionBundleId}' not found`,
|
|
5503
|
+
params: {
|
|
5504
|
+
subscriptionBundleId
|
|
5505
|
+
}
|
|
5506
|
+
});
|
|
5175
5507
|
}
|
|
5176
5508
|
if (existing.canceledAt === null) {
|
|
5177
5509
|
throw new import_common35.UnprocessableEntityException({
|
|
5178
|
-
code:
|
|
5179
|
-
message: `SubscriptionBundle '${subscriptionBundleId}'
|
|
5510
|
+
code: import_types16.BILLING_ERROR_CODES.SUBSCRIPTION_BUNDLE_NOT_CANCELLED,
|
|
5511
|
+
message: `SubscriptionBundle '${subscriptionBundleId}' is not cancelled.`,
|
|
5512
|
+
params: {
|
|
5513
|
+
subscriptionBundleId
|
|
5514
|
+
}
|
|
5180
5515
|
});
|
|
5181
5516
|
}
|
|
5182
5517
|
if (existing.canceledEffectiveAt !== null && existing.canceledEffectiveAt <= /* @__PURE__ */ new Date()) {
|
|
5183
5518
|
throw new import_common35.UnprocessableEntityException({
|
|
5184
|
-
code:
|
|
5185
|
-
message: "
|
|
5519
|
+
code: import_types16.BILLING_ERROR_CODES.SUBSCRIPTION_BUNDLE_CANCELLATION_EFFECTIVE,
|
|
5520
|
+
message: "Cancellation already in effect \u2014 book the bundle again."
|
|
5186
5521
|
});
|
|
5187
5522
|
}
|
|
5188
5523
|
return this.repo.reactivate(subscriptionBundleId);
|
|
@@ -5288,7 +5623,13 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
5288
5623
|
this.usageSnapshot.snapshot(tenantId)
|
|
5289
5624
|
]);
|
|
5290
5625
|
if (!sub) {
|
|
5291
|
-
throw new import_common36.NotFoundException(
|
|
5626
|
+
throw new import_common36.NotFoundException({
|
|
5627
|
+
code: import_types17.BILLING_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
5628
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
5629
|
+
params: {
|
|
5630
|
+
tenantId
|
|
5631
|
+
}
|
|
5632
|
+
});
|
|
5292
5633
|
}
|
|
5293
5634
|
const usage = {};
|
|
5294
5635
|
for (const key of /* @__PURE__ */ new Set([
|
|
@@ -5337,14 +5678,29 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
5337
5678
|
const userId = this.requireUserId(req);
|
|
5338
5679
|
const blockedTargets = this.blockedPlans?.asTarget ?? [];
|
|
5339
5680
|
if (blockedTargets.includes(dto.plan)) {
|
|
5340
|
-
throw new import_common36.ForbiddenException(
|
|
5681
|
+
throw new import_common36.ForbiddenException({
|
|
5682
|
+
code: import_types17.BILLING_ERROR_CODES.PLAN_NOT_SELF_SERVICE,
|
|
5683
|
+
message: `${dto.plan} is not activated via self-service.`,
|
|
5684
|
+
params: {
|
|
5685
|
+
planKey: dto.plan
|
|
5686
|
+
}
|
|
5687
|
+
});
|
|
5341
5688
|
}
|
|
5342
5689
|
const sub = await this.subscriptionUsage.findForTenant(tenantId);
|
|
5343
|
-
if (!sub)
|
|
5690
|
+
if (!sub) {
|
|
5691
|
+
throw new import_common36.NotFoundException({
|
|
5692
|
+
code: import_types17.BILLING_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
5693
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
5694
|
+
params: {
|
|
5695
|
+
tenantId
|
|
5696
|
+
}
|
|
5697
|
+
});
|
|
5698
|
+
}
|
|
5344
5699
|
const blockers = await this.planPreview.assertChangeAllowed(tenantId, dto.plan, dto.billingCycle);
|
|
5345
5700
|
if (blockers.length > 0) {
|
|
5346
5701
|
throw new import_common36.BadRequestException({
|
|
5347
|
-
|
|
5702
|
+
code: import_types17.BILLING_ERROR_CODES.PLAN_CHANGE_BLOCKED,
|
|
5703
|
+
message: "Plan change is blocked.",
|
|
5348
5704
|
blockers
|
|
5349
5705
|
});
|
|
5350
5706
|
}
|
|
@@ -5414,14 +5770,29 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
5414
5770
|
const warnings = [];
|
|
5415
5771
|
const blockedTargets = this.blockedPlans?.asTarget ?? [];
|
|
5416
5772
|
if (blockedTargets.includes(dto.plan)) {
|
|
5417
|
-
throw new import_common36.ForbiddenException(
|
|
5773
|
+
throw new import_common36.ForbiddenException({
|
|
5774
|
+
code: import_types17.BILLING_ERROR_CODES.PLAN_NOT_SELF_SERVICE,
|
|
5775
|
+
message: `${dto.plan} is not activated via self-service.`,
|
|
5776
|
+
params: {
|
|
5777
|
+
planKey: dto.plan
|
|
5778
|
+
}
|
|
5779
|
+
});
|
|
5418
5780
|
}
|
|
5419
5781
|
const sub = await this.subscriptionUsage.findForTenant(tenantId);
|
|
5420
|
-
if (!sub)
|
|
5782
|
+
if (!sub) {
|
|
5783
|
+
throw new import_common36.NotFoundException({
|
|
5784
|
+
code: import_types17.BILLING_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
5785
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
5786
|
+
params: {
|
|
5787
|
+
tenantId
|
|
5788
|
+
}
|
|
5789
|
+
});
|
|
5790
|
+
}
|
|
5421
5791
|
const blockers = await this.planPreview.assertChangeAllowed(tenantId, dto.plan, dto.billingCycle);
|
|
5422
5792
|
if (blockers.length > 0) {
|
|
5423
5793
|
throw new import_common36.BadRequestException({
|
|
5424
|
-
|
|
5794
|
+
code: import_types17.BILLING_ERROR_CODES.PLAN_CHANGE_BLOCKED,
|
|
5795
|
+
message: "Plan change during onboarding is blocked.",
|
|
5425
5796
|
blockers
|
|
5426
5797
|
});
|
|
5427
5798
|
}
|
|
@@ -5455,8 +5826,13 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
5455
5826
|
warnings.push(...this.collectPromoSkipReasons(dto.promoCode, sub));
|
|
5456
5827
|
}
|
|
5457
5828
|
} catch (err) {
|
|
5829
|
+
const reason = err instanceof Error ? err.message : null;
|
|
5458
5830
|
throw new import_common36.BadRequestException({
|
|
5459
|
-
|
|
5831
|
+
code: import_types17.BILLING_ERROR_CODES.ONBOARDING_CREATE_FAILED,
|
|
5832
|
+
message: reason ? `Onboarding creation failed: ${reason}` : "Onboarding creation failed",
|
|
5833
|
+
params: {
|
|
5834
|
+
reason
|
|
5835
|
+
}
|
|
5460
5836
|
});
|
|
5461
5837
|
}
|
|
5462
5838
|
} else {
|
|
@@ -5469,9 +5845,9 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
5469
5845
|
});
|
|
5470
5846
|
if (dto.promoCode) {
|
|
5471
5847
|
if (!this.promoCodes) {
|
|
5472
|
-
warnings.push("
|
|
5848
|
+
warnings.push("A promo code was sent, but PromoCodesModule is not loaded \u2014 the code was NOT redeemed.");
|
|
5473
5849
|
} else if (!sub.id) {
|
|
5474
|
-
warnings.push("
|
|
5850
|
+
warnings.push("A promo code was sent, but the adapter returns no SubscriptionUsageRecord.id \u2014 the code was NOT redeemed.");
|
|
5475
5851
|
} else {
|
|
5476
5852
|
try {
|
|
5477
5853
|
const redemption = await this.promoCodes.redeem({
|
|
@@ -5482,8 +5858,8 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
5482
5858
|
});
|
|
5483
5859
|
promoRedemption = this.toResponseRedemption(redemption, dto.promoCode);
|
|
5484
5860
|
} catch (err) {
|
|
5485
|
-
const message = err instanceof Error ? err.message : "
|
|
5486
|
-
warnings.push(`
|
|
5861
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
5862
|
+
warnings.push(`The promo code could not be redeemed: ${message}`);
|
|
5487
5863
|
}
|
|
5488
5864
|
}
|
|
5489
5865
|
}
|
|
@@ -5509,11 +5885,11 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
5509
5885
|
});
|
|
5510
5886
|
bundlesAdded += 1;
|
|
5511
5887
|
} catch (err) {
|
|
5512
|
-
warnings.push(`Bundle '${bundleVersionId}'
|
|
5888
|
+
warnings.push(`Bundle '${bundleVersionId}' could not be booked: ` + (err instanceof Error ? err.message : String(err)));
|
|
5513
5889
|
}
|
|
5514
5890
|
}
|
|
5515
5891
|
} else if (bundleVersionIds.length > 0 && !this.subscriptionBundles) {
|
|
5516
|
-
warnings.push("Bundle
|
|
5892
|
+
warnings.push("Bundle bookings were requested during onboarding, but SubscriptionBundleModule is not registered in the consumer. No bundles were created.");
|
|
5517
5893
|
}
|
|
5518
5894
|
return {
|
|
5519
5895
|
plan: planResult.plan,
|
|
@@ -5533,9 +5909,20 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
5533
5909
|
const tenantId = this.requireTenantId(req);
|
|
5534
5910
|
const userId = this.requireUserId(req);
|
|
5535
5911
|
const sub = await this.subscriptionUsage.findForTenant(tenantId);
|
|
5536
|
-
if (!sub)
|
|
5912
|
+
if (!sub) {
|
|
5913
|
+
throw new import_common36.NotFoundException({
|
|
5914
|
+
code: import_types17.BILLING_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
5915
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
5916
|
+
params: {
|
|
5917
|
+
tenantId
|
|
5918
|
+
}
|
|
5919
|
+
});
|
|
5920
|
+
}
|
|
5537
5921
|
if (!sub.pendingPlanVersion) {
|
|
5538
|
-
throw new import_common36.BadRequestException(
|
|
5922
|
+
throw new import_common36.BadRequestException({
|
|
5923
|
+
code: import_types17.BILLING_ERROR_CODES.NO_PENDING_PLAN_VERSION,
|
|
5924
|
+
message: "There is no pending plan version awaiting confirmation."
|
|
5925
|
+
});
|
|
5539
5926
|
}
|
|
5540
5927
|
const result = await this.subscriptionWrite.acceptPendingPlanVersion(tenantId, userId, /* @__PURE__ */ new Date());
|
|
5541
5928
|
if (!result.alreadyAccepted) {
|
|
@@ -5580,7 +5967,10 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
5580
5967
|
const resolver = this.tenantIdResolver ?? ((r) => r.user?.tenantId ?? null);
|
|
5581
5968
|
const tenantId = resolver(req);
|
|
5582
5969
|
if (!tenantId) {
|
|
5583
|
-
throw new import_common36.NotFoundException(
|
|
5970
|
+
throw new import_common36.NotFoundException({
|
|
5971
|
+
code: import_types17.AUTH_ERROR_CODES.TENANT_CONTEXT_MISSING,
|
|
5972
|
+
message: "No tenant ID found on the request"
|
|
5973
|
+
});
|
|
5584
5974
|
}
|
|
5585
5975
|
return tenantId;
|
|
5586
5976
|
}
|
|
@@ -5588,7 +5978,10 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
5588
5978
|
const resolver = this.userIdResolver ?? ((r) => r.user?.sub ?? r.user?.id ?? null);
|
|
5589
5979
|
const userId = resolver(req);
|
|
5590
5980
|
if (!userId) {
|
|
5591
|
-
throw new import_common36.NotFoundException(
|
|
5981
|
+
throw new import_common36.NotFoundException({
|
|
5982
|
+
code: import_types17.AUTH_ERROR_CODES.TENANT_CONTEXT_MISSING,
|
|
5983
|
+
message: "No user ID found on the request"
|
|
5984
|
+
});
|
|
5592
5985
|
}
|
|
5593
5986
|
return userId;
|
|
5594
5987
|
}
|
|
@@ -5628,10 +6021,10 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
5628
6021
|
collectPromoSkipReasons(promoCode, sub) {
|
|
5629
6022
|
const reasons = [];
|
|
5630
6023
|
if (!this.promoCodes) {
|
|
5631
|
-
reasons.push(`Promo-Code "${promoCode}"
|
|
6024
|
+
reasons.push(`Promo-Code "${promoCode}" was sent, but PromoCodesModule is not loaded \u2014 the code was NOT redeemed.`);
|
|
5632
6025
|
}
|
|
5633
6026
|
if (!sub.id) {
|
|
5634
|
-
reasons.push(`Promo-Code "${promoCode}"
|
|
6027
|
+
reasons.push(`Promo-Code "${promoCode}" was sent, but the adapter returns no SubscriptionUsageRecord.id \u2014 the code was NOT redeemed.`);
|
|
5635
6028
|
}
|
|
5636
6029
|
return reasons;
|
|
5637
6030
|
}
|
|
@@ -5646,7 +6039,7 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
5646
6039
|
try {
|
|
5647
6040
|
await this.contractFreeze.freezeOnPlanChange(tenantId, plan, cycle, /* @__PURE__ */ new Date());
|
|
5648
6041
|
} catch (err) {
|
|
5649
|
-
this.logger.error(`Contract
|
|
6042
|
+
this.logger.error(`Contract freeze after plan change failed (tenant ${tenantId}): ${String(err)}`);
|
|
5650
6043
|
}
|
|
5651
6044
|
}
|
|
5652
6045
|
/**
|
|
@@ -5957,7 +6350,7 @@ __name(normalizeQuotas, "normalizeQuotas");
|
|
|
5957
6350
|
|
|
5958
6351
|
// src/billing/subscription-bundle-preview.service.ts
|
|
5959
6352
|
var import_common39 = require("@nestjs/common");
|
|
5960
|
-
var
|
|
6353
|
+
var import_types18 = require("@saasicat/types");
|
|
5961
6354
|
function _ts_decorate39(decorators, target, key, desc) {
|
|
5962
6355
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
5963
6356
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -5996,7 +6389,13 @@ var SubscriptionBundlePreviewService = class {
|
|
|
5996
6389
|
async previewAdd(ctx, input, now = /* @__PURE__ */ new Date()) {
|
|
5997
6390
|
const bundleVersion = await this.bundles.findVersionById(input.bundleVersionId);
|
|
5998
6391
|
if (!bundleVersion) {
|
|
5999
|
-
throw new import_common39.NotFoundException(
|
|
6392
|
+
throw new import_common39.NotFoundException({
|
|
6393
|
+
code: import_types18.CATALOG_ERROR_CODES.BUNDLE_VERSION_NOT_FOUND,
|
|
6394
|
+
message: `BundleVersion '${input.bundleVersionId}' not found`,
|
|
6395
|
+
params: {
|
|
6396
|
+
bundleVersionId: input.bundleVersionId
|
|
6397
|
+
}
|
|
6398
|
+
});
|
|
6000
6399
|
}
|
|
6001
6400
|
const blockers = [];
|
|
6002
6401
|
const warnings = [];
|
|
@@ -6005,7 +6404,7 @@ var SubscriptionBundlePreviewService = class {
|
|
|
6005
6404
|
if (activeBundleVersions.some((bv) => bv.id === bundleVersion.id)) {
|
|
6006
6405
|
blockers.push({
|
|
6007
6406
|
code: "BUNDLE_ALREADY_SUBSCRIBED",
|
|
6008
|
-
message: "
|
|
6407
|
+
message: "This bundle is already actively booked."
|
|
6009
6408
|
});
|
|
6010
6409
|
}
|
|
6011
6410
|
const planFeatures = new Set(await this.resolvePlanFeatures(ctx.currentPlanKey, now));
|
|
@@ -6013,14 +6412,14 @@ var SubscriptionBundlePreviewService = class {
|
|
|
6013
6412
|
if (redundantFeatures.length > 0) {
|
|
6014
6413
|
warnings.push({
|
|
6015
6414
|
code: "REDUNDANT_FEATURES",
|
|
6016
|
-
message: `${redundantFeatures.length}
|
|
6415
|
+
message: `${redundantFeatures.length} feature${redundantFeatures.length === 1 ? " is" : "s are"} already included in the plan or another booked bundle \u2014 the bundle would be paid for twice.`
|
|
6017
6416
|
});
|
|
6018
6417
|
}
|
|
6019
6418
|
const missingRequires = await this.collectMissingRequires(bundleVersion, planFeatures, activeBundleVersions);
|
|
6020
6419
|
if (missingRequires.length > 0) {
|
|
6021
6420
|
blockers.push({
|
|
6022
6421
|
code: "BUNDLE_FEATURE_DEPENDENCY_UNSATISFIED",
|
|
6023
|
-
message: `
|
|
6422
|
+
message: `The bundle requires [${missingRequires.join(", ")}] \u2014 present neither in the plan nor in the active bundles.`
|
|
6024
6423
|
});
|
|
6025
6424
|
}
|
|
6026
6425
|
const priceNet = resolveBundlePriceNet(bundleVersion, ctx.currentPlanKey, ctx.billingCycle);
|
|
@@ -6049,18 +6448,30 @@ var SubscriptionBundlePreviewService = class {
|
|
|
6049
6448
|
async previewCancel(ctx, input, now = /* @__PURE__ */ new Date()) {
|
|
6050
6449
|
const existing = await this.subscriptionBundles.findById(input.subscriptionBundleId);
|
|
6051
6450
|
if (!existing || existing.subscriptionId !== ctx.subscriptionId) {
|
|
6052
|
-
throw new import_common39.NotFoundException(
|
|
6451
|
+
throw new import_common39.NotFoundException({
|
|
6452
|
+
code: import_types18.BILLING_ERROR_CODES.SUBSCRIPTION_BUNDLE_NOT_FOUND,
|
|
6453
|
+
message: `SubscriptionBundle '${input.subscriptionBundleId}' not found`,
|
|
6454
|
+
params: {
|
|
6455
|
+
subscriptionBundleId: input.subscriptionBundleId
|
|
6456
|
+
}
|
|
6457
|
+
});
|
|
6053
6458
|
}
|
|
6054
6459
|
const bundleVersion = await this.bundles.findVersionById(existing.bundleVersionId);
|
|
6055
6460
|
if (!bundleVersion) {
|
|
6056
|
-
throw new import_common39.NotFoundException(
|
|
6461
|
+
throw new import_common39.NotFoundException({
|
|
6462
|
+
code: import_types18.CATALOG_ERROR_CODES.BUNDLE_VERSION_NOT_FOUND,
|
|
6463
|
+
message: `BundleVersion '${existing.bundleVersionId}' not found`,
|
|
6464
|
+
params: {
|
|
6465
|
+
bundleVersionId: existing.bundleVersionId
|
|
6466
|
+
}
|
|
6467
|
+
});
|
|
6057
6468
|
}
|
|
6058
6469
|
const blockers = [];
|
|
6059
6470
|
const warnings = [];
|
|
6060
6471
|
if (existing.canceledAt !== null) {
|
|
6061
6472
|
blockers.push({
|
|
6062
|
-
code:
|
|
6063
|
-
message: "
|
|
6473
|
+
code: import_types18.BILLING_ERROR_CODES.SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED,
|
|
6474
|
+
message: "This bundle booking is already cancelled."
|
|
6064
6475
|
});
|
|
6065
6476
|
}
|
|
6066
6477
|
const effectiveAt = resolveBundleCancelEffectiveAt({
|
|
@@ -6072,7 +6483,7 @@ var SubscriptionBundlePreviewService = class {
|
|
|
6072
6483
|
if (existing.minimumTermEndsAt && existing.minimumTermEndsAt.getTime() > periodEnd.getTime()) {
|
|
6073
6484
|
warnings.push({
|
|
6074
6485
|
code: "MINIMUM_TERM_BINDS",
|
|
6075
|
-
message: "
|
|
6486
|
+
message: "The minimum term extends beyond the end of the period \u2014 the cancellation only takes effect when the minimum term ends."
|
|
6076
6487
|
});
|
|
6077
6488
|
}
|
|
6078
6489
|
return {
|
|
@@ -6091,26 +6502,26 @@ var SubscriptionBundlePreviewService = class {
|
|
|
6091
6502
|
if (bundleVersion.publishedAt === null) {
|
|
6092
6503
|
blockers.push({
|
|
6093
6504
|
code: "BUNDLE_VERSION_NOT_PUBLISHED",
|
|
6094
|
-
message: "
|
|
6505
|
+
message: "This bundle version is not published and cannot be booked."
|
|
6095
6506
|
});
|
|
6096
6507
|
}
|
|
6097
6508
|
if (bundleVersion.supersededAt !== null) {
|
|
6098
6509
|
blockers.push({
|
|
6099
6510
|
code: "BUNDLE_VERSION_SUPERSEDED",
|
|
6100
|
-
message: "
|
|
6511
|
+
message: "This bundle version has been superseded by a newer one."
|
|
6101
6512
|
});
|
|
6102
6513
|
}
|
|
6103
6514
|
const planIds = bundleVersion.compatibility?.planIds ?? [];
|
|
6104
6515
|
if (planIds.length > 0 && !planIds.includes(currentPlanKey)) {
|
|
6105
6516
|
blockers.push({
|
|
6106
6517
|
code: "BUNDLE_INCOMPATIBLE_WITH_PLAN",
|
|
6107
|
-
message: `
|
|
6518
|
+
message: `The bundle is not compatible with plan '${currentPlanKey}'. Allowed: [${planIds.join(", ")}].`
|
|
6108
6519
|
});
|
|
6109
6520
|
}
|
|
6110
6521
|
if (this.blockedBundles?.bundleKeys?.includes(bundleVersion.bundleKey)) {
|
|
6111
6522
|
blockers.push({
|
|
6112
6523
|
code: "BUNDLE_NOT_SELF_SERVICE",
|
|
6113
|
-
message: `Bundle '${bundleVersion.bundleKey}'
|
|
6524
|
+
message: `Bundle '${bundleVersion.bundleKey}' is only activated via a special contract. Please contact the contract manager.`
|
|
6114
6525
|
});
|
|
6115
6526
|
}
|
|
6116
6527
|
}
|
|
@@ -6159,12 +6570,12 @@ var SubscriptionBundlePreviewService = class {
|
|
|
6159
6570
|
const entries = await this.catalogEntries.listFeatures({
|
|
6160
6571
|
projectKey
|
|
6161
6572
|
});
|
|
6162
|
-
const requiresIndex = (0,
|
|
6573
|
+
const requiresIndex = (0, import_types18.buildFeatureRequiresIndex)(entries);
|
|
6163
6574
|
const covered = /* @__PURE__ */ new Set([
|
|
6164
6575
|
...planFeatures,
|
|
6165
6576
|
...activeBundleVersions.flatMap((bv) => bv.features ?? [])
|
|
6166
6577
|
]);
|
|
6167
|
-
return (0,
|
|
6578
|
+
return (0, import_types18.collectUnsatisfiedRequires)(bundleVersion.features ?? [], requiresIndex).filter((key) => !covered.has(key));
|
|
6168
6579
|
}
|
|
6169
6580
|
/** `projectKey` lives on the bundle stem, not on the version. */
|
|
6170
6581
|
async resolveProjectKey(bundleVersion) {
|
|
@@ -6223,6 +6634,7 @@ var import_common41 = require("@nestjs/common");
|
|
|
6223
6634
|
|
|
6224
6635
|
// src/billing/tenant-subscription-bundles.controller.ts
|
|
6225
6636
|
var import_common40 = require("@nestjs/common");
|
|
6637
|
+
var import_types19 = require("@saasicat/types");
|
|
6226
6638
|
|
|
6227
6639
|
// src/billing/dto/subscription-bundles.dto.ts
|
|
6228
6640
|
var import_class_validator5 = require("class-validator");
|
|
@@ -6303,7 +6715,7 @@ var CancelSubscriptionBundleDto = class {
|
|
|
6303
6715
|
_ts_decorate40([
|
|
6304
6716
|
(0, import_class_validator5.IsOptional)(),
|
|
6305
6717
|
(0, import_class_validator5.Matches)(ISO_DATE_PATTERN, {
|
|
6306
|
-
message: "canceledAt
|
|
6718
|
+
message: "canceledAt must be an ISO date (YYYY-MM-DD)"
|
|
6307
6719
|
}),
|
|
6308
6720
|
_ts_metadata28("design:type", String)
|
|
6309
6721
|
], CancelSubscriptionBundleDto.prototype, "canceledAt", void 0);
|
|
@@ -6367,7 +6779,10 @@ function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
|
6367
6779
|
const hasAdd = Boolean(dto.bundleVersionId);
|
|
6368
6780
|
const hasCancel = Boolean(dto.subscriptionBundleId);
|
|
6369
6781
|
if (hasAdd === hasCancel) {
|
|
6370
|
-
throw new import_common40.BadRequestException(
|
|
6782
|
+
throw new import_common40.BadRequestException({
|
|
6783
|
+
code: import_types19.BILLING_ERROR_CODES.BUNDLE_PREVIEW_ARGUMENT_AMBIGUOUS,
|
|
6784
|
+
message: "Exactly one of bundleVersionId (add preview) or subscriptionBundleId (cancel preview) must be given."
|
|
6785
|
+
});
|
|
6371
6786
|
}
|
|
6372
6787
|
const sub = await this.requireSubscription(this.requireTenantId(req));
|
|
6373
6788
|
const ctx = {
|
|
@@ -6407,21 +6822,30 @@ function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
|
6407
6822
|
}
|
|
6408
6823
|
requireSubscriptionPk(sub) {
|
|
6409
6824
|
if (!sub.id) {
|
|
6410
|
-
throw new import_common40.NotFoundException(
|
|
6825
|
+
throw new import_common40.NotFoundException(codedError(import_types19.BILLING_ERROR_CODES.SUBSCRIPTION_PK_MISSING));
|
|
6411
6826
|
}
|
|
6412
6827
|
return sub.id;
|
|
6413
6828
|
}
|
|
6414
6829
|
requireTenantId(req) {
|
|
6415
6830
|
const tenantId = this.tenantIdResolver(req);
|
|
6416
6831
|
if (!tenantId) {
|
|
6417
|
-
throw new import_common40.NotFoundException(
|
|
6832
|
+
throw new import_common40.NotFoundException({
|
|
6833
|
+
code: import_types19.AUTH_ERROR_CODES.TENANT_CONTEXT_MISSING,
|
|
6834
|
+
message: "No tenant ID found on the request"
|
|
6835
|
+
});
|
|
6418
6836
|
}
|
|
6419
6837
|
return tenantId;
|
|
6420
6838
|
}
|
|
6421
6839
|
async requireSubscription(tenantId) {
|
|
6422
6840
|
const sub = await this.subscriptionUsage.findForTenant(tenantId);
|
|
6423
6841
|
if (!sub) {
|
|
6424
|
-
throw new import_common40.NotFoundException(
|
|
6842
|
+
throw new import_common40.NotFoundException({
|
|
6843
|
+
code: import_types19.BILLING_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
6844
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
6845
|
+
params: {
|
|
6846
|
+
tenantId
|
|
6847
|
+
}
|
|
6848
|
+
});
|
|
6425
6849
|
}
|
|
6426
6850
|
return sub;
|
|
6427
6851
|
}
|
|
@@ -6440,7 +6864,7 @@ function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
|
6440
6864
|
try {
|
|
6441
6865
|
await this.contractFreeze.freezeOnPlanChange(tenantId, sub.planVersion.planId, sub.billingCycle, /* @__PURE__ */ new Date());
|
|
6442
6866
|
} catch (err) {
|
|
6443
|
-
this.logger.error(`Contract
|
|
6867
|
+
this.logger.error(`Contract freeze after bundle mutation failed (tenant ${tenantId}): ${String(err)}`);
|
|
6444
6868
|
}
|
|
6445
6869
|
}
|
|
6446
6870
|
};
|
|
@@ -6659,7 +7083,7 @@ __export(catalog_exports, {
|
|
|
6659
7083
|
|
|
6660
7084
|
// src/catalog/bundles.service.ts
|
|
6661
7085
|
var import_common43 = require("@nestjs/common");
|
|
6662
|
-
var
|
|
7086
|
+
var import_types20 = require("@saasicat/types");
|
|
6663
7087
|
|
|
6664
7088
|
// src/discovery/discovery.scanner.ts
|
|
6665
7089
|
var import_node_crypto2 = require("crypto");
|
|
@@ -6728,7 +7152,7 @@ var DiscoveryScanner = class _DiscoveryScanner {
|
|
|
6728
7152
|
(0, import_node_fs2.writeFileSync)(path, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
|
|
6729
7153
|
this.logger.log(`Discovery snapshot persisted to ${path}`);
|
|
6730
7154
|
} catch (err) {
|
|
6731
|
-
this.logger.warn(`
|
|
7155
|
+
this.logger.warn(`The discovery snapshot could not be written to ${path}: ${err.message}`);
|
|
6732
7156
|
}
|
|
6733
7157
|
}
|
|
6734
7158
|
/**
|
|
@@ -6813,7 +7237,7 @@ var DiscoveryScanner = class _DiscoveryScanner {
|
|
|
6813
7237
|
registerCapability(target, meta, declaredAt) {
|
|
6814
7238
|
const existing = target.get(meta.capabilityKey);
|
|
6815
7239
|
if (existing) {
|
|
6816
|
-
this.logger.warn(`Capability '${meta.capabilityKey}'
|
|
7240
|
+
this.logger.warn(`Capability '${meta.capabilityKey}' is declared in several places (${existing.declaredAt} and ${declaredAt}); the first declaration wins`);
|
|
6817
7241
|
return;
|
|
6818
7242
|
}
|
|
6819
7243
|
target.set(meta.capabilityKey, {
|
|
@@ -6834,7 +7258,7 @@ var DiscoveryScanner = class _DiscoveryScanner {
|
|
|
6834
7258
|
registerQuota(target, opts, declaredAt) {
|
|
6835
7259
|
const existing = target.get(opts.key);
|
|
6836
7260
|
if (existing) {
|
|
6837
|
-
this.logger.warn(`Quota '${opts.key}'
|
|
7261
|
+
this.logger.warn(`Quota '${opts.key}' is defined in several places (${existing.declaredAt} and ${declaredAt}); the first definition wins`);
|
|
6838
7262
|
return;
|
|
6839
7263
|
}
|
|
6840
7264
|
target.set(opts.key, {
|
|
@@ -7013,7 +7437,7 @@ function dependencyWarnings(features, snapshot, code) {
|
|
|
7013
7437
|
if (selected.has(required)) continue;
|
|
7014
7438
|
warnings.push({
|
|
7015
7439
|
code,
|
|
7016
|
-
message: `Feature '${feature}'
|
|
7440
|
+
message: `Feature '${feature}' requires '${required}', which is missing from this selection. The tenant must cover that dependency some other way (for example through the plan) \u2014 otherwise the feature will not work.`,
|
|
7017
7441
|
field: `features[${index}]`,
|
|
7018
7442
|
value: required
|
|
7019
7443
|
});
|
|
@@ -7032,7 +7456,7 @@ function validateBundleDraft(draft, snapshot, knownPlanKeys = null, marketedOnly
|
|
|
7032
7456
|
if (approved && !approved.features.has(feature)) {
|
|
7033
7457
|
warnings.push({
|
|
7034
7458
|
code: "BUNDLE_FEATURE_NOT_APPROVED",
|
|
7035
|
-
message: `Feature '${feature}'
|
|
7459
|
+
message: `Feature '${feature}' is not approved (status != approved). Only approved features are sellable \u2014 approve it in the discovery review (#20).`,
|
|
7036
7460
|
field: `features[${index}]`,
|
|
7037
7461
|
value: feature
|
|
7038
7462
|
});
|
|
@@ -7041,7 +7465,7 @@ function validateBundleDraft(draft, snapshot, knownPlanKeys = null, marketedOnly
|
|
|
7041
7465
|
}
|
|
7042
7466
|
warnings.push({
|
|
7043
7467
|
code: "BUNDLE_FEATURE_UNKNOWN",
|
|
7044
|
-
message: `Feature '${feature}'
|
|
7468
|
+
message: `Feature '${feature}' was not found in the discovery snapshot. Either the code is missing an @ImplementsCapability(..., { feature: '${feature}' }) annotation, or the feature is obsolete.`,
|
|
7045
7469
|
field: `features[${index}]`,
|
|
7046
7470
|
value: feature
|
|
7047
7471
|
});
|
|
@@ -7050,14 +7474,14 @@ function validateBundleDraft(draft, snapshot, knownPlanKeys = null, marketedOnly
|
|
|
7050
7474
|
if (!knownQuotas.has(quotaKey)) {
|
|
7051
7475
|
warnings.push({
|
|
7052
7476
|
code: "QUOTA_MISSING",
|
|
7053
|
-
message: `Quota '${quotaKey}'
|
|
7477
|
+
message: `Quota '${quotaKey}' was not found in the discovery snapshot. In code, a class must be annotated with @DefinesQuota({ key: '${quotaKey}', ... }).`,
|
|
7054
7478
|
field: `quotas.${quotaKey}`,
|
|
7055
7479
|
value: quotaKey
|
|
7056
7480
|
});
|
|
7057
7481
|
} else if (approved && !approved.quotas.has(quotaKey)) {
|
|
7058
7482
|
warnings.push({
|
|
7059
7483
|
code: "QUOTA_NOT_APPROVED",
|
|
7060
|
-
message: `Quota '${quotaKey}'
|
|
7484
|
+
message: `Quota '${quotaKey}' is not approved (status != approved). Only approved quotas are sellable \u2014 approve it in the discovery review (#20).`,
|
|
7061
7485
|
field: `quotas.${quotaKey}`,
|
|
7062
7486
|
value: quotaKey
|
|
7063
7487
|
});
|
|
@@ -7069,7 +7493,7 @@ function validateBundleDraft(draft, snapshot, knownPlanKeys = null, marketedOnly
|
|
|
7069
7493
|
if (knownPlanKeys.has(planKey)) return;
|
|
7070
7494
|
warnings.push({
|
|
7071
7495
|
code: "BUNDLE_PLAN_KEY_UNKNOWN",
|
|
7072
|
-
message: `Plan '${planKey}'
|
|
7496
|
+
message: `Plan '${planKey}' from compatibility.planIds does not exist in the project. Either fix the planKey, or create the plan record first.`,
|
|
7073
7497
|
field: `compatibility.planIds[${index}]`,
|
|
7074
7498
|
value: planKey
|
|
7075
7499
|
});
|
|
@@ -7089,7 +7513,7 @@ function validatePlanDraft(draft, snapshot, marketedOnlyFeatures = /* @__PURE__
|
|
|
7089
7513
|
if (approved && !approved.features.has(feature)) {
|
|
7090
7514
|
warnings.push({
|
|
7091
7515
|
code: "PLAN_FEATURE_NOT_APPROVED",
|
|
7092
|
-
message: `Feature '${feature}'
|
|
7516
|
+
message: `Feature '${feature}' is not approved (status != approved). Only approved features are sellable \u2014 approve it in the discovery review (#20).`,
|
|
7093
7517
|
field: `features[${index}]`,
|
|
7094
7518
|
value: feature
|
|
7095
7519
|
});
|
|
@@ -7098,7 +7522,7 @@ function validatePlanDraft(draft, snapshot, marketedOnlyFeatures = /* @__PURE__
|
|
|
7098
7522
|
}
|
|
7099
7523
|
warnings.push({
|
|
7100
7524
|
code: "PLAN_FEATURE_UNKNOWN",
|
|
7101
|
-
message: `Feature '${feature}'
|
|
7525
|
+
message: `Feature '${feature}' was not found in the discovery snapshot. Either the code is missing an @ImplementsCapability(..., { feature: '${feature}' }) annotation, or the feature is obsolete.`,
|
|
7102
7526
|
field: `features[${index}]`,
|
|
7103
7527
|
value: feature
|
|
7104
7528
|
});
|
|
@@ -7107,14 +7531,14 @@ function validatePlanDraft(draft, snapshot, marketedOnlyFeatures = /* @__PURE__
|
|
|
7107
7531
|
if (!knownQuotas.has(quotaKey)) {
|
|
7108
7532
|
warnings.push({
|
|
7109
7533
|
code: "QUOTA_MISSING",
|
|
7110
|
-
message: `Quota '${quotaKey}'
|
|
7534
|
+
message: `Quota '${quotaKey}' was not found in the discovery snapshot. In code, a class must be annotated with @DefinesQuota({ key: '${quotaKey}', ... }).`,
|
|
7111
7535
|
field: `quotas.${quotaKey}`,
|
|
7112
7536
|
value: quotaKey
|
|
7113
7537
|
});
|
|
7114
7538
|
} else if (approved && !approved.quotas.has(quotaKey)) {
|
|
7115
7539
|
warnings.push({
|
|
7116
7540
|
code: "QUOTA_NOT_APPROVED",
|
|
7117
|
-
message: `Quota '${quotaKey}'
|
|
7541
|
+
message: `Quota '${quotaKey}' is not approved (status != approved). Only approved quotas are sellable \u2014 approve it in the discovery review (#20).`,
|
|
7118
7542
|
field: `quotas.${quotaKey}`,
|
|
7119
7543
|
value: quotaKey
|
|
7120
7544
|
});
|
|
@@ -7166,7 +7590,7 @@ var BundlesService = class _BundlesService {
|
|
|
7166
7590
|
this.mode = config.strictModeCheckMode ?? "blocking";
|
|
7167
7591
|
this.marketedOnly = new Set(config.marketedOnlyFeatures ?? []);
|
|
7168
7592
|
if (this.mode === "blocking" && !hasDiscoverySnapshotSource(this.snapshot, this.scanner)) {
|
|
7169
|
-
this.logger.error("BundlesService: strictModeCheckMode=blocking
|
|
7593
|
+
this.logger.error("BundlesService: strictModeCheckMode=blocking without a DiscoverySnapshot source \u2014 degrading to warn-only. Wire DiscoveryModule so that enforcement takes effect (#25).");
|
|
7170
7594
|
this.mode = "warn-only";
|
|
7171
7595
|
}
|
|
7172
7596
|
}
|
|
@@ -7181,7 +7605,15 @@ var BundlesService = class _BundlesService {
|
|
|
7181
7605
|
}
|
|
7182
7606
|
async getBundle(bundleId) {
|
|
7183
7607
|
const bundle = await this.repo.findById(bundleId);
|
|
7184
|
-
if (!bundle)
|
|
7608
|
+
if (!bundle) {
|
|
7609
|
+
throw new import_common43.NotFoundException({
|
|
7610
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_NOT_FOUND,
|
|
7611
|
+
message: `Bundle '${bundleId}' not found`,
|
|
7612
|
+
params: {
|
|
7613
|
+
bundleId
|
|
7614
|
+
}
|
|
7615
|
+
});
|
|
7616
|
+
}
|
|
7185
7617
|
const versions = await this.annotateEditability(await this.repo.listVersions(bundleId));
|
|
7186
7618
|
return {
|
|
7187
7619
|
bundle,
|
|
@@ -7191,18 +7623,41 @@ var BundlesService = class _BundlesService {
|
|
|
7191
7623
|
async createBundle(data) {
|
|
7192
7624
|
const existing = await this.repo.findByKey(data.projectKey, data.bundleKey);
|
|
7193
7625
|
if (existing) {
|
|
7194
|
-
throw new import_common43.UnprocessableEntityException(
|
|
7626
|
+
throw new import_common43.UnprocessableEntityException({
|
|
7627
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_ALREADY_EXISTS,
|
|
7628
|
+
message: `Bundle '${data.bundleKey}' already exists in project '${data.projectKey}'`,
|
|
7629
|
+
params: {
|
|
7630
|
+
bundleKey: data.bundleKey,
|
|
7631
|
+
projectKey: data.projectKey
|
|
7632
|
+
}
|
|
7633
|
+
});
|
|
7195
7634
|
}
|
|
7196
7635
|
return this.repo.create(data);
|
|
7197
7636
|
}
|
|
7198
7637
|
async updateBundle(bundleId, data) {
|
|
7199
7638
|
const existing = await this.repo.findById(bundleId);
|
|
7200
|
-
if (!existing)
|
|
7639
|
+
if (!existing) {
|
|
7640
|
+
throw new import_common43.NotFoundException({
|
|
7641
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_NOT_FOUND,
|
|
7642
|
+
message: `Bundle '${bundleId}' not found`,
|
|
7643
|
+
params: {
|
|
7644
|
+
bundleId
|
|
7645
|
+
}
|
|
7646
|
+
});
|
|
7647
|
+
}
|
|
7201
7648
|
return this.repo.update(bundleId, data);
|
|
7202
7649
|
}
|
|
7203
7650
|
async softDeleteBundle(bundleId) {
|
|
7204
7651
|
const existing = await this.repo.findById(bundleId);
|
|
7205
|
-
if (!existing)
|
|
7652
|
+
if (!existing) {
|
|
7653
|
+
throw new import_common43.NotFoundException({
|
|
7654
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_NOT_FOUND,
|
|
7655
|
+
message: `Bundle '${bundleId}' not found`,
|
|
7656
|
+
params: {
|
|
7657
|
+
bundleId
|
|
7658
|
+
}
|
|
7659
|
+
});
|
|
7660
|
+
}
|
|
7206
7661
|
if (existing.deletedAt !== null) return;
|
|
7207
7662
|
await this.repo.softDelete(bundleId);
|
|
7208
7663
|
}
|
|
@@ -7214,18 +7669,27 @@ var BundlesService = class _BundlesService {
|
|
|
7214
7669
|
async discardBundleDraft(versionId) {
|
|
7215
7670
|
const existing = await this.repo.findVersionById(versionId);
|
|
7216
7671
|
if (!existing) {
|
|
7217
|
-
throw new import_common43.NotFoundException(
|
|
7672
|
+
throw new import_common43.NotFoundException({
|
|
7673
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_NOT_FOUND,
|
|
7674
|
+
message: `BundleVersion '${versionId}' not found`,
|
|
7675
|
+
params: {
|
|
7676
|
+
bundleVersionId: versionId
|
|
7677
|
+
}
|
|
7678
|
+
});
|
|
7218
7679
|
}
|
|
7219
7680
|
if (existing.publishedAt !== null) {
|
|
7220
7681
|
throw new import_common43.UnprocessableEntityException({
|
|
7221
|
-
code:
|
|
7222
|
-
message: `BundleVersion '${versionId}'
|
|
7682
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_ALREADY_PUBLISHED,
|
|
7683
|
+
message: `BundleVersion '${versionId}' is already published and cannot be discarded.`,
|
|
7684
|
+
params: {
|
|
7685
|
+
versionId
|
|
7686
|
+
}
|
|
7223
7687
|
});
|
|
7224
7688
|
}
|
|
7225
7689
|
if (typeof this.repo.deleteDraft !== "function") {
|
|
7226
7690
|
throw new import_common43.UnprocessableEntityException({
|
|
7227
|
-
code:
|
|
7228
|
-
message: "Discard
|
|
7691
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_DISCARD_NOT_IMPLEMENTED,
|
|
7692
|
+
message: "Discard is not implemented in the current repository. Implement BundleRepository.deleteDraft."
|
|
7229
7693
|
});
|
|
7230
7694
|
}
|
|
7231
7695
|
await this.repo.deleteDraft(versionId);
|
|
@@ -7239,7 +7703,13 @@ var BundlesService = class _BundlesService {
|
|
|
7239
7703
|
async getBundleVersion(versionId) {
|
|
7240
7704
|
const version = await this.repo.findVersionById(versionId);
|
|
7241
7705
|
if (!version) {
|
|
7242
|
-
throw new import_common43.NotFoundException(
|
|
7706
|
+
throw new import_common43.NotFoundException({
|
|
7707
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_NOT_FOUND,
|
|
7708
|
+
message: `BundleVersion '${versionId}' not found`,
|
|
7709
|
+
params: {
|
|
7710
|
+
bundleVersionId: versionId
|
|
7711
|
+
}
|
|
7712
|
+
});
|
|
7243
7713
|
}
|
|
7244
7714
|
const [annotated] = await this.annotateEditability([
|
|
7245
7715
|
version
|
|
@@ -7281,11 +7751,24 @@ var BundlesService = class _BundlesService {
|
|
|
7281
7751
|
async createBundleDraft(data) {
|
|
7282
7752
|
const bundle = await this.repo.findById(data.bundleId);
|
|
7283
7753
|
if (!bundle) {
|
|
7284
|
-
throw new import_common43.NotFoundException(
|
|
7754
|
+
throw new import_common43.NotFoundException({
|
|
7755
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_NOT_FOUND,
|
|
7756
|
+
message: `Bundle '${data.bundleId}' not found`,
|
|
7757
|
+
params: {
|
|
7758
|
+
bundleId: data.bundleId
|
|
7759
|
+
}
|
|
7760
|
+
});
|
|
7285
7761
|
}
|
|
7286
7762
|
const existingDraft = await this.repo.findCurrentDraft(data.bundleId);
|
|
7287
7763
|
if (existingDraft) {
|
|
7288
|
-
throw new import_common43.UnprocessableEntityException(
|
|
7764
|
+
throw new import_common43.UnprocessableEntityException({
|
|
7765
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_DRAFT_ALREADY_EXISTS,
|
|
7766
|
+
message: `Bundle '${bundle.bundleKey}' already has a draft version v${existingDraft.version}; publish or discard it first`,
|
|
7767
|
+
params: {
|
|
7768
|
+
bundleKey: bundle.bundleKey,
|
|
7769
|
+
draftVersion: existingDraft.version
|
|
7770
|
+
}
|
|
7771
|
+
});
|
|
7289
7772
|
}
|
|
7290
7773
|
if (data.baseVersionId === void 0) {
|
|
7291
7774
|
const latestLive = await this.repo.findLatestLive(data.bundleId);
|
|
@@ -7317,16 +7800,25 @@ var BundlesService = class _BundlesService {
|
|
|
7317
7800
|
async updateBundleDraft(versionId, data) {
|
|
7318
7801
|
const existing = await this.repo.findVersionById(versionId);
|
|
7319
7802
|
if (!existing) {
|
|
7320
|
-
throw new import_common43.NotFoundException(
|
|
7803
|
+
throw new import_common43.NotFoundException({
|
|
7804
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_NOT_FOUND,
|
|
7805
|
+
message: `BundleVersion '${versionId}' not found`,
|
|
7806
|
+
params: {
|
|
7807
|
+
bundleVersionId: versionId
|
|
7808
|
+
}
|
|
7809
|
+
});
|
|
7321
7810
|
}
|
|
7322
7811
|
const [annotated] = await this.annotateEditability([
|
|
7323
7812
|
existing
|
|
7324
7813
|
]);
|
|
7325
|
-
const { editable } = (0,
|
|
7814
|
+
const { editable } = (0, import_types20.isVersionEditable)(annotated);
|
|
7326
7815
|
if (!editable) {
|
|
7327
7816
|
throw new import_common43.UnprocessableEntityException({
|
|
7328
|
-
code:
|
|
7329
|
-
message: `BundleVersion '${versionId}'
|
|
7817
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_NOT_EDITABLE,
|
|
7818
|
+
message: `BundleVersion '${versionId}' is not editable. Only drafts and published versions are editable that are latest-in-chain, bind no subscription yet, and whose validFrom lies in the future.`,
|
|
7819
|
+
params: {
|
|
7820
|
+
versionId
|
|
7821
|
+
}
|
|
7330
7822
|
});
|
|
7331
7823
|
}
|
|
7332
7824
|
const merged = {
|
|
@@ -7357,10 +7849,22 @@ var BundlesService = class _BundlesService {
|
|
|
7357
7849
|
async publishBundleVersion(versionId, publishMeta) {
|
|
7358
7850
|
const draft = await this.repo.findVersionById(versionId);
|
|
7359
7851
|
if (!draft) {
|
|
7360
|
-
throw new import_common43.NotFoundException(
|
|
7852
|
+
throw new import_common43.NotFoundException({
|
|
7853
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_NOT_FOUND,
|
|
7854
|
+
message: `BundleVersion '${versionId}' not found`,
|
|
7855
|
+
params: {
|
|
7856
|
+
bundleVersionId: versionId
|
|
7857
|
+
}
|
|
7858
|
+
});
|
|
7361
7859
|
}
|
|
7362
7860
|
if (draft.publishedAt !== null) {
|
|
7363
|
-
throw new import_common43.UnprocessableEntityException(
|
|
7861
|
+
throw new import_common43.UnprocessableEntityException({
|
|
7862
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_ALREADY_PUBLISHED,
|
|
7863
|
+
message: `BundleVersion '${versionId}' is already published`,
|
|
7864
|
+
params: {
|
|
7865
|
+
versionId
|
|
7866
|
+
}
|
|
7867
|
+
});
|
|
7364
7868
|
}
|
|
7365
7869
|
const bundle = await this.repo.findById(draft.bundleId);
|
|
7366
7870
|
const warnings = await this.runStrictCheck({
|
|
@@ -7373,8 +7877,8 @@ var BundlesService = class _BundlesService {
|
|
|
7373
7877
|
const explicitZero = /* @__PURE__ */ __name((v) => v !== null && v !== void 0 && Number.parseFloat(String(v)) <= 0, "explicitZero");
|
|
7374
7878
|
if (explicitZero(draft.monthlyNet) || explicitZero(draft.yearlyNet)) {
|
|
7375
7879
|
throw new import_common43.UnprocessableEntityException({
|
|
7376
|
-
code:
|
|
7377
|
-
message: "
|
|
7880
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_ZERO_PRICE,
|
|
7881
|
+
message: "A bundle version cannot be published with an explicit price of 0.00 (guard against seed placeholders). For free bundles leave it null, or set allowZeroPrice.",
|
|
7378
7882
|
monthlyNet: draft.monthlyNet,
|
|
7379
7883
|
yearlyNet: draft.yearlyNet
|
|
7380
7884
|
});
|
|
@@ -7384,23 +7888,30 @@ var BundlesService = class _BundlesService {
|
|
|
7384
7888
|
const validFromInput = publishMeta.validFrom ?? draft.validFrom;
|
|
7385
7889
|
if (!validFromInput) {
|
|
7386
7890
|
throw new import_common43.UnprocessableEntityException({
|
|
7387
|
-
code:
|
|
7388
|
-
message: "
|
|
7891
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_VALID_FROM_REQUIRED,
|
|
7892
|
+
message: "validFrom must be set when publishing (on the draft or the publish call). SPEC_V2 \xA74.2."
|
|
7389
7893
|
});
|
|
7390
7894
|
}
|
|
7391
7895
|
const validFrom = new Date(validFromInput);
|
|
7392
7896
|
if (Number.isNaN(validFrom.getTime())) {
|
|
7393
7897
|
throw new import_common43.UnprocessableEntityException({
|
|
7394
|
-
code:
|
|
7395
|
-
message: `validFrom '${validFromInput}'
|
|
7898
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_VALID_FROM_INVALID,
|
|
7899
|
+
message: `validFrom '${validFromInput}' is not a valid date`,
|
|
7900
|
+
params: {
|
|
7901
|
+
validFrom: validFromInput
|
|
7902
|
+
}
|
|
7396
7903
|
});
|
|
7397
7904
|
}
|
|
7398
7905
|
if (previous?.validFrom) {
|
|
7399
7906
|
const prevFrom = new Date(previous.validFrom);
|
|
7400
7907
|
if (validFrom <= prevFrom) {
|
|
7401
7908
|
throw new import_common43.UnprocessableEntityException({
|
|
7402
|
-
code:
|
|
7403
|
-
message: `validFrom (${validFrom.toISOString()})
|
|
7909
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_VALID_FROM_NOT_AFTER_PREVIOUS,
|
|
7910
|
+
message: `validFrom (${validFrom.toISOString()}) must be strictly after the validFrom of the previous version (${previous.validFrom}).`,
|
|
7911
|
+
params: {
|
|
7912
|
+
validFrom: validFrom.toISOString(),
|
|
7913
|
+
previousValidFrom: previous.validFrom
|
|
7914
|
+
}
|
|
7404
7915
|
});
|
|
7405
7916
|
}
|
|
7406
7917
|
if (previous.validUntil) {
|
|
@@ -7409,8 +7920,11 @@ var BundlesService = class _BundlesService {
|
|
|
7409
7920
|
const requiredStart = new Date(prevUntil.getTime() + dayMs);
|
|
7410
7921
|
if (validFrom.getTime() !== requiredStart.getTime()) {
|
|
7411
7922
|
throw new import_common43.UnprocessableEntityException({
|
|
7412
|
-
code:
|
|
7413
|
-
message: `
|
|
7923
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_VALID_FROM_NOT_GAPLESS,
|
|
7924
|
+
message: `The predecessor has validUntil=${previous.validUntil.slice(0, 10)} \u2014 the successor must start seamlessly on the next day (${requiredStart.toISOString().slice(0, 10)}). Received: ${validFrom.toISOString().slice(0, 10)}.`,
|
|
7925
|
+
params: {
|
|
7926
|
+
received: validFrom.toISOString().slice(0, 10)
|
|
7927
|
+
},
|
|
7414
7928
|
requiredValidFrom: requiredStart.toISOString().slice(0, 10),
|
|
7415
7929
|
previousValidUntil: previous.validUntil
|
|
7416
7930
|
});
|
|
@@ -7421,17 +7935,24 @@ var BundlesService = class _BundlesService {
|
|
|
7421
7935
|
const validUntil = validUntilInput ? new Date(validUntilInput) : null;
|
|
7422
7936
|
if (validUntil && Number.isNaN(validUntil.getTime())) {
|
|
7423
7937
|
throw new import_common43.UnprocessableEntityException({
|
|
7424
|
-
code:
|
|
7425
|
-
message: `validUntil '${validUntilInput}'
|
|
7938
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_VALID_UNTIL_INVALID,
|
|
7939
|
+
message: `validUntil '${validUntilInput}' is not a valid date`,
|
|
7940
|
+
params: {
|
|
7941
|
+
validUntil: validUntilInput
|
|
7942
|
+
}
|
|
7426
7943
|
});
|
|
7427
7944
|
}
|
|
7428
7945
|
if (validUntil && validUntil <= validFrom) {
|
|
7429
7946
|
throw new import_common43.UnprocessableEntityException({
|
|
7430
|
-
code:
|
|
7431
|
-
message: `validUntil (${validUntil.toISOString()})
|
|
7947
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_VALID_UNTIL_BEFORE_FROM,
|
|
7948
|
+
message: `validUntil (${validUntil.toISOString()}) must be strictly after validFrom (${validFrom.toISOString()}).`,
|
|
7949
|
+
params: {
|
|
7950
|
+
validUntil: validUntil.toISOString(),
|
|
7951
|
+
validFrom: validFrom.toISOString()
|
|
7952
|
+
}
|
|
7432
7953
|
});
|
|
7433
7954
|
}
|
|
7434
|
-
const diff = previous ? (0,
|
|
7955
|
+
const diff = previous ? (0, import_types5.classifyBundleVersionDiff)({
|
|
7435
7956
|
features: previous.features,
|
|
7436
7957
|
quotas: previous.quotas,
|
|
7437
7958
|
monthlyNet: previous.monthlyNet,
|
|
@@ -7447,8 +7968,8 @@ var BundlesService = class _BundlesService {
|
|
|
7447
7968
|
};
|
|
7448
7969
|
if (!diff.nonRegressive && !publishMeta.forceRegressive) {
|
|
7449
7970
|
throw new import_common43.UnprocessableEntityException({
|
|
7450
|
-
code:
|
|
7451
|
-
message: "
|
|
7971
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_REGRESSION,
|
|
7972
|
+
message: "This bundle version is regressive (feature removed / quota lowered / price raised). Publishing requires an explicit `forceRegressive: true` (UI confirmation dialog with MFA).",
|
|
7452
7973
|
changes: diff.changes
|
|
7453
7974
|
});
|
|
7454
7975
|
}
|
|
@@ -7487,8 +8008,8 @@ var BundlesService = class _BundlesService {
|
|
|
7487
8008
|
const hasHardBlockingWarning = blocking.some((w) => w.code === "BUNDLE_PLAN_KEY_UNKNOWN");
|
|
7488
8009
|
if (this.mode === "blocking" || hasHardBlockingWarning) {
|
|
7489
8010
|
throw new import_common43.UnprocessableEntityException({
|
|
7490
|
-
code:
|
|
7491
|
-
message: "
|
|
8011
|
+
code: import_types20.CATALOG_ERROR_CODES.STRICT_MODE_VIOLATIONS,
|
|
8012
|
+
message: "The strict-mode check found drift against the discovery snapshot.",
|
|
7492
8013
|
warnings
|
|
7493
8014
|
});
|
|
7494
8015
|
}
|
|
@@ -7501,8 +8022,8 @@ var BundlesService = class _BundlesService {
|
|
|
7501
8022
|
if (!validFromInput) {
|
|
7502
8023
|
if (existing.publishedAt !== null) {
|
|
7503
8024
|
throw new import_common43.UnprocessableEntityException({
|
|
7504
|
-
code:
|
|
7505
|
-
message: "Published
|
|
8025
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_VALID_FROM_REQUIRED,
|
|
8026
|
+
message: "Published bundle versions must keep a validFrom. Set a new future date, or edit the draft before publishing."
|
|
7506
8027
|
});
|
|
7507
8028
|
}
|
|
7508
8029
|
return;
|
|
@@ -7510,14 +8031,21 @@ var BundlesService = class _BundlesService {
|
|
|
7510
8031
|
const validFrom = this.parseRequiredBundleDate(validFromInput, "validFrom", "BUNDLE_VERSION_VALID_FROM_INVALID");
|
|
7511
8032
|
if (existing.publishedAt !== null && validFrom.getTime() <= Date.now()) {
|
|
7512
8033
|
throw new import_common43.UnprocessableEntityException({
|
|
7513
|
-
code:
|
|
7514
|
-
message: `validFrom (${validFrom.toISOString()})
|
|
8034
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_VALID_FROM_NOT_FUTURE,
|
|
8035
|
+
message: `validFrom (${validFrom.toISOString()}) must, for a published-but-future bundle version, still lie in the future.`,
|
|
8036
|
+
params: {
|
|
8037
|
+
validFrom: validFrom.toISOString()
|
|
8038
|
+
}
|
|
7515
8039
|
});
|
|
7516
8040
|
}
|
|
7517
8041
|
if (validUntil && validUntil <= validFrom) {
|
|
7518
8042
|
throw new import_common43.UnprocessableEntityException({
|
|
7519
|
-
code:
|
|
7520
|
-
message: `validUntil (${validUntil.toISOString()})
|
|
8043
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_VALID_UNTIL_BEFORE_FROM,
|
|
8044
|
+
message: `validUntil (${validUntil.toISOString()}) must be strictly after validFrom (${validFrom.toISOString()}).`,
|
|
8045
|
+
params: {
|
|
8046
|
+
validUntil: validUntil.toISOString(),
|
|
8047
|
+
validFrom: validFrom.toISOString()
|
|
8048
|
+
}
|
|
7521
8049
|
});
|
|
7522
8050
|
}
|
|
7523
8051
|
const previous = await this.findPreviousBundleVersion(existing);
|
|
@@ -7526,8 +8054,12 @@ var BundlesService = class _BundlesService {
|
|
|
7526
8054
|
if (Number.isNaN(previousValidFrom.getTime())) return;
|
|
7527
8055
|
if (validFrom <= previousValidFrom) {
|
|
7528
8056
|
throw new import_common43.UnprocessableEntityException({
|
|
7529
|
-
code:
|
|
7530
|
-
message: `validFrom (${validFrom.toISOString()})
|
|
8057
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_VALID_FROM_NOT_AFTER_PREVIOUS,
|
|
8058
|
+
message: `validFrom (${validFrom.toISOString()}) must be strictly after the validFrom of the previous version (${previous.validFrom}).`,
|
|
8059
|
+
params: {
|
|
8060
|
+
validFrom: validFrom.toISOString(),
|
|
8061
|
+
previousValidFrom: previous.validFrom
|
|
8062
|
+
}
|
|
7531
8063
|
});
|
|
7532
8064
|
}
|
|
7533
8065
|
if (!previous.validUntil) return;
|
|
@@ -7537,8 +8069,11 @@ var BundlesService = class _BundlesService {
|
|
|
7537
8069
|
const requiredStart = new Date(previousValidUntil.getTime() + dayMs);
|
|
7538
8070
|
if (validFrom.getTime() !== requiredStart.getTime()) {
|
|
7539
8071
|
throw new import_common43.UnprocessableEntityException({
|
|
7540
|
-
code:
|
|
7541
|
-
message: `
|
|
8072
|
+
code: import_types20.CATALOG_ERROR_CODES.BUNDLE_VERSION_VALID_FROM_NOT_GAPLESS,
|
|
8073
|
+
message: `The predecessor has validUntil=${previous.validUntil.slice(0, 10)} \u2014 the successor must start seamlessly on the next day (${requiredStart.toISOString().slice(0, 10)}). Received: ${validFrom.toISOString().slice(0, 10)}.`,
|
|
8074
|
+
params: {
|
|
8075
|
+
received: validFrom.toISOString().slice(0, 10)
|
|
8076
|
+
},
|
|
7542
8077
|
requiredValidFrom: requiredStart.toISOString().slice(0, 10),
|
|
7543
8078
|
previousValidUntil: previous.validUntil
|
|
7544
8079
|
});
|
|
@@ -7557,7 +8092,12 @@ var BundlesService = class _BundlesService {
|
|
|
7557
8092
|
if (Number.isNaN(date.getTime())) {
|
|
7558
8093
|
throw new import_common43.UnprocessableEntityException({
|
|
7559
8094
|
code,
|
|
7560
|
-
message: `${field} '${input}'
|
|
8095
|
+
message: `${field} '${input}' is not a valid date`,
|
|
8096
|
+
// Keyed by the field name so these params match the publish
|
|
8097
|
+
// path, where the same codes carry `validFrom` / `validUntil`.
|
|
8098
|
+
params: {
|
|
8099
|
+
[field]: input
|
|
8100
|
+
}
|
|
7561
8101
|
});
|
|
7562
8102
|
}
|
|
7563
8103
|
return date;
|
|
@@ -7592,6 +8132,7 @@ BundlesService = _ts_decorate44([
|
|
|
7592
8132
|
|
|
7593
8133
|
// src/catalog/marketing-projections.service.ts
|
|
7594
8134
|
var import_common44 = require("@nestjs/common");
|
|
8135
|
+
var import_types21 = require("@saasicat/types");
|
|
7595
8136
|
function _ts_decorate45(decorators, target, key, desc) {
|
|
7596
8137
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
7597
8138
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -7623,7 +8164,13 @@ var MarketingProjectionsService = class {
|
|
|
7623
8164
|
async getById(id) {
|
|
7624
8165
|
const row = await this.repo.findById(id);
|
|
7625
8166
|
if (!row) {
|
|
7626
|
-
throw new import_common44.NotFoundException(
|
|
8167
|
+
throw new import_common44.NotFoundException({
|
|
8168
|
+
code: import_types21.CATALOG_ERROR_CODES.MARKETING_PROJECTION_NOT_FOUND,
|
|
8169
|
+
message: `MarketingProjection '${id}' not found`,
|
|
8170
|
+
params: {
|
|
8171
|
+
projectionId: id
|
|
8172
|
+
}
|
|
8173
|
+
});
|
|
7627
8174
|
}
|
|
7628
8175
|
return row;
|
|
7629
8176
|
}
|
|
@@ -7631,7 +8178,15 @@ var MarketingProjectionsService = class {
|
|
|
7631
8178
|
const locale = data.locale ?? "de";
|
|
7632
8179
|
const existing = await this.repo.findByTarget(data.targetType, data.targetVersionId, locale);
|
|
7633
8180
|
if (existing) {
|
|
7634
|
-
throw new import_common44.ConflictException(
|
|
8181
|
+
throw new import_common44.ConflictException({
|
|
8182
|
+
code: import_types21.CATALOG_ERROR_CODES.MARKETING_PROJECTION_ALREADY_EXISTS,
|
|
8183
|
+
message: `Marketing projection for ${data.targetType}/${data.targetVersionId}/${locale} already exists \u2014 use PATCH to edit it`,
|
|
8184
|
+
params: {
|
|
8185
|
+
targetType: data.targetType,
|
|
8186
|
+
targetVersionId: data.targetVersionId,
|
|
8187
|
+
locale
|
|
8188
|
+
}
|
|
8189
|
+
});
|
|
7635
8190
|
}
|
|
7636
8191
|
return this.repo.create({
|
|
7637
8192
|
...data,
|
|
@@ -7641,14 +8196,26 @@ var MarketingProjectionsService = class {
|
|
|
7641
8196
|
async update(id, data) {
|
|
7642
8197
|
const existing = await this.repo.findById(id);
|
|
7643
8198
|
if (!existing) {
|
|
7644
|
-
throw new import_common44.NotFoundException(
|
|
8199
|
+
throw new import_common44.NotFoundException({
|
|
8200
|
+
code: import_types21.CATALOG_ERROR_CODES.MARKETING_PROJECTION_NOT_FOUND,
|
|
8201
|
+
message: `MarketingProjection '${id}' not found`,
|
|
8202
|
+
params: {
|
|
8203
|
+
projectionId: id
|
|
8204
|
+
}
|
|
8205
|
+
});
|
|
7645
8206
|
}
|
|
7646
8207
|
return this.repo.update(id, data);
|
|
7647
8208
|
}
|
|
7648
8209
|
async delete(id) {
|
|
7649
8210
|
const existing = await this.repo.findById(id);
|
|
7650
8211
|
if (!existing) {
|
|
7651
|
-
throw new import_common44.NotFoundException(
|
|
8212
|
+
throw new import_common44.NotFoundException({
|
|
8213
|
+
code: import_types21.CATALOG_ERROR_CODES.MARKETING_PROJECTION_NOT_FOUND,
|
|
8214
|
+
message: `MarketingProjection '${id}' not found`,
|
|
8215
|
+
params: {
|
|
8216
|
+
projectionId: id
|
|
8217
|
+
}
|
|
8218
|
+
});
|
|
7652
8219
|
}
|
|
7653
8220
|
await this.repo.delete(id);
|
|
7654
8221
|
}
|
|
@@ -7664,6 +8231,7 @@ MarketingProjectionsService = _ts_decorate45([
|
|
|
7664
8231
|
|
|
7665
8232
|
// src/catalog/catalog-entries.service.ts
|
|
7666
8233
|
var import_common45 = require("@nestjs/common");
|
|
8234
|
+
var import_types22 = require("@saasicat/types");
|
|
7667
8235
|
|
|
7668
8236
|
// src/catalog/approval-signature.ts
|
|
7669
8237
|
function featureApprovalSignature(featureKey, snapshot) {
|
|
@@ -7776,14 +8344,14 @@ var CatalogEntriesService = class _CatalogEntriesService {
|
|
|
7776
8344
|
if (!this.autoSyncAtBoot) return;
|
|
7777
8345
|
const snapshot = resolveDiscoverySnapshot(this.snapshot, this.scanner);
|
|
7778
8346
|
if (!snapshot) {
|
|
7779
|
-
this.logger.warn("Discovery-
|
|
8347
|
+
this.logger.warn("Discovery auto-sync skipped: no snapshot (neither token nor DiscoveryScanner injected). Wire up DiscoveryModule (#25).");
|
|
7780
8348
|
return;
|
|
7781
8349
|
}
|
|
7782
8350
|
try {
|
|
7783
8351
|
const result = await this.syncFromSnapshot(snapshot);
|
|
7784
8352
|
this.logger.log(`Discovery-Auto-Sync (${snapshot.app.key}): Features ${result.features.discovered}\u2191/${result.features.retired}\u2193/${result.features.outdated}\u26A0, Quotas ${result.quotas.discovered}\u2191/${result.quotas.retired}\u2193/${result.quotas.outdated}\u26A0, Capabilities ${result.capabilities.discovered}\u2191/${result.capabilities.retired}\u2193`);
|
|
7785
8353
|
} catch (err) {
|
|
7786
|
-
this.logger.error(`Discovery-
|
|
8354
|
+
this.logger.error(`Discovery auto-sync on boot failed (${snapshot.app.key}) \u2014 the catalog may be stale; re-sync manually via POST /admin/catalog/discovery/sync`, err instanceof Error ? err.stack : String(err));
|
|
7787
8355
|
}
|
|
7788
8356
|
}
|
|
7789
8357
|
listCapabilities(projectKey, codeStatus) {
|
|
@@ -7813,7 +8381,14 @@ var CatalogEntriesService = class _CatalogEntriesService {
|
|
|
7813
8381
|
async reviewFeature(projectKey, featureKey, data, reviewedBy) {
|
|
7814
8382
|
const existing = await this.repo.findFeature(projectKey, featureKey);
|
|
7815
8383
|
if (!existing) {
|
|
7816
|
-
throw new import_common45.NotFoundException(
|
|
8384
|
+
throw new import_common45.NotFoundException({
|
|
8385
|
+
code: import_types22.CATALOG_ERROR_CODES.FEATURE_NOT_FOUND,
|
|
8386
|
+
message: `Feature '${featureKey}' not found in project '${projectKey}'`,
|
|
8387
|
+
params: {
|
|
8388
|
+
featureKey,
|
|
8389
|
+
projectKey
|
|
8390
|
+
}
|
|
8391
|
+
});
|
|
7817
8392
|
}
|
|
7818
8393
|
return this.repo.setFeatureReview(projectKey, featureKey, this.resolveReviewUpdate(existing, data.discoveryStatus, reviewedBy, (snapshot) => featureApprovalSignature(featureKey, snapshot)));
|
|
7819
8394
|
}
|
|
@@ -7821,24 +8396,47 @@ var CatalogEntriesService = class _CatalogEntriesService {
|
|
|
7821
8396
|
async reviewQuota(projectKey, quotaKey, data, reviewedBy) {
|
|
7822
8397
|
const existing = await this.repo.findQuota(projectKey, quotaKey);
|
|
7823
8398
|
if (!existing) {
|
|
7824
|
-
throw new import_common45.NotFoundException(
|
|
8399
|
+
throw new import_common45.NotFoundException({
|
|
8400
|
+
code: import_types22.CATALOG_ERROR_CODES.QUOTA_NOT_FOUND,
|
|
8401
|
+
message: `Quota '${quotaKey}' not found in project '${projectKey}'`,
|
|
8402
|
+
params: {
|
|
8403
|
+
quotaKey,
|
|
8404
|
+
projectKey
|
|
8405
|
+
}
|
|
8406
|
+
});
|
|
7825
8407
|
}
|
|
7826
8408
|
return this.repo.setQuotaReview(projectKey, quotaKey, this.resolveReviewUpdate(existing, data.discoveryStatus, reviewedBy, (snapshot) => {
|
|
7827
8409
|
const discovered = snapshot.quotas.find((q) => q.quotaKey === quotaKey);
|
|
7828
8410
|
if (!discovered) {
|
|
7829
|
-
throw new import_common45.UnprocessableEntityException(
|
|
8411
|
+
throw new import_common45.UnprocessableEntityException({
|
|
8412
|
+
code: import_types22.CATALOG_ERROR_CODES.QUOTA_NOT_IN_DISCOVERY_SNAPSHOT,
|
|
8413
|
+
message: `Quota '${quotaKey}' is not in the discovery snapshot \u2014 a quota that is not declared in code cannot be approved`,
|
|
8414
|
+
params: {
|
|
8415
|
+
quotaKey
|
|
8416
|
+
}
|
|
8417
|
+
});
|
|
7830
8418
|
}
|
|
7831
8419
|
return quotaApprovalSignature(discovered);
|
|
7832
8420
|
}));
|
|
7833
8421
|
}
|
|
7834
8422
|
resolveReviewUpdate(existing, target, reviewedBy, signatureOf) {
|
|
7835
8423
|
if (!REVIEW_TRANSITIONS[existing.discoveryStatus].includes(target)) {
|
|
7836
|
-
throw new import_common45.UnprocessableEntityException(
|
|
8424
|
+
throw new import_common45.UnprocessableEntityException({
|
|
8425
|
+
code: import_types22.CATALOG_ERROR_CODES.DISCOVERY_STATUS_TRANSITION_INVALID,
|
|
8426
|
+
message: `Transition '${existing.discoveryStatus}' \u2192 '${target}' is not allowed`,
|
|
8427
|
+
params: {
|
|
8428
|
+
from: existing.discoveryStatus,
|
|
8429
|
+
to: target
|
|
8430
|
+
}
|
|
8431
|
+
});
|
|
7837
8432
|
}
|
|
7838
8433
|
if (target === "approved") {
|
|
7839
8434
|
const snapshot = resolveDiscoverySnapshot(this.snapshot, this.scanner);
|
|
7840
8435
|
if (!snapshot) {
|
|
7841
|
-
throw new import_common45.UnprocessableEntityException(
|
|
8436
|
+
throw new import_common45.UnprocessableEntityException({
|
|
8437
|
+
code: import_types22.CATALOG_ERROR_CODES.DISCOVERY_NOT_INITIALIZED,
|
|
8438
|
+
message: "Approval requires a discovery snapshot \u2014 discovery is not initialised (#25)"
|
|
8439
|
+
});
|
|
7842
8440
|
}
|
|
7843
8441
|
return {
|
|
7844
8442
|
discoveryStatus: "approved",
|
|
@@ -8031,7 +8629,7 @@ var CatalogEntriesService = class _CatalogEntriesService {
|
|
|
8031
8629
|
const claimants = claimantsByOldKey.get(entry.key);
|
|
8032
8630
|
if (claimants && claimants.length > 0) {
|
|
8033
8631
|
if (claimants.length > 1) {
|
|
8034
|
-
this.logger.warn(`${type} '${entry.key}'
|
|
8632
|
+
this.logger.warn(`${type} '${entry.key}' is claimed by several replaces declarations (${claimants.join(", ")}); '${claimants[0]}' wins \u2014 clean up the replaces in the code (#39)`);
|
|
8035
8633
|
}
|
|
8036
8634
|
target = claimants[0];
|
|
8037
8635
|
}
|
|
@@ -8044,7 +8642,7 @@ var CatalogEntriesService = class _CatalogEntriesService {
|
|
|
8044
8642
|
if (changes.length === 0) return 0;
|
|
8045
8643
|
const setSuccessor = type === "feature" ? this.repo.setFeatureSuccessor?.bind(this.repo) : this.repo.setQuotaSuccessor?.bind(this.repo);
|
|
8046
8644
|
if (!setSuccessor) {
|
|
8047
|
-
this.logger.warn(`CatalogEntryRepository
|
|
8645
|
+
this.logger.warn(`CatalogEntryRepository does not implement set${type === "feature" ? "Feature" : "Quota"}Successor \u2014 ${changes.length} successor pointers (#39) are not persisted`);
|
|
8048
8646
|
return 0;
|
|
8049
8647
|
}
|
|
8050
8648
|
let replaced = 0;
|
|
@@ -8212,7 +8810,7 @@ var ListCatalogEntriesQueryDto = class {
|
|
|
8212
8810
|
_ts_decorate48([
|
|
8213
8811
|
(0, import_class_validator6.IsString)(),
|
|
8214
8812
|
(0, import_class_validator6.Matches)(PROJECT_KEY_PATTERN, {
|
|
8215
|
-
message: "projectKey
|
|
8813
|
+
message: "projectKey must be kebab-case"
|
|
8216
8814
|
}),
|
|
8217
8815
|
_ts_metadata35("design:type", String)
|
|
8218
8816
|
], ListCatalogEntriesQueryDto.prototype, "projectKey", void 0);
|
|
@@ -8237,7 +8835,7 @@ var ReviewCatalogEntryDto = class {
|
|
|
8237
8835
|
_ts_decorate48([
|
|
8238
8836
|
(0, import_class_validator6.IsString)(),
|
|
8239
8837
|
(0, import_class_validator6.IsIn)(REVIEW_STATUSES, {
|
|
8240
|
-
message: "discoveryStatus
|
|
8838
|
+
message: "discoveryStatus must be pending|approved|outdated|obsolete"
|
|
8241
8839
|
}),
|
|
8242
8840
|
_ts_metadata35("design:type", typeof DiscoveryStatus === "undefined" ? Object : DiscoveryStatus)
|
|
8243
8841
|
], ReviewCatalogEntryDto.prototype, "discoveryStatus", void 0);
|
|
@@ -8538,6 +9136,7 @@ __name(buildCatalogEntriesController, "buildCatalogEntriesController");
|
|
|
8538
9136
|
|
|
8539
9137
|
// src/catalog/promotions.service.ts
|
|
8540
9138
|
var import_common48 = require("@nestjs/common");
|
|
9139
|
+
var import_types23 = require("@saasicat/types");
|
|
8541
9140
|
function _ts_decorate50(decorators, target, key, desc) {
|
|
8542
9141
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
8543
9142
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -8571,7 +9170,13 @@ var PromotionsService = class {
|
|
|
8571
9170
|
async getById(id) {
|
|
8572
9171
|
const row = await this.repo.findById(id);
|
|
8573
9172
|
if (!row) {
|
|
8574
|
-
throw new import_common48.NotFoundException(
|
|
9173
|
+
throw new import_common48.NotFoundException({
|
|
9174
|
+
code: import_types23.CATALOG_ERROR_CODES.PROMOTION_NOT_FOUND,
|
|
9175
|
+
message: `Promotion '${id}' not found`,
|
|
9176
|
+
params: {
|
|
9177
|
+
promotionId: id
|
|
9178
|
+
}
|
|
9179
|
+
});
|
|
8575
9180
|
}
|
|
8576
9181
|
return row;
|
|
8577
9182
|
}
|
|
@@ -8581,14 +9186,26 @@ var PromotionsService = class {
|
|
|
8581
9186
|
async update(id, data) {
|
|
8582
9187
|
const existing = await this.repo.findById(id);
|
|
8583
9188
|
if (!existing) {
|
|
8584
|
-
throw new import_common48.NotFoundException(
|
|
9189
|
+
throw new import_common48.NotFoundException({
|
|
9190
|
+
code: import_types23.CATALOG_ERROR_CODES.PROMOTION_NOT_FOUND,
|
|
9191
|
+
message: `Promotion '${id}' not found`,
|
|
9192
|
+
params: {
|
|
9193
|
+
promotionId: id
|
|
9194
|
+
}
|
|
9195
|
+
});
|
|
8585
9196
|
}
|
|
8586
9197
|
return this.repo.update(id, data);
|
|
8587
9198
|
}
|
|
8588
9199
|
async delete(id) {
|
|
8589
9200
|
const existing = await this.repo.findById(id);
|
|
8590
9201
|
if (!existing) {
|
|
8591
|
-
throw new import_common48.NotFoundException(
|
|
9202
|
+
throw new import_common48.NotFoundException({
|
|
9203
|
+
code: import_types23.CATALOG_ERROR_CODES.PROMOTION_NOT_FOUND,
|
|
9204
|
+
message: `Promotion '${id}' not found`,
|
|
9205
|
+
params: {
|
|
9206
|
+
promotionId: id
|
|
9207
|
+
}
|
|
9208
|
+
});
|
|
8592
9209
|
}
|
|
8593
9210
|
await this.repo.delete(id);
|
|
8594
9211
|
}
|
|
@@ -8645,7 +9262,7 @@ var ListPromotionsQueryDto = class {
|
|
|
8645
9262
|
_ts_decorate51([
|
|
8646
9263
|
(0, import_class_validator7.IsString)(),
|
|
8647
9264
|
(0, import_class_validator7.Matches)(PROJECT_KEY_PATTERN2, {
|
|
8648
|
-
message: "projectKey
|
|
9265
|
+
message: "projectKey must be kebab-case"
|
|
8649
9266
|
}),
|
|
8650
9267
|
_ts_metadata38("design:type", String)
|
|
8651
9268
|
], ListPromotionsQueryDto.prototype, "projectKey", void 0);
|
|
@@ -8713,14 +9330,14 @@ _ts_decorate51([
|
|
|
8713
9330
|
_ts_decorate51([
|
|
8714
9331
|
(0, import_class_validator7.IsString)(),
|
|
8715
9332
|
(0, import_class_validator7.Matches)(ISO_DATE_PATTERN2, {
|
|
8716
|
-
message: "validFrom
|
|
9333
|
+
message: "validFrom must be an ISO date"
|
|
8717
9334
|
}),
|
|
8718
9335
|
_ts_metadata38("design:type", String)
|
|
8719
9336
|
], CreatePromotionDto.prototype, "validFrom", void 0);
|
|
8720
9337
|
_ts_decorate51([
|
|
8721
9338
|
(0, import_class_validator7.IsString)(),
|
|
8722
9339
|
(0, import_class_validator7.Matches)(ISO_DATE_PATTERN2, {
|
|
8723
|
-
message: "validTo
|
|
9340
|
+
message: "validTo must be an ISO date"
|
|
8724
9341
|
}),
|
|
8725
9342
|
_ts_metadata38("design:type", String)
|
|
8726
9343
|
], CreatePromotionDto.prototype, "validTo", void 0);
|
|
@@ -9041,7 +9658,7 @@ var ListMarketingSettingsQueryDto = class {
|
|
|
9041
9658
|
_ts_decorate54([
|
|
9042
9659
|
(0, import_class_validator8.IsString)(),
|
|
9043
9660
|
(0, import_class_validator8.Matches)(PROJECT_KEY_PATTERN3, {
|
|
9044
|
-
message: "projectKey
|
|
9661
|
+
message: "projectKey must be kebab-case"
|
|
9045
9662
|
}),
|
|
9046
9663
|
_ts_metadata41("design:type", String)
|
|
9047
9664
|
], ListMarketingSettingsQueryDto.prototype, "projectKey", void 0);
|
|
@@ -9055,7 +9672,7 @@ var UpdateMarketingSettingsDto = class {
|
|
|
9055
9672
|
_ts_decorate54([
|
|
9056
9673
|
(0, import_class_validator8.IsString)(),
|
|
9057
9674
|
(0, import_class_validator8.Matches)(PROJECT_KEY_PATTERN3, {
|
|
9058
|
-
message: "projectKey
|
|
9675
|
+
message: "projectKey must be kebab-case"
|
|
9059
9676
|
}),
|
|
9060
9677
|
_ts_metadata41("design:type", String)
|
|
9061
9678
|
], UpdateMarketingSettingsDto.prototype, "projectKey", void 0);
|
|
@@ -9067,7 +9684,7 @@ _ts_decorate54([
|
|
|
9067
9684
|
}),
|
|
9068
9685
|
(0, import_class_validator8.Matches)(LOCALE_PATTERN, {
|
|
9069
9686
|
each: true,
|
|
9070
|
-
message: "Locale
|
|
9687
|
+
message: "Locale must be ISO-639-1"
|
|
9071
9688
|
}),
|
|
9072
9689
|
_ts_metadata41("design:type", Array)
|
|
9073
9690
|
], UpdateMarketingSettingsDto.prototype, "activeLocales", void 0);
|
|
@@ -9142,7 +9759,7 @@ __name(buildMarketingSettingsController, "buildMarketingSettingsController");
|
|
|
9142
9759
|
|
|
9143
9760
|
// src/catalog/public-marketing-catalog.service.ts
|
|
9144
9761
|
var import_common52 = require("@nestjs/common");
|
|
9145
|
-
var
|
|
9762
|
+
var import_types24 = require("@saasicat/types");
|
|
9146
9763
|
function _ts_decorate56(decorators, target, key, desc) {
|
|
9147
9764
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
9148
9765
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -9245,7 +9862,7 @@ var PublicMarketingCatalogService = class {
|
|
|
9245
9862
|
});
|
|
9246
9863
|
}
|
|
9247
9864
|
out.sort((a, b) => b.priority - a.priority);
|
|
9248
|
-
const requiresIndex = (0,
|
|
9865
|
+
const requiresIndex = (0, import_types24.buildFeatureRequiresIndex)([
|
|
9249
9866
|
...labelMeta.features
|
|
9250
9867
|
].map(([featureKey, meta]) => ({
|
|
9251
9868
|
featureKey,
|
|
@@ -9299,7 +9916,7 @@ var PublicMarketingCatalogService = class {
|
|
|
9299
9916
|
const features = [
|
|
9300
9917
|
...bv.features ?? []
|
|
9301
9918
|
];
|
|
9302
|
-
const requiresFeatures = (0,
|
|
9919
|
+
const requiresFeatures = (0, import_types24.collectUnsatisfiedRequires)(features, requiresIndex);
|
|
9303
9920
|
return {
|
|
9304
9921
|
bundleKey: bv.bundleKey,
|
|
9305
9922
|
label: marketing?.displayLabel || bv.label,
|
|
@@ -9443,12 +10060,12 @@ var PublicMarketingCatalogService = class {
|
|
|
9443
10060
|
};
|
|
9444
10061
|
}
|
|
9445
10062
|
resolvePromo(promotions, targetKey, targetType, locale, monthlyNet, yearlyNet, asOf) {
|
|
9446
|
-
const monthlyPromo = (0,
|
|
9447
|
-
const yearlyPromo = (0,
|
|
10063
|
+
const monthlyPromo = (0, import_types24.pickActivePromo)(promotions, targetKey, locale, "monthly", asOf, targetType);
|
|
10064
|
+
const yearlyPromo = (0, import_types24.pickActivePromo)(promotions, targetKey, locale, "yearly", asOf, targetType);
|
|
9448
10065
|
const promo = monthlyPromo ?? yearlyPromo;
|
|
9449
10066
|
if (!promo) return null;
|
|
9450
|
-
const monthlyResult = (0,
|
|
9451
|
-
const yearlyResult = (0,
|
|
10067
|
+
const monthlyResult = (0, import_types24.applyPromo)(monthlyPromo, monthlyNet);
|
|
10068
|
+
const yearlyResult = (0, import_types24.applyPromo)(yearlyPromo, yearlyNet);
|
|
9452
10069
|
const i18n = promo.i18n?.[locale] ?? promo.i18n?.[DEFAULT_LOCALE] ?? {};
|
|
9453
10070
|
return {
|
|
9454
10071
|
type: promo.type,
|
|
@@ -9555,6 +10172,7 @@ __name(parseAsOf, "parseAsOf");
|
|
|
9555
10172
|
|
|
9556
10173
|
// src/catalog/plans.service.ts
|
|
9557
10174
|
var import_common54 = require("@nestjs/common");
|
|
10175
|
+
var import_types25 = require("@saasicat/types");
|
|
9558
10176
|
function _ts_decorate58(decorators, target, key, desc) {
|
|
9559
10177
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
9560
10178
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -9601,24 +10219,55 @@ var PlansService = class {
|
|
|
9601
10219
|
}
|
|
9602
10220
|
async getPlan(planId) {
|
|
9603
10221
|
const plan = await this.repo.findById(planId);
|
|
9604
|
-
if (!plan)
|
|
10222
|
+
if (!plan) {
|
|
10223
|
+
throw new import_common54.NotFoundException({
|
|
10224
|
+
code: import_types25.CATALOG_ERROR_CODES.PLAN_NOT_FOUND,
|
|
10225
|
+
message: `Plan '${planId}' not found`,
|
|
10226
|
+
params: {
|
|
10227
|
+
planId
|
|
10228
|
+
}
|
|
10229
|
+
});
|
|
10230
|
+
}
|
|
9605
10231
|
return plan;
|
|
9606
10232
|
}
|
|
9607
10233
|
async createPlan(data) {
|
|
9608
10234
|
const existing = await this.repo.findByKey(data.projectKey, data.planKey);
|
|
9609
10235
|
if (existing) {
|
|
9610
|
-
throw new import_common54.UnprocessableEntityException(
|
|
10236
|
+
throw new import_common54.UnprocessableEntityException({
|
|
10237
|
+
code: import_types25.CATALOG_ERROR_CODES.PLAN_ALREADY_EXISTS,
|
|
10238
|
+
message: `Plan '${data.planKey}' already exists in project '${data.projectKey}'`,
|
|
10239
|
+
params: {
|
|
10240
|
+
planKey: data.planKey,
|
|
10241
|
+
projectKey: data.projectKey
|
|
10242
|
+
}
|
|
10243
|
+
});
|
|
9611
10244
|
}
|
|
9612
10245
|
return this.repo.create(data);
|
|
9613
10246
|
}
|
|
9614
10247
|
async updatePlan(planId, data) {
|
|
9615
10248
|
const existing = await this.repo.findById(planId);
|
|
9616
|
-
if (!existing)
|
|
10249
|
+
if (!existing) {
|
|
10250
|
+
throw new import_common54.NotFoundException({
|
|
10251
|
+
code: import_types25.CATALOG_ERROR_CODES.PLAN_NOT_FOUND,
|
|
10252
|
+
message: `Plan '${planId}' not found`,
|
|
10253
|
+
params: {
|
|
10254
|
+
planId
|
|
10255
|
+
}
|
|
10256
|
+
});
|
|
10257
|
+
}
|
|
9617
10258
|
return this.repo.update(planId, data);
|
|
9618
10259
|
}
|
|
9619
10260
|
async softDeletePlan(planId) {
|
|
9620
10261
|
const existing = await this.repo.findById(planId);
|
|
9621
|
-
if (!existing)
|
|
10262
|
+
if (!existing) {
|
|
10263
|
+
throw new import_common54.NotFoundException({
|
|
10264
|
+
code: import_types25.CATALOG_ERROR_CODES.PLAN_NOT_FOUND,
|
|
10265
|
+
message: `Plan '${planId}' not found`,
|
|
10266
|
+
params: {
|
|
10267
|
+
planId
|
|
10268
|
+
}
|
|
10269
|
+
});
|
|
10270
|
+
}
|
|
9622
10271
|
if (existing.deletedAt !== null) return;
|
|
9623
10272
|
await this.assertNoPublishedVersions(existing.planKey, "soft-delete");
|
|
9624
10273
|
await this.repo.softDelete(planId);
|
|
@@ -9635,13 +10284,21 @@ var PlansService = class {
|
|
|
9635
10284
|
*/
|
|
9636
10285
|
async hardDeletePlan(planId) {
|
|
9637
10286
|
const existing = await this.repo.findById(planId);
|
|
9638
|
-
if (!existing)
|
|
10287
|
+
if (!existing) {
|
|
10288
|
+
throw new import_common54.NotFoundException({
|
|
10289
|
+
code: import_types25.CATALOG_ERROR_CODES.PLAN_NOT_FOUND,
|
|
10290
|
+
message: `Plan '${planId}' not found`,
|
|
10291
|
+
params: {
|
|
10292
|
+
planId
|
|
10293
|
+
}
|
|
10294
|
+
});
|
|
10295
|
+
}
|
|
9639
10296
|
await this.assertNoPublishedVersions(existing.planKey, "hard-delete");
|
|
9640
10297
|
await this.assertNoDrafts(existing.planKey);
|
|
9641
10298
|
if (typeof this.repo.hardDelete !== "function") {
|
|
9642
10299
|
throw new import_common54.UnprocessableEntityException({
|
|
9643
|
-
code:
|
|
9644
|
-
message: "
|
|
10300
|
+
code: import_types25.CATALOG_ERROR_CODES.PLAN_HARD_DELETE_NOT_IMPLEMENTED,
|
|
10301
|
+
message: "Hard delete is not implemented in the current repository. Implement PlanRepository.hardDelete."
|
|
9645
10302
|
});
|
|
9646
10303
|
}
|
|
9647
10304
|
await this.repo.hardDelete(planId);
|
|
@@ -9661,8 +10318,12 @@ var PlansService = class {
|
|
|
9661
10318
|
const live = published.filter((v) => v.supersededAt === null).length;
|
|
9662
10319
|
const superseded = published.length - live;
|
|
9663
10320
|
throw new import_common54.UnprocessableEntityException({
|
|
9664
|
-
code:
|
|
9665
|
-
message: `Plan '${planKey}'
|
|
10321
|
+
code: import_types25.CATALOG_ERROR_CODES.PLAN_HAS_PUBLISHED_VERSIONS,
|
|
10322
|
+
message: `Plan '${planKey}' cannot be ${op === "hard-delete" ? "deleted" : "archived"} \u2014 it has ${published.length} published version(s) (${live} live, ${superseded} superseded). Existing subscriptions reference those versions (contract protection P1), so the plan record must be preserved.`,
|
|
10323
|
+
params: {
|
|
10324
|
+
planKey,
|
|
10325
|
+
operation: op
|
|
10326
|
+
},
|
|
9666
10327
|
publishedCount: published.length,
|
|
9667
10328
|
liveCount: live,
|
|
9668
10329
|
supersededCount: superseded
|
|
@@ -9674,8 +10335,11 @@ var PlansService = class {
|
|
|
9674
10335
|
const drafts = versions.filter((v) => v.publishedAt === null);
|
|
9675
10336
|
if (drafts.length === 0) return;
|
|
9676
10337
|
throw new import_common54.UnprocessableEntityException({
|
|
9677
|
-
code:
|
|
9678
|
-
message: `Plan '${planKey}'
|
|
10338
|
+
code: import_types25.CATALOG_ERROR_CODES.PLAN_HAS_DRAFTS,
|
|
10339
|
+
message: `Plan '${planKey}' still has ${drafts.length} open draft version(s). Discard them first (DELETE /admin/catalog/plan-versions/:id) or publish them.`,
|
|
10340
|
+
params: {
|
|
10341
|
+
planKey
|
|
10342
|
+
},
|
|
9679
10343
|
draftCount: drafts.length
|
|
9680
10344
|
});
|
|
9681
10345
|
}
|
|
@@ -9694,7 +10358,7 @@ PlansService = _ts_decorate58([
|
|
|
9694
10358
|
|
|
9695
10359
|
// src/catalog/plan-versions.service.ts
|
|
9696
10360
|
var import_common55 = require("@nestjs/common");
|
|
9697
|
-
var
|
|
10361
|
+
var import_types26 = require("@saasicat/types");
|
|
9698
10362
|
function _ts_decorate59(decorators, target, key, desc) {
|
|
9699
10363
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
9700
10364
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -9733,7 +10397,7 @@ var PlanVersionsService = class _PlanVersionsService {
|
|
|
9733
10397
|
this.mode = config.strictModeCheckMode ?? "blocking";
|
|
9734
10398
|
this.marketedOnly = new Set(config.marketedOnlyFeatures ?? []);
|
|
9735
10399
|
if (this.mode === "blocking" && !hasDiscoverySnapshotSource(this.snapshot, this.scanner)) {
|
|
9736
|
-
this.logger.error("PlanVersionsService: strictModeCheckMode=blocking
|
|
10400
|
+
this.logger.error("PlanVersionsService: strictModeCheckMode=blocking without a DiscoverySnapshot source \u2014 degrading to warn-only. Wire DiscoveryModule (#25).");
|
|
9737
10401
|
this.mode = "warn-only";
|
|
9738
10402
|
}
|
|
9739
10403
|
const required = [
|
|
@@ -9747,7 +10411,7 @@ var PlanVersionsService = class _PlanVersionsService {
|
|
|
9747
10411
|
];
|
|
9748
10412
|
for (const key of required) {
|
|
9749
10413
|
if (typeof this.repo[key] !== "function") {
|
|
9750
|
-
throw new Error(`PlanVersionsService: PlanRepository.${String(key)}
|
|
10414
|
+
throw new Error(`PlanVersionsService: PlanRepository.${String(key)} is missing \u2014 the repository does not implement the lifecycle methods.`);
|
|
9751
10415
|
}
|
|
9752
10416
|
}
|
|
9753
10417
|
}
|
|
@@ -9763,18 +10427,27 @@ var PlanVersionsService = class _PlanVersionsService {
|
|
|
9763
10427
|
async discardPlanDraft(versionId) {
|
|
9764
10428
|
const existing = await this.repo.findVersionById(versionId);
|
|
9765
10429
|
if (!existing) {
|
|
9766
|
-
throw new import_common55.NotFoundException(
|
|
10430
|
+
throw new import_common55.NotFoundException({
|
|
10431
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_NOT_FOUND,
|
|
10432
|
+
message: `PlanVersion '${versionId}' not found`,
|
|
10433
|
+
params: {
|
|
10434
|
+
versionId
|
|
10435
|
+
}
|
|
10436
|
+
});
|
|
9767
10437
|
}
|
|
9768
10438
|
if (existing.publishedAt !== null) {
|
|
9769
10439
|
throw new import_common55.UnprocessableEntityException({
|
|
9770
|
-
code:
|
|
9771
|
-
message: `PlanVersion '${versionId}'
|
|
10440
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_ALREADY_PUBLISHED,
|
|
10441
|
+
message: `PlanVersion '${versionId}' is already published and cannot be discarded.`,
|
|
10442
|
+
params: {
|
|
10443
|
+
versionId
|
|
10444
|
+
}
|
|
9772
10445
|
});
|
|
9773
10446
|
}
|
|
9774
10447
|
if (typeof this.repo.deletePlanVersionDraft !== "function") {
|
|
9775
10448
|
throw new import_common55.UnprocessableEntityException({
|
|
9776
|
-
code:
|
|
9777
|
-
message: "Discard
|
|
10449
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_DISCARD_NOT_IMPLEMENTED,
|
|
10450
|
+
message: "Discard is not implemented in the current repository. Implement PlanRepository.deletePlanVersionDraft."
|
|
9778
10451
|
});
|
|
9779
10452
|
}
|
|
9780
10453
|
await this.repo.deletePlanVersionDraft(versionId);
|
|
@@ -9787,7 +10460,13 @@ var PlanVersionsService = class _PlanVersionsService {
|
|
|
9787
10460
|
async listPlanVersions(planUuid) {
|
|
9788
10461
|
const plan = await this.repo.findById(planUuid);
|
|
9789
10462
|
if (!plan) {
|
|
9790
|
-
throw new import_common55.NotFoundException(
|
|
10463
|
+
throw new import_common55.NotFoundException({
|
|
10464
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_NOT_FOUND,
|
|
10465
|
+
message: `Plan '${planUuid}' not found`,
|
|
10466
|
+
params: {
|
|
10467
|
+
planId: planUuid
|
|
10468
|
+
}
|
|
10469
|
+
});
|
|
9791
10470
|
}
|
|
9792
10471
|
const versions = await this.repo.listVersions(plan.planKey);
|
|
9793
10472
|
return this.annotateEditability(versions);
|
|
@@ -9795,7 +10474,13 @@ var PlanVersionsService = class _PlanVersionsService {
|
|
|
9795
10474
|
async getPlanVersion(versionId) {
|
|
9796
10475
|
const version = await this.repo.findVersionById(versionId);
|
|
9797
10476
|
if (!version) {
|
|
9798
|
-
throw new import_common55.NotFoundException(
|
|
10477
|
+
throw new import_common55.NotFoundException({
|
|
10478
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_NOT_FOUND,
|
|
10479
|
+
message: `PlanVersion '${versionId}' not found`,
|
|
10480
|
+
params: {
|
|
10481
|
+
versionId
|
|
10482
|
+
}
|
|
10483
|
+
});
|
|
9799
10484
|
}
|
|
9800
10485
|
const [annotated] = await this.annotateEditability([
|
|
9801
10486
|
version
|
|
@@ -9833,12 +10518,25 @@ var PlanVersionsService = class _PlanVersionsService {
|
|
|
9833
10518
|
async createPlanDraft(data) {
|
|
9834
10519
|
const plan = await this.repo.findById(data.planId);
|
|
9835
10520
|
if (!plan) {
|
|
9836
|
-
throw new import_common55.NotFoundException(
|
|
10521
|
+
throw new import_common55.NotFoundException({
|
|
10522
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_NOT_FOUND,
|
|
10523
|
+
message: `Plan '${data.planId}' not found`,
|
|
10524
|
+
params: {
|
|
10525
|
+
planId: data.planId
|
|
10526
|
+
}
|
|
10527
|
+
});
|
|
9837
10528
|
}
|
|
9838
10529
|
const planKey = plan.planKey;
|
|
9839
10530
|
const existingDraft = await this.repo.findCurrentDraft(planKey);
|
|
9840
10531
|
if (existingDraft) {
|
|
9841
|
-
throw new import_common55.UnprocessableEntityException(
|
|
10532
|
+
throw new import_common55.UnprocessableEntityException({
|
|
10533
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_DRAFT_ALREADY_EXISTS,
|
|
10534
|
+
message: `Plan '${planKey}' already has a draft version v${existingDraft.version}; publish or discard it first`,
|
|
10535
|
+
params: {
|
|
10536
|
+
planKey,
|
|
10537
|
+
draftVersion: existingDraft.version
|
|
10538
|
+
}
|
|
10539
|
+
});
|
|
9842
10540
|
}
|
|
9843
10541
|
let baseVersionId = data.baseVersionId;
|
|
9844
10542
|
if (baseVersionId === void 0) {
|
|
@@ -9863,16 +10561,25 @@ var PlanVersionsService = class _PlanVersionsService {
|
|
|
9863
10561
|
async updatePlanDraft(versionId, data) {
|
|
9864
10562
|
const existing = await this.repo.findVersionById(versionId);
|
|
9865
10563
|
if (!existing) {
|
|
9866
|
-
throw new import_common55.NotFoundException(
|
|
10564
|
+
throw new import_common55.NotFoundException({
|
|
10565
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_NOT_FOUND,
|
|
10566
|
+
message: `PlanVersion '${versionId}' not found`,
|
|
10567
|
+
params: {
|
|
10568
|
+
versionId
|
|
10569
|
+
}
|
|
10570
|
+
});
|
|
9867
10571
|
}
|
|
9868
10572
|
const [annotated] = await this.annotateEditability([
|
|
9869
10573
|
existing
|
|
9870
10574
|
]);
|
|
9871
|
-
const { editable } = (0,
|
|
10575
|
+
const { editable } = (0, import_types26.isVersionEditable)(annotated);
|
|
9872
10576
|
if (!editable) {
|
|
9873
10577
|
throw new import_common55.UnprocessableEntityException({
|
|
9874
|
-
code:
|
|
9875
|
-
message: `PlanVersion '${versionId}'
|
|
10578
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_NOT_EDITABLE,
|
|
10579
|
+
message: `PlanVersion '${versionId}' is not editable. Only drafts and published versions are editable that are latest-in-chain, bind no subscription yet, and whose validFrom lies in the future.`,
|
|
10580
|
+
params: {
|
|
10581
|
+
versionId
|
|
10582
|
+
}
|
|
9876
10583
|
});
|
|
9877
10584
|
}
|
|
9878
10585
|
const merged = {
|
|
@@ -9890,10 +10597,22 @@ var PlanVersionsService = class _PlanVersionsService {
|
|
|
9890
10597
|
async publishPlanVersion(versionId, publishMeta) {
|
|
9891
10598
|
const draft = await this.repo.findVersionById(versionId);
|
|
9892
10599
|
if (!draft) {
|
|
9893
|
-
throw new import_common55.NotFoundException(
|
|
10600
|
+
throw new import_common55.NotFoundException({
|
|
10601
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_NOT_FOUND,
|
|
10602
|
+
message: `PlanVersion '${versionId}' not found`,
|
|
10603
|
+
params: {
|
|
10604
|
+
versionId
|
|
10605
|
+
}
|
|
10606
|
+
});
|
|
9894
10607
|
}
|
|
9895
10608
|
if (draft.publishedAt !== null) {
|
|
9896
|
-
throw new import_common55.UnprocessableEntityException(
|
|
10609
|
+
throw new import_common55.UnprocessableEntityException({
|
|
10610
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_ALREADY_PUBLISHED,
|
|
10611
|
+
message: `PlanVersion '${versionId}' is already published`,
|
|
10612
|
+
params: {
|
|
10613
|
+
versionId
|
|
10614
|
+
}
|
|
10615
|
+
});
|
|
9897
10616
|
}
|
|
9898
10617
|
const warnings = await this.runStrictCheck({
|
|
9899
10618
|
features: draft.features,
|
|
@@ -9905,8 +10624,8 @@ var PlanVersionsService = class _PlanVersionsService {
|
|
|
9905
10624
|
const yearly = Number.parseFloat(String(draft.yearlyNet ?? "0"));
|
|
9906
10625
|
if (monthly <= 0 || yearly <= 0) {
|
|
9907
10626
|
throw new import_common55.UnprocessableEntityException({
|
|
9908
|
-
code:
|
|
9909
|
-
message: "
|
|
10627
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_ZERO_PRICE,
|
|
10628
|
+
message: "A plan version cannot be published with a price of 0.00 (guard against seed placeholder). For deliberately free special contracts, set allowZeroPrice.",
|
|
9910
10629
|
monthlyNet: String(draft.monthlyNet),
|
|
9911
10630
|
yearlyNet: String(draft.yearlyNet)
|
|
9912
10631
|
});
|
|
@@ -9916,23 +10635,30 @@ var PlanVersionsService = class _PlanVersionsService {
|
|
|
9916
10635
|
const validFromInput = publishMeta.validFrom ?? draft.validFrom;
|
|
9917
10636
|
if (!validFromInput) {
|
|
9918
10637
|
throw new import_common55.UnprocessableEntityException({
|
|
9919
|
-
code:
|
|
9920
|
-
message: "
|
|
10638
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_VALID_FROM_REQUIRED,
|
|
10639
|
+
message: "validFrom must be set when publishing (on the draft or the publish call). SPEC_V2 \xA74.2."
|
|
9921
10640
|
});
|
|
9922
10641
|
}
|
|
9923
10642
|
const validFrom = new Date(validFromInput);
|
|
9924
10643
|
if (Number.isNaN(validFrom.getTime())) {
|
|
9925
10644
|
throw new import_common55.UnprocessableEntityException({
|
|
9926
|
-
code:
|
|
9927
|
-
message: `validFrom '${validFromInput}'
|
|
10645
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_VALID_FROM_INVALID,
|
|
10646
|
+
message: `validFrom '${validFromInput}' is not a valid date`,
|
|
10647
|
+
params: {
|
|
10648
|
+
validFrom: validFromInput
|
|
10649
|
+
}
|
|
9928
10650
|
});
|
|
9929
10651
|
}
|
|
9930
10652
|
if (previous?.validFrom) {
|
|
9931
10653
|
const prevFrom = new Date(previous.validFrom);
|
|
9932
10654
|
if (validFrom <= prevFrom) {
|
|
9933
10655
|
throw new import_common55.UnprocessableEntityException({
|
|
9934
|
-
code:
|
|
9935
|
-
message: `validFrom (${validFrom.toISOString()})
|
|
10656
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_VALID_FROM_NOT_AFTER_PREVIOUS,
|
|
10657
|
+
message: `validFrom (${validFrom.toISOString()}) must be strictly after the validFrom of the previous version (${previous.validFrom}).`,
|
|
10658
|
+
params: {
|
|
10659
|
+
validFrom: validFrom.toISOString(),
|
|
10660
|
+
previousValidFrom: previous.validFrom
|
|
10661
|
+
}
|
|
9936
10662
|
});
|
|
9937
10663
|
}
|
|
9938
10664
|
if (previous.validUntil) {
|
|
@@ -9941,8 +10667,11 @@ var PlanVersionsService = class _PlanVersionsService {
|
|
|
9941
10667
|
const requiredStart = new Date(prevUntil.getTime() + dayMs);
|
|
9942
10668
|
if (validFrom.getTime() !== requiredStart.getTime()) {
|
|
9943
10669
|
throw new import_common55.UnprocessableEntityException({
|
|
9944
|
-
code:
|
|
9945
|
-
message: `
|
|
10670
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_VALID_FROM_NOT_GAPLESS,
|
|
10671
|
+
message: `The predecessor has validUntil=${previous.validUntil.slice(0, 10)} \u2014 the successor must start seamlessly on the next day (${requiredStart.toISOString().slice(0, 10)}). Received: ${validFrom.toISOString().slice(0, 10)}.`,
|
|
10672
|
+
params: {
|
|
10673
|
+
received: validFrom.toISOString().slice(0, 10)
|
|
10674
|
+
},
|
|
9946
10675
|
requiredValidFrom: requiredStart.toISOString().slice(0, 10),
|
|
9947
10676
|
previousValidUntil: previous.validUntil
|
|
9948
10677
|
});
|
|
@@ -9953,14 +10682,21 @@ var PlanVersionsService = class _PlanVersionsService {
|
|
|
9953
10682
|
const validUntil = validUntilInput ? new Date(validUntilInput) : null;
|
|
9954
10683
|
if (validUntil && Number.isNaN(validUntil.getTime())) {
|
|
9955
10684
|
throw new import_common55.UnprocessableEntityException({
|
|
9956
|
-
code:
|
|
9957
|
-
message: `validUntil '${validUntilInput}'
|
|
10685
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_VALID_UNTIL_INVALID,
|
|
10686
|
+
message: `validUntil '${validUntilInput}' is not a valid date`,
|
|
10687
|
+
params: {
|
|
10688
|
+
validUntil: validUntilInput
|
|
10689
|
+
}
|
|
9958
10690
|
});
|
|
9959
10691
|
}
|
|
9960
10692
|
if (validUntil && validUntil <= validFrom) {
|
|
9961
10693
|
throw new import_common55.UnprocessableEntityException({
|
|
9962
|
-
code:
|
|
9963
|
-
message: `validUntil (${validUntil.toISOString()})
|
|
10694
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_VALID_UNTIL_BEFORE_FROM,
|
|
10695
|
+
message: `validUntil (${validUntil.toISOString()}) must be strictly after validFrom (${validFrom.toISOString()}).`,
|
|
10696
|
+
params: {
|
|
10697
|
+
validUntil: validUntil.toISOString(),
|
|
10698
|
+
validFrom: validFrom.toISOString()
|
|
10699
|
+
}
|
|
9964
10700
|
});
|
|
9965
10701
|
}
|
|
9966
10702
|
const fieldsFor = /* @__PURE__ */ __name((v) => ({
|
|
@@ -9971,14 +10707,14 @@ var PlanVersionsService = class _PlanVersionsService {
|
|
|
9971
10707
|
monthlyNet: v.monthlyNet,
|
|
9972
10708
|
yearlyNet: v.yearlyNet
|
|
9973
10709
|
}), "fieldsFor");
|
|
9974
|
-
const diff = previous ? (0,
|
|
10710
|
+
const diff = previous ? (0, import_types5.classifyPlanDiff)(fieldsFor(previous), fieldsFor(draft)) : {
|
|
9975
10711
|
changes: [],
|
|
9976
10712
|
nonRegressive: true
|
|
9977
10713
|
};
|
|
9978
10714
|
if (!diff.nonRegressive && !publishMeta.forceRegressive) {
|
|
9979
10715
|
throw new import_common55.UnprocessableEntityException({
|
|
9980
|
-
code:
|
|
9981
|
-
message: "
|
|
10716
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_REGRESSION,
|
|
10717
|
+
message: "This plan version is regressive (feature removed / quota lowered / price raised). Publishing requires an explicit `forceRegressive: true` (UI confirmation dialog with MFA).",
|
|
9982
10718
|
changes: diff.changes
|
|
9983
10719
|
});
|
|
9984
10720
|
}
|
|
@@ -10013,36 +10749,51 @@ var PlanVersionsService = class _PlanVersionsService {
|
|
|
10013
10749
|
async terminatePlanVersion(versionId, endsAt) {
|
|
10014
10750
|
const existing = await this.repo.findVersionById(versionId);
|
|
10015
10751
|
if (!existing) {
|
|
10016
|
-
throw new import_common55.NotFoundException(
|
|
10752
|
+
throw new import_common55.NotFoundException({
|
|
10753
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_NOT_FOUND,
|
|
10754
|
+
message: `PlanVersion '${versionId}' not found`,
|
|
10755
|
+
params: {
|
|
10756
|
+
versionId
|
|
10757
|
+
}
|
|
10758
|
+
});
|
|
10017
10759
|
}
|
|
10018
10760
|
if (existing.publishedAt === null) {
|
|
10019
10761
|
throw new import_common55.UnprocessableEntityException({
|
|
10020
|
-
code:
|
|
10021
|
-
message: `PlanVersion '${versionId}'
|
|
10762
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_NOT_PUBLISHED,
|
|
10763
|
+
message: `PlanVersion '${versionId}' is not published and cannot be terminated.`,
|
|
10764
|
+
params: {
|
|
10765
|
+
versionId
|
|
10766
|
+
}
|
|
10022
10767
|
});
|
|
10023
10768
|
}
|
|
10024
10769
|
if (existing.supersededAt !== null) {
|
|
10025
10770
|
throw new import_common55.UnprocessableEntityException({
|
|
10026
|
-
code:
|
|
10027
|
-
message: `PlanVersion '${versionId}'
|
|
10771
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_VERSION_SUPERSEDED,
|
|
10772
|
+
message: `PlanVersion '${versionId}' has already been superseded by a newer version (supersededAt) and cannot be terminated.`,
|
|
10773
|
+
params: {
|
|
10774
|
+
versionId
|
|
10775
|
+
}
|
|
10028
10776
|
});
|
|
10029
10777
|
}
|
|
10030
10778
|
if (Number.isNaN(endsAt.getTime())) {
|
|
10031
10779
|
throw new import_common55.UnprocessableEntityException({
|
|
10032
|
-
code:
|
|
10033
|
-
message: "endsAt
|
|
10780
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_TERMINATE_INVALID_DATE,
|
|
10781
|
+
message: "endsAt is not a valid date."
|
|
10034
10782
|
});
|
|
10035
10783
|
}
|
|
10036
10784
|
if (endsAt.getTime() <= Date.now()) {
|
|
10037
10785
|
throw new import_common55.UnprocessableEntityException({
|
|
10038
|
-
code:
|
|
10039
|
-
message: `endsAt (${endsAt.toISOString()})
|
|
10786
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_TERMINATE_DATE_NOT_FUTURE,
|
|
10787
|
+
message: `endsAt (${endsAt.toISOString()}) must lie strictly in the future.`,
|
|
10788
|
+
params: {
|
|
10789
|
+
endsAt: endsAt.toISOString()
|
|
10790
|
+
}
|
|
10040
10791
|
});
|
|
10041
10792
|
}
|
|
10042
10793
|
if (typeof this.repo.terminate !== "function") {
|
|
10043
10794
|
throw new import_common55.UnprocessableEntityException({
|
|
10044
|
-
code:
|
|
10045
|
-
message: "Terminate
|
|
10795
|
+
code: import_types26.CATALOG_ERROR_CODES.PLAN_TERMINATE_NOT_IMPLEMENTED,
|
|
10796
|
+
message: "Terminate is not implemented in the current repository. Implement PlanRepository.terminate."
|
|
10046
10797
|
});
|
|
10047
10798
|
}
|
|
10048
10799
|
return this.repo.terminate(versionId, endsAt);
|
|
@@ -10060,8 +10811,8 @@ var PlanVersionsService = class _PlanVersionsService {
|
|
|
10060
10811
|
if (blockingStrictModeWarnings(warnings).length === 0) return;
|
|
10061
10812
|
if (this.mode === "blocking") {
|
|
10062
10813
|
throw new import_common55.UnprocessableEntityException({
|
|
10063
|
-
code:
|
|
10064
|
-
message: "
|
|
10814
|
+
code: import_types26.CATALOG_ERROR_CODES.STRICT_MODE_VIOLATIONS,
|
|
10815
|
+
message: "The strict-mode check found drift against the discovery snapshot.",
|
|
10065
10816
|
warnings
|
|
10066
10817
|
});
|
|
10067
10818
|
}
|
|
@@ -10128,7 +10879,7 @@ var CreateBundleDto = class {
|
|
|
10128
10879
|
_ts_decorate60([
|
|
10129
10880
|
(0, import_class_validator9.IsString)(),
|
|
10130
10881
|
(0, import_class_validator9.Matches)(PROJECT_KEY_PATTERN4, {
|
|
10131
|
-
message: 'projectKey
|
|
10882
|
+
message: 'projectKey must be kebab-case (e.g. "my-app")'
|
|
10132
10883
|
}),
|
|
10133
10884
|
(0, import_class_validator9.MaxLength)(64),
|
|
10134
10885
|
_ts_metadata47("design:type", String)
|
|
@@ -10136,7 +10887,7 @@ _ts_decorate60([
|
|
|
10136
10887
|
_ts_decorate60([
|
|
10137
10888
|
(0, import_class_validator9.IsString)(),
|
|
10138
10889
|
(0, import_class_validator9.Matches)(KEY_PATTERN, {
|
|
10139
|
-
message: 'bundleKey
|
|
10890
|
+
message: 'bundleKey must be SCREAMING_SNAKE_CASE (e.g. "BANKING")'
|
|
10140
10891
|
}),
|
|
10141
10892
|
(0, import_class_validator9.MaxLength)(64),
|
|
10142
10893
|
_ts_metadata47("design:type", String)
|
|
@@ -10236,7 +10987,7 @@ _ts_decorate60([
|
|
|
10236
10987
|
}),
|
|
10237
10988
|
(0, import_class_validator9.Matches)(FEATURE_KEY_PATTERN, {
|
|
10238
10989
|
each: true,
|
|
10239
|
-
message: "features
|
|
10990
|
+
message: "features entries must be SCREAMING_SNAKE_CASE"
|
|
10240
10991
|
}),
|
|
10241
10992
|
_ts_metadata47("design:type", Array)
|
|
10242
10993
|
], CreateBundleVersionDraftDto.prototype, "features", void 0);
|
|
@@ -10262,7 +11013,7 @@ _ts_decorate60([
|
|
|
10262
11013
|
no_symbols: false
|
|
10263
11014
|
}),
|
|
10264
11015
|
(0, import_class_validator9.Matches)(DECIMAL_PATTERN, {
|
|
10265
|
-
message: 'monthlyNet
|
|
11016
|
+
message: 'monthlyNet must be a decimal with at most 2 fraction digits (e.g. "9.90")'
|
|
10266
11017
|
}),
|
|
10267
11018
|
_ts_metadata47("design:type", Object)
|
|
10268
11019
|
], CreateBundleVersionDraftDto.prototype, "monthlyNet", void 0);
|
|
@@ -10273,7 +11024,7 @@ _ts_decorate60([
|
|
|
10273
11024
|
no_symbols: false
|
|
10274
11025
|
}),
|
|
10275
11026
|
(0, import_class_validator9.Matches)(DECIMAL_PATTERN, {
|
|
10276
|
-
message: "yearlyNet
|
|
11027
|
+
message: "yearlyNet must be a decimal with at most 2 fraction digits"
|
|
10277
11028
|
}),
|
|
10278
11029
|
_ts_metadata47("design:type", Object)
|
|
10279
11030
|
], CreateBundleVersionDraftDto.prototype, "yearlyNet", void 0);
|
|
@@ -10297,7 +11048,7 @@ _ts_decorate60([
|
|
|
10297
11048
|
(0, import_class_validator9.IsOptional)(),
|
|
10298
11049
|
(0, import_class_validator9.ValidateIf)((_o, value) => value !== null),
|
|
10299
11050
|
(0, import_class_validator9.Matches)(ISO_DATE_PATTERN3, {
|
|
10300
|
-
message: "validFrom
|
|
11051
|
+
message: "validFrom must be an ISO date (YYYY-MM-DD)"
|
|
10301
11052
|
}),
|
|
10302
11053
|
_ts_metadata47("design:type", Object)
|
|
10303
11054
|
], CreateBundleVersionDraftDto.prototype, "validFrom", void 0);
|
|
@@ -10305,7 +11056,7 @@ _ts_decorate60([
|
|
|
10305
11056
|
(0, import_class_validator9.IsOptional)(),
|
|
10306
11057
|
(0, import_class_validator9.ValidateIf)((_o, value) => value !== null),
|
|
10307
11058
|
(0, import_class_validator9.Matches)(ISO_DATE_PATTERN3, {
|
|
10308
|
-
message: "validUntil
|
|
11059
|
+
message: "validUntil must be an ISO date (YYYY-MM-DD)"
|
|
10309
11060
|
}),
|
|
10310
11061
|
_ts_metadata47("design:type", Object)
|
|
10311
11062
|
], CreateBundleVersionDraftDto.prototype, "validUntil", void 0);
|
|
@@ -10377,7 +11128,7 @@ _ts_decorate60([
|
|
|
10377
11128
|
(0, import_class_validator9.IsOptional)(),
|
|
10378
11129
|
(0, import_class_validator9.ValidateIf)((_o, value) => value !== null),
|
|
10379
11130
|
(0, import_class_validator9.Matches)(ISO_DATE_PATTERN3, {
|
|
10380
|
-
message: "validFrom
|
|
11131
|
+
message: "validFrom must be an ISO date (YYYY-MM-DD)"
|
|
10381
11132
|
}),
|
|
10382
11133
|
_ts_metadata47("design:type", Object)
|
|
10383
11134
|
], UpdateBundleVersionDraftDto.prototype, "validFrom", void 0);
|
|
@@ -10385,7 +11136,7 @@ _ts_decorate60([
|
|
|
10385
11136
|
(0, import_class_validator9.IsOptional)(),
|
|
10386
11137
|
(0, import_class_validator9.ValidateIf)((_o, value) => value !== null),
|
|
10387
11138
|
(0, import_class_validator9.Matches)(ISO_DATE_PATTERN3, {
|
|
10388
|
-
message: "validUntil
|
|
11139
|
+
message: "validUntil must be an ISO date (YYYY-MM-DD)"
|
|
10389
11140
|
}),
|
|
10390
11141
|
_ts_metadata47("design:type", Object)
|
|
10391
11142
|
], UpdateBundleVersionDraftDto.prototype, "validUntil", void 0);
|
|
@@ -10422,7 +11173,7 @@ _ts_decorate60([
|
|
|
10422
11173
|
(0, import_class_validator9.IsOptional)(),
|
|
10423
11174
|
(0, import_class_validator9.ValidateIf)((_o, value) => value !== null),
|
|
10424
11175
|
(0, import_class_validator9.Matches)(ISO_DATE_PATTERN3, {
|
|
10425
|
-
message: "validFrom
|
|
11176
|
+
message: "validFrom must be an ISO date (YYYY-MM-DD)"
|
|
10426
11177
|
}),
|
|
10427
11178
|
_ts_metadata47("design:type", Object)
|
|
10428
11179
|
], PublishBundleVersionDto.prototype, "validFrom", void 0);
|
|
@@ -10430,7 +11181,7 @@ _ts_decorate60([
|
|
|
10430
11181
|
(0, import_class_validator9.IsOptional)(),
|
|
10431
11182
|
(0, import_class_validator9.ValidateIf)((_o, value) => value !== null),
|
|
10432
11183
|
(0, import_class_validator9.Matches)(ISO_DATE_PATTERN3, {
|
|
10433
|
-
message: "validUntil
|
|
11184
|
+
message: "validUntil must be an ISO date (YYYY-MM-DD)"
|
|
10434
11185
|
}),
|
|
10435
11186
|
_ts_metadata47("design:type", Object)
|
|
10436
11187
|
], PublishBundleVersionDto.prototype, "validUntil", void 0);
|
|
@@ -10734,7 +11485,7 @@ var CreateMarketingProjectionDto = class {
|
|
|
10734
11485
|
_ts_decorate62([
|
|
10735
11486
|
(0, import_class_validator10.IsString)(),
|
|
10736
11487
|
(0, import_class_validator10.Matches)(PROJECT_KEY_PATTERN5, {
|
|
10737
|
-
message: "projectKey
|
|
11488
|
+
message: "projectKey must be kebab-case"
|
|
10738
11489
|
}),
|
|
10739
11490
|
(0, import_class_validator10.MaxLength)(64),
|
|
10740
11491
|
_ts_metadata49("design:type", String)
|
|
@@ -10752,7 +11503,7 @@ _ts_decorate62([
|
|
|
10752
11503
|
(0, import_class_validator10.IsOptional)(),
|
|
10753
11504
|
(0, import_class_validator10.IsString)(),
|
|
10754
11505
|
(0, import_class_validator10.Matches)(LOCALE_PATTERN2, {
|
|
10755
|
-
message: 'locale
|
|
11506
|
+
message: 'locale must be ISO-639-1 (e.g. "de", "en", "de-AT")'
|
|
10756
11507
|
}),
|
|
10757
11508
|
_ts_metadata49("design:type", String)
|
|
10758
11509
|
], CreateMarketingProjectionDto.prototype, "locale", void 0);
|
|
@@ -11083,7 +11834,7 @@ var CreatePlanDto = class {
|
|
|
11083
11834
|
_ts_decorate64([
|
|
11084
11835
|
(0, import_class_validator11.IsString)(),
|
|
11085
11836
|
(0, import_class_validator11.Matches)(PROJECT_KEY_PATTERN6, {
|
|
11086
|
-
message: 'projectKey
|
|
11837
|
+
message: 'projectKey must be kebab-case (e.g. "my-app")'
|
|
11087
11838
|
}),
|
|
11088
11839
|
(0, import_class_validator11.MaxLength)(64),
|
|
11089
11840
|
_ts_metadata51("design:type", String)
|
|
@@ -11091,7 +11842,7 @@ _ts_decorate64([
|
|
|
11091
11842
|
_ts_decorate64([
|
|
11092
11843
|
(0, import_class_validator11.IsString)(),
|
|
11093
11844
|
(0, import_class_validator11.Matches)(KEY_PATTERN2, {
|
|
11094
|
-
message: 'planKey
|
|
11845
|
+
message: 'planKey must be SCREAMING_SNAKE_CASE (e.g. "STARTER")'
|
|
11095
11846
|
}),
|
|
11096
11847
|
(0, import_class_validator11.MaxLength)(64),
|
|
11097
11848
|
_ts_metadata51("design:type", String)
|
|
@@ -11200,7 +11951,7 @@ _ts_decorate65([
|
|
|
11200
11951
|
}),
|
|
11201
11952
|
(0, import_class_validator12.Matches)(FEATURE_KEY_PATTERN2, {
|
|
11202
11953
|
each: true,
|
|
11203
|
-
message: "features
|
|
11954
|
+
message: "features entries must be SCREAMING_SNAKE_CASE"
|
|
11204
11955
|
}),
|
|
11205
11956
|
_ts_metadata52("design:type", Array)
|
|
11206
11957
|
], CreatePlanVersionDraftDto.prototype, "features", void 0);
|
|
@@ -11212,7 +11963,7 @@ _ts_decorate65([
|
|
|
11212
11963
|
}),
|
|
11213
11964
|
(0, import_class_validator12.Matches)(FEATURE_KEY_PATTERN2, {
|
|
11214
11965
|
each: true,
|
|
11215
|
-
message: "bundles
|
|
11966
|
+
message: "bundles entries must be SCREAMING_SNAKE_CASE"
|
|
11216
11967
|
}),
|
|
11217
11968
|
_ts_metadata52("design:type", Array)
|
|
11218
11969
|
], CreatePlanVersionDraftDto.prototype, "bundles", void 0);
|
|
@@ -11222,13 +11973,13 @@ _ts_decorate65([
|
|
|
11222
11973
|
], CreatePlanVersionDraftDto.prototype, "quotas", void 0);
|
|
11223
11974
|
_ts_decorate65([
|
|
11224
11975
|
(0, import_class_validator12.Matches)(DECIMAL_PATTERN2, {
|
|
11225
|
-
message: 'monthlyNet
|
|
11976
|
+
message: 'monthlyNet must be a decimal with at most 2 fraction digits (e.g. "9.90")'
|
|
11226
11977
|
}),
|
|
11227
11978
|
_ts_metadata52("design:type", String)
|
|
11228
11979
|
], CreatePlanVersionDraftDto.prototype, "monthlyNet", void 0);
|
|
11229
11980
|
_ts_decorate65([
|
|
11230
11981
|
(0, import_class_validator12.Matches)(DECIMAL_PATTERN2, {
|
|
11231
|
-
message: "yearlyNet
|
|
11982
|
+
message: "yearlyNet must be a decimal with at most 2 fraction digits"
|
|
11232
11983
|
}),
|
|
11233
11984
|
_ts_metadata52("design:type", String)
|
|
11234
11985
|
], CreatePlanVersionDraftDto.prototype, "yearlyNet", void 0);
|
|
@@ -11472,7 +12223,7 @@ function buildPlansController(guards) {
|
|
|
11472
12223
|
}
|
|
11473
12224
|
assertVersionsService() {
|
|
11474
12225
|
if (!this.versionsService) {
|
|
11475
|
-
throw new Error("PlanVersionsService
|
|
12226
|
+
throw new Error("PlanVersionsService is not registered \u2014 the app has not enabled the plan lifecycle editor.");
|
|
11476
12227
|
}
|
|
11477
12228
|
}
|
|
11478
12229
|
};
|
|
@@ -11837,9 +12588,9 @@ function runPreflight(input) {
|
|
|
11837
12588
|
__name(runPreflight, "runPreflight");
|
|
11838
12589
|
function formatPreflightReport(report) {
|
|
11839
12590
|
const lines = [];
|
|
11840
|
-
lines.push(`Preflight (Status: ${report.overall.toUpperCase()})`, ` Plans: ${report.counts.planFindings} \xB7 Bundles: ${report.counts.bundleFindings} \xB7
|
|
12591
|
+
lines.push(`Preflight (Status: ${report.overall.toUpperCase()})`, ` Plans: ${report.counts.planFindings} \xB7 Bundles: ${report.counts.bundleFindings} \xB7 Total: ${report.counts.total}`, "");
|
|
11841
12592
|
if (report.findings.length === 0) {
|
|
11842
|
-
lines.push(" \u2713
|
|
12593
|
+
lines.push(" \u2713 No strict-mode violations found.");
|
|
11843
12594
|
return lines.join("\n");
|
|
11844
12595
|
}
|
|
11845
12596
|
let lastEntity = "";
|
|
@@ -11909,9 +12660,9 @@ function validateSeedAgainstSnapshot(input) {
|
|
|
11909
12660
|
__name(validateSeedAgainstSnapshot, "validateSeedAgainstSnapshot");
|
|
11910
12661
|
function formatSeedGateReport(report) {
|
|
11911
12662
|
const lines = [];
|
|
11912
|
-
lines.push(`Seed-Gate (Status: ${report.overall.toUpperCase()})`, ` Plans: ${report.counts.planFindings} \xB7 Bundles: ${report.counts.bundleFindings} \xB7
|
|
12663
|
+
lines.push(`Seed-Gate (Status: ${report.overall.toUpperCase()})`, ` Plans: ${report.counts.planFindings} \xB7 Bundles: ${report.counts.bundleFindings} \xB7 Total: ${report.counts.total}`, "");
|
|
11913
12664
|
if (report.findings.length === 0) {
|
|
11914
|
-
lines.push(" \u2713
|
|
12665
|
+
lines.push(" \u2713 Every seeded plan/bundle feature is discovered (and approved where checked).");
|
|
11915
12666
|
return lines.join("\n");
|
|
11916
12667
|
}
|
|
11917
12668
|
let lastEntity = "";
|
|
@@ -11941,7 +12692,7 @@ var DiscoverySnapshotNotFoundError = class extends Error {
|
|
|
11941
12692
|
}
|
|
11942
12693
|
path;
|
|
11943
12694
|
constructor(path) {
|
|
11944
|
-
super(`Discovery
|
|
12695
|
+
super(`Discovery snapshot file not found: ${path}. Boot the app first with DISCOVERY_SNAPSHOT_PATH or DiscoveryModule.forRoot({ snapshotPath: '\u2026' }) so that the scanner writes the file on the OnApplicationBootstrap hook.`), this.path = path;
|
|
11945
12696
|
this.name = "DiscoverySnapshotNotFoundError";
|
|
11946
12697
|
}
|
|
11947
12698
|
};
|
|
@@ -11968,10 +12719,10 @@ function runSeedGateFromFile(options) {
|
|
|
11968
12719
|
} catch (err) {
|
|
11969
12720
|
if (err instanceof DiscoverySnapshotNotFoundError) {
|
|
11970
12721
|
if (mode === "blocking") {
|
|
11971
|
-
error(`[seed-gate]
|
|
12722
|
+
error(`[seed-gate] ERROR: no discovery snapshot at ${options.snapshotPath} \u2014 required in blocking mode (run the headless scan before seeding, #23).`);
|
|
11972
12723
|
return exit(4);
|
|
11973
12724
|
}
|
|
11974
|
-
warn(`[seed-gate]
|
|
12725
|
+
warn(`[seed-gate] No discovery snapshot at ${options.snapshotPath} \u2014 skipped (report-only).`);
|
|
11975
12726
|
return null;
|
|
11976
12727
|
}
|
|
11977
12728
|
throw err;
|
|
@@ -11985,10 +12736,10 @@ function runSeedGateFromFile(options) {
|
|
|
11985
12736
|
log(formatSeedGateReport(report));
|
|
11986
12737
|
if (report.overall === "error") {
|
|
11987
12738
|
if (mode === "blocking") {
|
|
11988
|
-
error("[seed-gate] blocking:
|
|
12739
|
+
error("[seed-gate] blocking: violations found \u2014 aborting the seed (#23).");
|
|
11989
12740
|
return exit(seedGateExitCode(report));
|
|
11990
12741
|
}
|
|
11991
|
-
warn("[seed-gate] report-only:
|
|
12742
|
+
warn("[seed-gate] report-only: violations found, seeding continues anyway.");
|
|
11992
12743
|
}
|
|
11993
12744
|
return report;
|
|
11994
12745
|
}
|
|
@@ -12007,7 +12758,7 @@ __export(checkout_offer_exports, {
|
|
|
12007
12758
|
|
|
12008
12759
|
// src/checkout-offer/checkout-offer.service.ts
|
|
12009
12760
|
var import_common60 = require("@nestjs/common");
|
|
12010
|
-
var
|
|
12761
|
+
var import_types27 = require("@saasicat/types");
|
|
12011
12762
|
|
|
12012
12763
|
// src/checkout-offer/tokens.ts
|
|
12013
12764
|
var CHECKOUT_OFFER_REPOSITORY_TOKEN = /* @__PURE__ */ Symbol.for("saas-platform/CheckoutOfferRepository");
|
|
@@ -12050,7 +12801,13 @@ var CheckoutOfferService = class {
|
|
|
12050
12801
|
async getById(id) {
|
|
12051
12802
|
const row = await this.repo.findById(id);
|
|
12052
12803
|
if (!row) {
|
|
12053
|
-
throw new import_common60.NotFoundException(
|
|
12804
|
+
throw new import_common60.NotFoundException({
|
|
12805
|
+
code: import_types27.CONTRACT_ERROR_CODES.CHECKOUT_OFFER_NOT_FOUND,
|
|
12806
|
+
message: `CheckoutOffer '${id}' not found`,
|
|
12807
|
+
params: {
|
|
12808
|
+
offerId: id
|
|
12809
|
+
}
|
|
12810
|
+
});
|
|
12054
12811
|
}
|
|
12055
12812
|
return row;
|
|
12056
12813
|
}
|
|
@@ -12068,7 +12825,7 @@ var CheckoutOfferService = class {
|
|
|
12068
12825
|
/** Customization in onboarding — only while the offer is `open`. */
|
|
12069
12826
|
async update(id, data) {
|
|
12070
12827
|
const existing = await this.getById(id);
|
|
12071
|
-
this.assertOpen(existing, "
|
|
12828
|
+
this.assertOpen(existing, "changed");
|
|
12072
12829
|
const next = this.normalizeUpdateData(existing, data);
|
|
12073
12830
|
await this.assertFeatureRequiresSatisfied({
|
|
12074
12831
|
projectKey: existing.projectKey,
|
|
@@ -12086,7 +12843,7 @@ var CheckoutOfferService = class {
|
|
|
12086
12843
|
*/
|
|
12087
12844
|
async consume(id) {
|
|
12088
12845
|
const existing = await this.getById(id);
|
|
12089
|
-
this.assertOpen(existing, "
|
|
12846
|
+
this.assertOpen(existing, "consumed");
|
|
12090
12847
|
await this.assertBundleVersionsStillBookable(existing);
|
|
12091
12848
|
return this.repo.consume(id);
|
|
12092
12849
|
}
|
|
@@ -12104,19 +12861,19 @@ var CheckoutOfferService = class {
|
|
|
12104
12861
|
const entries = await this.catalogEntries.listFeatures({
|
|
12105
12862
|
projectKey: input.projectKey
|
|
12106
12863
|
});
|
|
12107
|
-
const requiresIndex = (0,
|
|
12864
|
+
const requiresIndex = (0, import_types27.buildFeatureRequiresIndex)(entries);
|
|
12108
12865
|
if (requiresIndex.size === 0) return;
|
|
12109
12866
|
const selected = /* @__PURE__ */ new Set([
|
|
12110
12867
|
...await this.resolvePlanFeatures(input),
|
|
12111
12868
|
...await this.resolveBundleFeatures(input)
|
|
12112
12869
|
]);
|
|
12113
|
-
const missingRequires = (0,
|
|
12870
|
+
const missingRequires = (0, import_types27.collectUnsatisfiedRequires)([
|
|
12114
12871
|
...selected
|
|
12115
12872
|
], requiresIndex);
|
|
12116
12873
|
if (missingRequires.length > 0) {
|
|
12117
12874
|
throw new import_common60.UnprocessableEntityException({
|
|
12118
|
-
code:
|
|
12119
|
-
message: `
|
|
12875
|
+
code: import_types27.CONTRACT_ERROR_CODES.CHECKOUT_OFFER_FEATURE_DEPENDENCY_UNSATISFIED,
|
|
12876
|
+
message: `The selected plan does not cover all feature dependencies: [${missingRequires.join(", ")}] are missing from the plan + selected bundles.`,
|
|
12120
12877
|
missingRequires
|
|
12121
12878
|
});
|
|
12122
12879
|
}
|
|
@@ -12196,8 +12953,8 @@ var CheckoutOfferService = class {
|
|
|
12196
12953
|
}
|
|
12197
12954
|
if (violations.length > 0) {
|
|
12198
12955
|
throw new import_common60.UnprocessableEntityException({
|
|
12199
|
-
code:
|
|
12200
|
-
message: "
|
|
12956
|
+
code: import_types27.CONTRACT_ERROR_CODES.CHECKOUT_OFFER_BUNDLE_VERSION_NOT_BOOKABLE,
|
|
12957
|
+
message: "At least one bundle version from the checkout offer is no longer bookable.",
|
|
12201
12958
|
violations
|
|
12202
12959
|
});
|
|
12203
12960
|
}
|
|
@@ -12251,15 +13008,15 @@ var CheckoutOfferService = class {
|
|
|
12251
13008
|
const hasPlan = lineItems.some((item) => item.kind === "plan");
|
|
12252
13009
|
if (!hasPlan) {
|
|
12253
13010
|
throw new import_common60.UnprocessableEntityException({
|
|
12254
|
-
code:
|
|
12255
|
-
message: "
|
|
13011
|
+
code: import_types27.CONTRACT_ERROR_CODES.CHECKOUT_OFFER_PLAN_LINE_ITEM_REQUIRED,
|
|
13012
|
+
message: "A checkout offer requires a frozen plan line item."
|
|
12256
13013
|
});
|
|
12257
13014
|
}
|
|
12258
13015
|
const missingBundleVersionIds = input.bundleVersionIds.filter((versionId) => !lineItems.some((item) => item.kind === "bundle" && item.sourceVersionId === versionId));
|
|
12259
13016
|
if (missingBundleVersionIds.length > 0) {
|
|
12260
13017
|
throw new import_common60.UnprocessableEntityException({
|
|
12261
|
-
code:
|
|
12262
|
-
message: "
|
|
13018
|
+
code: import_types27.CONTRACT_ERROR_CODES.CHECKOUT_OFFER_BUNDLE_LINE_ITEMS_REQUIRED,
|
|
13019
|
+
message: "Every selected bundle version requires a frozen bundle line item.",
|
|
12263
13020
|
bundleVersionIds: missingBundleVersionIds
|
|
12264
13021
|
});
|
|
12265
13022
|
}
|
|
@@ -12291,13 +13048,36 @@ var CheckoutOfferService = class {
|
|
|
12291
13048
|
}
|
|
12292
13049
|
assertOpen(existing, action) {
|
|
12293
13050
|
if (existing.status === "consumed") {
|
|
12294
|
-
throw new import_common60.ConflictException(
|
|
13051
|
+
throw new import_common60.ConflictException({
|
|
13052
|
+
code: import_types27.CONTRACT_ERROR_CODES.CHECKOUT_OFFER_ALREADY_CONSUMED,
|
|
13053
|
+
message: `Checkout offer '${existing.id}' has already been consumed and cannot be ${action}`,
|
|
13054
|
+
params: {
|
|
13055
|
+
offerId: existing.id,
|
|
13056
|
+
action
|
|
13057
|
+
}
|
|
13058
|
+
});
|
|
12295
13059
|
}
|
|
12296
13060
|
if (existing.status === "expired") {
|
|
12297
|
-
throw new import_common60.ConflictException(
|
|
13061
|
+
throw new import_common60.ConflictException({
|
|
13062
|
+
code: import_types27.CONTRACT_ERROR_CODES.CHECKOUT_OFFER_EXPIRED,
|
|
13063
|
+
message: `Checkout offer '${existing.id}' has expired and cannot be ${action}`,
|
|
13064
|
+
params: {
|
|
13065
|
+
offerId: existing.id,
|
|
13066
|
+
action,
|
|
13067
|
+
validUntil: existing.validUntil ?? null
|
|
13068
|
+
}
|
|
13069
|
+
});
|
|
12298
13070
|
}
|
|
12299
13071
|
if (this.isExpired(existing)) {
|
|
12300
|
-
throw new import_common60.ConflictException(
|
|
13072
|
+
throw new import_common60.ConflictException({
|
|
13073
|
+
code: import_types27.CONTRACT_ERROR_CODES.CHECKOUT_OFFER_EXPIRED,
|
|
13074
|
+
message: `CheckoutOffer '${existing.id}' has expired and cannot be ${action}`,
|
|
13075
|
+
params: {
|
|
13076
|
+
offerId: existing.id,
|
|
13077
|
+
action,
|
|
13078
|
+
validUntil: existing.validUntil ?? null
|
|
13079
|
+
}
|
|
13080
|
+
});
|
|
12301
13081
|
}
|
|
12302
13082
|
}
|
|
12303
13083
|
isExpired(row) {
|
|
@@ -12315,7 +13095,7 @@ var CheckoutOfferService = class {
|
|
|
12315
13095
|
if (!value) return false;
|
|
12316
13096
|
const validUntil = new Date(value).getTime();
|
|
12317
13097
|
if (Number.isNaN(validUntil)) return true;
|
|
12318
|
-
return validUntil < (0,
|
|
13098
|
+
return validUntil < (0, import_types27.startOfUtcDay)(new Date(nowMs)).getTime();
|
|
12319
13099
|
}
|
|
12320
13100
|
};
|
|
12321
13101
|
CheckoutOfferService = _ts_decorate68([
|
|
@@ -12379,7 +13159,7 @@ var CreateCheckoutOfferDto = class {
|
|
|
12379
13159
|
_ts_decorate69([
|
|
12380
13160
|
(0, import_class_validator13.IsString)(),
|
|
12381
13161
|
(0, import_class_validator13.Matches)(PROJECT_KEY_PATTERN7, {
|
|
12382
|
-
message: "projectKey
|
|
13162
|
+
message: "projectKey must be kebab-case"
|
|
12383
13163
|
}),
|
|
12384
13164
|
(0, import_class_validator13.MaxLength)(64),
|
|
12385
13165
|
_ts_metadata55("design:type", String)
|
|
@@ -12867,7 +13647,7 @@ async function runHeadlessDiscoveryScan(appModule, options = {}) {
|
|
|
12867
13647
|
await app.close();
|
|
12868
13648
|
}
|
|
12869
13649
|
} catch (err) {
|
|
12870
|
-
console.error("[discovery-scan]
|
|
13650
|
+
console.error("[discovery-scan] ERROR:", err instanceof Error ? err.message : err);
|
|
12871
13651
|
return exit(4);
|
|
12872
13652
|
}
|
|
12873
13653
|
}
|
|
@@ -12946,7 +13726,7 @@ __export(platform_exports, {
|
|
|
12946
13726
|
// src/platform/saas-platform.module.ts
|
|
12947
13727
|
var import_common79 = require("@nestjs/common");
|
|
12948
13728
|
var import_core9 = require("@nestjs/core");
|
|
12949
|
-
var
|
|
13729
|
+
var import_types31 = require("@saasicat/types");
|
|
12950
13730
|
|
|
12951
13731
|
// src/promo/module.ts
|
|
12952
13732
|
var import_common70 = require("@nestjs/common");
|
|
@@ -12987,7 +13767,7 @@ var PromoCodeExpirer = class _PromoCodeExpirer {
|
|
|
12987
13767
|
const codes = await this.promoRepo.expireDueCodes(now);
|
|
12988
13768
|
const redemptions = await this.redemptionRepo.expireDueRedemptions(now);
|
|
12989
13769
|
if (codes > 0 || redemptions > 0) {
|
|
12990
|
-
this.logger.log(`PromoCodeExpirer: ${codes} Codes, ${redemptions}
|
|
13770
|
+
this.logger.log(`PromoCodeExpirer: ${codes} Codes, ${redemptions} redemptions expired.`);
|
|
12991
13771
|
}
|
|
12992
13772
|
}
|
|
12993
13773
|
};
|
|
@@ -13016,6 +13796,7 @@ var import_common68 = require("@nestjs/common");
|
|
|
13016
13796
|
|
|
13017
13797
|
// src/promo/rate-limit.guard.ts
|
|
13018
13798
|
var import_common67 = require("@nestjs/common");
|
|
13799
|
+
var import_types28 = require("@saasicat/types");
|
|
13019
13800
|
function _ts_decorate75(decorators, target, key, desc) {
|
|
13020
13801
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
13021
13802
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -13027,6 +13808,18 @@ var IP_WINDOW_MS = 6e4;
|
|
|
13027
13808
|
var IP_LIMIT = 20;
|
|
13028
13809
|
var SESSION_WINDOW_MS = 60 * 6e4;
|
|
13029
13810
|
var SESSION_LIMIT = 50;
|
|
13811
|
+
function rateLimited(windowMs) {
|
|
13812
|
+
return new import_common67.HttpException({
|
|
13813
|
+
...codedError(import_types28.REGISTRATION_ERROR_CODES.RATE_LIMITED, {
|
|
13814
|
+
retryAfterSeconds: Math.ceil(windowMs / 1e3)
|
|
13815
|
+
}),
|
|
13816
|
+
retryAfterSeconds: Math.ceil(windowMs / 1e3),
|
|
13817
|
+
valid: false,
|
|
13818
|
+
// Superseded by `code` — kept until all consumers read `code`.
|
|
13819
|
+
reason: "RATE_LIMITED"
|
|
13820
|
+
}, import_common67.HttpStatus.TOO_MANY_REQUESTS);
|
|
13821
|
+
}
|
|
13822
|
+
__name(rateLimited, "rateLimited");
|
|
13030
13823
|
var PromoCodeRateLimitGuard = class {
|
|
13031
13824
|
static {
|
|
13032
13825
|
__name(this, "PromoCodeRateLimitGuard");
|
|
@@ -13038,16 +13831,10 @@ var PromoCodeRateLimitGuard = class {
|
|
|
13038
13831
|
const ipKey = ipFingerprint(req);
|
|
13039
13832
|
const sessionKey = req.user?.id ?? null;
|
|
13040
13833
|
if (this.exceeded(this.ipBuckets, ipKey, IP_WINDOW_MS, IP_LIMIT)) {
|
|
13041
|
-
throw
|
|
13042
|
-
valid: false,
|
|
13043
|
-
reason: "RATE_LIMITED"
|
|
13044
|
-
}, import_common67.HttpStatus.TOO_MANY_REQUESTS);
|
|
13834
|
+
throw rateLimited(IP_WINDOW_MS);
|
|
13045
13835
|
}
|
|
13046
13836
|
if (sessionKey && this.exceeded(this.sessionBuckets, sessionKey, SESSION_WINDOW_MS, SESSION_LIMIT)) {
|
|
13047
|
-
throw
|
|
13048
|
-
valid: false,
|
|
13049
|
-
reason: "RATE_LIMITED"
|
|
13050
|
-
}, import_common67.HttpStatus.TOO_MANY_REQUESTS);
|
|
13837
|
+
throw rateLimited(SESSION_WINDOW_MS);
|
|
13051
13838
|
}
|
|
13052
13839
|
return true;
|
|
13053
13840
|
}
|
|
@@ -13111,21 +13898,21 @@ var PreviewPromoCodeDto = class {
|
|
|
13111
13898
|
_ts_decorate76([
|
|
13112
13899
|
(0, import_class_validator14.IsString)(),
|
|
13113
13900
|
(0, import_class_validator14.Matches)(CODE_PATTERN2, {
|
|
13114
|
-
message: 'code
|
|
13901
|
+
message: 'code must consist of A\u2013Z, 0\u20139, "-" and "_" (4\u201332 characters)'
|
|
13115
13902
|
}),
|
|
13116
13903
|
_ts_metadata59("design:type", String)
|
|
13117
13904
|
], PreviewPromoCodeDto.prototype, "code", void 0);
|
|
13118
13905
|
_ts_decorate76([
|
|
13119
13906
|
(0, import_class_validator14.IsString)(),
|
|
13120
13907
|
(0, import_class_validator14.Matches)(PLAN_OR_CYCLE_PATTERN3, {
|
|
13121
|
-
message: "plan
|
|
13908
|
+
message: "plan must be SCREAMING_SNAKE_CASE"
|
|
13122
13909
|
}),
|
|
13123
13910
|
_ts_metadata59("design:type", String)
|
|
13124
13911
|
], PreviewPromoCodeDto.prototype, "plan", void 0);
|
|
13125
13912
|
_ts_decorate76([
|
|
13126
13913
|
(0, import_class_validator14.IsString)(),
|
|
13127
13914
|
(0, import_class_validator14.Matches)(PLAN_OR_CYCLE_PATTERN3, {
|
|
13128
|
-
message: "billingCycle
|
|
13915
|
+
message: "billingCycle must be SCREAMING_SNAKE_CASE (e.g. MONTHLY, YEARLY)"
|
|
13129
13916
|
}),
|
|
13130
13917
|
_ts_metadata59("design:type", String)
|
|
13131
13918
|
], PreviewPromoCodeDto.prototype, "billingCycle", void 0);
|
|
@@ -13686,7 +14473,7 @@ var import_class_validator16 = require("class-validator");
|
|
|
13686
14473
|
|
|
13687
14474
|
// src/setup/setup.service.ts
|
|
13688
14475
|
var import_common71 = require("@nestjs/common");
|
|
13689
|
-
var
|
|
14476
|
+
var import_types29 = require("@saasicat/types");
|
|
13690
14477
|
var import_node_crypto3 = require("crypto");
|
|
13691
14478
|
var import_qrcode = __toESM(require("qrcode"), 1);
|
|
13692
14479
|
|
|
@@ -13742,9 +14529,7 @@ var SetupService = class {
|
|
|
13742
14529
|
await this.assertNoSuperAdmin();
|
|
13743
14530
|
const email = req.email.trim().toLowerCase();
|
|
13744
14531
|
if (!email.includes("@")) {
|
|
13745
|
-
throw new import_common71.BadRequestException(
|
|
13746
|
-
code: import_types10.SETUP_ERROR_CODES.INVALID_EMAIL
|
|
13747
|
-
});
|
|
14532
|
+
throw new import_common71.BadRequestException(codedError(import_types29.SETUP_ERROR_CODES.INVALID_EMAIL));
|
|
13748
14533
|
}
|
|
13749
14534
|
const generated = !req.password;
|
|
13750
14535
|
const password = req.password ?? (0, import_node_crypto3.randomBytes)(GENERATED_PASSWORD_BYTES).toString("base64url");
|
|
@@ -13755,9 +14540,9 @@ var SetupService = class {
|
|
|
13755
14540
|
password
|
|
13756
14541
|
});
|
|
13757
14542
|
} catch (err) {
|
|
13758
|
-
if ((0,
|
|
14543
|
+
if ((0, import_types29.isPlatformUserExistsError)(err)) {
|
|
13759
14544
|
throw new import_common71.ConflictException({
|
|
13760
|
-
code:
|
|
14545
|
+
code: import_types29.SETUP_ERROR_CODES.EMAIL_EXISTS,
|
|
13761
14546
|
message: err.message
|
|
13762
14547
|
});
|
|
13763
14548
|
}
|
|
@@ -13793,21 +14578,20 @@ var SetupService = class {
|
|
|
13793
14578
|
const expected = process.env[this.config.setupTokenEnvVar]?.trim();
|
|
13794
14579
|
if (!expected) {
|
|
13795
14580
|
throw new import_common71.ForbiddenException({
|
|
13796
|
-
code:
|
|
13797
|
-
message: `${this.config.setupTokenEnvVar}
|
|
14581
|
+
code: import_types29.SETUP_ERROR_CODES.SETUP_DISABLED,
|
|
14582
|
+
message: `${this.config.setupTokenEnvVar} is not set \u2014 setup is disabled.`,
|
|
14583
|
+
params: {
|
|
14584
|
+
envVar: this.config.setupTokenEnvVar
|
|
14585
|
+
}
|
|
13798
14586
|
});
|
|
13799
14587
|
}
|
|
13800
14588
|
if (!token || !timingSafeEqualStr(token, expected)) {
|
|
13801
|
-
throw new import_common71.UnauthorizedException(
|
|
13802
|
-
code: import_types10.SETUP_ERROR_CODES.INVALID_SETUP_TOKEN
|
|
13803
|
-
});
|
|
14589
|
+
throw new import_common71.UnauthorizedException(codedError(import_types29.SETUP_ERROR_CODES.INVALID_SETUP_TOKEN));
|
|
13804
14590
|
}
|
|
13805
14591
|
}
|
|
13806
14592
|
async assertNoSuperAdmin() {
|
|
13807
14593
|
if (await this.users.countSuperAdmins() > 0) {
|
|
13808
|
-
throw new import_common71.ConflictException(
|
|
13809
|
-
code: import_types10.SETUP_ERROR_CODES.SETUP_ALREADY_DONE
|
|
13810
|
-
});
|
|
14594
|
+
throw new import_common71.ConflictException(codedError(import_types29.SETUP_ERROR_CODES.SETUP_ALREADY_DONE));
|
|
13811
14595
|
}
|
|
13812
14596
|
}
|
|
13813
14597
|
};
|
|
@@ -14082,6 +14866,7 @@ StaticEntitlementService = _ts_decorate84([
|
|
|
14082
14866
|
// src/platform/static-feature.guard.ts
|
|
14083
14867
|
var import_common75 = require("@nestjs/common");
|
|
14084
14868
|
var import_core7 = require("@nestjs/core");
|
|
14869
|
+
var import_types30 = require("@saasicat/types");
|
|
14085
14870
|
function _ts_decorate85(decorators, target, key, desc) {
|
|
14086
14871
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
14087
14872
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -14120,15 +14905,32 @@ var StaticFeatureGuard = class {
|
|
|
14120
14905
|
if (!required || required.length === 0) return true;
|
|
14121
14906
|
const request = context.switchToHttp().getRequest();
|
|
14122
14907
|
const user = request.user;
|
|
14123
|
-
if (!user)
|
|
14908
|
+
if (!user) {
|
|
14909
|
+
throw new import_common75.ForbiddenException({
|
|
14910
|
+
code: import_types30.AUTH_ERROR_CODES.NOT_AUTHENTICATED,
|
|
14911
|
+
message: "Not authenticated"
|
|
14912
|
+
});
|
|
14913
|
+
}
|
|
14124
14914
|
const role = this.config?.userRoleResolver ? this.config.userRoleResolver(user) : user.role ?? user.platformRole;
|
|
14125
14915
|
if (role === "SUPER_ADMIN") return true;
|
|
14126
14916
|
const tenantId = this.config?.tenantIdResolver ? this.config.tenantIdResolver(request) : request.tenantId ?? user.tenantId;
|
|
14127
|
-
if (!tenantId)
|
|
14917
|
+
if (!tenantId) {
|
|
14918
|
+
throw new import_common75.ForbiddenException({
|
|
14919
|
+
code: import_types30.AUTH_ERROR_CODES.NO_TENANT_ASSIGNED,
|
|
14920
|
+
message: "No tenant assigned"
|
|
14921
|
+
});
|
|
14922
|
+
}
|
|
14128
14923
|
const snap = await this.entitlements.snapshot(tenantId);
|
|
14129
14924
|
const allowed = required.some((f) => snap.features.includes(f));
|
|
14130
14925
|
if (!allowed) {
|
|
14131
|
-
|
|
14926
|
+
const body = {
|
|
14927
|
+
code: import_types30.FEATURE_NOT_LICENSED,
|
|
14928
|
+
featureKey: required[0],
|
|
14929
|
+
featureKeys: required,
|
|
14930
|
+
offers: [],
|
|
14931
|
+
message: `Feature ${required.join(" / ")} is not included in the current plan.`
|
|
14932
|
+
};
|
|
14933
|
+
throw new import_common75.ForbiddenException(body);
|
|
14132
14934
|
}
|
|
14133
14935
|
return true;
|
|
14134
14936
|
}
|
|
@@ -14371,7 +15173,7 @@ function buildTenantManifestController(options) {
|
|
|
14371
15173
|
async get(req) {
|
|
14372
15174
|
const tenantId = req.user?.tenantId ?? req.tenantId;
|
|
14373
15175
|
if (!tenantId) {
|
|
14374
|
-
throw new Error("
|
|
15176
|
+
throw new Error("No tenant on the request \u2014 TenantManifest requires user.tenantId.");
|
|
14375
15177
|
}
|
|
14376
15178
|
return this.svc.getManifest(tenantId);
|
|
14377
15179
|
}
|
|
@@ -14608,7 +15410,7 @@ var SaasPlatformModule = class _SaasPlatformModule {
|
|
|
14608
15410
|
throw new Error(`SaasPlatformModule.forRoot: entitlement active or required by the standard stack, but adapters are missing: ${missing.join(", ")}`);
|
|
14609
15411
|
}
|
|
14610
15412
|
if (persistence) {
|
|
14611
|
-
(0,
|
|
15413
|
+
(0, import_types31.assertPersistenceCapabilities)(persistence.capabilities, {
|
|
14612
15414
|
transactions: true,
|
|
14613
15415
|
pessimisticLocking: true
|
|
14614
15416
|
}, "SaasPlatformModule entitlement (transactional enforceLimit)");
|
|
@@ -15064,8 +15866,8 @@ var REGISTRATION_CONFIG_TOKEN = /* @__PURE__ */ Symbol.for("saas-platform/Regist
|
|
|
15064
15866
|
|
|
15065
15867
|
// src/registration/pending-registration.service.ts
|
|
15066
15868
|
var import_common80 = require("@nestjs/common");
|
|
15067
|
-
var
|
|
15068
|
-
var
|
|
15869
|
+
var import_types32 = require("@saasicat/types");
|
|
15870
|
+
var import_types33 = require("@saasicat/types");
|
|
15069
15871
|
|
|
15070
15872
|
// src/registration/pricing.ts
|
|
15071
15873
|
function round24(value) {
|
|
@@ -15183,7 +15985,7 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15183
15985
|
});
|
|
15184
15986
|
} catch (error) {
|
|
15185
15987
|
const message = error instanceof Error ? error.message : String(error);
|
|
15186
|
-
this.logger.warn(`Audit
|
|
15988
|
+
this.logger.warn(`Audit log write failed (${eventType}): ${message}`);
|
|
15187
15989
|
}
|
|
15188
15990
|
}
|
|
15189
15991
|
/** Public signup plan list for step 3 (frontend `usePublicPlans()`). */
|
|
@@ -15193,28 +15995,20 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15193
15995
|
async selectPlan(input, context) {
|
|
15194
15996
|
const pending = await this.repo.findById(input.pendingRegistrationId);
|
|
15195
15997
|
if (!pending) {
|
|
15196
|
-
throw new import_common80.NotFoundException(
|
|
15197
|
-
code: "PENDING_REGISTRATION_NOT_FOUND"
|
|
15198
|
-
});
|
|
15998
|
+
throw new import_common80.NotFoundException(codedError(import_types32.REGISTRATION_ERROR_CODES.PENDING_REGISTRATION_NOT_FOUND));
|
|
15199
15999
|
}
|
|
15200
16000
|
if (pending.expiresAt.getTime() < Date.now()) {
|
|
15201
|
-
throw new import_common80.BadRequestException(
|
|
15202
|
-
code: "PENDING_REGISTRATION_EXPIRED"
|
|
15203
|
-
});
|
|
16001
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.PENDING_REGISTRATION_EXPIRED));
|
|
15204
16002
|
}
|
|
15205
16003
|
if (pending.status !== "EMAIL_VERIFIED" && pending.status !== "PLAN_SELECTED" && pending.status !== "CHECKOUT_STARTED") {
|
|
15206
|
-
throw new import_common80.BadRequestException(
|
|
15207
|
-
code: "INVALID_REGISTRATION_STATE"
|
|
15208
|
-
});
|
|
16004
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.INVALID_REGISTRATION_STATE));
|
|
15209
16005
|
}
|
|
15210
16006
|
const plan = await this.planCatalog.findPublicSignupPlan(input.planId);
|
|
15211
16007
|
if (!plan) {
|
|
15212
|
-
throw new import_common80.BadRequestException(
|
|
15213
|
-
code: "PLAN_NOT_AVAILABLE"
|
|
15214
|
-
});
|
|
16008
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.PLAN_NOT_AVAILABLE));
|
|
15215
16009
|
}
|
|
15216
16010
|
const now = /* @__PURE__ */ new Date();
|
|
15217
|
-
const expiresAt = new Date(now.getTime() +
|
|
16011
|
+
const expiresAt = new Date(now.getTime() + import_types33.PENDING_ONBOARDING_TTL_DAYS * 24 * 60 * 60 * 1e3);
|
|
15218
16012
|
const updated = await this.repo.update(pending.id, {
|
|
15219
16013
|
status: "PLAN_SELECTED",
|
|
15220
16014
|
currentStep: 4,
|
|
@@ -15227,37 +16021,27 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15227
16021
|
return {
|
|
15228
16022
|
pendingRegistrationId: updated.id,
|
|
15229
16023
|
status: updated.status,
|
|
15230
|
-
nextStep:
|
|
16024
|
+
nextStep: import_types33.REGISTRATION_STEP_BY_STATUS[updated.status],
|
|
15231
16025
|
selectedPlanId: updated.selectedPlanId ?? plan.id
|
|
15232
16026
|
};
|
|
15233
16027
|
}
|
|
15234
16028
|
async startCheckout(input, context) {
|
|
15235
16029
|
const pending = await this.repo.findById(input.pendingRegistrationId);
|
|
15236
16030
|
if (!pending) {
|
|
15237
|
-
throw new import_common80.NotFoundException(
|
|
15238
|
-
code: "PENDING_REGISTRATION_NOT_FOUND"
|
|
15239
|
-
});
|
|
16031
|
+
throw new import_common80.NotFoundException(codedError(import_types32.REGISTRATION_ERROR_CODES.PENDING_REGISTRATION_NOT_FOUND));
|
|
15240
16032
|
}
|
|
15241
16033
|
if (pending.expiresAt.getTime() < Date.now()) {
|
|
15242
|
-
throw new import_common80.BadRequestException(
|
|
15243
|
-
code: "PENDING_REGISTRATION_EXPIRED"
|
|
15244
|
-
});
|
|
16034
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.PENDING_REGISTRATION_EXPIRED));
|
|
15245
16035
|
}
|
|
15246
16036
|
if (pending.status !== "PLAN_SELECTED" && pending.status !== "CHECKOUT_STARTED") {
|
|
15247
|
-
throw new import_common80.BadRequestException(
|
|
15248
|
-
code: "PLAN_NOT_SELECTED"
|
|
15249
|
-
});
|
|
16037
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.PLAN_NOT_SELECTED));
|
|
15250
16038
|
}
|
|
15251
16039
|
if (!pending.selectedPlanId) {
|
|
15252
|
-
throw new import_common80.BadRequestException(
|
|
15253
|
-
code: "PLAN_NOT_SELECTED"
|
|
15254
|
-
});
|
|
16040
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.PLAN_NOT_SELECTED));
|
|
15255
16041
|
}
|
|
15256
16042
|
const plan = await this.planCatalog.findPublicSignupPlan(pending.selectedPlanId);
|
|
15257
16043
|
if (!plan) {
|
|
15258
|
-
throw new import_common80.BadRequestException(
|
|
15259
|
-
code: "PLAN_NOT_AVAILABLE"
|
|
15260
|
-
});
|
|
16044
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.PLAN_NOT_AVAILABLE));
|
|
15261
16045
|
}
|
|
15262
16046
|
const session = await this.paymentProvider.createCheckoutSession({
|
|
15263
16047
|
pendingRegistrationId: pending.id,
|
|
@@ -15267,7 +16051,7 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15267
16051
|
cancelUrl: input.cancelUrl
|
|
15268
16052
|
});
|
|
15269
16053
|
const now = /* @__PURE__ */ new Date();
|
|
15270
|
-
const expiresAt = new Date(now.getTime() +
|
|
16054
|
+
const expiresAt = new Date(now.getTime() + import_types33.PENDING_CHECKOUT_TTL_DAYS * 24 * 60 * 60 * 1e3);
|
|
15271
16055
|
const updated = await this.repo.update(pending.id, {
|
|
15272
16056
|
status: "CHECKOUT_STARTED",
|
|
15273
16057
|
currentStep: 4,
|
|
@@ -15282,7 +16066,7 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15282
16066
|
return {
|
|
15283
16067
|
pendingRegistrationId: updated.id,
|
|
15284
16068
|
status: updated.status,
|
|
15285
|
-
nextStep:
|
|
16069
|
+
nextStep: import_types33.REGISTRATION_STEP_BY_STATUS[updated.status],
|
|
15286
16070
|
checkoutSessionId: session.sessionId,
|
|
15287
16071
|
checkoutUrl: session.checkoutUrl
|
|
15288
16072
|
};
|
|
@@ -15308,7 +16092,7 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15308
16092
|
rawPayload: input.rawPayload
|
|
15309
16093
|
});
|
|
15310
16094
|
if (!claimed) {
|
|
15311
|
-
this.logger.warn(`
|
|
16095
|
+
this.logger.warn(`Idempotency: payment event ${input.eventId} already processed \u2014 duplicate discarded.`);
|
|
15312
16096
|
await this.record("PAYMENT_DUPLICATE_IGNORED", null, context, {
|
|
15313
16097
|
eventId: input.eventId,
|
|
15314
16098
|
sessionId: input.sessionId
|
|
@@ -15358,7 +16142,7 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15358
16142
|
tenantId: result.tenantId,
|
|
15359
16143
|
subscriptionId: result.subscriptionId
|
|
15360
16144
|
});
|
|
15361
|
-
this.logger.log(`
|
|
16145
|
+
this.logger.log(`Activation succeeded: pending=${pending.id} \u2192 user=${result.userId} tenant=${result.tenantId} subscription=${result.subscriptionId}`);
|
|
15362
16146
|
return {
|
|
15363
16147
|
activated: true,
|
|
15364
16148
|
result
|
|
@@ -15386,11 +16170,11 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15386
16170
|
await this.repo.delete(pending.id);
|
|
15387
16171
|
} catch (error) {
|
|
15388
16172
|
const message = error instanceof Error ? error.message : String(error);
|
|
15389
|
-
this.logger.warn(`Cleanup:
|
|
16173
|
+
this.logger.warn(`Cleanup: pending registration ${pending.id} could not be deleted (${message}).`);
|
|
15390
16174
|
}
|
|
15391
16175
|
}
|
|
15392
16176
|
if (expired.length > 0) {
|
|
15393
|
-
this.logger.log(`Cleanup: ${expired.length}
|
|
16177
|
+
this.logger.log(`Cleanup: ${expired.length} expired PendingRegistrations deleted.`);
|
|
15394
16178
|
}
|
|
15395
16179
|
return {
|
|
15396
16180
|
deleted: expired.length,
|
|
@@ -15400,7 +16184,7 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15400
16184
|
async start(input, context) {
|
|
15401
16185
|
const email = normalizeEmail(input.email);
|
|
15402
16186
|
if (await this.userLookup.hasActiveUser(email)) {
|
|
15403
|
-
this.logger.log(`register/start
|
|
16187
|
+
this.logger.log(`register/start ignored for an active account (${email}).`);
|
|
15404
16188
|
await this.record("REGISTRATION_NEUTRAL_ACTIVE_USER", null, context);
|
|
15405
16189
|
return {
|
|
15406
16190
|
neutral: true
|
|
@@ -15438,8 +16222,8 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15438
16222
|
const code = generateOtpCode();
|
|
15439
16223
|
const otpHash = hashOtpCode(code);
|
|
15440
16224
|
const now = /* @__PURE__ */ new Date();
|
|
15441
|
-
const otpExpiresAt = new Date(now.getTime() +
|
|
15442
|
-
const expiresAt = new Date(now.getTime() +
|
|
16225
|
+
const otpExpiresAt = new Date(now.getTime() + import_types33.OTP_TTL_MINUTES * 60 * 1e3);
|
|
16226
|
+
const expiresAt = new Date(now.getTime() + import_types33.PENDING_EMAIL_TTL_HOURS * 60 * 60 * 1e3);
|
|
15443
16227
|
const pending = await this.repo.create({
|
|
15444
16228
|
tenantName: input.tenantName.trim(),
|
|
15445
16229
|
tenantSlug: slug,
|
|
@@ -15466,14 +16250,12 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15466
16250
|
await this.record("OTP_VERIFY_FAILED", null, context, {
|
|
15467
16251
|
reason: "unknown_email"
|
|
15468
16252
|
});
|
|
15469
|
-
throw new import_common80.BadRequestException(
|
|
15470
|
-
code: "OTP_INVALID"
|
|
15471
|
-
});
|
|
16253
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.OTP_INVALID));
|
|
15472
16254
|
}
|
|
15473
16255
|
if (pending.status !== "PENDING_EMAIL_VERIFICATION") {
|
|
15474
16256
|
return {
|
|
15475
16257
|
status: pending.status,
|
|
15476
|
-
nextStep:
|
|
16258
|
+
nextStep: import_types33.REGISTRATION_STEP_BY_STATUS[pending.status],
|
|
15477
16259
|
pendingRegistrationId: pending.id
|
|
15478
16260
|
};
|
|
15479
16261
|
}
|
|
@@ -15481,43 +16263,33 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15481
16263
|
await this.record("OTP_VERIFY_FAILED", pending.id, context, {
|
|
15482
16264
|
reason: "no_otp_set"
|
|
15483
16265
|
});
|
|
15484
|
-
throw new import_common80.BadRequestException(
|
|
15485
|
-
code: "OTP_INVALID"
|
|
15486
|
-
});
|
|
16266
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.OTP_INVALID));
|
|
15487
16267
|
}
|
|
15488
16268
|
if (pending.otpExpiresAt.getTime() < Date.now()) {
|
|
15489
16269
|
await this.record("OTP_VERIFY_FAILED", pending.id, context, {
|
|
15490
16270
|
reason: "expired"
|
|
15491
16271
|
});
|
|
15492
|
-
throw new import_common80.BadRequestException(
|
|
15493
|
-
code: "OTP_EXPIRED"
|
|
15494
|
-
});
|
|
16272
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.OTP_EXPIRED));
|
|
15495
16273
|
}
|
|
15496
16274
|
const maxAttempts = resolveOtpVerifyMaxAttempts();
|
|
15497
16275
|
if (pending.otpAttemptCount >= maxAttempts) {
|
|
15498
16276
|
await this.record("OTP_VERIFY_FAILED", pending.id, context, {
|
|
15499
16277
|
reason: "locked"
|
|
15500
16278
|
});
|
|
15501
|
-
throw new import_common80.BadRequestException(
|
|
15502
|
-
code: "OTP_LOCKED"
|
|
15503
|
-
});
|
|
16279
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.OTP_LOCKED));
|
|
15504
16280
|
}
|
|
15505
16281
|
const attemptCount = await this.repo.incrementOtpAttemptCount(pending.id);
|
|
15506
16282
|
if (attemptCount > maxAttempts) {
|
|
15507
16283
|
await this.record("OTP_VERIFY_FAILED", pending.id, context, {
|
|
15508
16284
|
reason: "locked"
|
|
15509
16285
|
});
|
|
15510
|
-
throw new import_common80.BadRequestException(
|
|
15511
|
-
code: "OTP_LOCKED"
|
|
15512
|
-
});
|
|
16286
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.OTP_LOCKED));
|
|
15513
16287
|
}
|
|
15514
16288
|
if (!verifyOtpCode(pending.otpHash, code)) {
|
|
15515
16289
|
await this.record("OTP_VERIFY_FAILED", pending.id, context, {
|
|
15516
16290
|
reason: "wrong_code"
|
|
15517
16291
|
});
|
|
15518
|
-
throw new import_common80.BadRequestException(
|
|
15519
|
-
code: "OTP_INVALID"
|
|
15520
|
-
});
|
|
16292
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.OTP_INVALID));
|
|
15521
16293
|
}
|
|
15522
16294
|
const now = /* @__PURE__ */ new Date();
|
|
15523
16295
|
const updated = await this.repo.update(pending.id, {
|
|
@@ -15526,12 +16298,12 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15526
16298
|
emailVerifiedAt: now,
|
|
15527
16299
|
otpHash: null,
|
|
15528
16300
|
otpExpiresAt: null,
|
|
15529
|
-
expiresAt: new Date(now.getTime() +
|
|
16301
|
+
expiresAt: new Date(now.getTime() + import_types33.PENDING_ONBOARDING_TTL_DAYS * 24 * 60 * 60 * 1e3)
|
|
15530
16302
|
});
|
|
15531
16303
|
await this.record("OTP_VERIFIED", updated.id, context);
|
|
15532
16304
|
return {
|
|
15533
16305
|
status: updated.status,
|
|
15534
|
-
nextStep:
|
|
16306
|
+
nextStep: import_types33.REGISTRATION_STEP_BY_STATUS[updated.status],
|
|
15535
16307
|
pendingRegistrationId: updated.id
|
|
15536
16308
|
};
|
|
15537
16309
|
}
|
|
@@ -15556,15 +16328,15 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15556
16328
|
}
|
|
15557
16329
|
async regenerateOtp(pending) {
|
|
15558
16330
|
const now = /* @__PURE__ */ new Date();
|
|
15559
|
-
const windowMs =
|
|
16331
|
+
const windowMs = import_types33.OTP_RATE_LIMIT_WINDOW_MINUTES * 60 * 1e3;
|
|
15560
16332
|
const withinWindow = pending.lastOtpSentAt !== null && now.getTime() - pending.lastOtpSentAt.getTime() < windowMs;
|
|
15561
|
-
if (withinWindow && pending.otpSendCount >=
|
|
15562
|
-
this.logger.warn(`OTP
|
|
16333
|
+
if (withinWindow && pending.otpSendCount >= import_types33.OTP_RATE_LIMIT_MAX_SENDS) {
|
|
16334
|
+
this.logger.warn(`OTP rate limit reached for ${pending.email} \u2014 send is being skipped.`);
|
|
15563
16335
|
return;
|
|
15564
16336
|
}
|
|
15565
16337
|
const code = generateOtpCode();
|
|
15566
16338
|
const otpHash = hashOtpCode(code);
|
|
15567
|
-
const otpExpiresAt = new Date(now.getTime() +
|
|
16339
|
+
const otpExpiresAt = new Date(now.getTime() + import_types33.OTP_TTL_MINUTES * 60 * 1e3);
|
|
15568
16340
|
const otpSendCount = withinWindow ? pending.otpSendCount + 1 : 1;
|
|
15569
16341
|
await this.repo.update(pending.id, {
|
|
15570
16342
|
otpHash,
|
|
@@ -15587,7 +16359,7 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15587
16359
|
});
|
|
15588
16360
|
} catch (error) {
|
|
15589
16361
|
const message = error instanceof Error ? error.message : String(error);
|
|
15590
|
-
this.logger.error(`OTP
|
|
16362
|
+
this.logger.error(`OTP email to ${pending.email} failed: ${message}`);
|
|
15591
16363
|
}
|
|
15592
16364
|
}
|
|
15593
16365
|
/**
|
|
@@ -15601,7 +16373,7 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15601
16373
|
}
|
|
15602
16374
|
const token = await this.resumeTokenSigner.sign({
|
|
15603
16375
|
pendingRegistrationId: pending.id,
|
|
15604
|
-
ttlMinutes:
|
|
16376
|
+
ttlMinutes: import_types32.REGISTRATION_RESUME_TTL_MINUTES
|
|
15605
16377
|
});
|
|
15606
16378
|
const base = (this.resumeBaseUrl ?? "http://localhost").replace(/\/+$/, "");
|
|
15607
16379
|
const url = `${base}/login?resume=${encodeURIComponent(token)}`;
|
|
@@ -15614,7 +16386,7 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15614
16386
|
});
|
|
15615
16387
|
} catch (error) {
|
|
15616
16388
|
const message = error instanceof Error ? error.message : String(error);
|
|
15617
|
-
this.logger.error(`Resume
|
|
16389
|
+
this.logger.error(`Resume email to ${pending.email} failed: ${message}`);
|
|
15618
16390
|
}
|
|
15619
16391
|
}
|
|
15620
16392
|
/**
|
|
@@ -15628,28 +16400,20 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15628
16400
|
*/
|
|
15629
16401
|
async resumeWithToken(input, context) {
|
|
15630
16402
|
if (!this.resumeTokenSigner) {
|
|
15631
|
-
throw new import_common80.BadRequestException(
|
|
15632
|
-
code: "RESUME_NOT_CONFIGURED"
|
|
15633
|
-
});
|
|
16403
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.RESUME_NOT_CONFIGURED));
|
|
15634
16404
|
}
|
|
15635
16405
|
let decoded;
|
|
15636
16406
|
try {
|
|
15637
16407
|
decoded = await this.resumeTokenSigner.verify(input.token);
|
|
15638
16408
|
} catch {
|
|
15639
|
-
throw new import_common80.BadRequestException(
|
|
15640
|
-
code: "RESUME_TOKEN_INVALID"
|
|
15641
|
-
});
|
|
16409
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.RESUME_TOKEN_INVALID));
|
|
15642
16410
|
}
|
|
15643
16411
|
const pending = await this.repo.findById(decoded.pendingRegistrationId);
|
|
15644
16412
|
if (!pending) {
|
|
15645
|
-
throw new import_common80.BadRequestException(
|
|
15646
|
-
code: "RESUME_TOKEN_INVALID"
|
|
15647
|
-
});
|
|
16413
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.RESUME_TOKEN_INVALID));
|
|
15648
16414
|
}
|
|
15649
16415
|
if (pending.expiresAt.getTime() < Date.now()) {
|
|
15650
|
-
throw new import_common80.BadRequestException(
|
|
15651
|
-
code: "PENDING_REGISTRATION_EXPIRED"
|
|
15652
|
-
});
|
|
16416
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.PENDING_REGISTRATION_EXPIRED));
|
|
15653
16417
|
}
|
|
15654
16418
|
await this.record("REGISTRATION_NEUTRAL_REPLAY", pending.id, context, {
|
|
15655
16419
|
via: "resume_token",
|
|
@@ -15658,7 +16422,7 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15658
16422
|
return {
|
|
15659
16423
|
pendingRegistrationId: pending.id,
|
|
15660
16424
|
status: pending.status,
|
|
15661
|
-
nextStep:
|
|
16425
|
+
nextStep: import_types33.REGISTRATION_STEP_BY_STATUS[pending.status],
|
|
15662
16426
|
snapshot: toSnapshot2(pending)
|
|
15663
16427
|
};
|
|
15664
16428
|
}
|
|
@@ -15677,9 +16441,7 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15677
16441
|
/** Returns the (app-specific) configurator catalog. */
|
|
15678
16442
|
async getConfiguratorCatalog() {
|
|
15679
16443
|
if (!this.configuratorLookup) {
|
|
15680
|
-
throw new import_common80.BadRequestException(
|
|
15681
|
-
code: "CONFIGURATOR_NOT_CONFIGURED"
|
|
15682
|
-
});
|
|
16444
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.CONFIGURATOR_NOT_CONFIGURED));
|
|
15683
16445
|
}
|
|
15684
16446
|
return this.configuratorLookup.getCatalog();
|
|
15685
16447
|
}
|
|
@@ -15696,32 +16458,22 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15696
16458
|
*/
|
|
15697
16459
|
async saveConfiguration(input, context) {
|
|
15698
16460
|
if (!this.configuratorLookup) {
|
|
15699
|
-
throw new import_common80.BadRequestException(
|
|
15700
|
-
code: "CONFIGURATOR_NOT_CONFIGURED"
|
|
15701
|
-
});
|
|
16461
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.CONFIGURATOR_NOT_CONFIGURED));
|
|
15702
16462
|
}
|
|
15703
16463
|
const pending = await this.repo.findById(input.pendingRegistrationId);
|
|
15704
16464
|
if (!pending) {
|
|
15705
|
-
throw new import_common80.NotFoundException(
|
|
15706
|
-
code: "PENDING_REGISTRATION_NOT_FOUND"
|
|
15707
|
-
});
|
|
16465
|
+
throw new import_common80.NotFoundException(codedError(import_types32.REGISTRATION_ERROR_CODES.PENDING_REGISTRATION_NOT_FOUND));
|
|
15708
16466
|
}
|
|
15709
16467
|
if (pending.expiresAt.getTime() < Date.now()) {
|
|
15710
|
-
throw new import_common80.BadRequestException(
|
|
15711
|
-
code: "PENDING_REGISTRATION_EXPIRED"
|
|
15712
|
-
});
|
|
16468
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.PENDING_REGISTRATION_EXPIRED));
|
|
15713
16469
|
}
|
|
15714
16470
|
if (pending.status !== "EMAIL_VERIFIED" && pending.status !== "PLAN_SELECTED" && pending.status !== "CHECKOUT_STARTED") {
|
|
15715
|
-
throw new import_common80.BadRequestException(
|
|
15716
|
-
code: "INVALID_REGISTRATION_STATE"
|
|
15717
|
-
});
|
|
16471
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.INVALID_REGISTRATION_STATE));
|
|
15718
16472
|
}
|
|
15719
16473
|
const catalog = await this.configuratorLookup.getCatalog();
|
|
15720
16474
|
const model = catalog.models.find((m) => m.id === input.selection.modelId);
|
|
15721
16475
|
if (!model) {
|
|
15722
|
-
throw new import_common80.BadRequestException(
|
|
15723
|
-
code: "MODEL_NOT_AVAILABLE"
|
|
15724
|
-
});
|
|
16476
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.MODEL_NOT_AVAILABLE));
|
|
15725
16477
|
}
|
|
15726
16478
|
let promoEval;
|
|
15727
16479
|
if (input.selection.appliedPromoCode && this.promoPreview) {
|
|
@@ -15753,7 +16505,7 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15753
16505
|
};
|
|
15754
16506
|
}
|
|
15755
16507
|
const now = /* @__PURE__ */ new Date();
|
|
15756
|
-
const expiresAt = new Date(now.getTime() +
|
|
16508
|
+
const expiresAt = new Date(now.getTime() + import_types33.PENDING_ONBOARDING_TTL_DAYS * 24 * 60 * 60 * 1e3);
|
|
15757
16509
|
const updated = await this.repo.update(pending.id, {
|
|
15758
16510
|
status: "PLAN_SELECTED",
|
|
15759
16511
|
currentStep: 4,
|
|
@@ -15772,7 +16524,7 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15772
16524
|
return {
|
|
15773
16525
|
pendingRegistrationId: updated.id,
|
|
15774
16526
|
status: updated.status,
|
|
15775
|
-
nextStep:
|
|
16527
|
+
nextStep: import_types33.REGISTRATION_STEP_BY_STATUS[updated.status],
|
|
15776
16528
|
selection: input.selection,
|
|
15777
16529
|
breakdown
|
|
15778
16530
|
};
|
|
@@ -15785,28 +16537,20 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15785
16537
|
*/
|
|
15786
16538
|
async previewConfigPromo(pendingRegistrationId, code) {
|
|
15787
16539
|
if (!this.configuratorLookup) {
|
|
15788
|
-
throw new import_common80.BadRequestException(
|
|
15789
|
-
code: "CONFIGURATOR_NOT_CONFIGURED"
|
|
15790
|
-
});
|
|
16540
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.CONFIGURATOR_NOT_CONFIGURED));
|
|
15791
16541
|
}
|
|
15792
16542
|
const pending = await this.repo.findById(pendingRegistrationId);
|
|
15793
16543
|
if (!pending) {
|
|
15794
|
-
throw new import_common80.NotFoundException(
|
|
15795
|
-
code: "PENDING_REGISTRATION_NOT_FOUND"
|
|
15796
|
-
});
|
|
16544
|
+
throw new import_common80.NotFoundException(codedError(import_types32.REGISTRATION_ERROR_CODES.PENDING_REGISTRATION_NOT_FOUND));
|
|
15797
16545
|
}
|
|
15798
16546
|
const config = pending.configJson;
|
|
15799
16547
|
if (!config) {
|
|
15800
|
-
throw new import_common80.BadRequestException(
|
|
15801
|
-
code: "CONFIG_NOT_SAVED"
|
|
15802
|
-
});
|
|
16548
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.CONFIG_NOT_SAVED));
|
|
15803
16549
|
}
|
|
15804
16550
|
const catalog = await this.configuratorLookup.getCatalog();
|
|
15805
16551
|
const model = catalog.models.find((m) => m.id === config.modelId);
|
|
15806
16552
|
if (!model) {
|
|
15807
|
-
throw new import_common80.BadRequestException(
|
|
15808
|
-
code: "MODEL_NOT_AVAILABLE"
|
|
15809
|
-
});
|
|
16553
|
+
throw new import_common80.BadRequestException(codedError(import_types32.REGISTRATION_ERROR_CODES.MODEL_NOT_AVAILABLE));
|
|
15810
16554
|
}
|
|
15811
16555
|
let promoEval;
|
|
15812
16556
|
if (code && this.promoPreview) {
|
|
@@ -15864,7 +16608,7 @@ var PendingRegistrationService = class _PendingRegistrationService {
|
|
|
15864
16608
|
return candidate;
|
|
15865
16609
|
}
|
|
15866
16610
|
}
|
|
15867
|
-
throw new Error(`
|
|
16611
|
+
throw new Error(`Could not find a free slug for "${tenantName}".`);
|
|
15868
16612
|
}
|
|
15869
16613
|
};
|
|
15870
16614
|
PendingRegistrationService = _ts_decorate90([
|
|
@@ -15914,9 +16658,9 @@ function normalizeEmail(value) {
|
|
|
15914
16658
|
__name(normalizeEmail, "normalizeEmail");
|
|
15915
16659
|
function resolveOtpVerifyMaxAttempts() {
|
|
15916
16660
|
const raw = process.env.SAAS_PLATFORM_OTP_VERIFY_MAX_ATTEMPTS;
|
|
15917
|
-
if (!raw) return
|
|
16661
|
+
if (!raw) return import_types33.OTP_VERIFY_MAX_ATTEMPTS;
|
|
15918
16662
|
const parsed = Number.parseInt(raw, 10);
|
|
15919
|
-
return Number.isInteger(parsed) && parsed > 0 ? parsed :
|
|
16663
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : import_types33.OTP_VERIFY_MAX_ATTEMPTS;
|
|
15920
16664
|
}
|
|
15921
16665
|
__name(resolveOtpVerifyMaxAttempts, "resolveOtpVerifyMaxAttempts");
|
|
15922
16666
|
function toSnapshot2(pending) {
|
|
@@ -15989,7 +16733,7 @@ var RegistrationCleanupCron = class _RegistrationCleanupCron {
|
|
|
15989
16733
|
}
|
|
15990
16734
|
}
|
|
15991
16735
|
if (totalDeleted > 0) {
|
|
15992
|
-
this.logger.log(`
|
|
16736
|
+
this.logger.log(`Pending registration cleanup finished: ${totalDeleted} deleted in ${iterations} batches.`);
|
|
15993
16737
|
}
|
|
15994
16738
|
}
|
|
15995
16739
|
};
|
|
@@ -16011,6 +16755,7 @@ RegistrationCleanupCron = _ts_decorate91([
|
|
|
16011
16755
|
|
|
16012
16756
|
// src/registration/ip-rate-limit.guard.ts
|
|
16013
16757
|
var import_common82 = require("@nestjs/common");
|
|
16758
|
+
var import_types34 = require("@saasicat/types");
|
|
16014
16759
|
function _ts_decorate92(decorators, target, key, desc) {
|
|
16015
16760
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
16016
16761
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -16039,9 +16784,14 @@ var BaseIpRateLimitGuard = class {
|
|
|
16039
16784
|
const key = ipFingerprint2(req);
|
|
16040
16785
|
if (this.exceeded(key)) {
|
|
16041
16786
|
throw new import_common82.HttpException({
|
|
16042
|
-
|
|
16043
|
-
|
|
16044
|
-
|
|
16787
|
+
...codedError(import_types34.REGISTRATION_ERROR_CODES.RATE_LIMITED, {
|
|
16788
|
+
retryAfterSeconds: Math.ceil(this.options.windowMs / 1e3)
|
|
16789
|
+
}),
|
|
16790
|
+
retryAfterSeconds: Math.ceil(this.options.windowMs / 1e3),
|
|
16791
|
+
// Superseded by `code`. Note this carries the limiter name,
|
|
16792
|
+
// not the code — the two emitters of RATE_LIMITED disagree
|
|
16793
|
+
// on what `reason` means, which is why it is going away.
|
|
16794
|
+
reason: this.options.name
|
|
16045
16795
|
}, import_common82.HttpStatus.TOO_MANY_REQUESTS);
|
|
16046
16796
|
}
|
|
16047
16797
|
return true;
|
|
@@ -16183,7 +16933,7 @@ _ts_decorate94([
|
|
|
16183
16933
|
(0, import_class_validator17.IsOptional)(),
|
|
16184
16934
|
(0, import_class_validator17.IsString)(),
|
|
16185
16935
|
(0, import_class_validator17.Matches)(/^[a-z0-9-]+$/, {
|
|
16186
|
-
message: "tenantSlug
|
|
16936
|
+
message: "tenantSlug may only contain lower-case letters, digits and hyphens."
|
|
16187
16937
|
}),
|
|
16188
16938
|
(0, import_class_validator17.MaxLength)(60),
|
|
16189
16939
|
_ts_metadata73("design:type", String)
|
|
@@ -16219,7 +16969,7 @@ _ts_decorate94([
|
|
|
16219
16969
|
], RegisterStartDto.prototype, "password", void 0);
|
|
16220
16970
|
_ts_decorate94([
|
|
16221
16971
|
(0, import_class_validator17.Equals)(true, {
|
|
16222
|
-
message: "
|
|
16972
|
+
message: "The terms of service, privacy policy and data processing agreement must be accepted."
|
|
16223
16973
|
}),
|
|
16224
16974
|
_ts_metadata73("design:type", Boolean)
|
|
16225
16975
|
], RegisterStartDto.prototype, "termsAccepted", void 0);
|
|
@@ -16259,7 +17009,7 @@ _ts_decorate95([
|
|
|
16259
17009
|
_ts_decorate95([
|
|
16260
17010
|
(0, import_class_validator18.IsString)(),
|
|
16261
17011
|
(0, import_class_validator18.Matches)(/^\d{6}$/, {
|
|
16262
|
-
message: "OTP
|
|
17012
|
+
message: "The OTP must be 6 digits."
|
|
16263
17013
|
}),
|
|
16264
17014
|
_ts_metadata74("design:type", String)
|
|
16265
17015
|
], VerifyRegistrationOtpDto.prototype, "otp", void 0);
|
|
@@ -16790,9 +17540,10 @@ __export(src_exports, {
|
|
|
16790
17540
|
buildPublicMarketingCatalogController: () => buildPublicMarketingCatalogController,
|
|
16791
17541
|
buildReplacedByIndex: () => buildReplacedByIndex,
|
|
16792
17542
|
buildTenantSubscriptionBundlesController: () => buildTenantSubscriptionBundlesController,
|
|
16793
|
-
classifyBundleVersionDiff: () =>
|
|
16794
|
-
classifyPlanDiff: () =>
|
|
17543
|
+
classifyBundleVersionDiff: () => import_types5.classifyBundleVersionDiff,
|
|
17544
|
+
classifyPlanDiff: () => import_types5.classifyPlanDiff,
|
|
16795
17545
|
clearPendingPlanVersionFields: () => clearPendingPlanVersionFields,
|
|
17546
|
+
codedError: () => codedError,
|
|
16796
17547
|
collectSubscriptionBundleFeatures: () => collectSubscriptionBundleFeatures,
|
|
16797
17548
|
computeBreakdown: () => computeBreakdown,
|
|
16798
17549
|
computeCarriedTrialEndsAt: () => computeCarriedTrialEndsAt,
|
|
@@ -16884,7 +17635,7 @@ __export(testing_exports, {
|
|
|
16884
17635
|
});
|
|
16885
17636
|
|
|
16886
17637
|
// src/testing/fake-repositories.ts
|
|
16887
|
-
var
|
|
17638
|
+
var import_types35 = require("@saasicat/types");
|
|
16888
17639
|
var FakeSubscriptionRepository = class {
|
|
16889
17640
|
static {
|
|
16890
17641
|
__name(this, "FakeSubscriptionRepository");
|
|
@@ -16975,10 +17726,10 @@ var FakeSubscriptionBundleRepository = class {
|
|
|
16975
17726
|
async cancel(subscriptionBundleId, data) {
|
|
16976
17727
|
const existing = this.byId.get(subscriptionBundleId);
|
|
16977
17728
|
if (!existing) {
|
|
16978
|
-
throw new Error(`SubscriptionBundle '${subscriptionBundleId}'
|
|
17729
|
+
throw new Error(`SubscriptionBundle '${subscriptionBundleId}' not found`);
|
|
16979
17730
|
}
|
|
16980
17731
|
if (existing.canceledAt !== null) {
|
|
16981
|
-
throw new Error(`SubscriptionBundle '${subscriptionBundleId}'
|
|
17732
|
+
throw new Error(`SubscriptionBundle '${subscriptionBundleId}' is already cancelled`);
|
|
16982
17733
|
}
|
|
16983
17734
|
const updated = {
|
|
16984
17735
|
...existing,
|
|
@@ -16992,7 +17743,7 @@ var FakeSubscriptionBundleRepository = class {
|
|
|
16992
17743
|
async reactivate(subscriptionBundleId) {
|
|
16993
17744
|
const existing = this.byId.get(subscriptionBundleId);
|
|
16994
17745
|
if (!existing) {
|
|
16995
|
-
throw new Error(`SubscriptionBundle '${subscriptionBundleId}'
|
|
17746
|
+
throw new Error(`SubscriptionBundle '${subscriptionBundleId}' not found`);
|
|
16996
17747
|
}
|
|
16997
17748
|
const updated = {
|
|
16998
17749
|
...existing,
|
|
@@ -17102,7 +17853,7 @@ var FakeSubscriptionContractRepository = class {
|
|
|
17102
17853
|
}
|
|
17103
17854
|
async terminate(contractId, data) {
|
|
17104
17855
|
const existing = this.byId.get(contractId);
|
|
17105
|
-
if (!existing) throw new Error(`SubscriptionContract '${contractId}'
|
|
17856
|
+
if (!existing) throw new Error(`SubscriptionContract '${contractId}' not found`);
|
|
17106
17857
|
const updated = {
|
|
17107
17858
|
...existing,
|
|
17108
17859
|
status: data.status,
|
|
@@ -17262,7 +18013,7 @@ var FakeBundleRepository = class {
|
|
|
17262
18013
|
}
|
|
17263
18014
|
async update(bundleId, data) {
|
|
17264
18015
|
const existing = this.bundles.get(bundleId);
|
|
17265
|
-
if (!existing) throw new Error(`Bundle '${bundleId}'
|
|
18016
|
+
if (!existing) throw new Error(`Bundle '${bundleId}' not found`);
|
|
17266
18017
|
const updated = {
|
|
17267
18018
|
...existing,
|
|
17268
18019
|
label: data.label ?? existing.label,
|
|
@@ -17277,7 +18028,7 @@ var FakeBundleRepository = class {
|
|
|
17277
18028
|
}
|
|
17278
18029
|
async softDelete(bundleId) {
|
|
17279
18030
|
const existing = this.bundles.get(bundleId);
|
|
17280
|
-
if (!existing) throw new Error(`Bundle '${bundleId}'
|
|
18031
|
+
if (!existing) throw new Error(`Bundle '${bundleId}' not found`);
|
|
17281
18032
|
this.bundles.set(bundleId, {
|
|
17282
18033
|
...existing,
|
|
17283
18034
|
deletedAt: this.nowIso()
|
|
@@ -17309,7 +18060,7 @@ var FakeBundleRepository = class {
|
|
|
17309
18060
|
async createDraft(data) {
|
|
17310
18061
|
const draft = await this.findCurrentDraft(data.bundleId);
|
|
17311
18062
|
if (draft) {
|
|
17312
|
-
throw new Error(`Bundle '${data.bundleId}'
|
|
18063
|
+
throw new Error(`Bundle '${data.bundleId}' already has a draft version v${draft.version}`);
|
|
17313
18064
|
}
|
|
17314
18065
|
const all = await this.listVersions(data.bundleId);
|
|
17315
18066
|
const nextVersion = all.length === 0 ? 1 : Math.max(...all.map((v) => v.version)) + 1;
|
|
@@ -17345,7 +18096,7 @@ var FakeBundleRepository = class {
|
|
|
17345
18096
|
}
|
|
17346
18097
|
async updateDraft(versionId, data) {
|
|
17347
18098
|
const existing = this.versions.get(versionId);
|
|
17348
|
-
if (!existing) throw new Error(`BundleVersion '${versionId}'
|
|
18099
|
+
if (!existing) throw new Error(`BundleVersion '${versionId}' not found`);
|
|
17349
18100
|
const updated = {
|
|
17350
18101
|
...existing,
|
|
17351
18102
|
features: data.features ?? existing.features,
|
|
@@ -17367,15 +18118,15 @@ var FakeBundleRepository = class {
|
|
|
17367
18118
|
const existing = this.versions.get(versionId);
|
|
17368
18119
|
if (!existing) return;
|
|
17369
18120
|
if (existing.publishedAt !== null) {
|
|
17370
|
-
throw new Error(`BundleVersion '${versionId}'
|
|
18121
|
+
throw new Error(`BundleVersion '${versionId}' is already published`);
|
|
17371
18122
|
}
|
|
17372
18123
|
this.versions.delete(versionId);
|
|
17373
18124
|
}
|
|
17374
18125
|
async publishDraft(versionId, publishMeta) {
|
|
17375
18126
|
const draft = this.versions.get(versionId);
|
|
17376
|
-
if (!draft) throw new Error(`BundleVersion '${versionId}'
|
|
18127
|
+
if (!draft) throw new Error(`BundleVersion '${versionId}' not found`);
|
|
17377
18128
|
if (draft.publishedAt !== null) {
|
|
17378
|
-
throw new Error(`BundleVersion '${versionId}'
|
|
18129
|
+
throw new Error(`BundleVersion '${versionId}' is already published`);
|
|
17379
18130
|
}
|
|
17380
18131
|
const now = this.nowIso();
|
|
17381
18132
|
const validFromIso = publishMeta.validFrom.toISOString();
|
|
@@ -17451,7 +18202,7 @@ var FakeMarketingProjectionRepository = class {
|
|
|
17451
18202
|
const locale = data.locale ?? "de";
|
|
17452
18203
|
const existing = await this.findByTarget(data.targetType, data.targetVersionId, locale);
|
|
17453
18204
|
if (existing) {
|
|
17454
|
-
throw new Error(`
|
|
18205
|
+
throw new Error(`Marketing projection for ${data.targetType}/${data.targetVersionId}/${locale} already exists`);
|
|
17455
18206
|
}
|
|
17456
18207
|
const now = this.nowIso();
|
|
17457
18208
|
const row = {
|
|
@@ -17479,7 +18230,7 @@ var FakeMarketingProjectionRepository = class {
|
|
|
17479
18230
|
}
|
|
17480
18231
|
async update(id, data) {
|
|
17481
18232
|
const existing = this.rows.get(id);
|
|
17482
|
-
if (!existing) throw new Error(`MarketingProjection '${id}'
|
|
18233
|
+
if (!existing) throw new Error(`MarketingProjection '${id}' not found`);
|
|
17483
18234
|
const updated = {
|
|
17484
18235
|
...existing,
|
|
17485
18236
|
displayLabel: data.displayLabel ?? existing.displayLabel,
|
|
@@ -17499,7 +18250,7 @@ var FakeMarketingProjectionRepository = class {
|
|
|
17499
18250
|
return updated;
|
|
17500
18251
|
}
|
|
17501
18252
|
async delete(id) {
|
|
17502
|
-
if (!this.rows.has(id)) throw new Error(`MarketingProjection '${id}'
|
|
18253
|
+
if (!this.rows.has(id)) throw new Error(`MarketingProjection '${id}' not found`);
|
|
17503
18254
|
this.rows.delete(id);
|
|
17504
18255
|
}
|
|
17505
18256
|
};
|
|
@@ -17573,7 +18324,7 @@ var FakePlanRepository = class {
|
|
|
17573
18324
|
}
|
|
17574
18325
|
async update(planId, data) {
|
|
17575
18326
|
const existing = this.plans.get(planId);
|
|
17576
|
-
if (!existing) throw new Error(`Plan '${planId}'
|
|
18327
|
+
if (!existing) throw new Error(`Plan '${planId}' not found`);
|
|
17577
18328
|
const updated = {
|
|
17578
18329
|
...existing,
|
|
17579
18330
|
label: data.label ?? existing.label,
|
|
@@ -17587,7 +18338,7 @@ var FakePlanRepository = class {
|
|
|
17587
18338
|
}
|
|
17588
18339
|
async softDelete(planId) {
|
|
17589
18340
|
const existing = this.plans.get(planId);
|
|
17590
|
-
if (!existing) throw new Error(`Plan '${planId}'
|
|
18341
|
+
if (!existing) throw new Error(`Plan '${planId}' not found`);
|
|
17591
18342
|
const updated = {
|
|
17592
18343
|
...existing,
|
|
17593
18344
|
deletedAt: this.nowIso(),
|
|
@@ -17640,7 +18391,7 @@ var FakePlanRepository = class {
|
|
|
17640
18391
|
}
|
|
17641
18392
|
async findActivePlanVersion(planKey, asOf = /* @__PURE__ */ new Date()) {
|
|
17642
18393
|
const t = asOf.getTime();
|
|
17643
|
-
const dayStart = (0,
|
|
18394
|
+
const dayStart = (0, import_types35.startOfUtcDay)(asOf).getTime();
|
|
17644
18395
|
const matches = [
|
|
17645
18396
|
...this.versions.values()
|
|
17646
18397
|
].filter((v) => {
|
|
@@ -17704,7 +18455,7 @@ var FakePlanRepository = class {
|
|
|
17704
18455
|
}
|
|
17705
18456
|
async updatePlanVersionDraft(versionId, data) {
|
|
17706
18457
|
const existing = this.versions.get(versionId);
|
|
17707
|
-
if (!existing) throw new Error(`PlanVersion '${versionId}'
|
|
18458
|
+
if (!existing) throw new Error(`PlanVersion '${versionId}' not found`);
|
|
17708
18459
|
const updated = {
|
|
17709
18460
|
...existing,
|
|
17710
18461
|
features: data.features ?? existing.features,
|
|
@@ -17721,7 +18472,7 @@ var FakePlanRepository = class {
|
|
|
17721
18472
|
}
|
|
17722
18473
|
async publishPlanVersionDraft(versionId, publishMeta) {
|
|
17723
18474
|
const draft = this.versions.get(versionId);
|
|
17724
|
-
if (!draft) throw new Error(`PlanVersion '${versionId}'
|
|
18475
|
+
if (!draft) throw new Error(`PlanVersion '${versionId}' not found`);
|
|
17725
18476
|
const planKey = draft.planId;
|
|
17726
18477
|
const now = this.nowIso();
|
|
17727
18478
|
const dayMs = 24 * 60 * 60 * 1e3;
|
|
@@ -17753,14 +18504,14 @@ var FakePlanRepository = class {
|
|
|
17753
18504
|
const existing = this.versions.get(versionId);
|
|
17754
18505
|
if (!existing) return;
|
|
17755
18506
|
if (existing.publishedAt !== null) {
|
|
17756
|
-
throw new Error(`FakePlanRepository:
|
|
18507
|
+
throw new Error(`FakePlanRepository: plan version '${versionId}' is already published \u2014 discard is not allowed`);
|
|
17757
18508
|
}
|
|
17758
18509
|
this.versions.delete(versionId);
|
|
17759
18510
|
}
|
|
17760
18511
|
async terminate(versionId, endsAt) {
|
|
17761
18512
|
const existing = this.versions.get(versionId);
|
|
17762
18513
|
if (!existing) {
|
|
17763
|
-
throw new Error(`FakePlanRepository: PlanVersion '${versionId}'
|
|
18514
|
+
throw new Error(`FakePlanRepository: PlanVersion '${versionId}' not found`);
|
|
17764
18515
|
}
|
|
17765
18516
|
const updated = {
|
|
17766
18517
|
...existing,
|