@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
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
PromoCodesService
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-VQQWF7MW.js";
|
|
4
4
|
import {
|
|
5
5
|
SubscriptionContractService
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-B7NRY2LV.js";
|
|
7
7
|
import {
|
|
8
8
|
AUDIT_CONTEXT_RESOLVER_TOKEN,
|
|
9
9
|
PENDING_PLAN_QUERY_PORT_TOKEN,
|
|
@@ -28,16 +28,19 @@ import {
|
|
|
28
28
|
SUBSCRIPTION_BUNDLE_REPOSITORY_TOKEN,
|
|
29
29
|
isLimitExceededError,
|
|
30
30
|
toEffectiveLimitsSnapshot
|
|
31
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-AGOG5B77.js";
|
|
32
32
|
import {
|
|
33
33
|
PLAN_CATALOG_TOKEN,
|
|
34
34
|
findPlan,
|
|
35
35
|
getMarketedPlans,
|
|
36
36
|
getPlanPriceNet
|
|
37
|
-
} from "./chunk-
|
|
37
|
+
} from "./chunk-DQKCK7DX.js";
|
|
38
38
|
import {
|
|
39
39
|
SUBSCRIPTION_CONTRACT_REPOSITORY_TOKEN
|
|
40
40
|
} from "./chunk-NYLON2VC.js";
|
|
41
|
+
import {
|
|
42
|
+
codedError
|
|
43
|
+
} from "./chunk-EBJJNTW6.js";
|
|
41
44
|
import {
|
|
42
45
|
ENTITLEMENT_SERVICE_TOKEN
|
|
43
46
|
} from "./chunk-WKVBNTYC.js";
|
|
@@ -103,35 +106,35 @@ var PublishValidationError = class extends Error {
|
|
|
103
106
|
function assertChangeNote(changeNote) {
|
|
104
107
|
const trimmed = (changeNote ?? "").trim();
|
|
105
108
|
if (trimmed.length === 0) {
|
|
106
|
-
throw new PublishValidationError("CHANGE_NOTE_REQUIRED", "changeNote
|
|
109
|
+
throw new PublishValidationError("CHANGE_NOTE_REQUIRED", "changeNote is required when publishing a version.");
|
|
107
110
|
}
|
|
108
111
|
return trimmed;
|
|
109
112
|
}
|
|
110
113
|
__name(assertChangeNote, "assertChangeNote");
|
|
111
114
|
function assertDraftPublishable(draft, draftId) {
|
|
112
115
|
if (!draft) {
|
|
113
|
-
throw new PublishValidationError("NOT_FOUND", `PlanVersion ${draftId}
|
|
116
|
+
throw new PublishValidationError("NOT_FOUND", `PlanVersion ${draftId} not found`);
|
|
114
117
|
}
|
|
115
118
|
if (draft.publishedAt !== null) {
|
|
116
|
-
throw new PublishValidationError("ALREADY_PUBLISHED", `PlanVersion ${draftId}
|
|
119
|
+
throw new PublishValidationError("ALREADY_PUBLISHED", `PlanVersion ${draftId} has already been published.`);
|
|
117
120
|
}
|
|
118
121
|
if (!draft.baseVersionId) {
|
|
119
|
-
throw new PublishValidationError("NO_BASE_VERSION", `Draft ${draftId}
|
|
122
|
+
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.`);
|
|
120
123
|
}
|
|
121
124
|
}
|
|
122
125
|
__name(assertDraftPublishable, "assertDraftPublishable");
|
|
123
126
|
function assertBaseVersionFresh(base, baseId, planContext) {
|
|
124
127
|
if (!base) {
|
|
125
|
-
throw new PublishValidationError("BASE_NOT_FOUND", `baseVersion ${baseId}
|
|
128
|
+
throw new PublishValidationError("BASE_NOT_FOUND", `baseVersion ${baseId} no longer exists.`);
|
|
126
129
|
}
|
|
127
130
|
if (base.supersededAt !== null) {
|
|
128
|
-
throw new PublishValidationError("BASE_SUPERSEDED", `
|
|
131
|
+
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.`);
|
|
129
132
|
}
|
|
130
133
|
}
|
|
131
134
|
__name(assertBaseVersionFresh, "assertBaseVersionFresh");
|
|
132
135
|
function assertOptimisticLockHeld(updateCount, draftId) {
|
|
133
136
|
if (updateCount !== 1) {
|
|
134
|
-
throw new PublishValidationError("OPTIMISTIC_LOCK_CONFLICT", `Draft ${draftId}
|
|
137
|
+
throw new PublishValidationError("OPTIMISTIC_LOCK_CONFLICT", `Draft ${draftId} has meanwhile been published by another admin.`);
|
|
135
138
|
}
|
|
136
139
|
}
|
|
137
140
|
__name(assertOptimisticLockHeld, "assertOptimisticLockHeld");
|
|
@@ -182,7 +185,7 @@ var FEATURE_GUARD_CONFIG_TOKEN = /* @__PURE__ */ Symbol.for("saas-platform/Featu
|
|
|
182
185
|
// src/billing/feature.guard.ts
|
|
183
186
|
import { ForbiddenException, Inject, Injectable, Logger, Optional } from "@nestjs/common";
|
|
184
187
|
import { Reflector } from "@nestjs/core";
|
|
185
|
-
import { FEATURE_NOT_LICENSED } from "@saasicat/types";
|
|
188
|
+
import { AUTH_ERROR_CODES, FEATURE_NOT_LICENSED } from "@saasicat/types";
|
|
186
189
|
|
|
187
190
|
// src/billing/upsell.tokens.ts
|
|
188
191
|
var UPSELL_OFFER_RESOLVER_TOKEN = /* @__PURE__ */ Symbol.for("saas-platform/UpsellOfferResolver");
|
|
@@ -229,11 +232,21 @@ var FeatureGuard = class _FeatureGuard {
|
|
|
229
232
|
if (!required || required.length === 0) return true;
|
|
230
233
|
const request = context.switchToHttp().getRequest();
|
|
231
234
|
const user = request.user;
|
|
232
|
-
if (!user)
|
|
235
|
+
if (!user) {
|
|
236
|
+
throw new ForbiddenException({
|
|
237
|
+
code: AUTH_ERROR_CODES.NOT_AUTHENTICATED,
|
|
238
|
+
message: "Not authenticated"
|
|
239
|
+
});
|
|
240
|
+
}
|
|
233
241
|
const role = this.config?.userRoleResolver ? this.config.userRoleResolver(user) : user.role ?? user.platformRole;
|
|
234
242
|
if (role === "SUPER_ADMIN") return true;
|
|
235
243
|
const tenantId = this.config?.tenantIdResolver ? this.config.tenantIdResolver(request) : request.tenantId ?? user.tenantId;
|
|
236
|
-
if (!tenantId)
|
|
244
|
+
if (!tenantId) {
|
|
245
|
+
throw new ForbiddenException({
|
|
246
|
+
code: AUTH_ERROR_CODES.NO_TENANT_ASSIGNED,
|
|
247
|
+
message: "No tenant assigned"
|
|
248
|
+
});
|
|
249
|
+
}
|
|
237
250
|
const compute = /* @__PURE__ */ __name(() => this.entitlements.computeLimits(tenantId), "compute");
|
|
238
251
|
const limits = this.config?.tenantContextRunner ? await this.config.tenantContextRunner(tenantId, compute) : await compute();
|
|
239
252
|
const allowed = required.some((f) => limits.features.has(f));
|
|
@@ -243,25 +256,27 @@ var FeatureGuard = class _FeatureGuard {
|
|
|
243
256
|
return true;
|
|
244
257
|
}
|
|
245
258
|
/**
|
|
246
|
-
* Upsell response (#36):
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
259
|
+
* Upsell response (#36): the 403 body is always the full
|
|
260
|
+
* `FeatureNotLicensedBody`, so a consumer that matches on `code` can read
|
|
261
|
+
* `featureKey`/`offers` unconditionally. Without a registered
|
|
262
|
+
* `UpsellOfferResolver` there is simply nothing to offer — `offers` is
|
|
263
|
+
* empty, the same degradation a failing resolver produces.
|
|
250
264
|
*
|
|
251
265
|
* Deliberately 403 + `code` field instead of 402 — rationale in
|
|
252
266
|
* `@saasicat/types` upsell.types.ts (402 is reserved / inconsistently
|
|
253
267
|
* supported; SPA interceptors must not treat the 403 as an auth error, the
|
|
254
268
|
* distinction runs through `code`).
|
|
269
|
+
*
|
|
270
|
+
* The message must keep starting with the word `Feature` — consumers
|
|
271
|
+
* classify the response by that prefix.
|
|
255
272
|
*/
|
|
256
273
|
async buildNotLicensedException(required, tenantId) {
|
|
257
|
-
const message = `Feature ${required.join(" / ")} nicht im aktuellen Paket enthalten.`;
|
|
258
|
-
if (!this.upsellResolver) return new ForbiddenException(message);
|
|
259
274
|
const body = {
|
|
260
275
|
code: FEATURE_NOT_LICENSED,
|
|
261
276
|
featureKey: required[0],
|
|
262
277
|
featureKeys: required,
|
|
263
|
-
offers: await this.resolveOffersSafe(required, tenantId),
|
|
264
|
-
message
|
|
278
|
+
offers: this.upsellResolver ? await this.resolveOffersSafe(required, tenantId) : [],
|
|
279
|
+
message: `Feature ${required.join(" / ")} is not included in the current plan.`
|
|
265
280
|
};
|
|
266
281
|
return new ForbiddenException(body);
|
|
267
282
|
}
|
|
@@ -273,7 +288,7 @@ var FeatureGuard = class _FeatureGuard {
|
|
|
273
288
|
try {
|
|
274
289
|
return await this.upsellResolver.resolveOffers(required, tenantId);
|
|
275
290
|
} catch (error) {
|
|
276
|
-
this.logger.warn(`UpsellOfferResolver
|
|
291
|
+
this.logger.warn(`UpsellOfferResolver failed for [${required.join(", ")}]: ${String(error)}`);
|
|
277
292
|
return [];
|
|
278
293
|
}
|
|
279
294
|
}
|
|
@@ -426,6 +441,7 @@ __name(compareRankedOffers, "compareRankedOffers");
|
|
|
426
441
|
// src/billing/limit-exceeded.filter.ts
|
|
427
442
|
import { Catch, HttpStatus, Logger as Logger2 } from "@nestjs/common";
|
|
428
443
|
import { BaseExceptionFilter } from "@nestjs/core";
|
|
444
|
+
import { BILLING_ERROR_CODES } from "@saasicat/types";
|
|
429
445
|
function _ts_decorate3(decorators, target, key, desc) {
|
|
430
446
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
431
447
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -446,11 +462,14 @@ var LimitExceededFilter = class _LimitExceededFilter extends BaseExceptionFilter
|
|
|
446
462
|
const ctx = host.switchToHttp();
|
|
447
463
|
const response = ctx.getResponse();
|
|
448
464
|
const request = ctx.getRequest();
|
|
449
|
-
this.logger.warn(`Limit ${exception.dimension}
|
|
465
|
+
this.logger.warn(`Limit ${exception.dimension} exceeded: ${exception.used}/${exception.max} on ${request.method ?? "?"} ${request.url ?? ""}`);
|
|
450
466
|
response.status(HttpStatus.PAYMENT_REQUIRED).send({
|
|
451
467
|
statusCode: HttpStatus.PAYMENT_REQUIRED,
|
|
452
468
|
error: "PaymentRequired",
|
|
453
|
-
|
|
469
|
+
code: BILLING_ERROR_CODES.LIMIT_EXCEEDED,
|
|
470
|
+
// Deprecated: superseded by `code`, kept until consumers have
|
|
471
|
+
// migrated. Same value, only under the older field name.
|
|
472
|
+
reason: BILLING_ERROR_CODES.LIMIT_EXCEEDED,
|
|
454
473
|
dimension: exception.dimension,
|
|
455
474
|
used: exception.used,
|
|
456
475
|
max: exception.max,
|
|
@@ -476,8 +495,8 @@ var PlanCatalogValidationError = class extends Error {
|
|
|
476
495
|
source;
|
|
477
496
|
errors;
|
|
478
497
|
constructor(source, errors) {
|
|
479
|
-
const messages = errors.map((e) => `${e.instancePath || "/"}: ${e.message ?? "
|
|
480
|
-
super(`Plan
|
|
498
|
+
const messages = errors.map((e) => `${e.instancePath || "/"}: ${e.message ?? "unknown"}`).join("\n ");
|
|
499
|
+
super(`Plan catalog validation failed for ${source}:
|
|
481
500
|
${messages}`), this.source = source, this.errors = errors;
|
|
482
501
|
this.name = "PlanCatalogValidationError";
|
|
483
502
|
}
|
|
@@ -494,7 +513,7 @@ __name(loadPlanCatalogFromFile, "loadPlanCatalogFromFile");
|
|
|
494
513
|
function loadPlanCatalogFromString(yamlContent, opts) {
|
|
495
514
|
const parsed = yaml.load(yamlContent);
|
|
496
515
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
497
|
-
throw new Error(`YAML
|
|
516
|
+
throw new Error(`The YAML content of ${opts.source} is not an object`);
|
|
498
517
|
}
|
|
499
518
|
const ajv = new Ajv2020({
|
|
500
519
|
strict: false,
|
|
@@ -518,19 +537,19 @@ function validateConsistency(catalog, source) {
|
|
|
518
537
|
const planIds = /* @__PURE__ */ new Set();
|
|
519
538
|
for (const plan of catalog.plans ?? []) {
|
|
520
539
|
if (planIds.has(plan.id)) {
|
|
521
|
-
errors.push(`plans[].id:
|
|
540
|
+
errors.push(`plans[].id: duplicate plan id "${plan.id}"`);
|
|
522
541
|
}
|
|
523
542
|
planIds.add(plan.id);
|
|
524
543
|
if (catalog.features) {
|
|
525
544
|
for (const fk of plan.features) {
|
|
526
545
|
if (!declaredFeatureKeys.has(fk)) {
|
|
527
|
-
errors.push(`plans[id=${plan.id}].features:
|
|
546
|
+
errors.push(`plans[id=${plan.id}].features: Unknown featureKey "${fk}" \u2014 not declared in catalog.features`);
|
|
528
547
|
}
|
|
529
548
|
}
|
|
530
549
|
}
|
|
531
550
|
}
|
|
532
551
|
if (errors.length > 0) {
|
|
533
|
-
const err = new Error(`Plan
|
|
552
|
+
const err = new Error(`Plan catalog consistency error in ${source}:
|
|
534
553
|
${errors.join("\n ")}`);
|
|
535
554
|
err.name = "PlanCatalogConsistencyError";
|
|
536
555
|
throw err;
|
|
@@ -613,7 +632,7 @@ var PlanCatalogImporterService = class {
|
|
|
613
632
|
if (planResult.created) report.plansCreated++;
|
|
614
633
|
else report.plansSkipped++;
|
|
615
634
|
if (plan.monthlyNet === null || plan.monthlyNet === void 0) {
|
|
616
|
-
report.warnings.push(`Plan '${plan.id}'
|
|
635
|
+
report.warnings.push(`Plan '${plan.id}' has no monthlyNet \u2014 skipped (on-request plans need a manual plan version).`);
|
|
617
636
|
continue;
|
|
618
637
|
}
|
|
619
638
|
const versionResult = await this.sink.upsertPlanVersion({
|
|
@@ -1034,6 +1053,7 @@ PublicCatalogModule = _ts_decorate8([
|
|
|
1034
1053
|
|
|
1035
1054
|
// src/billing/composed-tenant-auth.guard.ts
|
|
1036
1055
|
import { ForbiddenException as ForbiddenException2, Inject as Inject6, Injectable as Injectable4, Optional as Optional4 } from "@nestjs/common";
|
|
1056
|
+
import { AUTH_ERROR_CODES as AUTH_ERROR_CODES2 } from "@saasicat/types";
|
|
1037
1057
|
function _ts_decorate9(decorators, target, key, desc) {
|
|
1038
1058
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
1039
1059
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1061,7 +1081,10 @@ var ComposedTenantAuthGuard = class {
|
|
|
1061
1081
|
}
|
|
1062
1082
|
async canActivate(context) {
|
|
1063
1083
|
if (!this.guards || this.guards.length === 0) {
|
|
1064
|
-
throw new ForbiddenException2(
|
|
1084
|
+
throw new ForbiddenException2({
|
|
1085
|
+
code: AUTH_ERROR_CODES2.AUTH_GUARDS_NOT_CONFIGURED,
|
|
1086
|
+
message: "TenantBillingModule.forRoot.authGuards is not configured."
|
|
1087
|
+
});
|
|
1065
1088
|
}
|
|
1066
1089
|
for (const guard of this.guards) {
|
|
1067
1090
|
const result = await Promise.resolve(guard.canActivate(context));
|
|
@@ -1082,6 +1105,7 @@ ComposedTenantAuthGuard = _ts_decorate9([
|
|
|
1082
1105
|
|
|
1083
1106
|
// src/billing/tenant-admin.guard.ts
|
|
1084
1107
|
import { ForbiddenException as ForbiddenException3, Injectable as Injectable5 } from "@nestjs/common";
|
|
1108
|
+
import { AUTH_ERROR_CODES as AUTH_ERROR_CODES3 } from "@saasicat/types";
|
|
1085
1109
|
function _ts_decorate10(decorators, target, key, desc) {
|
|
1086
1110
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
1087
1111
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1100,11 +1124,17 @@ var TenantAdminGuard = class {
|
|
|
1100
1124
|
canActivate(context) {
|
|
1101
1125
|
const request = context.switchToHttp().getRequest();
|
|
1102
1126
|
if (!request.user) {
|
|
1103
|
-
throw new ForbiddenException3(
|
|
1127
|
+
throw new ForbiddenException3({
|
|
1128
|
+
code: AUTH_ERROR_CODES3.NOT_AUTHENTICATED,
|
|
1129
|
+
message: "Not authenticated"
|
|
1130
|
+
});
|
|
1104
1131
|
}
|
|
1105
1132
|
const role = request.user.platformRole ?? request.user.role;
|
|
1106
1133
|
if (!role || !ADMIN_ROLES.has(role)) {
|
|
1107
|
-
throw new ForbiddenException3(
|
|
1134
|
+
throw new ForbiddenException3({
|
|
1135
|
+
code: AUTH_ERROR_CODES3.TENANT_ADMIN_REQUIRED,
|
|
1136
|
+
message: "This action requires the TENANT_ADMIN role."
|
|
1137
|
+
});
|
|
1108
1138
|
}
|
|
1109
1139
|
return true;
|
|
1110
1140
|
}
|
|
@@ -1142,6 +1172,7 @@ __name(round2, "round2");
|
|
|
1142
1172
|
|
|
1143
1173
|
// src/billing/plan-change-preview.service.ts
|
|
1144
1174
|
import { Inject as Inject7, Injectable as Injectable6, NotFoundException, Optional as Optional5 } from "@nestjs/common";
|
|
1175
|
+
import { BILLING_ERROR_CODES as BILLING_ERROR_CODES2 } from "@saasicat/types";
|
|
1145
1176
|
function _ts_decorate11(decorators, target, key, desc) {
|
|
1146
1177
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
1147
1178
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1180,11 +1211,24 @@ var PlanChangePreviewService = class {
|
|
|
1180
1211
|
async preview(tenantId, targetPlan, targetCycle, now = /* @__PURE__ */ new Date()) {
|
|
1181
1212
|
const sub = await this.subscriptions.findForTenant(tenantId);
|
|
1182
1213
|
if (!sub) {
|
|
1183
|
-
throw new NotFoundException(
|
|
1214
|
+
throw new NotFoundException({
|
|
1215
|
+
code: BILLING_ERROR_CODES2.SUBSCRIPTION_NOT_FOUND,
|
|
1216
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
1217
|
+
params: {
|
|
1218
|
+
tenantId
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1184
1221
|
}
|
|
1185
1222
|
const targetPlanDef = findPlan(this.catalog, targetPlan);
|
|
1186
1223
|
if (!targetPlanDef) {
|
|
1187
|
-
throw new NotFoundException(
|
|
1224
|
+
throw new NotFoundException({
|
|
1225
|
+
code: BILLING_ERROR_CODES2.PLAN_NOT_IN_CATALOG,
|
|
1226
|
+
message: `Plan "${targetPlan}" is not in the catalog (${this.catalog.projectKey})`,
|
|
1227
|
+
params: {
|
|
1228
|
+
planKey: targetPlan,
|
|
1229
|
+
projectKey: this.catalog.projectKey
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1188
1232
|
}
|
|
1189
1233
|
const ctx = {
|
|
1190
1234
|
currentPeriodStart: sub.currentPeriodStart,
|
|
@@ -1249,13 +1293,13 @@ var PlanChangePreviewService = class {
|
|
|
1249
1293
|
if (blockedTargets.includes(targetPlan)) {
|
|
1250
1294
|
blockers.push({
|
|
1251
1295
|
code: `${targetPlan}_NOT_SELF_SERVICE`,
|
|
1252
|
-
message: `${targetSnap.name}
|
|
1296
|
+
message: `${targetSnap.name} is only activated via a special contract. Please contact the contract manager.`
|
|
1253
1297
|
});
|
|
1254
1298
|
}
|
|
1255
1299
|
if (blockedSources.includes(sub.plan)) {
|
|
1256
1300
|
blockers.push({
|
|
1257
1301
|
code: `${sub.plan}_LOCKED`,
|
|
1258
|
-
message: `
|
|
1302
|
+
message: `Active ${currentSnap.name} special contract \u2014 please contact the contract manager to change plans.`
|
|
1259
1303
|
});
|
|
1260
1304
|
}
|
|
1261
1305
|
for (const [key, row] of Object.entries(limitsCheck)) {
|
|
@@ -1263,19 +1307,19 @@ var PlanChangePreviewService = class {
|
|
|
1263
1307
|
const usedDisplay = isFloatQuota(key) ? row.used.toFixed(1) : row.used.toString();
|
|
1264
1308
|
blockers.push({
|
|
1265
1309
|
code: `${key.toUpperCase()}_OVER_TARGET`,
|
|
1266
|
-
message: `
|
|
1310
|
+
message: `Current usage ${usedDisplay} exceeds the target limit ${row.targetMax} (${key}) in the ${targetSnap.name} plan. Please reduce usage first.`
|
|
1267
1311
|
});
|
|
1268
1312
|
}
|
|
1269
1313
|
if (featuresLost.length > 0) {
|
|
1270
1314
|
warnings.push({
|
|
1271
1315
|
code: "FEATURES_LOST",
|
|
1272
|
-
message: `
|
|
1316
|
+
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.`
|
|
1273
1317
|
});
|
|
1274
1318
|
}
|
|
1275
1319
|
if (changeType === "NOOP") {
|
|
1276
1320
|
warnings.push({
|
|
1277
1321
|
code: "NO_CHANGE",
|
|
1278
|
-
message: "
|
|
1322
|
+
message: "Target plan and billing cycle already match the current state."
|
|
1279
1323
|
});
|
|
1280
1324
|
}
|
|
1281
1325
|
const projectedTrialEndsAt = this.trialProjection && sub.status === "TRIAL" ? await this.trialProjection.projectTrialEndsAt({
|
|
@@ -1433,13 +1477,13 @@ var PendingPlanMaterializationService = class _PendingPlanMaterializationService
|
|
|
1433
1477
|
this.entitlements.invalidateTenant(change.tenantId);
|
|
1434
1478
|
applied += 1;
|
|
1435
1479
|
} catch (err) {
|
|
1436
|
-
this.logger.error(`Pending
|
|
1480
|
+
this.logger.error(`Pending plan materialisation failed (tenant ${change.tenantId}): ${String(err)}`);
|
|
1437
1481
|
continue;
|
|
1438
1482
|
}
|
|
1439
1483
|
await this.tryFreeze(change.tenantId, change.pendingPlan, cycle, now);
|
|
1440
1484
|
}
|
|
1441
1485
|
if (applied > 0) {
|
|
1442
|
-
this.logger.log(`Pending
|
|
1486
|
+
this.logger.log(`Pending plan materialisation: applied ${applied} scheduled plan change(s).`);
|
|
1443
1487
|
}
|
|
1444
1488
|
return {
|
|
1445
1489
|
applied
|
|
@@ -1450,7 +1494,7 @@ var PendingPlanMaterializationService = class _PendingPlanMaterializationService
|
|
|
1450
1494
|
try {
|
|
1451
1495
|
await this.contractFreeze.freezeOnPlanChange(tenantId, plan, cycle, now);
|
|
1452
1496
|
} catch (err) {
|
|
1453
|
-
this.logger.error(`Contract
|
|
1497
|
+
this.logger.error(`Contract freeze after materialisation failed (tenant ${tenantId}): ${String(err)}`);
|
|
1454
1498
|
}
|
|
1455
1499
|
}
|
|
1456
1500
|
};
|
|
@@ -1628,14 +1672,14 @@ var PreviewPlanChangeDto = class {
|
|
|
1628
1672
|
_ts_decorate14([
|
|
1629
1673
|
IsString2(),
|
|
1630
1674
|
Matches(PLAN_OR_CYCLE_PATTERN, {
|
|
1631
|
-
message: "plan
|
|
1675
|
+
message: "plan must be SCREAMING_SNAKE_CASE"
|
|
1632
1676
|
}),
|
|
1633
1677
|
_ts_metadata10("design:type", String)
|
|
1634
1678
|
], PreviewPlanChangeDto.prototype, "plan", void 0);
|
|
1635
1679
|
_ts_decorate14([
|
|
1636
1680
|
IsString2(),
|
|
1637
1681
|
Matches(PLAN_OR_CYCLE_PATTERN, {
|
|
1638
|
-
message: "billingCycle
|
|
1682
|
+
message: "billingCycle must be SCREAMING_SNAKE_CASE (e.g. MONTHLY, YEARLY)"
|
|
1639
1683
|
}),
|
|
1640
1684
|
_ts_metadata10("design:type", String)
|
|
1641
1685
|
], PreviewPlanChangeDto.prototype, "billingCycle", void 0);
|
|
@@ -1699,14 +1743,14 @@ var CompleteOnboardingSubscriptionDto = class {
|
|
|
1699
1743
|
_ts_decorate15([
|
|
1700
1744
|
IsString3(),
|
|
1701
1745
|
Matches2(PLAN_OR_CYCLE_PATTERN2, {
|
|
1702
|
-
message: "plan
|
|
1746
|
+
message: "plan must be SCREAMING_SNAKE_CASE"
|
|
1703
1747
|
}),
|
|
1704
1748
|
_ts_metadata11("design:type", String)
|
|
1705
1749
|
], CompleteOnboardingSubscriptionDto.prototype, "plan", void 0);
|
|
1706
1750
|
_ts_decorate15([
|
|
1707
1751
|
IsString3(),
|
|
1708
1752
|
Matches2(PLAN_OR_CYCLE_PATTERN2, {
|
|
1709
|
-
message: "billingCycle
|
|
1753
|
+
message: "billingCycle must be SCREAMING_SNAKE_CASE (e.g. MONTHLY, YEARLY)"
|
|
1710
1754
|
}),
|
|
1711
1755
|
_ts_metadata11("design:type", String)
|
|
1712
1756
|
], CompleteOnboardingSubscriptionDto.prototype, "billingCycle", void 0);
|
|
@@ -1714,7 +1758,7 @@ _ts_decorate15([
|
|
|
1714
1758
|
IsOptional3(),
|
|
1715
1759
|
IsString3(),
|
|
1716
1760
|
Matches2(PROMO_CODE_PATTERN, {
|
|
1717
|
-
message: 'promoCode
|
|
1761
|
+
message: 'promoCode must consist of A\u2013Z, 0\u20139, "-", "_" (4\u201332 characters)'
|
|
1718
1762
|
}),
|
|
1719
1763
|
_ts_metadata11("design:type", String)
|
|
1720
1764
|
], CompleteOnboardingSubscriptionDto.prototype, "promoCode", void 0);
|
|
@@ -1730,9 +1774,11 @@ _ts_decorate15([
|
|
|
1730
1774
|
|
|
1731
1775
|
// src/billing/tenant-billing.controller.ts
|
|
1732
1776
|
import { BadRequestException, Body as Body2, Controller as Controller3, ForbiddenException as ForbiddenException4, Get as Get2, HttpCode, HttpStatus as HttpStatus2, Inject as Inject11, Logger as Logger4, NotFoundException as NotFoundException3, Optional as Optional8, Post as Post2, Req, UseGuards as UseGuards2 } from "@nestjs/common";
|
|
1777
|
+
import { AUTH_ERROR_CODES as AUTH_ERROR_CODES4, BILLING_ERROR_CODES as BILLING_ERROR_CODES4 } from "@saasicat/types";
|
|
1733
1778
|
|
|
1734
1779
|
// src/billing/subscription-bundles.service.ts
|
|
1735
1780
|
import { Inject as Inject10, Injectable as Injectable9, NotFoundException as NotFoundException2, Optional as Optional7, UnprocessableEntityException } from "@nestjs/common";
|
|
1781
|
+
import { BILLING_ERROR_CODES as BILLING_ERROR_CODES3, CATALOG_ERROR_CODES } from "@saasicat/types";
|
|
1736
1782
|
function _ts_decorate16(decorators, target, key, desc) {
|
|
1737
1783
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
1738
1784
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1780,38 +1826,61 @@ var SubscriptionBundlesService = class {
|
|
|
1780
1826
|
async addBundleToSubscription(input) {
|
|
1781
1827
|
const bundleVersion = await this.bundles.findVersionById(input.bundleVersionId);
|
|
1782
1828
|
if (!bundleVersion) {
|
|
1783
|
-
throw new NotFoundException2(
|
|
1829
|
+
throw new NotFoundException2({
|
|
1830
|
+
code: CATALOG_ERROR_CODES.BUNDLE_VERSION_NOT_FOUND,
|
|
1831
|
+
message: `BundleVersion '${input.bundleVersionId}' not found`,
|
|
1832
|
+
params: {
|
|
1833
|
+
bundleVersionId: input.bundleVersionId
|
|
1834
|
+
}
|
|
1835
|
+
});
|
|
1784
1836
|
}
|
|
1785
1837
|
if (bundleVersion.publishedAt === null) {
|
|
1786
1838
|
throw new UnprocessableEntityException({
|
|
1787
|
-
code:
|
|
1788
|
-
message: `BundleVersion '${input.bundleVersionId}'
|
|
1839
|
+
code: CATALOG_ERROR_CODES.BUNDLE_VERSION_NOT_PUBLISHED,
|
|
1840
|
+
message: `BundleVersion '${input.bundleVersionId}' is not published and cannot be booked.`,
|
|
1841
|
+
params: {
|
|
1842
|
+
bundleVersionId: input.bundleVersionId
|
|
1843
|
+
}
|
|
1789
1844
|
});
|
|
1790
1845
|
}
|
|
1791
1846
|
if (bundleVersion.supersededAt !== null) {
|
|
1792
1847
|
throw new UnprocessableEntityException({
|
|
1793
|
-
code:
|
|
1794
|
-
message: `BundleVersion '${input.bundleVersionId}'
|
|
1848
|
+
code: CATALOG_ERROR_CODES.BUNDLE_VERSION_SUPERSEDED,
|
|
1849
|
+
message: `BundleVersion '${input.bundleVersionId}' has been superseded by a newer version.`,
|
|
1850
|
+
params: {
|
|
1851
|
+
bundleVersionId: input.bundleVersionId
|
|
1852
|
+
}
|
|
1795
1853
|
});
|
|
1796
1854
|
}
|
|
1797
1855
|
if (this.blockedBundles?.bundleKeys?.includes(bundleVersion.bundleKey)) {
|
|
1798
1856
|
throw new UnprocessableEntityException({
|
|
1799
|
-
code:
|
|
1800
|
-
message: `Bundle '${bundleVersion.bundleKey}'
|
|
1857
|
+
code: BILLING_ERROR_CODES3.BUNDLE_NOT_SELF_SERVICE,
|
|
1858
|
+
message: `Bundle '${bundleVersion.bundleKey}' is only activated via a special contract. Please contact the contract manager.`,
|
|
1859
|
+
params: {
|
|
1860
|
+
bundleKey: bundleVersion.bundleKey
|
|
1861
|
+
}
|
|
1801
1862
|
});
|
|
1802
1863
|
}
|
|
1803
1864
|
const planIds = bundleVersion.compatibility?.planIds ?? [];
|
|
1804
1865
|
if (planIds.length > 0 && !planIds.includes(input.currentPlanKey)) {
|
|
1805
1866
|
throw new UnprocessableEntityException({
|
|
1806
|
-
code:
|
|
1807
|
-
message: `BundleVersion '${input.bundleVersionId}'
|
|
1867
|
+
code: BILLING_ERROR_CODES3.BUNDLE_INCOMPATIBLE_WITH_PLAN,
|
|
1868
|
+
message: `BundleVersion '${input.bundleVersionId}' is not compatible with plan '${input.currentPlanKey}'. Allowed: [${planIds.join(", ")}].`,
|
|
1869
|
+
params: {
|
|
1870
|
+
bundleVersionId: input.bundleVersionId,
|
|
1871
|
+
planKey: input.currentPlanKey,
|
|
1872
|
+
allowedPlanKeys: planIds
|
|
1873
|
+
}
|
|
1808
1874
|
});
|
|
1809
1875
|
}
|
|
1810
1876
|
const active = await this.repo.listActiveBySubscription(input.subscriptionId);
|
|
1811
1877
|
if (active.some((b) => b.bundleVersionId === input.bundleVersionId)) {
|
|
1812
1878
|
throw new UnprocessableEntityException({
|
|
1813
|
-
code:
|
|
1814
|
-
message: `Subscription '${input.subscriptionId}'
|
|
1879
|
+
code: BILLING_ERROR_CODES3.BUNDLE_ALREADY_SUBSCRIBED,
|
|
1880
|
+
message: `Subscription '${input.subscriptionId}' has already actively booked this bundle.`,
|
|
1881
|
+
params: {
|
|
1882
|
+
subscriptionId: input.subscriptionId
|
|
1883
|
+
}
|
|
1815
1884
|
});
|
|
1816
1885
|
}
|
|
1817
1886
|
const startedAt = input.startedAt ?? /* @__PURE__ */ new Date();
|
|
@@ -1827,12 +1896,21 @@ var SubscriptionBundlesService = class {
|
|
|
1827
1896
|
async cancelBundleFromSubscription(input) {
|
|
1828
1897
|
const existing = await this.repo.findById(input.subscriptionBundleId);
|
|
1829
1898
|
if (!existing) {
|
|
1830
|
-
throw new NotFoundException2(
|
|
1899
|
+
throw new NotFoundException2({
|
|
1900
|
+
code: BILLING_ERROR_CODES3.SUBSCRIPTION_BUNDLE_NOT_FOUND,
|
|
1901
|
+
message: `SubscriptionBundle '${input.subscriptionBundleId}' not found`,
|
|
1902
|
+
params: {
|
|
1903
|
+
subscriptionBundleId: input.subscriptionBundleId
|
|
1904
|
+
}
|
|
1905
|
+
});
|
|
1831
1906
|
}
|
|
1832
1907
|
if (existing.canceledAt !== null) {
|
|
1833
1908
|
throw new UnprocessableEntityException({
|
|
1834
|
-
code:
|
|
1835
|
-
message: `SubscriptionBundle '${input.subscriptionBundleId}'
|
|
1909
|
+
code: BILLING_ERROR_CODES3.SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED,
|
|
1910
|
+
message: `SubscriptionBundle '${input.subscriptionBundleId}' is already cancelled.`,
|
|
1911
|
+
params: {
|
|
1912
|
+
subscriptionBundleId: input.subscriptionBundleId
|
|
1913
|
+
}
|
|
1836
1914
|
});
|
|
1837
1915
|
}
|
|
1838
1916
|
const canceledAt = input.canceledAt ?? /* @__PURE__ */ new Date();
|
|
@@ -1853,18 +1931,27 @@ var SubscriptionBundlesService = class {
|
|
|
1853
1931
|
async reactivateBundle(subscriptionBundleId) {
|
|
1854
1932
|
const existing = await this.repo.findById(subscriptionBundleId);
|
|
1855
1933
|
if (!existing) {
|
|
1856
|
-
throw new NotFoundException2(
|
|
1934
|
+
throw new NotFoundException2({
|
|
1935
|
+
code: BILLING_ERROR_CODES3.SUBSCRIPTION_BUNDLE_NOT_FOUND,
|
|
1936
|
+
message: `SubscriptionBundle '${subscriptionBundleId}' not found`,
|
|
1937
|
+
params: {
|
|
1938
|
+
subscriptionBundleId
|
|
1939
|
+
}
|
|
1940
|
+
});
|
|
1857
1941
|
}
|
|
1858
1942
|
if (existing.canceledAt === null) {
|
|
1859
1943
|
throw new UnprocessableEntityException({
|
|
1860
|
-
code:
|
|
1861
|
-
message: `SubscriptionBundle '${subscriptionBundleId}'
|
|
1944
|
+
code: BILLING_ERROR_CODES3.SUBSCRIPTION_BUNDLE_NOT_CANCELLED,
|
|
1945
|
+
message: `SubscriptionBundle '${subscriptionBundleId}' is not cancelled.`,
|
|
1946
|
+
params: {
|
|
1947
|
+
subscriptionBundleId
|
|
1948
|
+
}
|
|
1862
1949
|
});
|
|
1863
1950
|
}
|
|
1864
1951
|
if (existing.canceledEffectiveAt !== null && existing.canceledEffectiveAt <= /* @__PURE__ */ new Date()) {
|
|
1865
1952
|
throw new UnprocessableEntityException({
|
|
1866
|
-
code:
|
|
1867
|
-
message: "
|
|
1953
|
+
code: BILLING_ERROR_CODES3.SUBSCRIPTION_BUNDLE_CANCELLATION_EFFECTIVE,
|
|
1954
|
+
message: "Cancellation already in effect \u2014 book the bundle again."
|
|
1868
1955
|
});
|
|
1869
1956
|
}
|
|
1870
1957
|
return this.repo.reactivate(subscriptionBundleId);
|
|
@@ -1970,7 +2057,13 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1970
2057
|
this.usageSnapshot.snapshot(tenantId)
|
|
1971
2058
|
]);
|
|
1972
2059
|
if (!sub) {
|
|
1973
|
-
throw new NotFoundException3(
|
|
2060
|
+
throw new NotFoundException3({
|
|
2061
|
+
code: BILLING_ERROR_CODES4.SUBSCRIPTION_NOT_FOUND,
|
|
2062
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
2063
|
+
params: {
|
|
2064
|
+
tenantId
|
|
2065
|
+
}
|
|
2066
|
+
});
|
|
1974
2067
|
}
|
|
1975
2068
|
const usage = {};
|
|
1976
2069
|
for (const key of /* @__PURE__ */ new Set([
|
|
@@ -2019,14 +2112,29 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2019
2112
|
const userId = this.requireUserId(req);
|
|
2020
2113
|
const blockedTargets = this.blockedPlans?.asTarget ?? [];
|
|
2021
2114
|
if (blockedTargets.includes(dto.plan)) {
|
|
2022
|
-
throw new ForbiddenException4(
|
|
2115
|
+
throw new ForbiddenException4({
|
|
2116
|
+
code: BILLING_ERROR_CODES4.PLAN_NOT_SELF_SERVICE,
|
|
2117
|
+
message: `${dto.plan} is not activated via self-service.`,
|
|
2118
|
+
params: {
|
|
2119
|
+
planKey: dto.plan
|
|
2120
|
+
}
|
|
2121
|
+
});
|
|
2023
2122
|
}
|
|
2024
2123
|
const sub = await this.subscriptionUsage.findForTenant(tenantId);
|
|
2025
|
-
if (!sub)
|
|
2124
|
+
if (!sub) {
|
|
2125
|
+
throw new NotFoundException3({
|
|
2126
|
+
code: BILLING_ERROR_CODES4.SUBSCRIPTION_NOT_FOUND,
|
|
2127
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
2128
|
+
params: {
|
|
2129
|
+
tenantId
|
|
2130
|
+
}
|
|
2131
|
+
});
|
|
2132
|
+
}
|
|
2026
2133
|
const blockers = await this.planPreview.assertChangeAllowed(tenantId, dto.plan, dto.billingCycle);
|
|
2027
2134
|
if (blockers.length > 0) {
|
|
2028
2135
|
throw new BadRequestException({
|
|
2029
|
-
|
|
2136
|
+
code: BILLING_ERROR_CODES4.PLAN_CHANGE_BLOCKED,
|
|
2137
|
+
message: "Plan change is blocked.",
|
|
2030
2138
|
blockers
|
|
2031
2139
|
});
|
|
2032
2140
|
}
|
|
@@ -2096,14 +2204,29 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2096
2204
|
const warnings = [];
|
|
2097
2205
|
const blockedTargets = this.blockedPlans?.asTarget ?? [];
|
|
2098
2206
|
if (blockedTargets.includes(dto.plan)) {
|
|
2099
|
-
throw new ForbiddenException4(
|
|
2207
|
+
throw new ForbiddenException4({
|
|
2208
|
+
code: BILLING_ERROR_CODES4.PLAN_NOT_SELF_SERVICE,
|
|
2209
|
+
message: `${dto.plan} is not activated via self-service.`,
|
|
2210
|
+
params: {
|
|
2211
|
+
planKey: dto.plan
|
|
2212
|
+
}
|
|
2213
|
+
});
|
|
2100
2214
|
}
|
|
2101
2215
|
const sub = await this.subscriptionUsage.findForTenant(tenantId);
|
|
2102
|
-
if (!sub)
|
|
2216
|
+
if (!sub) {
|
|
2217
|
+
throw new NotFoundException3({
|
|
2218
|
+
code: BILLING_ERROR_CODES4.SUBSCRIPTION_NOT_FOUND,
|
|
2219
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
2220
|
+
params: {
|
|
2221
|
+
tenantId
|
|
2222
|
+
}
|
|
2223
|
+
});
|
|
2224
|
+
}
|
|
2103
2225
|
const blockers = await this.planPreview.assertChangeAllowed(tenantId, dto.plan, dto.billingCycle);
|
|
2104
2226
|
if (blockers.length > 0) {
|
|
2105
2227
|
throw new BadRequestException({
|
|
2106
|
-
|
|
2228
|
+
code: BILLING_ERROR_CODES4.PLAN_CHANGE_BLOCKED,
|
|
2229
|
+
message: "Plan change during onboarding is blocked.",
|
|
2107
2230
|
blockers
|
|
2108
2231
|
});
|
|
2109
2232
|
}
|
|
@@ -2137,8 +2260,13 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2137
2260
|
warnings.push(...this.collectPromoSkipReasons(dto.promoCode, sub));
|
|
2138
2261
|
}
|
|
2139
2262
|
} catch (err) {
|
|
2263
|
+
const reason = err instanceof Error ? err.message : null;
|
|
2140
2264
|
throw new BadRequestException({
|
|
2141
|
-
|
|
2265
|
+
code: BILLING_ERROR_CODES4.ONBOARDING_CREATE_FAILED,
|
|
2266
|
+
message: reason ? `Onboarding creation failed: ${reason}` : "Onboarding creation failed",
|
|
2267
|
+
params: {
|
|
2268
|
+
reason
|
|
2269
|
+
}
|
|
2142
2270
|
});
|
|
2143
2271
|
}
|
|
2144
2272
|
} else {
|
|
@@ -2151,9 +2279,9 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2151
2279
|
});
|
|
2152
2280
|
if (dto.promoCode) {
|
|
2153
2281
|
if (!this.promoCodes) {
|
|
2154
|
-
warnings.push("
|
|
2282
|
+
warnings.push("A promo code was sent, but PromoCodesModule is not loaded \u2014 the code was NOT redeemed.");
|
|
2155
2283
|
} else if (!sub.id) {
|
|
2156
|
-
warnings.push("
|
|
2284
|
+
warnings.push("A promo code was sent, but the adapter returns no SubscriptionUsageRecord.id \u2014 the code was NOT redeemed.");
|
|
2157
2285
|
} else {
|
|
2158
2286
|
try {
|
|
2159
2287
|
const redemption = await this.promoCodes.redeem({
|
|
@@ -2164,8 +2292,8 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2164
2292
|
});
|
|
2165
2293
|
promoRedemption = this.toResponseRedemption(redemption, dto.promoCode);
|
|
2166
2294
|
} catch (err) {
|
|
2167
|
-
const message = err instanceof Error ? err.message : "
|
|
2168
|
-
warnings.push(`
|
|
2295
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
2296
|
+
warnings.push(`The promo code could not be redeemed: ${message}`);
|
|
2169
2297
|
}
|
|
2170
2298
|
}
|
|
2171
2299
|
}
|
|
@@ -2191,11 +2319,11 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2191
2319
|
});
|
|
2192
2320
|
bundlesAdded += 1;
|
|
2193
2321
|
} catch (err) {
|
|
2194
|
-
warnings.push(`Bundle '${bundleVersionId}'
|
|
2322
|
+
warnings.push(`Bundle '${bundleVersionId}' could not be booked: ` + (err instanceof Error ? err.message : String(err)));
|
|
2195
2323
|
}
|
|
2196
2324
|
}
|
|
2197
2325
|
} else if (bundleVersionIds.length > 0 && !this.subscriptionBundles) {
|
|
2198
|
-
warnings.push("Bundle
|
|
2326
|
+
warnings.push("Bundle bookings were requested during onboarding, but SubscriptionBundleModule is not registered in the consumer. No bundles were created.");
|
|
2199
2327
|
}
|
|
2200
2328
|
return {
|
|
2201
2329
|
plan: planResult.plan,
|
|
@@ -2215,9 +2343,20 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2215
2343
|
const tenantId = this.requireTenantId(req);
|
|
2216
2344
|
const userId = this.requireUserId(req);
|
|
2217
2345
|
const sub = await this.subscriptionUsage.findForTenant(tenantId);
|
|
2218
|
-
if (!sub)
|
|
2346
|
+
if (!sub) {
|
|
2347
|
+
throw new NotFoundException3({
|
|
2348
|
+
code: BILLING_ERROR_CODES4.SUBSCRIPTION_NOT_FOUND,
|
|
2349
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
2350
|
+
params: {
|
|
2351
|
+
tenantId
|
|
2352
|
+
}
|
|
2353
|
+
});
|
|
2354
|
+
}
|
|
2219
2355
|
if (!sub.pendingPlanVersion) {
|
|
2220
|
-
throw new BadRequestException(
|
|
2356
|
+
throw new BadRequestException({
|
|
2357
|
+
code: BILLING_ERROR_CODES4.NO_PENDING_PLAN_VERSION,
|
|
2358
|
+
message: "There is no pending plan version awaiting confirmation."
|
|
2359
|
+
});
|
|
2221
2360
|
}
|
|
2222
2361
|
const result = await this.subscriptionWrite.acceptPendingPlanVersion(tenantId, userId, /* @__PURE__ */ new Date());
|
|
2223
2362
|
if (!result.alreadyAccepted) {
|
|
@@ -2262,7 +2401,10 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2262
2401
|
const resolver = this.tenantIdResolver ?? ((r) => r.user?.tenantId ?? null);
|
|
2263
2402
|
const tenantId = resolver(req);
|
|
2264
2403
|
if (!tenantId) {
|
|
2265
|
-
throw new NotFoundException3(
|
|
2404
|
+
throw new NotFoundException3({
|
|
2405
|
+
code: AUTH_ERROR_CODES4.TENANT_CONTEXT_MISSING,
|
|
2406
|
+
message: "No tenant ID found on the request"
|
|
2407
|
+
});
|
|
2266
2408
|
}
|
|
2267
2409
|
return tenantId;
|
|
2268
2410
|
}
|
|
@@ -2270,7 +2412,10 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2270
2412
|
const resolver = this.userIdResolver ?? ((r) => r.user?.sub ?? r.user?.id ?? null);
|
|
2271
2413
|
const userId = resolver(req);
|
|
2272
2414
|
if (!userId) {
|
|
2273
|
-
throw new NotFoundException3(
|
|
2415
|
+
throw new NotFoundException3({
|
|
2416
|
+
code: AUTH_ERROR_CODES4.TENANT_CONTEXT_MISSING,
|
|
2417
|
+
message: "No user ID found on the request"
|
|
2418
|
+
});
|
|
2274
2419
|
}
|
|
2275
2420
|
return userId;
|
|
2276
2421
|
}
|
|
@@ -2310,10 +2455,10 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2310
2455
|
collectPromoSkipReasons(promoCode, sub) {
|
|
2311
2456
|
const reasons = [];
|
|
2312
2457
|
if (!this.promoCodes) {
|
|
2313
|
-
reasons.push(`Promo-Code "${promoCode}"
|
|
2458
|
+
reasons.push(`Promo-Code "${promoCode}" was sent, but PromoCodesModule is not loaded \u2014 the code was NOT redeemed.`);
|
|
2314
2459
|
}
|
|
2315
2460
|
if (!sub.id) {
|
|
2316
|
-
reasons.push(`Promo-Code "${promoCode}"
|
|
2461
|
+
reasons.push(`Promo-Code "${promoCode}" was sent, but the adapter returns no SubscriptionUsageRecord.id \u2014 the code was NOT redeemed.`);
|
|
2317
2462
|
}
|
|
2318
2463
|
return reasons;
|
|
2319
2464
|
}
|
|
@@ -2328,7 +2473,7 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2328
2473
|
try {
|
|
2329
2474
|
await this.contractFreeze.freezeOnPlanChange(tenantId, plan, cycle, /* @__PURE__ */ new Date());
|
|
2330
2475
|
} catch (err) {
|
|
2331
|
-
this.logger.error(`Contract
|
|
2476
|
+
this.logger.error(`Contract freeze after plan change failed (tenant ${tenantId}): ${String(err)}`);
|
|
2332
2477
|
}
|
|
2333
2478
|
}
|
|
2334
2479
|
/**
|
|
@@ -2639,7 +2784,7 @@ __name(normalizeQuotas, "normalizeQuotas");
|
|
|
2639
2784
|
|
|
2640
2785
|
// src/billing/subscription-bundle-preview.service.ts
|
|
2641
2786
|
import { Inject as Inject12, Injectable as Injectable11, NotFoundException as NotFoundException4, Optional as Optional9 } from "@nestjs/common";
|
|
2642
|
-
import { buildFeatureRequiresIndex as buildFeatureRequiresIndex3, collectUnsatisfiedRequires as collectUnsatisfiedRequires3 } from "@saasicat/types";
|
|
2787
|
+
import { BILLING_ERROR_CODES as BILLING_ERROR_CODES5, CATALOG_ERROR_CODES as CATALOG_ERROR_CODES2, buildFeatureRequiresIndex as buildFeatureRequiresIndex3, collectUnsatisfiedRequires as collectUnsatisfiedRequires3 } from "@saasicat/types";
|
|
2643
2788
|
function _ts_decorate20(decorators, target, key, desc) {
|
|
2644
2789
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
2645
2790
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -2678,7 +2823,13 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2678
2823
|
async previewAdd(ctx, input, now = /* @__PURE__ */ new Date()) {
|
|
2679
2824
|
const bundleVersion = await this.bundles.findVersionById(input.bundleVersionId);
|
|
2680
2825
|
if (!bundleVersion) {
|
|
2681
|
-
throw new NotFoundException4(
|
|
2826
|
+
throw new NotFoundException4({
|
|
2827
|
+
code: CATALOG_ERROR_CODES2.BUNDLE_VERSION_NOT_FOUND,
|
|
2828
|
+
message: `BundleVersion '${input.bundleVersionId}' not found`,
|
|
2829
|
+
params: {
|
|
2830
|
+
bundleVersionId: input.bundleVersionId
|
|
2831
|
+
}
|
|
2832
|
+
});
|
|
2682
2833
|
}
|
|
2683
2834
|
const blockers = [];
|
|
2684
2835
|
const warnings = [];
|
|
@@ -2687,7 +2838,7 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2687
2838
|
if (activeBundleVersions.some((bv) => bv.id === bundleVersion.id)) {
|
|
2688
2839
|
blockers.push({
|
|
2689
2840
|
code: "BUNDLE_ALREADY_SUBSCRIBED",
|
|
2690
|
-
message: "
|
|
2841
|
+
message: "This bundle is already actively booked."
|
|
2691
2842
|
});
|
|
2692
2843
|
}
|
|
2693
2844
|
const planFeatures = new Set(await this.resolvePlanFeatures(ctx.currentPlanKey, now));
|
|
@@ -2695,14 +2846,14 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2695
2846
|
if (redundantFeatures.length > 0) {
|
|
2696
2847
|
warnings.push({
|
|
2697
2848
|
code: "REDUNDANT_FEATURES",
|
|
2698
|
-
message: `${redundantFeatures.length}
|
|
2849
|
+
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.`
|
|
2699
2850
|
});
|
|
2700
2851
|
}
|
|
2701
2852
|
const missingRequires = await this.collectMissingRequires(bundleVersion, planFeatures, activeBundleVersions);
|
|
2702
2853
|
if (missingRequires.length > 0) {
|
|
2703
2854
|
blockers.push({
|
|
2704
2855
|
code: "BUNDLE_FEATURE_DEPENDENCY_UNSATISFIED",
|
|
2705
|
-
message: `
|
|
2856
|
+
message: `The bundle requires [${missingRequires.join(", ")}] \u2014 present neither in the plan nor in the active bundles.`
|
|
2706
2857
|
});
|
|
2707
2858
|
}
|
|
2708
2859
|
const priceNet = resolveBundlePriceNet(bundleVersion, ctx.currentPlanKey, ctx.billingCycle);
|
|
@@ -2731,18 +2882,30 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2731
2882
|
async previewCancel(ctx, input, now = /* @__PURE__ */ new Date()) {
|
|
2732
2883
|
const existing = await this.subscriptionBundles.findById(input.subscriptionBundleId);
|
|
2733
2884
|
if (!existing || existing.subscriptionId !== ctx.subscriptionId) {
|
|
2734
|
-
throw new NotFoundException4(
|
|
2885
|
+
throw new NotFoundException4({
|
|
2886
|
+
code: BILLING_ERROR_CODES5.SUBSCRIPTION_BUNDLE_NOT_FOUND,
|
|
2887
|
+
message: `SubscriptionBundle '${input.subscriptionBundleId}' not found`,
|
|
2888
|
+
params: {
|
|
2889
|
+
subscriptionBundleId: input.subscriptionBundleId
|
|
2890
|
+
}
|
|
2891
|
+
});
|
|
2735
2892
|
}
|
|
2736
2893
|
const bundleVersion = await this.bundles.findVersionById(existing.bundleVersionId);
|
|
2737
2894
|
if (!bundleVersion) {
|
|
2738
|
-
throw new NotFoundException4(
|
|
2895
|
+
throw new NotFoundException4({
|
|
2896
|
+
code: CATALOG_ERROR_CODES2.BUNDLE_VERSION_NOT_FOUND,
|
|
2897
|
+
message: `BundleVersion '${existing.bundleVersionId}' not found`,
|
|
2898
|
+
params: {
|
|
2899
|
+
bundleVersionId: existing.bundleVersionId
|
|
2900
|
+
}
|
|
2901
|
+
});
|
|
2739
2902
|
}
|
|
2740
2903
|
const blockers = [];
|
|
2741
2904
|
const warnings = [];
|
|
2742
2905
|
if (existing.canceledAt !== null) {
|
|
2743
2906
|
blockers.push({
|
|
2744
|
-
code:
|
|
2745
|
-
message: "
|
|
2907
|
+
code: BILLING_ERROR_CODES5.SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED,
|
|
2908
|
+
message: "This bundle booking is already cancelled."
|
|
2746
2909
|
});
|
|
2747
2910
|
}
|
|
2748
2911
|
const effectiveAt = resolveBundleCancelEffectiveAt({
|
|
@@ -2754,7 +2917,7 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2754
2917
|
if (existing.minimumTermEndsAt && existing.minimumTermEndsAt.getTime() > periodEnd.getTime()) {
|
|
2755
2918
|
warnings.push({
|
|
2756
2919
|
code: "MINIMUM_TERM_BINDS",
|
|
2757
|
-
message: "
|
|
2920
|
+
message: "The minimum term extends beyond the end of the period \u2014 the cancellation only takes effect when the minimum term ends."
|
|
2758
2921
|
});
|
|
2759
2922
|
}
|
|
2760
2923
|
return {
|
|
@@ -2773,26 +2936,26 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2773
2936
|
if (bundleVersion.publishedAt === null) {
|
|
2774
2937
|
blockers.push({
|
|
2775
2938
|
code: "BUNDLE_VERSION_NOT_PUBLISHED",
|
|
2776
|
-
message: "
|
|
2939
|
+
message: "This bundle version is not published and cannot be booked."
|
|
2777
2940
|
});
|
|
2778
2941
|
}
|
|
2779
2942
|
if (bundleVersion.supersededAt !== null) {
|
|
2780
2943
|
blockers.push({
|
|
2781
2944
|
code: "BUNDLE_VERSION_SUPERSEDED",
|
|
2782
|
-
message: "
|
|
2945
|
+
message: "This bundle version has been superseded by a newer one."
|
|
2783
2946
|
});
|
|
2784
2947
|
}
|
|
2785
2948
|
const planIds = bundleVersion.compatibility?.planIds ?? [];
|
|
2786
2949
|
if (planIds.length > 0 && !planIds.includes(currentPlanKey)) {
|
|
2787
2950
|
blockers.push({
|
|
2788
2951
|
code: "BUNDLE_INCOMPATIBLE_WITH_PLAN",
|
|
2789
|
-
message: `
|
|
2952
|
+
message: `The bundle is not compatible with plan '${currentPlanKey}'. Allowed: [${planIds.join(", ")}].`
|
|
2790
2953
|
});
|
|
2791
2954
|
}
|
|
2792
2955
|
if (this.blockedBundles?.bundleKeys?.includes(bundleVersion.bundleKey)) {
|
|
2793
2956
|
blockers.push({
|
|
2794
2957
|
code: "BUNDLE_NOT_SELF_SERVICE",
|
|
2795
|
-
message: `Bundle '${bundleVersion.bundleKey}'
|
|
2958
|
+
message: `Bundle '${bundleVersion.bundleKey}' is only activated via a special contract. Please contact the contract manager.`
|
|
2796
2959
|
});
|
|
2797
2960
|
}
|
|
2798
2961
|
}
|
|
@@ -2905,6 +3068,7 @@ import { Module as Module4 } from "@nestjs/common";
|
|
|
2905
3068
|
|
|
2906
3069
|
// src/billing/tenant-subscription-bundles.controller.ts
|
|
2907
3070
|
import { BadRequestException as BadRequestException2, Body as Body3, Controller as Controller4, Delete, Get as Get3, HttpCode as HttpCode2, HttpStatus as HttpStatus3, Inject as Inject13, Logger as Logger5, NotFoundException as NotFoundException5, Optional as Optional10, Param, ParseUUIDPipe, Post as Post3, Req as Req2, UseGuards as UseGuards3 } from "@nestjs/common";
|
|
3071
|
+
import { AUTH_ERROR_CODES as AUTH_ERROR_CODES5, BILLING_ERROR_CODES as BILLING_ERROR_CODES6 } from "@saasicat/types";
|
|
2908
3072
|
|
|
2909
3073
|
// src/billing/dto/subscription-bundles.dto.ts
|
|
2910
3074
|
import { IsInt, IsOptional as IsOptional4, IsUUID as IsUUID2, Matches as Matches3, Max, Min } from "class-validator";
|
|
@@ -2985,7 +3149,7 @@ var CancelSubscriptionBundleDto = class {
|
|
|
2985
3149
|
_ts_decorate21([
|
|
2986
3150
|
IsOptional4(),
|
|
2987
3151
|
Matches3(ISO_DATE_PATTERN, {
|
|
2988
|
-
message: "canceledAt
|
|
3152
|
+
message: "canceledAt must be an ISO date (YYYY-MM-DD)"
|
|
2989
3153
|
}),
|
|
2990
3154
|
_ts_metadata15("design:type", String)
|
|
2991
3155
|
], CancelSubscriptionBundleDto.prototype, "canceledAt", void 0);
|
|
@@ -3049,7 +3213,10 @@ function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
|
3049
3213
|
const hasAdd = Boolean(dto.bundleVersionId);
|
|
3050
3214
|
const hasCancel = Boolean(dto.subscriptionBundleId);
|
|
3051
3215
|
if (hasAdd === hasCancel) {
|
|
3052
|
-
throw new BadRequestException2(
|
|
3216
|
+
throw new BadRequestException2({
|
|
3217
|
+
code: BILLING_ERROR_CODES6.BUNDLE_PREVIEW_ARGUMENT_AMBIGUOUS,
|
|
3218
|
+
message: "Exactly one of bundleVersionId (add preview) or subscriptionBundleId (cancel preview) must be given."
|
|
3219
|
+
});
|
|
3053
3220
|
}
|
|
3054
3221
|
const sub = await this.requireSubscription(this.requireTenantId(req));
|
|
3055
3222
|
const ctx = {
|
|
@@ -3089,21 +3256,30 @@ function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
|
3089
3256
|
}
|
|
3090
3257
|
requireSubscriptionPk(sub) {
|
|
3091
3258
|
if (!sub.id) {
|
|
3092
|
-
throw new NotFoundException5(
|
|
3259
|
+
throw new NotFoundException5(codedError(BILLING_ERROR_CODES6.SUBSCRIPTION_PK_MISSING));
|
|
3093
3260
|
}
|
|
3094
3261
|
return sub.id;
|
|
3095
3262
|
}
|
|
3096
3263
|
requireTenantId(req) {
|
|
3097
3264
|
const tenantId = this.tenantIdResolver(req);
|
|
3098
3265
|
if (!tenantId) {
|
|
3099
|
-
throw new NotFoundException5(
|
|
3266
|
+
throw new NotFoundException5({
|
|
3267
|
+
code: AUTH_ERROR_CODES5.TENANT_CONTEXT_MISSING,
|
|
3268
|
+
message: "No tenant ID found on the request"
|
|
3269
|
+
});
|
|
3100
3270
|
}
|
|
3101
3271
|
return tenantId;
|
|
3102
3272
|
}
|
|
3103
3273
|
async requireSubscription(tenantId) {
|
|
3104
3274
|
const sub = await this.subscriptionUsage.findForTenant(tenantId);
|
|
3105
3275
|
if (!sub) {
|
|
3106
|
-
throw new NotFoundException5(
|
|
3276
|
+
throw new NotFoundException5({
|
|
3277
|
+
code: BILLING_ERROR_CODES6.SUBSCRIPTION_NOT_FOUND,
|
|
3278
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
3279
|
+
params: {
|
|
3280
|
+
tenantId
|
|
3281
|
+
}
|
|
3282
|
+
});
|
|
3107
3283
|
}
|
|
3108
3284
|
return sub;
|
|
3109
3285
|
}
|
|
@@ -3122,7 +3298,7 @@ function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
|
3122
3298
|
try {
|
|
3123
3299
|
await this.contractFreeze.freezeOnPlanChange(tenantId, sub.planVersion.planId, sub.billingCycle, /* @__PURE__ */ new Date());
|
|
3124
3300
|
} catch (err) {
|
|
3125
|
-
this.logger.error(`Contract
|
|
3301
|
+
this.logger.error(`Contract freeze after bundle mutation failed (tenant ${tenantId}): ${String(err)}`);
|
|
3126
3302
|
}
|
|
3127
3303
|
}
|
|
3128
3304
|
};
|