@saasicat/nest 0.26.0 → 0.27.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.
Files changed (43) hide show
  1. package/LICENSE +90 -201
  2. package/dist/_entries.cjs +3265 -2846
  3. package/dist/billing/index.d.cts +4 -4
  4. package/dist/billing/index.d.ts +4 -4
  5. package/dist/billing/index.js +33 -31
  6. package/dist/catalog/index.js +5 -5
  7. package/dist/checkout-offer/index.js +2 -2
  8. package/dist/chunk-2FR6ZL7R.js +0 -0
  9. package/dist/chunk-2JTISFUZ.js +792 -0
  10. package/dist/chunk-2SLTRKXC.js +0 -0
  11. package/dist/chunk-3E6ONOMG.js +50 -0
  12. package/dist/{chunk-UOWK2HYG.js → chunk-7ZPFQXHM.js} +1 -1
  13. package/dist/{chunk-B7NRY2LV.js → chunk-CJFAOD7K.js} +1 -39
  14. package/dist/{chunk-J62YI25C.js → chunk-EHKMPP6J.js} +1194 -775
  15. package/dist/{chunk-RPO4IVUY.js → chunk-F4B2NJUZ.js} +26 -91
  16. package/dist/chunk-H4DOTO7S.js +85 -0
  17. package/dist/{chunk-V3MRZ7SH.js → chunk-UHD6FJBQ.js} +421 -1197
  18. package/dist/{define-saasicat-BK6RRWWG.d.ts → define-saasicat-3ois8t8s.d.ts} +26 -3
  19. package/dist/{define-saasicat-D30I-WET.d.cts → define-saasicat-CFxut1VR.d.cts} +26 -3
  20. package/dist/{enforce-quota.interceptor-B9QueAFf.d.ts → enforce-quota.interceptor-CYDNvAQz.d.ts} +3 -3
  21. package/dist/{enforce-quota.interceptor-DY6CLjoP.d.cts → enforce-quota.interceptor-N80KC34N.d.cts} +3 -3
  22. package/dist/entitlement/index.js +7 -4
  23. package/dist/index.d.cts +5 -5
  24. package/dist/index.d.ts +5 -5
  25. package/dist/index.js +87 -79
  26. package/dist/{saas-platform.module-D1mUZPrz.d.ts → module-options-ByJL7FZm.d.ts} +40 -27
  27. package/dist/{saas-platform.module-CgalxzcT.d.cts → module-options-klrPnWGF.d.cts} +40 -27
  28. package/dist/platform/index.d.cts +201 -19
  29. package/dist/platform/index.d.ts +201 -19
  30. package/dist/platform/index.js +58 -33
  31. package/dist/promo/index.js +2 -2
  32. package/dist/subscription-contract/index.js +5 -2
  33. package/dist/{tenant-billing.controller-DHSUrT_N.d.cts → tenant-billing.controller-CAMMdfn1.d.cts} +1 -1
  34. package/dist/{tenant-billing.controller-BSlpFqVA.d.ts → tenant-billing.controller-CYYK0CpT.d.ts} +1 -1
  35. package/dist/{subscription-bundles.module-BDbODWvA.d.ts → tenant-billing.module-B0stLoJn.d.ts} +51 -51
  36. package/dist/{subscription-bundles.module-Cfi-lFWF.d.cts → tenant-billing.module-BxJS6fX4.d.cts} +51 -51
  37. package/dist/testing/index.d.cts +8 -8
  38. package/dist/testing/index.d.ts +8 -8
  39. package/dist/testing/index.js +13 -11
  40. package/package.json +4 -4
  41. package/dist/{chunk-QB7MICKQ.js → chunk-7X5MSAIL.js} +9 -9
  42. package/dist/{chunk-FY4EJTV4.js → chunk-OYNHOY45.js} +0 -0
  43. package/dist/{chunk-I4XD43AN.js → chunk-XTVAFJ3P.js} +3 -3
