@saasicat/nest 0.18.0 → 0.19.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 +1309 -519
- package/dist/admin/index.js +1 -1
- package/dist/billing/index.d.cts +7 -4
- package/dist/billing/index.d.ts +7 -4
- package/dist/billing/index.js +6 -6
- package/dist/catalog/index.js +4 -4
- package/dist/checkout-offer/index.js +2 -2
- package/dist/{chunk-Z3FWN3HJ.js → chunk-5ZXBXYXU.js} +3 -3
- package/dist/{chunk-RZVSKKXX.js → chunk-6WA4X5U3.js} +46 -45
- package/dist/{chunk-V34AA7C7.js → chunk-6WL6VQ7Z.js} +1 -1
- package/dist/{chunk-ABB2CRAS.js → chunk-74SCJ4HF.js} +1 -1
- package/dist/{chunk-PLJEAUFU.js → chunk-7JIHG53V.js} +76 -39
- package/dist/{chunk-7RRWI5LH.js → chunk-AFK2IE2L.js} +10 -10
- 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-GLLCB4BP.js → chunk-EUU3B6NB.js} +17 -13
- package/dist/{chunk-EXOLOJFZ.js → chunk-F6DCLVBB.js} +29 -8
- package/dist/{chunk-5KH2O4SF.js → chunk-I77VS3YH.js} +287 -105
- package/dist/{chunk-YITTE646.js → chunk-KUDKD7ZG.js} +34 -14
- package/dist/{chunk-P3DP6GJE.js → chunk-LXUBCLRK.js} +486 -160
- package/dist/{chunk-GJBXOCRZ.js → chunk-ORWIGSNN.js} +45 -17
- package/dist/{chunk-E7AJ42AB.js → chunk-QLJHIIOY.js} +168 -28
- package/dist/{chunk-BOADBIGB.js → chunk-YYUXKLXK.js} +2 -2
- package/dist/{define-saasicat-Rc0rXrYG.d.ts → define-saasicat-CqkhnHn-.d.ts} +1 -1
- package/dist/{define-saasicat-CeDBX9ji.d.cts → define-saasicat-D8c6Bs4T.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-DMw-bPyk.d.cts → enforce-quota.interceptor-Dk0eEtzV.d.cts} +1 -1
- package/dist/{enforce-quota.interceptor-CmbWX39L.d.ts → enforce-quota.interceptor-Dp8wTYro.d.ts} +1 -1
- package/dist/entitlement/index.js +2 -2
- package/dist/index.d.cts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.js +17 -17
- package/dist/{module-DgYJur6f.d.ts → module-BwD-wAor.d.ts} +1 -1
- package/dist/{module-CSN8j8Tp.d.cts → module-DsDkbawm.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 +13 -13
- package/dist/promo/index.d.cts +3 -3
- package/dist/promo/index.d.ts +3 -3
- package/dist/promo/index.js +3 -3
- package/dist/registration/index.js +1 -1
- package/dist/{saas-platform.module-DLhoqKNA.d.ts → saas-platform.module-BQmrDGVU.d.ts} +1 -1
- package/dist/{saas-platform.module-D3rUTiho.d.cts → saas-platform.module-DducnQ9s.d.cts} +1 -1
- package/dist/{service-DX8KbGXl.d.cts → service-DPZ2W1E-.d.cts} +2 -2
- package/dist/{service-DX8KbGXl.d.ts → service-DPZ2W1E-.d.ts} +2 -2
- package/dist/subscription-contract/index.js +2 -2
- package/dist/{tenant-billing.controller-BwZRnJ3q.d.ts → tenant-billing.controller-b1G1yB09.d.ts} +2 -2
- package/dist/{tenant-billing.controller-CG4CaFUP.d.cts → tenant-billing.controller-m42YQ_Ei.d.cts} +2 -2
- package/dist/testing/index.d.cts +5 -5
- package/dist/testing/index.d.ts +5 -5
- package/dist/testing/index.js +33 -33
- package/package.json +3 -3
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
PromoCodesService
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-QLJHIIOY.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,13 +28,13 @@ import {
|
|
|
28
28
|
SUBSCRIPTION_BUNDLE_REPOSITORY_TOKEN,
|
|
29
29
|
isLimitExceededError,
|
|
30
30
|
toEffectiveLimitsSnapshot
|
|
31
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-F6DCLVBB.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";
|
|
@@ -103,35 +103,35 @@ var PublishValidationError = class extends Error {
|
|
|
103
103
|
function assertChangeNote(changeNote) {
|
|
104
104
|
const trimmed = (changeNote ?? "").trim();
|
|
105
105
|
if (trimmed.length === 0) {
|
|
106
|
-
throw new PublishValidationError("CHANGE_NOTE_REQUIRED", "changeNote
|
|
106
|
+
throw new PublishValidationError("CHANGE_NOTE_REQUIRED", "changeNote is required when publishing a version.");
|
|
107
107
|
}
|
|
108
108
|
return trimmed;
|
|
109
109
|
}
|
|
110
110
|
__name(assertChangeNote, "assertChangeNote");
|
|
111
111
|
function assertDraftPublishable(draft, draftId) {
|
|
112
112
|
if (!draft) {
|
|
113
|
-
throw new PublishValidationError("NOT_FOUND", `PlanVersion ${draftId}
|
|
113
|
+
throw new PublishValidationError("NOT_FOUND", `PlanVersion ${draftId} not found`);
|
|
114
114
|
}
|
|
115
115
|
if (draft.publishedAt !== null) {
|
|
116
|
-
throw new PublishValidationError("ALREADY_PUBLISHED", `PlanVersion ${draftId}
|
|
116
|
+
throw new PublishValidationError("ALREADY_PUBLISHED", `PlanVersion ${draftId} has already been published.`);
|
|
117
117
|
}
|
|
118
118
|
if (!draft.baseVersionId) {
|
|
119
|
-
throw new PublishValidationError("NO_BASE_VERSION", `Draft ${draftId}
|
|
119
|
+
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
120
|
}
|
|
121
121
|
}
|
|
122
122
|
__name(assertDraftPublishable, "assertDraftPublishable");
|
|
123
123
|
function assertBaseVersionFresh(base, baseId, planContext) {
|
|
124
124
|
if (!base) {
|
|
125
|
-
throw new PublishValidationError("BASE_NOT_FOUND", `baseVersion ${baseId}
|
|
125
|
+
throw new PublishValidationError("BASE_NOT_FOUND", `baseVersion ${baseId} no longer exists.`);
|
|
126
126
|
}
|
|
127
127
|
if (base.supersededAt !== null) {
|
|
128
|
-
throw new PublishValidationError("BASE_SUPERSEDED", `
|
|
128
|
+
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
129
|
}
|
|
130
130
|
}
|
|
131
131
|
__name(assertBaseVersionFresh, "assertBaseVersionFresh");
|
|
132
132
|
function assertOptimisticLockHeld(updateCount, draftId) {
|
|
133
133
|
if (updateCount !== 1) {
|
|
134
|
-
throw new PublishValidationError("OPTIMISTIC_LOCK_CONFLICT", `Draft ${draftId}
|
|
134
|
+
throw new PublishValidationError("OPTIMISTIC_LOCK_CONFLICT", `Draft ${draftId} has meanwhile been published by another admin.`);
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
__name(assertOptimisticLockHeld, "assertOptimisticLockHeld");
|
|
@@ -182,7 +182,7 @@ var FEATURE_GUARD_CONFIG_TOKEN = /* @__PURE__ */ Symbol.for("saas-platform/Featu
|
|
|
182
182
|
// src/billing/feature.guard.ts
|
|
183
183
|
import { ForbiddenException, Inject, Injectable, Logger, Optional } from "@nestjs/common";
|
|
184
184
|
import { Reflector } from "@nestjs/core";
|
|
185
|
-
import { FEATURE_NOT_LICENSED } from "@saasicat/types";
|
|
185
|
+
import { AUTH_ERROR_CODES, FEATURE_NOT_LICENSED } from "@saasicat/types";
|
|
186
186
|
|
|
187
187
|
// src/billing/upsell.tokens.ts
|
|
188
188
|
var UPSELL_OFFER_RESOLVER_TOKEN = /* @__PURE__ */ Symbol.for("saas-platform/UpsellOfferResolver");
|
|
@@ -229,11 +229,21 @@ var FeatureGuard = class _FeatureGuard {
|
|
|
229
229
|
if (!required || required.length === 0) return true;
|
|
230
230
|
const request = context.switchToHttp().getRequest();
|
|
231
231
|
const user = request.user;
|
|
232
|
-
if (!user)
|
|
232
|
+
if (!user) {
|
|
233
|
+
throw new ForbiddenException({
|
|
234
|
+
code: AUTH_ERROR_CODES.NOT_AUTHENTICATED,
|
|
235
|
+
message: "Not authenticated"
|
|
236
|
+
});
|
|
237
|
+
}
|
|
233
238
|
const role = this.config?.userRoleResolver ? this.config.userRoleResolver(user) : user.role ?? user.platformRole;
|
|
234
239
|
if (role === "SUPER_ADMIN") return true;
|
|
235
240
|
const tenantId = this.config?.tenantIdResolver ? this.config.tenantIdResolver(request) : request.tenantId ?? user.tenantId;
|
|
236
|
-
if (!tenantId)
|
|
241
|
+
if (!tenantId) {
|
|
242
|
+
throw new ForbiddenException({
|
|
243
|
+
code: AUTH_ERROR_CODES.NO_TENANT_ASSIGNED,
|
|
244
|
+
message: "No tenant assigned"
|
|
245
|
+
});
|
|
246
|
+
}
|
|
237
247
|
const compute = /* @__PURE__ */ __name(() => this.entitlements.computeLimits(tenantId), "compute");
|
|
238
248
|
const limits = this.config?.tenantContextRunner ? await this.config.tenantContextRunner(tenantId, compute) : await compute();
|
|
239
249
|
const allowed = required.some((f) => limits.features.has(f));
|
|
@@ -245,17 +255,28 @@ var FeatureGuard = class _FeatureGuard {
|
|
|
245
255
|
/**
|
|
246
256
|
* Upsell response (#36): with a registered `UpsellOfferResolver` the 403
|
|
247
257
|
* becomes machine-readable (`FeatureNotLicensedBody`), so consumer UIs can
|
|
248
|
-
* render a purchase offer. Without a resolver the
|
|
249
|
-
*
|
|
258
|
+
* render a purchase offer. Without a resolver the body carries the same
|
|
259
|
+
* `code` but no offers.
|
|
250
260
|
*
|
|
251
261
|
* Deliberately 403 + `code` field instead of 402 — rationale in
|
|
252
262
|
* `@saasicat/types` upsell.types.ts (402 is reserved / inconsistently
|
|
253
263
|
* supported; SPA interceptors must not treat the 403 as an auth error, the
|
|
254
264
|
* distinction runs through `code`).
|
|
265
|
+
*
|
|
266
|
+
* The message must keep starting with the word `Feature` — consumers
|
|
267
|
+
* classify the response by that prefix.
|
|
255
268
|
*/
|
|
256
269
|
async buildNotLicensedException(required, tenantId) {
|
|
257
|
-
const message = `Feature ${required.join(" / ")}
|
|
258
|
-
if (!this.upsellResolver)
|
|
270
|
+
const message = `Feature ${required.join(" / ")} is not included in the current plan.`;
|
|
271
|
+
if (!this.upsellResolver) {
|
|
272
|
+
return new ForbiddenException({
|
|
273
|
+
code: FEATURE_NOT_LICENSED,
|
|
274
|
+
message,
|
|
275
|
+
params: {
|
|
276
|
+
featureKeys: required
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
259
280
|
const body = {
|
|
260
281
|
code: FEATURE_NOT_LICENSED,
|
|
261
282
|
featureKey: required[0],
|
|
@@ -273,7 +294,7 @@ var FeatureGuard = class _FeatureGuard {
|
|
|
273
294
|
try {
|
|
274
295
|
return await this.upsellResolver.resolveOffers(required, tenantId);
|
|
275
296
|
} catch (error) {
|
|
276
|
-
this.logger.warn(`UpsellOfferResolver
|
|
297
|
+
this.logger.warn(`UpsellOfferResolver failed for [${required.join(", ")}]: ${String(error)}`);
|
|
277
298
|
return [];
|
|
278
299
|
}
|
|
279
300
|
}
|
|
@@ -426,6 +447,7 @@ __name(compareRankedOffers, "compareRankedOffers");
|
|
|
426
447
|
// src/billing/limit-exceeded.filter.ts
|
|
427
448
|
import { Catch, HttpStatus, Logger as Logger2 } from "@nestjs/common";
|
|
428
449
|
import { BaseExceptionFilter } from "@nestjs/core";
|
|
450
|
+
import { BILLING_ERROR_CODES } from "@saasicat/types";
|
|
429
451
|
function _ts_decorate3(decorators, target, key, desc) {
|
|
430
452
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
431
453
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -446,11 +468,14 @@ var LimitExceededFilter = class _LimitExceededFilter extends BaseExceptionFilter
|
|
|
446
468
|
const ctx = host.switchToHttp();
|
|
447
469
|
const response = ctx.getResponse();
|
|
448
470
|
const request = ctx.getRequest();
|
|
449
|
-
this.logger.warn(`Limit ${exception.dimension}
|
|
471
|
+
this.logger.warn(`Limit ${exception.dimension} exceeded: ${exception.used}/${exception.max} on ${request.method ?? "?"} ${request.url ?? ""}`);
|
|
450
472
|
response.status(HttpStatus.PAYMENT_REQUIRED).send({
|
|
451
473
|
statusCode: HttpStatus.PAYMENT_REQUIRED,
|
|
452
474
|
error: "PaymentRequired",
|
|
453
|
-
|
|
475
|
+
code: BILLING_ERROR_CODES.LIMIT_EXCEEDED,
|
|
476
|
+
// Deprecated: superseded by `code`, kept until consumers have
|
|
477
|
+
// migrated. Same value, only under the older field name.
|
|
478
|
+
reason: BILLING_ERROR_CODES.LIMIT_EXCEEDED,
|
|
454
479
|
dimension: exception.dimension,
|
|
455
480
|
used: exception.used,
|
|
456
481
|
max: exception.max,
|
|
@@ -476,8 +501,8 @@ var PlanCatalogValidationError = class extends Error {
|
|
|
476
501
|
source;
|
|
477
502
|
errors;
|
|
478
503
|
constructor(source, errors) {
|
|
479
|
-
const messages = errors.map((e) => `${e.instancePath || "/"}: ${e.message ?? "
|
|
480
|
-
super(`Plan
|
|
504
|
+
const messages = errors.map((e) => `${e.instancePath || "/"}: ${e.message ?? "unknown"}`).join("\n ");
|
|
505
|
+
super(`Plan catalog validation failed for ${source}:
|
|
481
506
|
${messages}`), this.source = source, this.errors = errors;
|
|
482
507
|
this.name = "PlanCatalogValidationError";
|
|
483
508
|
}
|
|
@@ -494,7 +519,7 @@ __name(loadPlanCatalogFromFile, "loadPlanCatalogFromFile");
|
|
|
494
519
|
function loadPlanCatalogFromString(yamlContent, opts) {
|
|
495
520
|
const parsed = yaml.load(yamlContent);
|
|
496
521
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
497
|
-
throw new Error(`YAML
|
|
522
|
+
throw new Error(`The YAML content of ${opts.source} is not an object`);
|
|
498
523
|
}
|
|
499
524
|
const ajv = new Ajv2020({
|
|
500
525
|
strict: false,
|
|
@@ -518,19 +543,19 @@ function validateConsistency(catalog, source) {
|
|
|
518
543
|
const planIds = /* @__PURE__ */ new Set();
|
|
519
544
|
for (const plan of catalog.plans ?? []) {
|
|
520
545
|
if (planIds.has(plan.id)) {
|
|
521
|
-
errors.push(`plans[].id:
|
|
546
|
+
errors.push(`plans[].id: duplicate plan id "${plan.id}"`);
|
|
522
547
|
}
|
|
523
548
|
planIds.add(plan.id);
|
|
524
549
|
if (catalog.features) {
|
|
525
550
|
for (const fk of plan.features) {
|
|
526
551
|
if (!declaredFeatureKeys.has(fk)) {
|
|
527
|
-
errors.push(`plans[id=${plan.id}].features:
|
|
552
|
+
errors.push(`plans[id=${plan.id}].features: Unknown featureKey "${fk}" \u2014 not declared in catalog.features`);
|
|
528
553
|
}
|
|
529
554
|
}
|
|
530
555
|
}
|
|
531
556
|
}
|
|
532
557
|
if (errors.length > 0) {
|
|
533
|
-
const err = new Error(`Plan
|
|
558
|
+
const err = new Error(`Plan catalog consistency error in ${source}:
|
|
534
559
|
${errors.join("\n ")}`);
|
|
535
560
|
err.name = "PlanCatalogConsistencyError";
|
|
536
561
|
throw err;
|
|
@@ -613,7 +638,7 @@ var PlanCatalogImporterService = class {
|
|
|
613
638
|
if (planResult.created) report.plansCreated++;
|
|
614
639
|
else report.plansSkipped++;
|
|
615
640
|
if (plan.monthlyNet === null || plan.monthlyNet === void 0) {
|
|
616
|
-
report.warnings.push(`Plan '${plan.id}'
|
|
641
|
+
report.warnings.push(`Plan '${plan.id}' has no monthlyNet \u2014 skipped (on-request plans need a manual plan version).`);
|
|
617
642
|
continue;
|
|
618
643
|
}
|
|
619
644
|
const versionResult = await this.sink.upsertPlanVersion({
|
|
@@ -1034,6 +1059,7 @@ PublicCatalogModule = _ts_decorate8([
|
|
|
1034
1059
|
|
|
1035
1060
|
// src/billing/composed-tenant-auth.guard.ts
|
|
1036
1061
|
import { ForbiddenException as ForbiddenException2, Inject as Inject6, Injectable as Injectable4, Optional as Optional4 } from "@nestjs/common";
|
|
1062
|
+
import { AUTH_ERROR_CODES as AUTH_ERROR_CODES2 } from "@saasicat/types";
|
|
1037
1063
|
function _ts_decorate9(decorators, target, key, desc) {
|
|
1038
1064
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
1039
1065
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1061,7 +1087,10 @@ var ComposedTenantAuthGuard = class {
|
|
|
1061
1087
|
}
|
|
1062
1088
|
async canActivate(context) {
|
|
1063
1089
|
if (!this.guards || this.guards.length === 0) {
|
|
1064
|
-
throw new ForbiddenException2(
|
|
1090
|
+
throw new ForbiddenException2({
|
|
1091
|
+
code: AUTH_ERROR_CODES2.AUTH_GUARDS_NOT_CONFIGURED,
|
|
1092
|
+
message: "TenantBillingModule.forRoot.authGuards is not configured."
|
|
1093
|
+
});
|
|
1065
1094
|
}
|
|
1066
1095
|
for (const guard of this.guards) {
|
|
1067
1096
|
const result = await Promise.resolve(guard.canActivate(context));
|
|
@@ -1082,6 +1111,7 @@ ComposedTenantAuthGuard = _ts_decorate9([
|
|
|
1082
1111
|
|
|
1083
1112
|
// src/billing/tenant-admin.guard.ts
|
|
1084
1113
|
import { ForbiddenException as ForbiddenException3, Injectable as Injectable5 } from "@nestjs/common";
|
|
1114
|
+
import { AUTH_ERROR_CODES as AUTH_ERROR_CODES3 } from "@saasicat/types";
|
|
1085
1115
|
function _ts_decorate10(decorators, target, key, desc) {
|
|
1086
1116
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
1087
1117
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1100,11 +1130,17 @@ var TenantAdminGuard = class {
|
|
|
1100
1130
|
canActivate(context) {
|
|
1101
1131
|
const request = context.switchToHttp().getRequest();
|
|
1102
1132
|
if (!request.user) {
|
|
1103
|
-
throw new ForbiddenException3(
|
|
1133
|
+
throw new ForbiddenException3({
|
|
1134
|
+
code: AUTH_ERROR_CODES3.NOT_AUTHENTICATED,
|
|
1135
|
+
message: "Not authenticated"
|
|
1136
|
+
});
|
|
1104
1137
|
}
|
|
1105
1138
|
const role = request.user.platformRole ?? request.user.role;
|
|
1106
1139
|
if (!role || !ADMIN_ROLES.has(role)) {
|
|
1107
|
-
throw new ForbiddenException3(
|
|
1140
|
+
throw new ForbiddenException3({
|
|
1141
|
+
code: AUTH_ERROR_CODES3.TENANT_ADMIN_REQUIRED,
|
|
1142
|
+
message: "This action requires the TENANT_ADMIN role."
|
|
1143
|
+
});
|
|
1108
1144
|
}
|
|
1109
1145
|
return true;
|
|
1110
1146
|
}
|
|
@@ -1142,6 +1178,7 @@ __name(round2, "round2");
|
|
|
1142
1178
|
|
|
1143
1179
|
// src/billing/plan-change-preview.service.ts
|
|
1144
1180
|
import { Inject as Inject7, Injectable as Injectable6, NotFoundException, Optional as Optional5 } from "@nestjs/common";
|
|
1181
|
+
import { BILLING_ERROR_CODES as BILLING_ERROR_CODES2 } from "@saasicat/types";
|
|
1145
1182
|
function _ts_decorate11(decorators, target, key, desc) {
|
|
1146
1183
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
1147
1184
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1180,11 +1217,24 @@ var PlanChangePreviewService = class {
|
|
|
1180
1217
|
async preview(tenantId, targetPlan, targetCycle, now = /* @__PURE__ */ new Date()) {
|
|
1181
1218
|
const sub = await this.subscriptions.findForTenant(tenantId);
|
|
1182
1219
|
if (!sub) {
|
|
1183
|
-
throw new NotFoundException(
|
|
1220
|
+
throw new NotFoundException({
|
|
1221
|
+
code: BILLING_ERROR_CODES2.SUBSCRIPTION_NOT_FOUND,
|
|
1222
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
1223
|
+
params: {
|
|
1224
|
+
tenantId
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1184
1227
|
}
|
|
1185
1228
|
const targetPlanDef = findPlan(this.catalog, targetPlan);
|
|
1186
1229
|
if (!targetPlanDef) {
|
|
1187
|
-
throw new NotFoundException(
|
|
1230
|
+
throw new NotFoundException({
|
|
1231
|
+
code: BILLING_ERROR_CODES2.PLAN_NOT_IN_CATALOG,
|
|
1232
|
+
message: `Plan "${targetPlan}" is not in the catalog (${this.catalog.projectKey})`,
|
|
1233
|
+
params: {
|
|
1234
|
+
planKey: targetPlan,
|
|
1235
|
+
projectKey: this.catalog.projectKey
|
|
1236
|
+
}
|
|
1237
|
+
});
|
|
1188
1238
|
}
|
|
1189
1239
|
const ctx = {
|
|
1190
1240
|
currentPeriodStart: sub.currentPeriodStart,
|
|
@@ -1249,13 +1299,13 @@ var PlanChangePreviewService = class {
|
|
|
1249
1299
|
if (blockedTargets.includes(targetPlan)) {
|
|
1250
1300
|
blockers.push({
|
|
1251
1301
|
code: `${targetPlan}_NOT_SELF_SERVICE`,
|
|
1252
|
-
message: `${targetSnap.name}
|
|
1302
|
+
message: `${targetSnap.name} is only activated via a special contract. Please contact the contract manager.`
|
|
1253
1303
|
});
|
|
1254
1304
|
}
|
|
1255
1305
|
if (blockedSources.includes(sub.plan)) {
|
|
1256
1306
|
blockers.push({
|
|
1257
1307
|
code: `${sub.plan}_LOCKED`,
|
|
1258
|
-
message: `
|
|
1308
|
+
message: `Active ${currentSnap.name} special contract \u2014 please contact the contract manager to change plans.`
|
|
1259
1309
|
});
|
|
1260
1310
|
}
|
|
1261
1311
|
for (const [key, row] of Object.entries(limitsCheck)) {
|
|
@@ -1263,19 +1313,19 @@ var PlanChangePreviewService = class {
|
|
|
1263
1313
|
const usedDisplay = isFloatQuota(key) ? row.used.toFixed(1) : row.used.toString();
|
|
1264
1314
|
blockers.push({
|
|
1265
1315
|
code: `${key.toUpperCase()}_OVER_TARGET`,
|
|
1266
|
-
message: `
|
|
1316
|
+
message: `Current usage ${usedDisplay} exceeds the target limit ${row.targetMax} (${key}) in the ${targetSnap.name} plan. Please reduce usage first.`
|
|
1267
1317
|
});
|
|
1268
1318
|
}
|
|
1269
1319
|
if (featuresLost.length > 0) {
|
|
1270
1320
|
warnings.push({
|
|
1271
1321
|
code: "FEATURES_LOST",
|
|
1272
|
-
message: `
|
|
1322
|
+
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
1323
|
});
|
|
1274
1324
|
}
|
|
1275
1325
|
if (changeType === "NOOP") {
|
|
1276
1326
|
warnings.push({
|
|
1277
1327
|
code: "NO_CHANGE",
|
|
1278
|
-
message: "
|
|
1328
|
+
message: "Target plan and billing cycle already match the current state."
|
|
1279
1329
|
});
|
|
1280
1330
|
}
|
|
1281
1331
|
const projectedTrialEndsAt = this.trialProjection && sub.status === "TRIAL" ? await this.trialProjection.projectTrialEndsAt({
|
|
@@ -1433,13 +1483,13 @@ var PendingPlanMaterializationService = class _PendingPlanMaterializationService
|
|
|
1433
1483
|
this.entitlements.invalidateTenant(change.tenantId);
|
|
1434
1484
|
applied += 1;
|
|
1435
1485
|
} catch (err) {
|
|
1436
|
-
this.logger.error(`Pending
|
|
1486
|
+
this.logger.error(`Pending plan materialisation failed (tenant ${change.tenantId}): ${String(err)}`);
|
|
1437
1487
|
continue;
|
|
1438
1488
|
}
|
|
1439
1489
|
await this.tryFreeze(change.tenantId, change.pendingPlan, cycle, now);
|
|
1440
1490
|
}
|
|
1441
1491
|
if (applied > 0) {
|
|
1442
|
-
this.logger.log(`Pending
|
|
1492
|
+
this.logger.log(`Pending plan materialisation: applied ${applied} scheduled plan change(s).`);
|
|
1443
1493
|
}
|
|
1444
1494
|
return {
|
|
1445
1495
|
applied
|
|
@@ -1450,7 +1500,7 @@ var PendingPlanMaterializationService = class _PendingPlanMaterializationService
|
|
|
1450
1500
|
try {
|
|
1451
1501
|
await this.contractFreeze.freezeOnPlanChange(tenantId, plan, cycle, now);
|
|
1452
1502
|
} catch (err) {
|
|
1453
|
-
this.logger.error(`Contract
|
|
1503
|
+
this.logger.error(`Contract freeze after materialisation failed (tenant ${tenantId}): ${String(err)}`);
|
|
1454
1504
|
}
|
|
1455
1505
|
}
|
|
1456
1506
|
};
|
|
@@ -1628,14 +1678,14 @@ var PreviewPlanChangeDto = class {
|
|
|
1628
1678
|
_ts_decorate14([
|
|
1629
1679
|
IsString2(),
|
|
1630
1680
|
Matches(PLAN_OR_CYCLE_PATTERN, {
|
|
1631
|
-
message: "plan
|
|
1681
|
+
message: "plan must be SCREAMING_SNAKE_CASE"
|
|
1632
1682
|
}),
|
|
1633
1683
|
_ts_metadata10("design:type", String)
|
|
1634
1684
|
], PreviewPlanChangeDto.prototype, "plan", void 0);
|
|
1635
1685
|
_ts_decorate14([
|
|
1636
1686
|
IsString2(),
|
|
1637
1687
|
Matches(PLAN_OR_CYCLE_PATTERN, {
|
|
1638
|
-
message: "billingCycle
|
|
1688
|
+
message: "billingCycle must be SCREAMING_SNAKE_CASE (e.g. MONTHLY, YEARLY)"
|
|
1639
1689
|
}),
|
|
1640
1690
|
_ts_metadata10("design:type", String)
|
|
1641
1691
|
], PreviewPlanChangeDto.prototype, "billingCycle", void 0);
|
|
@@ -1699,14 +1749,14 @@ var CompleteOnboardingSubscriptionDto = class {
|
|
|
1699
1749
|
_ts_decorate15([
|
|
1700
1750
|
IsString3(),
|
|
1701
1751
|
Matches2(PLAN_OR_CYCLE_PATTERN2, {
|
|
1702
|
-
message: "plan
|
|
1752
|
+
message: "plan must be SCREAMING_SNAKE_CASE"
|
|
1703
1753
|
}),
|
|
1704
1754
|
_ts_metadata11("design:type", String)
|
|
1705
1755
|
], CompleteOnboardingSubscriptionDto.prototype, "plan", void 0);
|
|
1706
1756
|
_ts_decorate15([
|
|
1707
1757
|
IsString3(),
|
|
1708
1758
|
Matches2(PLAN_OR_CYCLE_PATTERN2, {
|
|
1709
|
-
message: "billingCycle
|
|
1759
|
+
message: "billingCycle must be SCREAMING_SNAKE_CASE (e.g. MONTHLY, YEARLY)"
|
|
1710
1760
|
}),
|
|
1711
1761
|
_ts_metadata11("design:type", String)
|
|
1712
1762
|
], CompleteOnboardingSubscriptionDto.prototype, "billingCycle", void 0);
|
|
@@ -1714,7 +1764,7 @@ _ts_decorate15([
|
|
|
1714
1764
|
IsOptional3(),
|
|
1715
1765
|
IsString3(),
|
|
1716
1766
|
Matches2(PROMO_CODE_PATTERN, {
|
|
1717
|
-
message: 'promoCode
|
|
1767
|
+
message: 'promoCode must consist of A\u2013Z, 0\u20139, "-", "_" (4\u201332 characters)'
|
|
1718
1768
|
}),
|
|
1719
1769
|
_ts_metadata11("design:type", String)
|
|
1720
1770
|
], CompleteOnboardingSubscriptionDto.prototype, "promoCode", void 0);
|
|
@@ -1730,9 +1780,11 @@ _ts_decorate15([
|
|
|
1730
1780
|
|
|
1731
1781
|
// src/billing/tenant-billing.controller.ts
|
|
1732
1782
|
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";
|
|
1783
|
+
import { AUTH_ERROR_CODES as AUTH_ERROR_CODES4, BILLING_ERROR_CODES as BILLING_ERROR_CODES4 } from "@saasicat/types";
|
|
1733
1784
|
|
|
1734
1785
|
// src/billing/subscription-bundles.service.ts
|
|
1735
1786
|
import { Inject as Inject10, Injectable as Injectable9, NotFoundException as NotFoundException2, Optional as Optional7, UnprocessableEntityException } from "@nestjs/common";
|
|
1787
|
+
import { BILLING_ERROR_CODES as BILLING_ERROR_CODES3, CATALOG_ERROR_CODES } from "@saasicat/types";
|
|
1736
1788
|
function _ts_decorate16(decorators, target, key, desc) {
|
|
1737
1789
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
1738
1790
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1780,38 +1832,61 @@ var SubscriptionBundlesService = class {
|
|
|
1780
1832
|
async addBundleToSubscription(input) {
|
|
1781
1833
|
const bundleVersion = await this.bundles.findVersionById(input.bundleVersionId);
|
|
1782
1834
|
if (!bundleVersion) {
|
|
1783
|
-
throw new NotFoundException2(
|
|
1835
|
+
throw new NotFoundException2({
|
|
1836
|
+
code: CATALOG_ERROR_CODES.BUNDLE_VERSION_NOT_FOUND,
|
|
1837
|
+
message: `BundleVersion '${input.bundleVersionId}' not found`,
|
|
1838
|
+
params: {
|
|
1839
|
+
bundleVersionId: input.bundleVersionId
|
|
1840
|
+
}
|
|
1841
|
+
});
|
|
1784
1842
|
}
|
|
1785
1843
|
if (bundleVersion.publishedAt === null) {
|
|
1786
1844
|
throw new UnprocessableEntityException({
|
|
1787
|
-
code:
|
|
1788
|
-
message: `BundleVersion '${input.bundleVersionId}'
|
|
1845
|
+
code: CATALOG_ERROR_CODES.BUNDLE_VERSION_NOT_PUBLISHED,
|
|
1846
|
+
message: `BundleVersion '${input.bundleVersionId}' is not published and cannot be booked.`,
|
|
1847
|
+
params: {
|
|
1848
|
+
bundleVersionId: input.bundleVersionId
|
|
1849
|
+
}
|
|
1789
1850
|
});
|
|
1790
1851
|
}
|
|
1791
1852
|
if (bundleVersion.supersededAt !== null) {
|
|
1792
1853
|
throw new UnprocessableEntityException({
|
|
1793
|
-
code:
|
|
1794
|
-
message: `BundleVersion '${input.bundleVersionId}'
|
|
1854
|
+
code: CATALOG_ERROR_CODES.BUNDLE_VERSION_SUPERSEDED,
|
|
1855
|
+
message: `BundleVersion '${input.bundleVersionId}' has been superseded by a newer version.`,
|
|
1856
|
+
params: {
|
|
1857
|
+
bundleVersionId: input.bundleVersionId
|
|
1858
|
+
}
|
|
1795
1859
|
});
|
|
1796
1860
|
}
|
|
1797
1861
|
if (this.blockedBundles?.bundleKeys?.includes(bundleVersion.bundleKey)) {
|
|
1798
1862
|
throw new UnprocessableEntityException({
|
|
1799
|
-
code:
|
|
1800
|
-
message: `Bundle '${bundleVersion.bundleKey}'
|
|
1863
|
+
code: BILLING_ERROR_CODES3.BUNDLE_NOT_SELF_SERVICE,
|
|
1864
|
+
message: `Bundle '${bundleVersion.bundleKey}' is only activated via a special contract. Please contact the contract manager.`,
|
|
1865
|
+
params: {
|
|
1866
|
+
bundleKey: bundleVersion.bundleKey
|
|
1867
|
+
}
|
|
1801
1868
|
});
|
|
1802
1869
|
}
|
|
1803
1870
|
const planIds = bundleVersion.compatibility?.planIds ?? [];
|
|
1804
1871
|
if (planIds.length > 0 && !planIds.includes(input.currentPlanKey)) {
|
|
1805
1872
|
throw new UnprocessableEntityException({
|
|
1806
|
-
code:
|
|
1807
|
-
message: `BundleVersion '${input.bundleVersionId}'
|
|
1873
|
+
code: BILLING_ERROR_CODES3.BUNDLE_INCOMPATIBLE_WITH_PLAN,
|
|
1874
|
+
message: `BundleVersion '${input.bundleVersionId}' is not compatible with plan '${input.currentPlanKey}'. Allowed: [${planIds.join(", ")}].`,
|
|
1875
|
+
params: {
|
|
1876
|
+
bundleVersionId: input.bundleVersionId,
|
|
1877
|
+
planKey: input.currentPlanKey,
|
|
1878
|
+
allowedPlanKeys: planIds
|
|
1879
|
+
}
|
|
1808
1880
|
});
|
|
1809
1881
|
}
|
|
1810
1882
|
const active = await this.repo.listActiveBySubscription(input.subscriptionId);
|
|
1811
1883
|
if (active.some((b) => b.bundleVersionId === input.bundleVersionId)) {
|
|
1812
1884
|
throw new UnprocessableEntityException({
|
|
1813
|
-
code:
|
|
1814
|
-
message: `Subscription '${input.subscriptionId}'
|
|
1885
|
+
code: BILLING_ERROR_CODES3.BUNDLE_ALREADY_SUBSCRIBED,
|
|
1886
|
+
message: `Subscription '${input.subscriptionId}' has already actively booked this bundle.`,
|
|
1887
|
+
params: {
|
|
1888
|
+
subscriptionId: input.subscriptionId
|
|
1889
|
+
}
|
|
1815
1890
|
});
|
|
1816
1891
|
}
|
|
1817
1892
|
const startedAt = input.startedAt ?? /* @__PURE__ */ new Date();
|
|
@@ -1827,12 +1902,21 @@ var SubscriptionBundlesService = class {
|
|
|
1827
1902
|
async cancelBundleFromSubscription(input) {
|
|
1828
1903
|
const existing = await this.repo.findById(input.subscriptionBundleId);
|
|
1829
1904
|
if (!existing) {
|
|
1830
|
-
throw new NotFoundException2(
|
|
1905
|
+
throw new NotFoundException2({
|
|
1906
|
+
code: BILLING_ERROR_CODES3.SUBSCRIPTION_BUNDLE_NOT_FOUND,
|
|
1907
|
+
message: `SubscriptionBundle '${input.subscriptionBundleId}' not found`,
|
|
1908
|
+
params: {
|
|
1909
|
+
subscriptionBundleId: input.subscriptionBundleId
|
|
1910
|
+
}
|
|
1911
|
+
});
|
|
1831
1912
|
}
|
|
1832
1913
|
if (existing.canceledAt !== null) {
|
|
1833
1914
|
throw new UnprocessableEntityException({
|
|
1834
|
-
code:
|
|
1835
|
-
message: `SubscriptionBundle '${input.subscriptionBundleId}'
|
|
1915
|
+
code: BILLING_ERROR_CODES3.SUBSCRIPTION_BUNDLE_ALREADY_CANCELED,
|
|
1916
|
+
message: `SubscriptionBundle '${input.subscriptionBundleId}' is already cancelled.`,
|
|
1917
|
+
params: {
|
|
1918
|
+
subscriptionBundleId: input.subscriptionBundleId
|
|
1919
|
+
}
|
|
1836
1920
|
});
|
|
1837
1921
|
}
|
|
1838
1922
|
const canceledAt = input.canceledAt ?? /* @__PURE__ */ new Date();
|
|
@@ -1853,18 +1937,27 @@ var SubscriptionBundlesService = class {
|
|
|
1853
1937
|
async reactivateBundle(subscriptionBundleId) {
|
|
1854
1938
|
const existing = await this.repo.findById(subscriptionBundleId);
|
|
1855
1939
|
if (!existing) {
|
|
1856
|
-
throw new NotFoundException2(
|
|
1940
|
+
throw new NotFoundException2({
|
|
1941
|
+
code: BILLING_ERROR_CODES3.SUBSCRIPTION_BUNDLE_NOT_FOUND,
|
|
1942
|
+
message: `SubscriptionBundle '${subscriptionBundleId}' not found`,
|
|
1943
|
+
params: {
|
|
1944
|
+
subscriptionBundleId
|
|
1945
|
+
}
|
|
1946
|
+
});
|
|
1857
1947
|
}
|
|
1858
1948
|
if (existing.canceledAt === null) {
|
|
1859
1949
|
throw new UnprocessableEntityException({
|
|
1860
|
-
code:
|
|
1861
|
-
message: `SubscriptionBundle '${subscriptionBundleId}'
|
|
1950
|
+
code: BILLING_ERROR_CODES3.SUBSCRIPTION_BUNDLE_NOT_CANCELED,
|
|
1951
|
+
message: `SubscriptionBundle '${subscriptionBundleId}' is not cancelled.`,
|
|
1952
|
+
params: {
|
|
1953
|
+
subscriptionBundleId
|
|
1954
|
+
}
|
|
1862
1955
|
});
|
|
1863
1956
|
}
|
|
1864
1957
|
if (existing.canceledEffectiveAt !== null && existing.canceledEffectiveAt <= /* @__PURE__ */ new Date()) {
|
|
1865
1958
|
throw new UnprocessableEntityException({
|
|
1866
|
-
code:
|
|
1867
|
-
message: "
|
|
1959
|
+
code: BILLING_ERROR_CODES3.SUBSCRIPTION_BUNDLE_CANCELLATION_EFFECTIVE,
|
|
1960
|
+
message: "Cancellation already in effect \u2014 book the bundle again."
|
|
1868
1961
|
});
|
|
1869
1962
|
}
|
|
1870
1963
|
return this.repo.reactivate(subscriptionBundleId);
|
|
@@ -1970,7 +2063,13 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1970
2063
|
this.usageSnapshot.snapshot(tenantId)
|
|
1971
2064
|
]);
|
|
1972
2065
|
if (!sub) {
|
|
1973
|
-
throw new NotFoundException3(
|
|
2066
|
+
throw new NotFoundException3({
|
|
2067
|
+
code: BILLING_ERROR_CODES4.SUBSCRIPTION_NOT_FOUND,
|
|
2068
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
2069
|
+
params: {
|
|
2070
|
+
tenantId
|
|
2071
|
+
}
|
|
2072
|
+
});
|
|
1974
2073
|
}
|
|
1975
2074
|
const usage = {};
|
|
1976
2075
|
for (const key of /* @__PURE__ */ new Set([
|
|
@@ -2019,14 +2118,29 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2019
2118
|
const userId = this.requireUserId(req);
|
|
2020
2119
|
const blockedTargets = this.blockedPlans?.asTarget ?? [];
|
|
2021
2120
|
if (blockedTargets.includes(dto.plan)) {
|
|
2022
|
-
throw new ForbiddenException4(
|
|
2121
|
+
throw new ForbiddenException4({
|
|
2122
|
+
code: BILLING_ERROR_CODES4.PLAN_NOT_SELF_SERVICE,
|
|
2123
|
+
message: `${dto.plan} is not activated via self-service.`,
|
|
2124
|
+
params: {
|
|
2125
|
+
planKey: dto.plan
|
|
2126
|
+
}
|
|
2127
|
+
});
|
|
2023
2128
|
}
|
|
2024
2129
|
const sub = await this.subscriptionUsage.findForTenant(tenantId);
|
|
2025
|
-
if (!sub)
|
|
2130
|
+
if (!sub) {
|
|
2131
|
+
throw new NotFoundException3({
|
|
2132
|
+
code: BILLING_ERROR_CODES4.SUBSCRIPTION_NOT_FOUND,
|
|
2133
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
2134
|
+
params: {
|
|
2135
|
+
tenantId
|
|
2136
|
+
}
|
|
2137
|
+
});
|
|
2138
|
+
}
|
|
2026
2139
|
const blockers = await this.planPreview.assertChangeAllowed(tenantId, dto.plan, dto.billingCycle);
|
|
2027
2140
|
if (blockers.length > 0) {
|
|
2028
2141
|
throw new BadRequestException({
|
|
2029
|
-
|
|
2142
|
+
code: BILLING_ERROR_CODES4.PLAN_CHANGE_BLOCKED,
|
|
2143
|
+
message: "Plan change is blocked.",
|
|
2030
2144
|
blockers
|
|
2031
2145
|
});
|
|
2032
2146
|
}
|
|
@@ -2096,14 +2210,29 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2096
2210
|
const warnings = [];
|
|
2097
2211
|
const blockedTargets = this.blockedPlans?.asTarget ?? [];
|
|
2098
2212
|
if (blockedTargets.includes(dto.plan)) {
|
|
2099
|
-
throw new ForbiddenException4(
|
|
2213
|
+
throw new ForbiddenException4({
|
|
2214
|
+
code: BILLING_ERROR_CODES4.PLAN_NOT_SELF_SERVICE,
|
|
2215
|
+
message: `${dto.plan} is not activated via self-service.`,
|
|
2216
|
+
params: {
|
|
2217
|
+
planKey: dto.plan
|
|
2218
|
+
}
|
|
2219
|
+
});
|
|
2100
2220
|
}
|
|
2101
2221
|
const sub = await this.subscriptionUsage.findForTenant(tenantId);
|
|
2102
|
-
if (!sub)
|
|
2222
|
+
if (!sub) {
|
|
2223
|
+
throw new NotFoundException3({
|
|
2224
|
+
code: BILLING_ERROR_CODES4.SUBSCRIPTION_NOT_FOUND,
|
|
2225
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
2226
|
+
params: {
|
|
2227
|
+
tenantId
|
|
2228
|
+
}
|
|
2229
|
+
});
|
|
2230
|
+
}
|
|
2103
2231
|
const blockers = await this.planPreview.assertChangeAllowed(tenantId, dto.plan, dto.billingCycle);
|
|
2104
2232
|
if (blockers.length > 0) {
|
|
2105
2233
|
throw new BadRequestException({
|
|
2106
|
-
|
|
2234
|
+
code: BILLING_ERROR_CODES4.PLAN_CHANGE_BLOCKED,
|
|
2235
|
+
message: "Plan change during onboarding is blocked.",
|
|
2107
2236
|
blockers
|
|
2108
2237
|
});
|
|
2109
2238
|
}
|
|
@@ -2137,8 +2266,13 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2137
2266
|
warnings.push(...this.collectPromoSkipReasons(dto.promoCode, sub));
|
|
2138
2267
|
}
|
|
2139
2268
|
} catch (err) {
|
|
2269
|
+
const reason = err instanceof Error ? err.message : null;
|
|
2140
2270
|
throw new BadRequestException({
|
|
2141
|
-
|
|
2271
|
+
code: BILLING_ERROR_CODES4.ONBOARDING_CREATE_FAILED,
|
|
2272
|
+
message: reason ? `Onboarding creation failed: ${reason}` : "Onboarding creation failed",
|
|
2273
|
+
params: {
|
|
2274
|
+
reason
|
|
2275
|
+
}
|
|
2142
2276
|
});
|
|
2143
2277
|
}
|
|
2144
2278
|
} else {
|
|
@@ -2151,9 +2285,9 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2151
2285
|
});
|
|
2152
2286
|
if (dto.promoCode) {
|
|
2153
2287
|
if (!this.promoCodes) {
|
|
2154
|
-
warnings.push("
|
|
2288
|
+
warnings.push("A promo code was sent, but PromoCodesModule is not loaded \u2014 the code was NOT redeemed.");
|
|
2155
2289
|
} else if (!sub.id) {
|
|
2156
|
-
warnings.push("
|
|
2290
|
+
warnings.push("A promo code was sent, but the adapter returns no SubscriptionUsageRecord.id \u2014 the code was NOT redeemed.");
|
|
2157
2291
|
} else {
|
|
2158
2292
|
try {
|
|
2159
2293
|
const redemption = await this.promoCodes.redeem({
|
|
@@ -2164,8 +2298,8 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2164
2298
|
});
|
|
2165
2299
|
promoRedemption = this.toResponseRedemption(redemption, dto.promoCode);
|
|
2166
2300
|
} catch (err) {
|
|
2167
|
-
const message = err instanceof Error ? err.message : "
|
|
2168
|
-
warnings.push(`
|
|
2301
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
2302
|
+
warnings.push(`The promo code could not be redeemed: ${message}`);
|
|
2169
2303
|
}
|
|
2170
2304
|
}
|
|
2171
2305
|
}
|
|
@@ -2191,11 +2325,11 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2191
2325
|
});
|
|
2192
2326
|
bundlesAdded += 1;
|
|
2193
2327
|
} catch (err) {
|
|
2194
|
-
warnings.push(`Bundle '${bundleVersionId}'
|
|
2328
|
+
warnings.push(`Bundle '${bundleVersionId}' could not be booked: ` + (err instanceof Error ? err.message : String(err)));
|
|
2195
2329
|
}
|
|
2196
2330
|
}
|
|
2197
2331
|
} else if (bundleVersionIds.length > 0 && !this.subscriptionBundles) {
|
|
2198
|
-
warnings.push("Bundle
|
|
2332
|
+
warnings.push("Bundle bookings were requested during onboarding, but SubscriptionBundleModule is not registered in the consumer. No bundles were created.");
|
|
2199
2333
|
}
|
|
2200
2334
|
return {
|
|
2201
2335
|
plan: planResult.plan,
|
|
@@ -2215,9 +2349,20 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2215
2349
|
const tenantId = this.requireTenantId(req);
|
|
2216
2350
|
const userId = this.requireUserId(req);
|
|
2217
2351
|
const sub = await this.subscriptionUsage.findForTenant(tenantId);
|
|
2218
|
-
if (!sub)
|
|
2352
|
+
if (!sub) {
|
|
2353
|
+
throw new NotFoundException3({
|
|
2354
|
+
code: BILLING_ERROR_CODES4.SUBSCRIPTION_NOT_FOUND,
|
|
2355
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
2356
|
+
params: {
|
|
2357
|
+
tenantId
|
|
2358
|
+
}
|
|
2359
|
+
});
|
|
2360
|
+
}
|
|
2219
2361
|
if (!sub.pendingPlanVersion) {
|
|
2220
|
-
throw new BadRequestException(
|
|
2362
|
+
throw new BadRequestException({
|
|
2363
|
+
code: BILLING_ERROR_CODES4.NO_PENDING_PLAN_VERSION,
|
|
2364
|
+
message: "There is no pending plan version awaiting confirmation."
|
|
2365
|
+
});
|
|
2221
2366
|
}
|
|
2222
2367
|
const result = await this.subscriptionWrite.acceptPendingPlanVersion(tenantId, userId, /* @__PURE__ */ new Date());
|
|
2223
2368
|
if (!result.alreadyAccepted) {
|
|
@@ -2262,7 +2407,10 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2262
2407
|
const resolver = this.tenantIdResolver ?? ((r) => r.user?.tenantId ?? null);
|
|
2263
2408
|
const tenantId = resolver(req);
|
|
2264
2409
|
if (!tenantId) {
|
|
2265
|
-
throw new NotFoundException3(
|
|
2410
|
+
throw new NotFoundException3({
|
|
2411
|
+
code: AUTH_ERROR_CODES4.TENANT_CONTEXT_MISSING,
|
|
2412
|
+
message: "No tenant ID found on the request"
|
|
2413
|
+
});
|
|
2266
2414
|
}
|
|
2267
2415
|
return tenantId;
|
|
2268
2416
|
}
|
|
@@ -2270,7 +2418,10 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2270
2418
|
const resolver = this.userIdResolver ?? ((r) => r.user?.sub ?? r.user?.id ?? null);
|
|
2271
2419
|
const userId = resolver(req);
|
|
2272
2420
|
if (!userId) {
|
|
2273
|
-
throw new NotFoundException3(
|
|
2421
|
+
throw new NotFoundException3({
|
|
2422
|
+
code: AUTH_ERROR_CODES4.TENANT_CONTEXT_MISSING,
|
|
2423
|
+
message: "No user ID found on the request"
|
|
2424
|
+
});
|
|
2274
2425
|
}
|
|
2275
2426
|
return userId;
|
|
2276
2427
|
}
|
|
@@ -2310,10 +2461,10 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2310
2461
|
collectPromoSkipReasons(promoCode, sub) {
|
|
2311
2462
|
const reasons = [];
|
|
2312
2463
|
if (!this.promoCodes) {
|
|
2313
|
-
reasons.push(`Promo-Code "${promoCode}"
|
|
2464
|
+
reasons.push(`Promo-Code "${promoCode}" was sent, but PromoCodesModule is not loaded \u2014 the code was NOT redeemed.`);
|
|
2314
2465
|
}
|
|
2315
2466
|
if (!sub.id) {
|
|
2316
|
-
reasons.push(`Promo-Code "${promoCode}"
|
|
2467
|
+
reasons.push(`Promo-Code "${promoCode}" was sent, but the adapter returns no SubscriptionUsageRecord.id \u2014 the code was NOT redeemed.`);
|
|
2317
2468
|
}
|
|
2318
2469
|
return reasons;
|
|
2319
2470
|
}
|
|
@@ -2328,7 +2479,7 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
2328
2479
|
try {
|
|
2329
2480
|
await this.contractFreeze.freezeOnPlanChange(tenantId, plan, cycle, /* @__PURE__ */ new Date());
|
|
2330
2481
|
} catch (err) {
|
|
2331
|
-
this.logger.error(`Contract
|
|
2482
|
+
this.logger.error(`Contract freeze after plan change failed (tenant ${tenantId}): ${String(err)}`);
|
|
2332
2483
|
}
|
|
2333
2484
|
}
|
|
2334
2485
|
/**
|
|
@@ -2639,7 +2790,7 @@ __name(normalizeQuotas, "normalizeQuotas");
|
|
|
2639
2790
|
|
|
2640
2791
|
// src/billing/subscription-bundle-preview.service.ts
|
|
2641
2792
|
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";
|
|
2793
|
+
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
2794
|
function _ts_decorate20(decorators, target, key, desc) {
|
|
2644
2795
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
2645
2796
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -2678,7 +2829,13 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2678
2829
|
async previewAdd(ctx, input, now = /* @__PURE__ */ new Date()) {
|
|
2679
2830
|
const bundleVersion = await this.bundles.findVersionById(input.bundleVersionId);
|
|
2680
2831
|
if (!bundleVersion) {
|
|
2681
|
-
throw new NotFoundException4(
|
|
2832
|
+
throw new NotFoundException4({
|
|
2833
|
+
code: CATALOG_ERROR_CODES2.BUNDLE_VERSION_NOT_FOUND,
|
|
2834
|
+
message: `BundleVersion '${input.bundleVersionId}' not found`,
|
|
2835
|
+
params: {
|
|
2836
|
+
bundleVersionId: input.bundleVersionId
|
|
2837
|
+
}
|
|
2838
|
+
});
|
|
2682
2839
|
}
|
|
2683
2840
|
const blockers = [];
|
|
2684
2841
|
const warnings = [];
|
|
@@ -2687,7 +2844,7 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2687
2844
|
if (activeBundleVersions.some((bv) => bv.id === bundleVersion.id)) {
|
|
2688
2845
|
blockers.push({
|
|
2689
2846
|
code: "BUNDLE_ALREADY_SUBSCRIBED",
|
|
2690
|
-
message: "
|
|
2847
|
+
message: "This bundle is already actively booked."
|
|
2691
2848
|
});
|
|
2692
2849
|
}
|
|
2693
2850
|
const planFeatures = new Set(await this.resolvePlanFeatures(ctx.currentPlanKey, now));
|
|
@@ -2695,14 +2852,14 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2695
2852
|
if (redundantFeatures.length > 0) {
|
|
2696
2853
|
warnings.push({
|
|
2697
2854
|
code: "REDUNDANT_FEATURES",
|
|
2698
|
-
message: `${redundantFeatures.length}
|
|
2855
|
+
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
2856
|
});
|
|
2700
2857
|
}
|
|
2701
2858
|
const missingRequires = await this.collectMissingRequires(bundleVersion, planFeatures, activeBundleVersions);
|
|
2702
2859
|
if (missingRequires.length > 0) {
|
|
2703
2860
|
blockers.push({
|
|
2704
2861
|
code: "BUNDLE_FEATURE_DEPENDENCY_UNSATISFIED",
|
|
2705
|
-
message: `
|
|
2862
|
+
message: `The bundle requires [${missingRequires.join(", ")}] \u2014 present neither in the plan nor in the active bundles.`
|
|
2706
2863
|
});
|
|
2707
2864
|
}
|
|
2708
2865
|
const priceNet = resolveBundlePriceNet(bundleVersion, ctx.currentPlanKey, ctx.billingCycle);
|
|
@@ -2731,18 +2888,30 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2731
2888
|
async previewCancel(ctx, input, now = /* @__PURE__ */ new Date()) {
|
|
2732
2889
|
const existing = await this.subscriptionBundles.findById(input.subscriptionBundleId);
|
|
2733
2890
|
if (!existing || existing.subscriptionId !== ctx.subscriptionId) {
|
|
2734
|
-
throw new NotFoundException4(
|
|
2891
|
+
throw new NotFoundException4({
|
|
2892
|
+
code: BILLING_ERROR_CODES5.SUBSCRIPTION_BUNDLE_NOT_FOUND,
|
|
2893
|
+
message: `SubscriptionBundle '${input.subscriptionBundleId}' not found`,
|
|
2894
|
+
params: {
|
|
2895
|
+
subscriptionBundleId: input.subscriptionBundleId
|
|
2896
|
+
}
|
|
2897
|
+
});
|
|
2735
2898
|
}
|
|
2736
2899
|
const bundleVersion = await this.bundles.findVersionById(existing.bundleVersionId);
|
|
2737
2900
|
if (!bundleVersion) {
|
|
2738
|
-
throw new NotFoundException4(
|
|
2901
|
+
throw new NotFoundException4({
|
|
2902
|
+
code: CATALOG_ERROR_CODES2.BUNDLE_VERSION_NOT_FOUND,
|
|
2903
|
+
message: `BundleVersion '${existing.bundleVersionId}' not found`,
|
|
2904
|
+
params: {
|
|
2905
|
+
bundleVersionId: existing.bundleVersionId
|
|
2906
|
+
}
|
|
2907
|
+
});
|
|
2739
2908
|
}
|
|
2740
2909
|
const blockers = [];
|
|
2741
2910
|
const warnings = [];
|
|
2742
2911
|
if (existing.canceledAt !== null) {
|
|
2743
2912
|
blockers.push({
|
|
2744
2913
|
code: "SUBSCRIPTION_BUNDLE_ALREADY_CANCELED",
|
|
2745
|
-
message: "
|
|
2914
|
+
message: "This bundle booking is already cancelled."
|
|
2746
2915
|
});
|
|
2747
2916
|
}
|
|
2748
2917
|
const effectiveAt = resolveBundleCancelEffectiveAt({
|
|
@@ -2754,7 +2923,7 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2754
2923
|
if (existing.minimumTermEndsAt && existing.minimumTermEndsAt.getTime() > periodEnd.getTime()) {
|
|
2755
2924
|
warnings.push({
|
|
2756
2925
|
code: "MINIMUM_TERM_BINDS",
|
|
2757
|
-
message: "
|
|
2926
|
+
message: "The minimum term extends beyond the end of the period \u2014 the cancellation only takes effect when the minimum term ends."
|
|
2758
2927
|
});
|
|
2759
2928
|
}
|
|
2760
2929
|
return {
|
|
@@ -2773,26 +2942,26 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2773
2942
|
if (bundleVersion.publishedAt === null) {
|
|
2774
2943
|
blockers.push({
|
|
2775
2944
|
code: "BUNDLE_VERSION_NOT_PUBLISHED",
|
|
2776
|
-
message: "
|
|
2945
|
+
message: "This bundle version is not published and cannot be booked."
|
|
2777
2946
|
});
|
|
2778
2947
|
}
|
|
2779
2948
|
if (bundleVersion.supersededAt !== null) {
|
|
2780
2949
|
blockers.push({
|
|
2781
2950
|
code: "BUNDLE_VERSION_SUPERSEDED",
|
|
2782
|
-
message: "
|
|
2951
|
+
message: "This bundle version has been superseded by a newer one."
|
|
2783
2952
|
});
|
|
2784
2953
|
}
|
|
2785
2954
|
const planIds = bundleVersion.compatibility?.planIds ?? [];
|
|
2786
2955
|
if (planIds.length > 0 && !planIds.includes(currentPlanKey)) {
|
|
2787
2956
|
blockers.push({
|
|
2788
2957
|
code: "BUNDLE_INCOMPATIBLE_WITH_PLAN",
|
|
2789
|
-
message: `
|
|
2958
|
+
message: `The bundle is not compatible with plan '${currentPlanKey}'. Allowed: [${planIds.join(", ")}].`
|
|
2790
2959
|
});
|
|
2791
2960
|
}
|
|
2792
2961
|
if (this.blockedBundles?.bundleKeys?.includes(bundleVersion.bundleKey)) {
|
|
2793
2962
|
blockers.push({
|
|
2794
2963
|
code: "BUNDLE_NOT_SELF_SERVICE",
|
|
2795
|
-
message: `Bundle '${bundleVersion.bundleKey}'
|
|
2964
|
+
message: `Bundle '${bundleVersion.bundleKey}' is only activated via a special contract. Please contact the contract manager.`
|
|
2796
2965
|
});
|
|
2797
2966
|
}
|
|
2798
2967
|
}
|
|
@@ -2905,6 +3074,7 @@ import { Module as Module4 } from "@nestjs/common";
|
|
|
2905
3074
|
|
|
2906
3075
|
// src/billing/tenant-subscription-bundles.controller.ts
|
|
2907
3076
|
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";
|
|
3077
|
+
import { AUTH_ERROR_CODES as AUTH_ERROR_CODES5, BILLING_ERROR_CODES as BILLING_ERROR_CODES6 } from "@saasicat/types";
|
|
2908
3078
|
|
|
2909
3079
|
// src/billing/dto/subscription-bundles.dto.ts
|
|
2910
3080
|
import { IsInt, IsOptional as IsOptional4, IsUUID as IsUUID2, Matches as Matches3, Max, Min } from "class-validator";
|
|
@@ -2985,7 +3155,7 @@ var CancelSubscriptionBundleDto = class {
|
|
|
2985
3155
|
_ts_decorate21([
|
|
2986
3156
|
IsOptional4(),
|
|
2987
3157
|
Matches3(ISO_DATE_PATTERN, {
|
|
2988
|
-
message: "canceledAt
|
|
3158
|
+
message: "canceledAt must be an ISO date (YYYY-MM-DD)"
|
|
2989
3159
|
}),
|
|
2990
3160
|
_ts_metadata15("design:type", String)
|
|
2991
3161
|
], CancelSubscriptionBundleDto.prototype, "canceledAt", void 0);
|
|
@@ -3049,7 +3219,10 @@ function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
|
3049
3219
|
const hasAdd = Boolean(dto.bundleVersionId);
|
|
3050
3220
|
const hasCancel = Boolean(dto.subscriptionBundleId);
|
|
3051
3221
|
if (hasAdd === hasCancel) {
|
|
3052
|
-
throw new BadRequestException2(
|
|
3222
|
+
throw new BadRequestException2({
|
|
3223
|
+
code: BILLING_ERROR_CODES6.BUNDLE_PREVIEW_ARGUMENT_AMBIGUOUS,
|
|
3224
|
+
message: "Exactly one of bundleVersionId (add preview) or subscriptionBundleId (cancel preview) must be given."
|
|
3225
|
+
});
|
|
3053
3226
|
}
|
|
3054
3227
|
const sub = await this.requireSubscription(this.requireTenantId(req));
|
|
3055
3228
|
const ctx = {
|
|
@@ -3089,21 +3262,30 @@ function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
|
3089
3262
|
}
|
|
3090
3263
|
requireSubscriptionPk(sub) {
|
|
3091
3264
|
if (!sub.id) {
|
|
3092
|
-
throw new NotFoundException5("SubscriptionUsageRecord
|
|
3265
|
+
throw new NotFoundException5("SubscriptionUsageRecord without `id` \u2014 the adapter must return the subscription primary key (see SubscriptionUsageRecord.id).");
|
|
3093
3266
|
}
|
|
3094
3267
|
return sub.id;
|
|
3095
3268
|
}
|
|
3096
3269
|
requireTenantId(req) {
|
|
3097
3270
|
const tenantId = this.tenantIdResolver(req);
|
|
3098
3271
|
if (!tenantId) {
|
|
3099
|
-
throw new NotFoundException5(
|
|
3272
|
+
throw new NotFoundException5({
|
|
3273
|
+
code: AUTH_ERROR_CODES5.TENANT_CONTEXT_MISSING,
|
|
3274
|
+
message: "No tenant ID found on the request"
|
|
3275
|
+
});
|
|
3100
3276
|
}
|
|
3101
3277
|
return tenantId;
|
|
3102
3278
|
}
|
|
3103
3279
|
async requireSubscription(tenantId) {
|
|
3104
3280
|
const sub = await this.subscriptionUsage.findForTenant(tenantId);
|
|
3105
3281
|
if (!sub) {
|
|
3106
|
-
throw new NotFoundException5(
|
|
3282
|
+
throw new NotFoundException5({
|
|
3283
|
+
code: BILLING_ERROR_CODES6.SUBSCRIPTION_NOT_FOUND,
|
|
3284
|
+
message: `No subscription for tenant ${tenantId}`,
|
|
3285
|
+
params: {
|
|
3286
|
+
tenantId
|
|
3287
|
+
}
|
|
3288
|
+
});
|
|
3107
3289
|
}
|
|
3108
3290
|
return sub;
|
|
3109
3291
|
}
|
|
@@ -3122,7 +3304,7 @@ function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
|
3122
3304
|
try {
|
|
3123
3305
|
await this.contractFreeze.freezeOnPlanChange(tenantId, sub.planVersion.planId, sub.billingCycle, /* @__PURE__ */ new Date());
|
|
3124
3306
|
} catch (err) {
|
|
3125
|
-
this.logger.error(`Contract
|
|
3307
|
+
this.logger.error(`Contract freeze after bundle mutation failed (tenant ${tenantId}): ${String(err)}`);
|
|
3126
3308
|
}
|
|
3127
3309
|
}
|
|
3128
3310
|
};
|