@@ -0,0 +1,792 @@
1
+ import {
2
+ FEATURE_GUARD_MARKER,
3
+ PUBLIC_CATALOG_BUNDLE_REPOSITORY_TOKEN,
4
+ PUBLIC_CATALOG_CATALOG_ENTRY_REPOSITORY_TOKEN,
5
+ PUBLIC_CATALOG_PROJECT_KEY_TOKEN,
6
+ REQUIRE_FEATURE_KEY,
7
+ periodEndAfter
8
+ } from "./chunk-UHD6FJBQ.js";
9
+ import {
10
+ EntitlementService,
11
+ isLimitExceededError
12
+ } from "./chunk-F4B2NJUZ.js";
13
+ import {
14
+ ENTITLEMENT_SERVICE_TOKEN
15
+ } from "./chunk-WKVBNTYC.js";
16
+ import {
17
+ asProvider
18
+ } from "./chunk-SABTXESR.js";
19
+ import {
20
+ __name
21
+ } from "./chunk-SHUYVCID.js";
22
+
23
+ // src/billing/version-publish.ts
24
+ var PublishValidationError = class extends Error {
25
+ static {
26
+ __name(this, "PublishValidationError");
27
+ }
28
+ code;
29
+ constructor(code, message) {
30
+ super(message), this.code = code;
31
+ this.name = "PublishValidationError";
32
+ }
33
+ };
34
+ function assertChangeNote(changeNote) {
35
+ const trimmed = (changeNote ?? "").trim();
36
+ if (trimmed.length === 0) {
37
+ throw new PublishValidationError("CHANGE_NOTE_REQUIRED", "changeNote is required when publishing a version.");
38
+ }
39
+ return trimmed;
40
+ }
41
+ __name(assertChangeNote, "assertChangeNote");
42
+ function assertDraftPublishable(draft, draftId) {
43
+ if (!draft) {
44
+ throw new PublishValidationError("NOT_FOUND", `PlanVersion ${draftId} not found`);
45
+ }
46
+ if (draft.publishedAt !== null) {
47
+ throw new PublishValidationError("ALREADY_PUBLISHED", `PlanVersion ${draftId} has already been published.`);
48
+ }
49
+ if (!draft.baseVersionId) {
50
+ 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.`);
51
+ }
52
+ }
53
+ __name(assertDraftPublishable, "assertDraftPublishable");
54
+ function assertBaseVersionFresh(base, baseId, planContext) {
55
+ if (!base) {
56
+ throw new PublishValidationError("BASE_NOT_FOUND", `baseVersion ${baseId} no longer exists.`);
57
+ }
58
+ if (base.supersededAt !== null) {
59
+ 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.`);
60
+ }
61
+ }
62
+ __name(assertBaseVersionFresh, "assertBaseVersionFresh");
63
+ function assertOptimisticLockHeld(updateCount, draftId) {
64
+ if (updateCount !== 1) {
65
+ throw new PublishValidationError("OPTIMISTIC_LOCK_CONFLICT", `Draft ${draftId} has meanwhile been published by another admin.`);
66
+ }
67
+ }
68
+ __name(assertOptimisticLockHeld, "assertOptimisticLockHeld");
69
+
70
+ // src/billing/version-renewal.ts
71
+ function decideRenewal(sub, now) {
72
+ if (!sub.pendingPlanVersionId || !sub.pendingPlanVersionEffectiveAt) return "SKIP";
73
+ if (sub.pendingPlanVersionEffectiveAt > now) return "SKIP";
74
+ if (sub.pendingPlanVersionNonRegressive || sub.pendingPlanVersionAccepted) {
75
+ return "ROLL_FORWARD";
76
+ }
77
+ return "CLEAR_PENDING";
78
+ }
79
+ __name(decideRenewal, "decideRenewal");
80
+ function clearPendingPlanVersionFields() {
81
+ return {
82
+ pendingPlanVersionId: null,
83
+ pendingPlanVersionEffectiveAt: null,
84
+ pendingPlanVersionAccepted: false,
85
+ pendingPlanVersionAcceptedAt: null,
86
+ pendingPlanVersionAcceptedByUserId: null,
87
+ pendingPlanVersionNotifiedAt: null,
88
+ pendingPlanVersionReminderSentAt: null
89
+ };
90
+ }
91
+ __name(clearPendingPlanVersionFields, "clearPendingPlanVersionFields");
92
+ function computeNextPeriod(sub, now) {
93
+ if (sub.canceledAt !== null) return null;
94
+ if (sub.currentPeriodEnd === null) return null;
95
+ if (sub.currentPeriodEnd > now) return null;
96
+ const newStart = sub.currentPeriodEnd;
97
+ const newEnd = periodEndAfter(newStart, sub.billingCycle, now);
98
+ return {
99
+ currentPeriodStart: newStart,
100
+ currentPeriodEnd: newEnd
101
+ };
102
+ }
103
+ __name(computeNextPeriod, "computeNextPeriod");
104
+
105
+ // src/billing/feature-guard.tokens.ts
106
+ var FEATURE_GUARD_CONFIG_TOKEN = /* @__PURE__ */ Symbol.for("saas-platform/FeatureGuardConfig");
107
+
108
+ // src/billing/feature.guard.ts
109
+ import { ForbiddenException, Inject, Injectable, Logger, Optional } from "@nestjs/common";
110
+ import { Reflector } from "@nestjs/core";
111
+ import { AUTH_ERROR_CODES, FEATURE_NOT_LICENSED } from "@saasicat/types";
112
+
113
+ // src/billing/upsell.tokens.ts
114
+ var UPSELL_OFFER_RESOLVER_TOKEN = /* @__PURE__ */ Symbol.for("saas-platform/UpsellOfferResolver");
115
+ var UPSELL_OFFER_CURRENCY_TOKEN = /* @__PURE__ */ Symbol.for("saas-platform/UpsellOfferCurrency");
116
+
117
+ // src/billing/feature.guard.ts
118
+ function _ts_decorate(decorators, target, key, desc) {
119
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
120
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
121
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
122
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
123
+ }
124
+ __name(_ts_decorate, "_ts_decorate");
125
+ function _ts_metadata(k, v) {
126
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
127
+ }
128
+ __name(_ts_metadata, "_ts_metadata");
129
+ function _ts_param(paramIndex, decorator) {
130
+ return function(target, key) {
131
+ decorator(target, key, paramIndex);
132
+ };
133
+ }
134
+ __name(_ts_param, "_ts_param");
135
+ var FeatureGuard = class _FeatureGuard {
136
+ static {
137
+ __name(this, "FeatureGuard");
138
+ }
139
+ reflector;
140
+ entitlements;
141
+ config;
142
+ upsellResolver;
143
+ /** Lets `EnforcementChainCheck` recognise this guard across CJS entries. */
144
+ static [FEATURE_GUARD_MARKER] = true;
145
+ logger = new Logger(_FeatureGuard.name);
146
+ constructor(reflector, entitlements, config = null, upsellResolver = null) {
147
+ this.reflector = reflector;
148
+ this.entitlements = entitlements;
149
+ this.config = config;
150
+ this.upsellResolver = upsellResolver;
151
+ }
152
+ async canActivate(context) {
153
+ const required = this.reflector.getAllAndOverride(REQUIRE_FEATURE_KEY, [
154
+ context.getHandler(),
155
+ context.getClass()
156
+ ]);
157
+ if (!required || required.length === 0) return true;
158
+ const request = context.switchToHttp().getRequest();
159
+ const user = request.user;
160
+ if (!user) {
161
+ throw new ForbiddenException({
162
+ code: AUTH_ERROR_CODES.NOT_AUTHENTICATED,
163
+ message: "Not authenticated"
164
+ });
165
+ }
166
+ const role = this.config?.userRoleResolver ? this.config.userRoleResolver(user) : user.role ?? user.platformRole;
167
+ if (role === "SUPER_ADMIN") return true;
168
+ const tenantId = this.config?.tenantIdResolver ? this.config.tenantIdResolver(request) : request.tenantId ?? user.tenantId;
169
+ if (!tenantId) {
170
+ throw new ForbiddenException({
171
+ code: AUTH_ERROR_CODES.NO_TENANT_ASSIGNED,
172
+ message: "No tenant assigned"
173
+ });
174
+ }
175
+ const compute = /* @__PURE__ */ __name(() => this.entitlements.computeLimits(tenantId), "compute");
176
+ const limits = this.config?.tenantContextRunner ? await this.config.tenantContextRunner(tenantId, compute) : await compute();
177
+ const allowed = required.some((f) => limits.features.has(f));
178
+ if (!allowed) {
179
+ throw await this.buildNotLicensedException(required, tenantId);
180
+ }
181
+ return true;
182
+ }
183
+ /**
184
+ * Upsell response (#36): the 403 body is always the full
185
+ * `FeatureNotLicensedBody`, so a consumer that matches on `code` can read
186
+ * `featureKey`/`offers` unconditionally. Without a registered
187
+ * `UpsellOfferResolver` there is simply nothing to offer — `offers` is
188
+ * empty, the same degradation a failing resolver produces.
189
+ *
190
+ * Deliberately 403 + `code` field instead of 402 — rationale in
191
+ * `@saasicat/types` upsell.types.ts (402 is reserved / inconsistently
192
+ * supported; SPA interceptors must not treat the 403 as an auth error, the
193
+ * distinction runs through `code`).
194
+ *
195
+ * The message must keep starting with the word `Feature` — consumers
196
+ * classify the response by that prefix.
197
+ */
198
+ async buildNotLicensedException(required, tenantId) {
199
+ const body = {
200
+ code: FEATURE_NOT_LICENSED,
201
+ featureKey: required[0],
202
+ featureKeys: required,
203
+ offers: this.upsellResolver ? await this.resolveOffersSafe(required, tenantId) : [],
204
+ message: `Feature ${required.join(" / ")} is not included in the current plan.`
205
+ };
206
+ return new ForbiddenException(body);
207
+ }
208
+ /**
209
+ * A resolver error must not turn the semantically correct 403 into a 500 —
210
+ * degrade to empty offers and log.
211
+ */
212
+ async resolveOffersSafe(required, tenantId) {
213
+ try {
214
+ return await this.upsellResolver.resolveOffers(required, tenantId);
215
+ } catch (error) {
216
+ this.logger.warn(`UpsellOfferResolver failed for [${required.join(", ")}]: ${String(error)}`);
217
+ return [];
218
+ }
219
+ }
220
+ };
221
+ FeatureGuard = _ts_decorate([
222
+ Injectable(),
223
+ _ts_param(0, Inject(Reflector)),
224
+ _ts_param(1, Inject(ENTITLEMENT_SERVICE_TOKEN)),
225
+ _ts_param(2, Optional()),
226
+ _ts_param(2, Inject(FEATURE_GUARD_CONFIG_TOKEN)),
227
+ _ts_param(3, Optional()),
228
+ _ts_param(3, Inject(UPSELL_OFFER_RESOLVER_TOKEN)),
229
+ _ts_metadata("design:type", Function),
230
+ _ts_metadata("design:paramtypes", [
231
+ typeof Reflector === "undefined" ? Object : Reflector,
232
+ typeof EntitlementService === "undefined" ? Object : EntitlementService,
233
+ Object,
234
+ Object
235
+ ])
236
+ ], FeatureGuard);
237
+
238
+ // src/billing/catalog-bundle-upsell-resolver.ts
239
+ import { Inject as Inject2, Injectable as Injectable2, Optional as Optional2 } from "@nestjs/common";
240
+ import { buildFeatureRequiresIndex, collectUnsatisfiedRequires } from "@saasicat/types";
241
+ function _ts_decorate2(decorators, target, key, desc) {
242
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
243
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
244
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
245
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
246
+ }
247
+ __name(_ts_decorate2, "_ts_decorate");
248
+ function _ts_metadata2(k, v) {
249
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
250
+ }
251
+ __name(_ts_metadata2, "_ts_metadata");
252
+ function _ts_param2(paramIndex, decorator) {
253
+ return function(target, key) {
254
+ decorator(target, key, paramIndex);
255
+ };
256
+ }
257
+ __name(_ts_param2, "_ts_param");
258
+ var DEFAULT_CURRENCY = "EUR";
259
+ var CatalogBundleUpsellResolver = class {
260
+ static {
261
+ __name(this, "CatalogBundleUpsellResolver");
262
+ }
263
+ bundleRepo;
264
+ projectKey;
265
+ catalogEntryRepo;
266
+ currency;
267
+ constructor(bundleRepo, projectKey, catalogEntryRepo = null, currency = null) {
268
+ this.bundleRepo = bundleRepo;
269
+ this.projectKey = projectKey;
270
+ this.catalogEntryRepo = catalogEntryRepo;
271
+ this.currency = currency;
272
+ }
273
+ async resolveOffers(featureKeys, tenantId) {
274
+ void tenantId;
275
+ if (featureKeys.length === 0) return [];
276
+ const missing = new Set(featureKeys);
277
+ const wanted = /* @__PURE__ */ new Set([
278
+ ...featureKeys,
279
+ ...await this.lookupUnmetRequires(featureKeys)
280
+ ]);
281
+ const ranked = [];
282
+ for (const live of await this.listLiveMarketedBundles()) {
283
+ const features = live.features;
284
+ if (!features.some((key) => missing.has(key))) continue;
285
+ ranked.push({
286
+ offer: this.toOffer(live),
287
+ coverage: features.filter((key) => wanted.has(key)).length
288
+ });
289
+ }
290
+ return ranked.sort(compareRankedOffers).map((r) => r.offer);
291
+ }
292
+ /**
293
+ * Uncovered dependencies of the missing features (#35) — feed into the
294
+ * coverage ranking. Without a CatalogEntryRepository: empty (ranking
295
+ * degrades to price).
296
+ */
297
+ async lookupUnmetRequires(featureKeys) {
298
+ if (!this.catalogEntryRepo) return [];
299
+ const rows = await this.catalogEntryRepo.listFeatures({
300
+ projectKey: this.projectKey
301
+ });
302
+ return collectUnsatisfiedRequires(featureKeys, buildFeatureRequiresIndex(rows));
303
+ }
304
+ /** Only published-and-live AND marketed — non-marketed bundles are not an offer. */
305
+ async listLiveMarketedBundles() {
306
+ const stems = await this.bundleRepo.list({
307
+ projectKey: this.projectKey,
308
+ excludeDeleted: true
309
+ });
310
+ const out = [];
311
+ for (const stem of stems) {
312
+ const live = await this.bundleRepo.findLatestLive(stem.id);
313
+ if (live?.marketed) out.push(live);
314
+ }
315
+ return out;
316
+ }
317
+ toOffer(live) {
318
+ return {
319
+ bundleKey: live.bundleKey,
320
+ bundleVersionId: live.id,
321
+ priceMonthlyNet: parsePrice(live.monthlyNet),
322
+ currency: this.currency ?? DEFAULT_CURRENCY,
323
+ label: live.label
324
+ };
325
+ }
326
+ };
327
+ CatalogBundleUpsellResolver = _ts_decorate2([
328
+ Injectable2(),
329
+ _ts_param2(0, Inject2(PUBLIC_CATALOG_BUNDLE_REPOSITORY_TOKEN)),
330
+ _ts_param2(1, Inject2(PUBLIC_CATALOG_PROJECT_KEY_TOKEN)),
331
+ _ts_param2(2, Optional2()),
332
+ _ts_param2(2, Inject2(PUBLIC_CATALOG_CATALOG_ENTRY_REPOSITORY_TOKEN)),
333
+ _ts_param2(3, Optional2()),
334
+ _ts_param2(3, Inject2(UPSELL_OFFER_CURRENCY_TOKEN)),
335
+ _ts_metadata2("design:type", Function),
336
+ _ts_metadata2("design:paramtypes", [
337
+ typeof BundleRepository === "undefined" ? Object : BundleRepository,
338
+ String,
339
+ Object,
340
+ Object
341
+ ])
342
+ ], CatalogBundleUpsellResolver);
343
+ function parsePrice(decimal) {
344
+ if (decimal === null) return null;
345
+ const n = Number.parseFloat(decimal);
346
+ return Number.isFinite(n) ? n : null;
347
+ }
348
+ __name(parsePrice, "parsePrice");
349
+ function compareRankedOffers(a, b) {
350
+ if (a.coverage !== b.coverage) return b.coverage - a.coverage;
351
+ const priceA = a.offer.priceMonthlyNet ?? Number.POSITIVE_INFINITY;
352
+ const priceB = b.offer.priceMonthlyNet ?? Number.POSITIVE_INFINITY;
353
+ if (priceA !== priceB) return priceA - priceB;
354
+ return a.offer.bundleKey.localeCompare(b.offer.bundleKey);
355
+ }
356
+ __name(compareRankedOffers, "compareRankedOffers");
357
+
358
+ // src/billing/limit-exceeded.filter.ts
359
+ import { Catch, HttpStatus, Logger as Logger2 } from "@nestjs/common";
360
+ import { BaseExceptionFilter } from "@nestjs/core";
361
+ import { BILLING_ERROR_CODES } from "@saasicat/types";
362
+ function _ts_decorate3(decorators, target, key, desc) {
363
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
364
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
365
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
366
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
367
+ }
368
+ __name(_ts_decorate3, "_ts_decorate");
369
+ var LimitExceededFilter = class _LimitExceededFilter extends BaseExceptionFilter {
370
+ static {
371
+ __name(this, "LimitExceededFilter");
372
+ }
373
+ logger = new Logger2(_LimitExceededFilter.name);
374
+ catch(exception, host) {
375
+ if (!isLimitExceededError(exception)) {
376
+ super.catch(exception, host);
377
+ return;
378
+ }
379
+ const ctx = host.switchToHttp();
380
+ const response = ctx.getResponse();
381
+ const request = ctx.getRequest();
382
+ this.logger.warn(`Limit ${exception.dimension} exceeded: ${exception.used}/${exception.max} on ${request.method ?? "?"} ${request.url ?? ""}`);
383
+ response.status(HttpStatus.PAYMENT_REQUIRED).send({
384
+ statusCode: HttpStatus.PAYMENT_REQUIRED,
385
+ error: "PaymentRequired",
386
+ code: BILLING_ERROR_CODES.LIMIT_EXCEEDED,
387
+ // Deprecated: superseded by `code`, kept until consumers have
388
+ // migrated. Same value, only under the older field name.
389
+ reason: BILLING_ERROR_CODES.LIMIT_EXCEEDED,
390
+ dimension: exception.dimension,
391
+ used: exception.used,
392
+ max: exception.max,
393
+ message: exception.message
394
+ });
395
+ }
396
+ };
397
+ LimitExceededFilter = _ts_decorate3([
398
+ Catch()
399
+ ], LimitExceededFilter);
400
+
401
+ // src/billing/plan-catalog-loader.ts
402
+ import { readFileSync } from "fs";
403
+ import { resolve as resolvePath } from "path";
404
+ import yaml from "js-yaml";
405
+ import Ajv2020 from "ajv/dist/2020.js";
406
+ import addFormats from "ajv-formats";
407
+ import { planCatalogSchema } from "@saasicat/spec";
408
+ var PlanCatalogValidationError = class extends Error {
409
+ static {
410
+ __name(this, "PlanCatalogValidationError");
411
+ }
412
+ source;
413
+ errors;
414
+ constructor(source, errors) {
415
+ const messages = errors.map((e) => `${e.instancePath || "/"}: ${e.message ?? "unknown"}`).join("\n ");
416
+ super(`Plan catalog validation failed for ${source}:
417
+ ${messages}`), this.source = source, this.errors = errors;
418
+ this.name = "PlanCatalogValidationError";
419
+ }
420
+ };
421
+ function loadPlanCatalogFromFile(opts) {
422
+ const absolutePath = resolvePath(opts.path);
423
+ const raw = readFileSync(absolutePath, "utf-8");
424
+ return loadPlanCatalogFromString(raw, {
425
+ source: absolutePath,
426
+ crossFieldChecks: opts.crossFieldChecks ?? true
427
+ });
428
+ }
429
+ __name(loadPlanCatalogFromFile, "loadPlanCatalogFromFile");
430
+ function loadPlanCatalogFromString(yamlContent, opts) {
431
+ const parsed = yaml.load(yamlContent);
432
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
433
+ throw new Error(`The YAML content of ${opts.source} is not an object`);
434
+ }
435
+ const ajv = new Ajv2020({
436
+ strict: false,
437
+ allErrors: true
438
+ });
439
+ addFormats.default(ajv);
440
+ const validate = ajv.compile(planCatalogSchema);
441
+ if (!validate(parsed)) {
442
+ throw new PlanCatalogValidationError(opts.source, validate.errors ?? []);
443
+ }
444
+ const catalog = parsed;
445
+ if (opts.crossFieldChecks ?? true) {
446
+ validateConsistency(catalog, opts.source);
447
+ }
448
+ return catalog;
449
+ }
450
+ __name(loadPlanCatalogFromString, "loadPlanCatalogFromString");
451
+ function validateConsistency(catalog, source) {
452
+ const errors = [];
453
+ const declaredFeatureKeys = new Set((catalog.features ?? []).map((f) => f.key));
454
+ const planIds = /* @__PURE__ */ new Set();
455
+ for (const plan of catalog.plans ?? []) {
456
+ if (planIds.has(plan.id)) {
457
+ errors.push(`plans[].id: duplicate plan id "${plan.id}"`);
458
+ }
459
+ planIds.add(plan.id);
460
+ if (catalog.features) {
461
+ for (const fk of plan.features) {
462
+ if (!declaredFeatureKeys.has(fk)) {
463
+ errors.push(`plans[id=${plan.id}].features: Unknown featureKey "${fk}" \u2014 not declared in catalog.features`);
464
+ }
465
+ }
466
+ }
467
+ }
468
+ if (errors.length > 0) {
469
+ const err = new Error(`Plan catalog consistency error in ${source}:
470
+ ${errors.join("\n ")}`);
471
+ err.name = "PlanCatalogConsistencyError";
472
+ throw err;
473
+ }
474
+ }
475
+ __name(validateConsistency, "validateConsistency");
476
+
477
+ // src/billing/plan-catalog-importer.service.ts
478
+ import { Inject as Inject3, Injectable as Injectable3 } from "@nestjs/common";
479
+ function _ts_decorate4(decorators, target, key, desc) {
480
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
481
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
482
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
483
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
484
+ }
485
+ __name(_ts_decorate4, "_ts_decorate");
486
+ function _ts_metadata3(k, v) {
487
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
488
+ }
489
+ __name(_ts_metadata3, "_ts_metadata");
490
+ function _ts_param3(paramIndex, decorator) {
491
+ return function(target, key) {
492
+ decorator(target, key, paramIndex);
493
+ };
494
+ }
495
+ __name(_ts_param3, "_ts_param");
496
+ var PLAN_CATALOG_IMPORT_SINK_TOKEN = /* @__PURE__ */ Symbol.for("saas-platform/PlanCatalogImportSink");
497
+ var IMPORT_CHANGE_NOTE = "Initial import from saas.yaml";
498
+ var PlanCatalogImporterService = class {
499
+ static {
500
+ __name(this, "PlanCatalogImporterService");
501
+ }
502
+ sink;
503
+ constructor(sink) {
504
+ this.sink = sink;
505
+ }
506
+ /**
507
+ * Import directly from a YAML string. Apps pass the contents of the
508
+ * `saas.yaml` through via the HTTP body; CLI tools can read the file
509
+ * contents themselves beforehand.
510
+ *
511
+ * @throws on a schema failure of the YAML (validated first via the loader).
512
+ */
513
+ async importFromYaml(yamlContent, opts = {}) {
514
+ const catalog = loadPlanCatalogFromString(yamlContent, {
515
+ source: opts.source ?? "http-import",
516
+ crossFieldChecks: opts.crossFieldChecks ?? true
517
+ });
518
+ return this.importCatalog(catalog);
519
+ }
520
+ async importCatalog(catalog) {
521
+ const report = {
522
+ plansCreated: 0,
523
+ plansSkipped: 0,
524
+ planVersionsCreated: 0,
525
+ planVersionsSkipped: 0,
526
+ featureEntriesCreated: 0,
527
+ featureEntriesSkipped: 0,
528
+ warnings: []
529
+ };
530
+ for (const feature of catalog.features ?? []) {
531
+ const result = await this.sink.upsertFeatureCatalogEntry({
532
+ projectKey: catalog.projectKey,
533
+ featureKey: feature.key,
534
+ label: feature.label,
535
+ icon: feature.icon,
536
+ tier: feature.tier,
537
+ plannedOnly: feature.plannedOnly
538
+ });
539
+ if (result.created) report.featureEntriesCreated++;
540
+ else report.featureEntriesSkipped++;
541
+ }
542
+ for (const plan of catalog.plans ?? []) {
543
+ const planResult = await this.sink.upsertPlan({
544
+ projectKey: catalog.projectKey,
545
+ planKey: plan.id,
546
+ label: plan.name ?? plan.id,
547
+ description: plan.tagline ?? null
548
+ });
549
+ if (planResult.created) report.plansCreated++;
550
+ else report.plansSkipped++;
551
+ if (plan.monthlyNet === null || plan.monthlyNet === void 0) {
552
+ report.warnings.push(`Plan '${plan.id}' has no monthlyNet \u2014 skipped (on-request plans need a manual plan version).`);
553
+ continue;
554
+ }
555
+ const versionResult = await this.sink.upsertPlanVersion({
556
+ planKey: plan.id,
557
+ version: 1,
558
+ features: plan.features,
559
+ quotas: plan.quotas,
560
+ monthlyNet: plan.monthlyNet.toFixed(2),
561
+ yearlyNet: (plan.yearlyNet ?? plan.monthlyNet * 10).toFixed(2),
562
+ marketed: plan.marketed ?? true,
563
+ publish: true,
564
+ changeNote: IMPORT_CHANGE_NOTE
565
+ });
566
+ if (versionResult.created) report.planVersionsCreated++;
567
+ else report.planVersionsSkipped++;
568
+ }
569
+ return report;
570
+ }
571
+ };
572
+ PlanCatalogImporterService = _ts_decorate4([
573
+ Injectable3(),
574
+ _ts_param3(0, Inject3(PLAN_CATALOG_IMPORT_SINK_TOKEN)),
575
+ _ts_metadata3("design:type", Function),
576
+ _ts_metadata3("design:paramtypes", [
577
+ typeof PlanCatalogImportSink === "undefined" ? Object : PlanCatalogImportSink
578
+ ])
579
+ ], PlanCatalogImporterService);
580
+
581
+ // src/billing/plan-catalog-importer.module.ts
582
+ import { Module } from "@nestjs/common";
583
+
584
+ // src/billing/plan-catalog-importer.controller.ts
585
+ import { Body, Controller, Inject as Inject4, Post, UseGuards } from "@nestjs/common";
586
+ import { IsBoolean, IsOptional, IsString, MinLength } from "class-validator";
587
+ function _ts_decorate5(decorators, target, key, desc) {
588
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
589
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
590
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
591
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
592
+ }
593
+ __name(_ts_decorate5, "_ts_decorate");
594
+ function _ts_metadata4(k, v) {
595
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
596
+ }
597
+ __name(_ts_metadata4, "_ts_metadata");
598
+ function _ts_param4(paramIndex, decorator) {
599
+ return function(target, key) {
600
+ decorator(target, key, paramIndex);
601
+ };
602
+ }
603
+ __name(_ts_param4, "_ts_param");
604
+ var PlanCatalogImportDto = class {
605
+ static {
606
+ __name(this, "PlanCatalogImportDto");
607
+ }
608
+ yamlContent;
609
+ crossFieldChecks;
610
+ };
611
+ _ts_decorate5([
612
+ IsString(),
613
+ MinLength(1),
614
+ _ts_metadata4("design:type", String)
615
+ ], PlanCatalogImportDto.prototype, "yamlContent", void 0);
616
+ _ts_decorate5([
617
+ IsOptional(),
618
+ IsBoolean(),
619
+ _ts_metadata4("design:type", Boolean)
620
+ ], PlanCatalogImportDto.prototype, "crossFieldChecks", void 0);
621
+ function buildPlanCatalogImporterController(guards) {
622
+ let GeneratedPlanCatalogImporterController = class GeneratedPlanCatalogImporterController {
623
+ static {
624
+ __name(this, "GeneratedPlanCatalogImporterController");
625
+ }
626
+ service;
627
+ constructor(service) {
628
+ this.service = service;
629
+ }
630
+ async import(dto) {
631
+ return this.service.importFromYaml(dto.yamlContent, {
632
+ crossFieldChecks: dto.crossFieldChecks
633
+ });
634
+ }
635
+ };
636
+ _ts_decorate5([
637
+ Post("import"),
638
+ _ts_param4(0, Body()),
639
+ _ts_metadata4("design:type", Function),
640
+ _ts_metadata4("design:paramtypes", [
641
+ typeof PlanCatalogImportDto === "undefined" ? Object : PlanCatalogImportDto
642
+ ]),
643
+ _ts_metadata4("design:returntype", Promise)
644
+ ], GeneratedPlanCatalogImporterController.prototype, "import", null);
645
+ GeneratedPlanCatalogImporterController = _ts_decorate5([
646
+ Controller("admin/billing/plan-catalog"),
647
+ UseGuards(...guards),
648
+ _ts_param4(0, Inject4(PlanCatalogImporterService)),
649
+ _ts_metadata4("design:type", Function),
650
+ _ts_metadata4("design:paramtypes", [
651
+ typeof PlanCatalogImporterService === "undefined" ? Object : PlanCatalogImporterService
652
+ ])
653
+ ], GeneratedPlanCatalogImporterController);
654
+ return GeneratedPlanCatalogImporterController;
655
+ }
656
+ __name(buildPlanCatalogImporterController, "buildPlanCatalogImporterController");
657
+
658
+ // src/billing/plan-catalog-importer.module.ts
659
+ function _ts_decorate6(decorators, target, key, desc) {
660
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
661
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
662
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
663
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
664
+ }
665
+ __name(_ts_decorate6, "_ts_decorate");
666
+ var PlanCatalogImporterModule = class _PlanCatalogImporterModule {
667
+ static {
668
+ __name(this, "PlanCatalogImporterModule");
669
+ }
670
+ static forRoot(options) {
671
+ const providers = [
672
+ ...options.extraProviders ?? [],
673
+ asProvider(PLAN_CATALOG_IMPORT_SINK_TOKEN, options.sink),
674
+ PlanCatalogImporterService
675
+ ];
676
+ const controllers = [];
677
+ if (options.controller) {
678
+ controllers.push(buildPlanCatalogImporterController(options.controller.guards));
679
+ }
680
+ return {
681
+ module: _PlanCatalogImporterModule,
682
+ global: options.global ?? false,
683
+ imports: options.imports ?? [],
684
+ controllers,
685
+ providers,
686
+ exports: [
687
+ PlanCatalogImporterService
688
+ ]
689
+ };
690
+ }
691
+ };
692
+ PlanCatalogImporterModule = _ts_decorate6([
693
+ Module({})
694
+ ], PlanCatalogImporterModule);
695
+
696
+ // src/billing/trial-carryover.ts
697
+ var MS_PER_DAY = 864e5;
698
+ function computeCarriedTrialEndsAt(currentTrialDays, newTrialDays, currentTrialEndsAt, now) {
699
+ const daysRemaining = Math.max(0, Math.ceil((currentTrialEndsAt.getTime() - now.getTime()) / MS_PER_DAY));
700
+ const usedDays = Math.max(0, currentTrialDays - daysRemaining);
701
+ const newDays = Math.max(0, newTrialDays - usedDays);
702
+ return new Date(now.getTime() + newDays * MS_PER_DAY);
703
+ }
704
+ __name(computeCarriedTrialEndsAt, "computeCarriedTrialEndsAt");
705
+
706
+ // src/billing/configurator-catalog-builder.ts
707
+ import { Injectable as Injectable4 } from "@nestjs/common";
708
+ function _ts_decorate7(decorators, target, key, desc) {
709
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
710
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
711
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
712
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
713
+ }
714
+ __name(_ts_decorate7, "_ts_decorate");
715
+ var ConfiguratorCatalogBuilder = class {
716
+ static {
717
+ __name(this, "ConfiguratorCatalogBuilder");
718
+ }
719
+ async build(input) {
720
+ const { sources, marketing } = input;
721
+ const planRows = await sources.listLivePlans();
722
+ const planMarketingByPlanId = new Map(marketing.listPlanMarketing().map((m) => [
723
+ m.planId,
724
+ m
725
+ ]));
726
+ return {
727
+ cycleDiscount: marketing.getCycleDiscount(),
728
+ currency: marketing.getCurrency(),
729
+ vatRate: marketing.getVatRate(),
730
+ models: planRows.filter((row) => row.marketed).map((row) => buildModel(row, planMarketingByPlanId)).filter((m) => m !== null)
731
+ };
732
+ }
733
+ };
734
+ ConfiguratorCatalogBuilder = _ts_decorate7([
735
+ Injectable4()
736
+ ], ConfiguratorCatalogBuilder);
737
+ function buildModel(row, marketing) {
738
+ const m = marketing.get(row.planId);
739
+ if (!m) return null;
740
+ return {
741
+ id: row.planId.toLowerCase(),
742
+ code: m.code,
743
+ name: m.name,
744
+ glyph: m.glyph,
745
+ tagline: m.tagline,
746
+ planId: row.planId,
747
+ monthlyNet: row.monthlyNet,
748
+ yearlyNet: row.yearlyNet,
749
+ tags: m.tags,
750
+ includedFeatureKeys: row.features,
751
+ quotaBase: normalizeQuotas(row.quotas),
752
+ ...m.popular ? {
753
+ popular: true
754
+ } : {}
755
+ };
756
+ }
757
+ __name(buildModel, "buildModel");
758
+ function normalizeQuotas(quotas) {
759
+ const out = {};
760
+ for (const [k, v] of Object.entries(quotas)) {
761
+ out[k] = v === -1 ? Number.MAX_SAFE_INTEGER : v;
762
+ }
763
+ return out;
764
+ }
765
+ __name(normalizeQuotas, "normalizeQuotas");
766
+
767
+ export {
768
+ PublishValidationError,
769
+ assertChangeNote,
770
+ assertDraftPublishable,
771
+ assertBaseVersionFresh,
772
+ assertOptimisticLockHeld,
773
+ decideRenewal,
774
+ clearPendingPlanVersionFields,
775
+ computeNextPeriod,
776
+ FEATURE_GUARD_CONFIG_TOKEN,
777
+ UPSELL_OFFER_RESOLVER_TOKEN,
778
+ UPSELL_OFFER_CURRENCY_TOKEN,
779
+ FeatureGuard,
780
+ CatalogBundleUpsellResolver,
781
+ LimitExceededFilter,
782
+ PlanCatalogValidationError,
783
+ loadPlanCatalogFromFile,
784
+ loadPlanCatalogFromString,
785
+ PLAN_CATALOG_IMPORT_SINK_TOKEN,
786
+ PlanCatalogImporterService,
787
+ PlanCatalogImportDto,
788
+ buildPlanCatalogImporterController,
789
+ PlanCatalogImporterModule,
790
+ computeCarriedTrialEndsAt,
791
+ ConfiguratorCatalogBuilder
792
+ };