@saasicat/nest 1.0.0-rc.2 → 1.0.0-rc.21

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 (154) hide show
  1. package/README.md +44 -8
  2. package/dist/.build-stamp +1 -1
  3. package/dist/_entries.cjs +10214 -4796
  4. package/dist/admin/index.d.cts +5 -4
  5. package/dist/admin/index.d.ts +5 -4
  6. package/dist/admin/index.js +13 -9
  7. package/dist/{admin-resources.module-CpvzcfCc.d.cts → admin-resources.module-B4XNvdH-.d.cts} +1 -2
  8. package/dist/{admin-resources.module-DF5_Q7RD.d.ts → admin-resources.module-CWqdI9BV.d.ts} +1 -2
  9. package/dist/{admin-stats.service-TYiSUwIR.d.ts → admin-stats.service-CsLNt19Z.d.ts} +11 -7
  10. package/dist/{admin-stats.service-DfKupywR.d.cts → admin-stats.service-DrZrU8IJ.d.cts} +11 -7
  11. package/dist/{aggregation-B3CD0v_d.d.cts → aggregation-ChpZDh0g.d.ts} +39 -6
  12. package/dist/{aggregation-BFzW07DE.d.ts → aggregation-OpnhwzF5.d.cts} +39 -6
  13. package/dist/billing/index.d.cts +448 -108
  14. package/dist/billing/index.d.ts +448 -108
  15. package/dist/billing/index.js +108 -41
  16. package/dist/catalog/index.d.cts +74 -45
  17. package/dist/catalog/index.d.ts +74 -45
  18. package/dist/catalog/index.js +21 -13
  19. package/dist/catalog.module-C9O7BxRq.d.ts +180 -0
  20. package/dist/catalog.module-T-I5Ricz.d.cts +180 -0
  21. package/dist/checkout-offer/index.d.cts +17 -22
  22. package/dist/checkout-offer/index.d.ts +17 -22
  23. package/dist/checkout-offer/index.js +21 -7
  24. package/dist/checkout-offer.module-DFhGy6JO.d.cts +58 -0
  25. package/dist/checkout-offer.module-x7SP_dX4.d.ts +58 -0
  26. package/dist/checkout-offer.service-BeHgYikI.d.ts +242 -0
  27. package/dist/checkout-offer.service-CCVxG9b2.d.cts +242 -0
  28. package/dist/chunk-34XVPR6P.js +222 -0
  29. package/dist/{chunk-AOQJEYOL.js → chunk-3QVGA6JX.js} +3 -1
  30. package/dist/chunk-3RLWML2S.js +896 -0
  31. package/dist/{chunk-TXC3LHHB.js → chunk-4SDSYV4A.js} +21 -13
  32. package/dist/chunk-55QEDAMX.js +946 -0
  33. package/dist/chunk-5CKC7O52.js +140 -0
  34. package/dist/chunk-6PJE7EWO.js +185 -0
  35. package/dist/chunk-7NJKVC5X.js +353 -0
  36. package/dist/{chunk-RUNZ3X4C.js → chunk-AOS2JPDD.js} +1263 -185
  37. package/dist/{chunk-WOVQPXV4.js → chunk-CQ2ZZMTD.js} +702 -725
  38. package/dist/{chunk-NA6O63B7.js → chunk-F5X66HF5.js} +5 -5
  39. package/dist/chunk-FEQZCWUL.js +174 -0
  40. package/dist/{chunk-VIJ5NJWC.js → chunk-G6EZWECL.js} +107 -31
  41. package/dist/chunk-G7RQO2XO.js +0 -0
  42. package/dist/{chunk-N3L3AICU.js → chunk-GPQWGA6B.js} +5 -6
  43. package/dist/chunk-GZTL64QK.js +7 -0
  44. package/dist/{chunk-SABTXESR.js → chunk-J7NJRK3K.js} +17 -1
  45. package/dist/{chunk-SEPN52AD.js → chunk-JBCC6C3M.js} +566 -307
  46. package/dist/{chunk-I7GTA3RX.js → chunk-JQRA724W.js} +1 -1
  47. package/dist/{chunk-WXYJHZCN.js → chunk-JVAEKTJ4.js} +2 -14
  48. package/dist/chunk-KPEMTBOP.js +26 -0
  49. package/dist/chunk-LF6J4YYN.js +301 -0
  50. package/dist/{chunk-LTT736P3.js → chunk-LLYVYRGJ.js} +228 -387
  51. package/dist/{chunk-6Z7JR4EW.js → chunk-M47BNEY2.js} +1897 -633
  52. package/dist/{chunk-KFT5AIIH.js → chunk-NDLC5GYK.js} +206 -24
  53. package/dist/{chunk-O2J2HDXA.js → chunk-OOTEXP47.js} +3 -8
  54. package/dist/chunk-QPVDCKYS.js +702 -0
  55. package/dist/{chunk-NHVDCYK5.js → chunk-R5YCJBHY.js} +71 -106
  56. package/dist/{chunk-XBYAFEOR.js → chunk-S33SO5XX.js} +1 -1
  57. package/dist/chunk-SZ7RFPXA.js +10 -0
  58. package/dist/chunk-TBBZWZQT.js +98 -0
  59. package/dist/chunk-TOEFN7DN.js +295 -0
  60. package/dist/{chunk-7ZEGFL42.js → chunk-WHJSYKNC.js} +6 -6
  61. package/dist/chunk-WOEJ6K7M.js +55 -0
  62. package/dist/{chunk-AU3OOREM.js → chunk-XSSWYPP5.js} +16 -3
  63. package/dist/contract-line-item-money-B0Z3Ogur.d.cts +43 -0
  64. package/dist/contract-line-item-money-B0Z3Ogur.d.ts +43 -0
  65. package/dist/discovery/index.d.cts +2 -2
  66. package/dist/discovery/index.d.ts +2 -2
  67. package/dist/discovery/index.js +6 -6
  68. package/dist/{discovery.scanner-9cMqF95o.d.cts → discovery.scanner-DXKc6JkV.d.cts} +1 -1
  69. package/dist/{discovery.scanner-9cMqF95o.d.ts → discovery.scanner-DXKc6JkV.d.ts} +1 -1
  70. package/dist/{enforce-quota.interceptor-Df0e3OWe.d.cts → enforce-quota.interceptor-BFyVdWe8.d.cts} +6 -6
  71. package/dist/{enforce-quota.interceptor-DW3etVyI.d.ts → enforce-quota.interceptor-GaBXboGx.d.ts} +6 -6
  72. package/dist/entitlement/index.d.cts +27 -4
  73. package/dist/entitlement/index.d.ts +27 -4
  74. package/dist/entitlement/index.js +13 -8
  75. package/dist/index.d.cts +209 -28
  76. package/dist/index.d.ts +209 -28
  77. package/dist/index.js +285 -163
  78. package/dist/issuer-identity.check-CBO0vcsq.d.cts +197 -0
  79. package/dist/issuer-identity.check-Cz9CEMkZ.d.ts +197 -0
  80. package/dist/{module-options-COSEb0VW.d.ts → module-options-DmQ3G4sZ.d.ts} +135 -41
  81. package/dist/{module-options-BG3MzQva.d.cts → module-options-qAOwinHR.d.cts} +135 -41
  82. package/dist/payment-callback.service-CR84Xw1Z.d.cts +125 -0
  83. package/dist/payment-callback.service-CR84Xw1Z.d.ts +125 -0
  84. package/dist/payments/index.cjs +4 -0
  85. package/dist/payments/index.d.cts +187 -0
  86. package/dist/payments/index.d.ts +187 -0
  87. package/dist/payments/index.js +141 -0
  88. package/dist/payments.module-BdUq9ot_.d.cts +57 -0
  89. package/dist/payments.module-CQGv_VG7.d.ts +57 -0
  90. package/dist/plan-catalog-source-DWe-BGY1.d.cts +21 -0
  91. package/dist/plan-catalog-source-DWe-BGY1.d.ts +21 -0
  92. package/dist/{plan-resolution-Cgo_TR1H.d.cts → plan-resolution-CwUy_OqC.d.cts} +14 -0
  93. package/dist/{plan-resolution-Cgo_TR1H.d.ts → plan-resolution-CwUy_OqC.d.ts} +14 -0
  94. package/dist/{plan-versions.service-DChhbK6h.d.ts → plan-versions.service-C0N-Em2W.d.ts} +38 -64
  95. package/dist/{plan-versions.service-TPEtbN0r.d.cts → plan-versions.service-DARLh-qI.d.cts} +38 -64
  96. package/dist/platform/index.d.cts +52 -23
  97. package/dist/platform/index.d.ts +52 -23
  98. package/dist/platform/index.js +57 -36
  99. package/dist/promo/index.d.cts +23 -8
  100. package/dist/promo/index.d.ts +23 -8
  101. package/dist/promo/index.js +15 -7
  102. package/dist/{promo.module-DtvIycpt.d.cts → promo.module-CKkECmU8.d.cts} +9 -3
  103. package/dist/{promo.module-Z8h1PNX-.d.ts → promo.module-DuIJdK3U.d.ts} +9 -3
  104. package/dist/promo.service-BYBu6dy3.d.cts +215 -0
  105. package/dist/promo.service-CFjfF0Xm.d.ts +215 -0
  106. package/dist/registration/index.d.cts +104 -47
  107. package/dist/registration/index.d.ts +104 -47
  108. package/dist/registration/index.js +15 -9
  109. package/dist/subscriber/index.cjs +4 -0
  110. package/dist/subscriber/index.d.cts +28 -0
  111. package/dist/subscriber/index.d.ts +28 -0
  112. package/dist/subscriber/index.js +16 -0
  113. package/dist/subscriber.service-5W6Gi-1B.d.cts +68 -0
  114. package/dist/subscriber.service-5W6Gi-1B.d.ts +68 -0
  115. package/dist/subscription-contract/index.d.cts +4 -2
  116. package/dist/subscription-contract/index.d.ts +4 -2
  117. package/dist/subscription-contract/index.js +13 -5
  118. package/dist/{subscription-contract.module-fDFdQ0jm.d.ts → subscription-contract.module-BfIRpc1o.d.ts} +8 -2
  119. package/dist/{subscription-contract.module-CEK8HS5-.d.cts → subscription-contract.module-DTMuUJYd.d.cts} +8 -2
  120. package/dist/{subscription-contract.service-hD87MKyg.d.cts → subscription-contract.service-CPZrTM9C.d.ts} +32 -3
  121. package/dist/{subscription-contract.service-hD87MKyg.d.ts → subscription-contract.service-CfNApLuP.d.cts} +32 -3
  122. package/dist/tenant-billing.controller-BLqbsONe.d.ts +812 -0
  123. package/dist/tenant-billing.controller-DKbAqTbM.d.cts +812 -0
  124. package/dist/tenant-billing.module-ClbjzocC.d.cts +364 -0
  125. package/dist/tenant-billing.module-DohgJJ2m.d.ts +364 -0
  126. package/dist/tenant-billing.tokens-G-1xOlMr.d.cts +129 -0
  127. package/dist/tenant-billing.tokens-G-1xOlMr.d.ts +129 -0
  128. package/dist/testing/index.d.cts +44 -17
  129. package/dist/testing/index.d.ts +44 -17
  130. package/dist/testing/index.js +189 -40
  131. package/package.json +24 -4
  132. package/dist/catalog.module-BDDO6iwq.d.cts +0 -103
  133. package/dist/catalog.module-BTnMsE6u.d.ts +0 -103
  134. package/dist/checkout-offer.module-CMtiQJTT.d.ts +0 -39
  135. package/dist/checkout-offer.module-CVpWrzbe.d.cts +0 -39
  136. package/dist/checkout-offer.service-BLOv2HOo.d.cts +0 -49
  137. package/dist/checkout-offer.service-BLOv2HOo.d.ts +0 -49
  138. package/dist/chunk-57V6ZTI6.js +0 -22
  139. package/dist/chunk-BSK6YBLI.js +0 -87
  140. package/dist/chunk-DQKCK7DX.js +0 -217
  141. package/dist/chunk-VXEYLNIB.js +0 -632
  142. package/dist/chunk-WUDYIPYH.js +0 -715
  143. package/dist/define-saasicat-DROBe-b1.d.ts +0 -69
  144. package/dist/define-saasicat-vGBCkFys.d.cts +0 -69
  145. package/dist/promo.service-BJbKAmw3.d.cts +0 -112
  146. package/dist/promo.service-BJbKAmw3.d.ts +0 -112
  147. package/dist/tenant-billing.controller-BQ67mlmI.d.ts +0 -438
  148. package/dist/tenant-billing.controller-DS8kB8a7.d.cts +0 -438
  149. package/dist/tenant-billing.module-BznC3XtW.d.cts +0 -299
  150. package/dist/tenant-billing.module-wUbsQSFG.d.ts +0 -299
  151. /package/dist/{chunk-2FR6ZL7R.js → chunk-BHZOH2DY.js} +0 -0
  152. /package/dist/{chunk-2SLTRKXC.js → chunk-DFOW3JVO.js} +0 -0
  153. /package/dist/{chunk-DBZGV3HC.js → chunk-DU56UKEH.js} +0 -0
  154. /package/dist/{chunk-OYNHOY45.js → chunk-GAUEVIAE.js} +0 -0
@@ -1,632 +0,0 @@
1
- import {
2
- PLAN_CATALOG_TOKEN,
3
- computeIncludedVat,
4
- getPlanPriceGross,
5
- round2
6
- } from "./chunk-DQKCK7DX.js";
7
- import {
8
- __name
9
- } from "./chunk-SHUYVCID.js";
10
-
11
- // src/promo/calculator.ts
12
- function computeDiscountGross(plan, code) {
13
- const v = Number(code.value);
14
- if (code.valueType === "PERCENT") {
15
- return round2(plan.gross * v / 100);
16
- }
17
- return round2(v);
18
- }
19
- __name(computeDiscountGross, "computeDiscountGross");
20
- function computeDiscountedGross(planGross, discountGross) {
21
- return round2(planGross - discountGross);
22
- }
23
- __name(computeDiscountedGross, "computeDiscountedGross");
24
- function computeRegularStartsAt(startsAt, cycle, durationType, durationValue) {
25
- const result = new Date(startsAt);
26
- if (durationType === "ONCE") {
27
- return addCycles(result, cycle, 1);
28
- }
29
- if (durationType === "MONTHS") {
30
- result.setMonth(result.getMonth() + (durationValue ?? 0));
31
- return result;
32
- }
33
- return addCycles(result, cycle, durationValue ?? 0);
34
- }
35
- __name(computeRegularStartsAt, "computeRegularStartsAt");
36
- function addCycles(date, cycle, n) {
37
- const r = new Date(date);
38
- if (cycle === "YEARLY") {
39
- r.setFullYear(r.getFullYear() + n);
40
- } else {
41
- r.setMonth(r.getMonth() + n);
42
- }
43
- return r;
44
- }
45
- __name(addCycles, "addCycles");
46
- var LEGACY_LABEL_LOCALE = "de-DE";
47
- var LEGACY_LABEL_CURRENCY = "EUR";
48
- var ICU_NON_BREAKING_SPACES = /[\u00A0\u202F]/g;
49
- function requireSupportedLocale(locale) {
50
- if (Intl.NumberFormat.supportedLocalesOf([
51
- locale
52
- ]).length === 0) {
53
- throw new RangeError(`buildLabel: locale "${locale}" names a language this runtime cannot serve. It would be formatted as "${new Intl.NumberFormat(locale).resolvedOptions().locale}" instead \u2014 a language nobody chose. Pass a tag the runtime serves.`);
54
- }
55
- }
56
- __name(requireSupportedLocale, "requireSupportedLocale");
57
- function formatPromoValue(value, valueType, { locale = LEGACY_LABEL_LOCALE, currency = LEGACY_LABEL_CURRENCY }) {
58
- requireSupportedLocale(locale);
59
- const numberFormat = valueType === "PERCENT" ? {
60
- style: "unit",
61
- unit: "percent"
62
- } : {
63
- style: "currency",
64
- currency
65
- };
66
- return new Intl.NumberFormat(locale, numberFormat).format(value).replace(ICU_NON_BREAKING_SPACES, " ");
67
- }
68
- __name(formatPromoValue, "formatPromoValue");
69
- function buildLabel(code, cycle, options = {}) {
70
- const value = Number(code.value);
71
- const valueStr = formatPromoValue(value, code.valueType, options);
72
- if (code.durationType === "ONCE") {
73
- return `${valueStr} once`;
74
- }
75
- if (code.durationType === "MONTHS") {
76
- const m = code.durationValue ?? 0;
77
- return m === 1 ? `${valueStr} for the first month` : `${valueStr} for ${m} months`;
78
- }
79
- const n = code.durationValue ?? 0;
80
- if (n === 1) return cycle === "YEARLY" ? `${valueStr} for the first year` : `${valueStr} for the first month`;
81
- return cycle === "YEARLY" ? `${valueStr} for the first ${n} years` : `${valueStr} for the first ${n} months`;
82
- }
83
- __name(buildLabel, "buildLabel");
84
-
85
- // src/promo/promo.tokens.ts
86
- var PROMO_CODE_REPOSITORY_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PromoCodeRepository");
87
- var PROMO_CODE_REDEMPTION_REPOSITORY_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PromoCodeRedemptionRepository");
88
- var PROMO_CODE_VALIDATION_LOG_REPOSITORY_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PromoCodeValidationLogRepository");
89
- var PROMO_FIRST_TIME_CUSTOMER_CHECK_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PromoFirstTimeCustomerCheck");
90
- var PROMO_SUBSCRIPTION_LOOKUP_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PromoSubscriptionLookup");
91
- var PROMO_REVENUE_DEDUCTION_AGGREGATOR_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PromoRevenueDeductionAggregator");
92
- var PROMO_TRANSACTION_RUNNER_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PromoTransactionRunner");
93
- var PROMO_SERVICE_CONFIG_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PromoServiceConfig");
94
-
95
- // src/promo/promo.service.ts
96
- import { BadRequestException, Inject, Injectable, NotFoundException } from "@nestjs/common";
97
- import { BILLING_ERROR_CODES, PROMO_ERROR_CODES } from "@saasicat/core";
98
- function _ts_decorate(decorators, target, key, desc) {
99
- var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
100
- if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
101
- 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;
102
- return c > 3 && r && Object.defineProperty(target, key, r), r;
103
- }
104
- __name(_ts_decorate, "_ts_decorate");
105
- function _ts_metadata(k, v) {
106
- if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
107
- }
108
- __name(_ts_metadata, "_ts_metadata");
109
- function _ts_param(paramIndex, decorator) {
110
- return function(target, key) {
111
- decorator(target, key, paramIndex);
112
- };
113
- }
114
- __name(_ts_param, "_ts_param");
115
- var CODE_MIN_LENGTH = 4;
116
- var CODE_MAX_LENGTH = 32;
117
- var CODE_PATTERN = /^[A-Z0-9_-]{4,32}$/;
118
- var PERCENT_MIN_EXCLUSIVE = 0;
119
- var PERCENT_MAX = 100;
120
- var DURATION_MIN = 1;
121
- var DURATION_MAX = 24;
122
- function notRedeemable(reason) {
123
- return new BadRequestException({
124
- code: PROMO_ERROR_CODES.PROMO_CODE_NOT_REDEEMABLE,
125
- message: `Code cannot be redeemed: ${reason}`,
126
- params: {
127
- reason
128
- }
129
- });
130
- }
131
- __name(notRedeemable, "notRedeemable");
132
- var PromoCodesService = class {
133
- static {
134
- __name(this, "PromoCodesService");
135
- }
136
- promoRepo;
137
- redemptionRepo;
138
- validationLogRepo;
139
- firstTimeCheck;
140
- subscriptionLookup;
141
- revenueAggregator;
142
- transactionRunner;
143
- planCatalog;
144
- config;
145
- constructor(promoRepo, redemptionRepo, validationLogRepo, firstTimeCheck, subscriptionLookup, revenueAggregator, transactionRunner, planCatalog, config) {
146
- this.promoRepo = promoRepo;
147
- this.redemptionRepo = redemptionRepo;
148
- this.validationLogRepo = validationLogRepo;
149
- this.firstTimeCheck = firstTimeCheck;
150
- this.subscriptionLookup = subscriptionLookup;
151
- this.revenueAggregator = revenueAggregator;
152
- this.transactionRunner = transactionRunner;
153
- this.planCatalog = planCatalog;
154
- this.config = config;
155
- }
156
- // ─── ADMIN: Creation / Editing ─────────────────────────────────────────
157
- async create(input) {
158
- const code = input.code.trim().toUpperCase();
159
- if (!CODE_PATTERN.test(code)) {
160
- throw new BadRequestException({
161
- code: PROMO_ERROR_CODES.PROMO_CODE_FORMAT_INVALID,
162
- message: 'The code may only contain upper-case letters, digits, "-" and "_" (4\u201332 characters).',
163
- params: {
164
- promoCode: code,
165
- minLength: CODE_MIN_LENGTH,
166
- maxLength: CODE_MAX_LENGTH
167
- }
168
- });
169
- }
170
- if (input.valueType === "PERCENT") {
171
- if (!(input.value > PERCENT_MIN_EXCLUSIVE && input.value <= PERCENT_MAX)) {
172
- throw new BadRequestException({
173
- code: PROMO_ERROR_CODES.PROMO_PERCENT_OUT_OF_RANGE,
174
- message: "The percentage must be between 0 and 100.",
175
- params: {
176
- value: input.value,
177
- min: PERCENT_MIN_EXCLUSIVE,
178
- max: PERCENT_MAX
179
- }
180
- });
181
- }
182
- } else if (!(input.value > 0)) {
183
- throw new BadRequestException({
184
- code: PROMO_ERROR_CODES.PROMO_AMOUNT_NOT_POSITIVE,
185
- message: "The amount must be positive.",
186
- params: {
187
- value: input.value,
188
- min: 0
189
- }
190
- });
191
- }
192
- if (input.durationType === "ONCE") {
193
- if (input.durationValue != null) {
194
- throw new BadRequestException({
195
- code: PROMO_ERROR_CODES.PROMO_ONE_OFF_WITH_DURATION,
196
- message: "A one-off discount must not set a duration.",
197
- params: {
198
- durationValue: input.durationValue
199
- }
200
- });
201
- }
202
- } else {
203
- const v = input.durationValue;
204
- if (v == null || v < DURATION_MIN || v > DURATION_MAX) {
205
- throw new BadRequestException({
206
- code: PROMO_ERROR_CODES.PROMO_DURATION_INVALID,
207
- message: "Invalid duration (at most 24 months or billing periods).",
208
- params: {
209
- durationValue: v ?? null,
210
- min: DURATION_MIN,
211
- max: DURATION_MAX
212
- }
213
- });
214
- }
215
- }
216
- if (input.validFrom && input.validUntil && input.validFrom >= input.validUntil) {
217
- throw new BadRequestException({
218
- code: PROMO_ERROR_CODES.PROMO_VALIDITY_WINDOW_INVALID,
219
- message: "Invalid validity window.",
220
- params: {
221
- validFrom: input.validFrom.toISOString(),
222
- validUntil: input.validUntil.toISOString()
223
- }
224
- });
225
- }
226
- const plans = input.appliesToPlans ?? [];
227
- const blocked = this.config.nonRedeemablePlans ?? [];
228
- for (const p of plans) {
229
- if (blocked.includes(p)) {
230
- throw new BadRequestException({
231
- code: PROMO_ERROR_CODES.PROMO_PLAN_NOT_DISCOUNTABLE,
232
- message: `${p}-Plan cannot be discounted.`,
233
- params: {
234
- plan: p
235
- }
236
- });
237
- }
238
- }
239
- if (input.minimumPlanAmountGross != null && input.minimumPlanAmountGross <= 0) {
240
- throw new BadRequestException({
241
- code: PROMO_ERROR_CODES.PROMO_MIN_AMOUNT_NOT_POSITIVE,
242
- message: "The minimum plan gross amount must be positive.",
243
- params: {
244
- value: input.minimumPlanAmountGross,
245
- min: 0
246
- }
247
- });
248
- }
249
- if (input.valueType === "ABSOLUTE" && !input.allowZeroInvoice) {
250
- const lowestApplicableGross = this.lowestApplicablePlanGross(plans);
251
- if (lowestApplicableGross != null && input.value >= lowestApplicableGross) {
252
- throw new BadRequestException({
253
- code: PROMO_ERROR_CODES.PROMO_WOULD_PRODUCE_ZERO_INVOICE,
254
- message: "For absolute amounts the discount must stay below the lowest applicable plan price, or allowZeroInvoice must be enabled.",
255
- params: {
256
- value: input.value,
257
- lowestApplicablePlanGross: lowestApplicableGross
258
- }
259
- });
260
- }
261
- }
262
- const exists = await this.promoRepo.findByCode(code);
263
- if (exists) {
264
- throw new BadRequestException({
265
- code: PROMO_ERROR_CODES.PROMO_CODE_ALREADY_EXISTS,
266
- message: "The code already exists.",
267
- params: {
268
- promoCode: code
269
- }
270
- });
271
- }
272
- return this.promoRepo.create({
273
- ...input,
274
- code
275
- });
276
- }
277
- async update(id, input) {
278
- const existing = await this.promoRepo.findById(id);
279
- if (!existing) {
280
- throw new NotFoundException({
281
- code: PROMO_ERROR_CODES.PROMO_CODE_NOT_FOUND,
282
- message: "Code not found",
283
- params: {
284
- promoCodeId: id,
285
- promoCode: null
286
- }
287
- });
288
- }
289
- if (input.maxRedemptions != null && existing.maxRedemptions != null) {
290
- if (input.maxRedemptions < existing.maxRedemptions) {
291
- throw new BadRequestException({
292
- code: PROMO_ERROR_CODES.PROMO_MAX_REDEMPTIONS_LOWERED,
293
- message: "maxRedemptions cannot be lowered.",
294
- params: {
295
- current: existing.maxRedemptions,
296
- requested: input.maxRedemptions
297
- }
298
- });
299
- }
300
- }
301
- return this.promoRepo.update(id, input);
302
- }
303
- async softDelete(id) {
304
- const redemptions = await this.redemptionRepo.countByPromoCode(id);
305
- if (redemptions > 0) {
306
- throw new BadRequestException({
307
- code: PROMO_ERROR_CODES.PROMO_CODE_HAS_REDEMPTIONS,
308
- message: "The code already has redemptions \u2014 it cannot be soft-deleted. Pause it instead.",
309
- params: {
310
- promoCodeId: id,
311
- redemptions
312
- }
313
- });
314
- }
315
- await this.promoRepo.softDelete(id);
316
- }
317
- async findAll(filter = {}) {
318
- await this.lazyExpire();
319
- return this.promoRepo.findMany(filter);
320
- }
321
- async findOne(id) {
322
- await this.lazyExpire();
323
- const code = await this.promoRepo.findById(id);
324
- if (!code || code.deletedAt) {
325
- throw new NotFoundException({
326
- code: PROMO_ERROR_CODES.PROMO_CODE_NOT_FOUND,
327
- message: "Code not found",
328
- params: {
329
- promoCodeId: id,
330
- promoCode: null
331
- }
332
- });
333
- }
334
- return code;
335
- }
336
- async stats(id) {
337
- const code = await this.findOne(id);
338
- const [active, total, reversed, expired, validations, revenueDeductionGross] = await Promise.all([
339
- this.redemptionRepo.countByPromoCode(id, "ACTIVE"),
340
- this.redemptionRepo.countByPromoCode(id),
341
- this.redemptionRepo.countByPromoCode(id, "REVERSED"),
342
- this.redemptionRepo.countByPromoCode(id, "EXPIRED"),
343
- this.validationLogRepo.countValid(id),
344
- this.revenueAggregator.sumGrossForPromoCode(id)
345
- ]);
346
- return {
347
- code,
348
- validations,
349
- redemptions: {
350
- active,
351
- total,
352
- reversed,
353
- expired
354
- },
355
- revenueDeductionGross
356
- };
357
- }
358
- async listRedemptions(promoCodeId) {
359
- return this.redemptionRepo.listByPromoCode(promoCodeId);
360
- }
361
- // ─── PUBLIC: Preview / Validation ──────────────────────────────────────
362
- async preview(input) {
363
- const code = input.code.trim().toUpperCase();
364
- if (!CODE_PATTERN.test(code)) {
365
- await this.validationLogRepo.log({
366
- promoCodeId: null,
367
- codeAttempt: code,
368
- result: "NOT_FOUND",
369
- ipHash: input.ipHash,
370
- sessionId: input.sessionId
371
- });
372
- return {
373
- valid: false,
374
- reason: "NOT_FOUND"
375
- };
376
- }
377
- await this.lazyExpire();
378
- const promo = await this.promoRepo.findByCode(code);
379
- const reason = this.checkEligibility(promo, input) ?? await this.checkFirstTimeCustomer(promo, input.email);
380
- if (reason) {
381
- await this.validationLogRepo.log({
382
- promoCodeId: promo?.id ?? null,
383
- codeAttempt: code,
384
- result: reason,
385
- ipHash: input.ipHash,
386
- sessionId: input.sessionId
387
- });
388
- return {
389
- valid: false,
390
- reason
391
- };
392
- }
393
- const planGross = getPlanPriceGross(this.planCatalog, input.planId, input.billingCycle);
394
- const discountGross = Math.min(computeDiscountGross({
395
- gross: planGross
396
- }, promo), planGross);
397
- const discountedGross = computeDiscountedGross(planGross, discountGross);
398
- const includedVat = computeIncludedVat(discountedGross, this.planCatalog.vatRate);
399
- if (!promo.allowZeroInvoice && discountedGross <= 0) {
400
- await this.validationLogRepo.log({
401
- promoCodeId: promo.id,
402
- codeAttempt: code,
403
- result: "WOULD_PRODUCE_ZERO_INVOICE",
404
- ipHash: input.ipHash,
405
- sessionId: input.sessionId
406
- });
407
- return {
408
- valid: false,
409
- reason: "WOULD_PRODUCE_ZERO_INVOICE"
410
- };
411
- }
412
- const startsAt = /* @__PURE__ */ new Date();
413
- const regularStartsAt = computeRegularStartsAt(startsAt, input.billingCycle, promo.durationType, promo.durationValue);
414
- await this.validationLogRepo.log({
415
- promoCodeId: promo.id,
416
- codeAttempt: code,
417
- result: "VALID",
418
- ipHash: input.ipHash,
419
- sessionId: input.sessionId
420
- });
421
- return {
422
- valid: true,
423
- code: promo.code,
424
- label: buildLabel(promo, input.billingCycle),
425
- discount: {
426
- valueType: promo.valueType,
427
- value: Number(promo.value).toFixed(2),
428
- durationType: promo.durationType,
429
- durationValue: promo.durationValue
430
- },
431
- price: {
432
- originalGross: planGross.toFixed(2),
433
- discountGross: discountGross.toFixed(2),
434
- discountedGross: discountedGross.toFixed(2),
435
- includedVat: includedVat.toFixed(2),
436
- nextRegularAmountGross: planGross.toFixed(2),
437
- regularStartsAt: regularStartsAt ? regularStartsAt.toISOString() : null
438
- }
439
- };
440
- }
441
- // ─── Onboarding path: atomic redemption ────────────────────────────────
442
- /**
443
- * Wraps `redeemInTransaction` in its own transaction-runner call —
444
- * the default path when the caller has no external tx context
445
- * (e.g. POST /billing/promo/redeem as a stand-alone endpoint).
446
- */
447
- async redeem(input) {
448
- return this.transactionRunner.run((tx) => this.redeemInTransaction(input, tx));
449
- }
450
- /**
451
- * Redeems a promo code within an EXTERNAL transaction — the caller
452
- * (typically `TenantSubscriptionWritePort.applyOnboardingSelection`)
453
- * already has `prisma.$transaction(...)` open and passes the `tx`
454
- * context through. This lands plan change, add-on insert, and redemption
455
- * insert in a single DB transaction (P10.1.1).
456
- */
457
- async redeemInTransaction(input, tx) {
458
- const code = input.code.trim().toUpperCase();
459
- const promo = await this.promoRepo.findByCode(code, tx);
460
- if (!promo || promo.deletedAt) {
461
- throw new BadRequestException({
462
- code: PROMO_ERROR_CODES.PROMO_CODE_NOT_FOUND,
463
- // The lookup runs on the code string, so there is no id to report.
464
- message: "Code not found",
465
- params: {
466
- promoCodeId: null,
467
- promoCode: code
468
- }
469
- });
470
- }
471
- const sub = await this.subscriptionLookup.findById(input.subscriptionId, tx);
472
- if (!sub) {
473
- throw new NotFoundException({
474
- code: BILLING_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
475
- message: "Subscription not found",
476
- params: {
477
- tenantId: input.tenantId
478
- }
479
- });
480
- }
481
- if (sub.tenantId !== input.tenantId) {
482
- throw new BadRequestException({
483
- code: BILLING_ERROR_CODES.SUBSCRIPTION_TENANT_MISMATCH,
484
- message: "Subscription does not belong to the tenant",
485
- params: {
486
- subscriptionId: sub.id,
487
- tenantId: input.tenantId
488
- }
489
- });
490
- }
491
- const reason = this.checkEligibility(promo, {
492
- code,
493
- planId: sub.plan,
494
- billingCycle: sub.billingCycle
495
- });
496
- if (reason) throw notRedeemable(reason);
497
- const firstTimeReason = await this.checkFirstTimeCustomer(promo, input.email, {
498
- requireEmail: true
499
- });
500
- if (firstTimeReason) {
501
- throw notRedeemable(firstTimeReason);
502
- }
503
- const claimed = await this.promoRepo.claimSlot(promo.id, tx);
504
- if (!claimed) {
505
- throw notRedeemable("EXHAUSTED");
506
- }
507
- await this.promoRepo.markExhaustedIfFull(promo.id, tx);
508
- const startsAt = sub.startedAt ?? /* @__PURE__ */ new Date();
509
- const endsAt = promo.durationType === "ONCE" ? null : computeRegularStartsAt(startsAt, sub.billingCycle, promo.durationType, promo.durationValue);
510
- return this.redemptionRepo.create({
511
- promoCodeId: promo.id,
512
- subscriptionId: sub.id,
513
- tenantId: sub.tenantId,
514
- appliedValueType: promo.valueType,
515
- appliedValue: promo.value,
516
- appliedDurationType: promo.durationType,
517
- appliedDurationValue: promo.durationValue,
518
- startsAt,
519
- endsAt
520
- }, tx);
521
- }
522
- async reverse(subscriptionId) {
523
- return this.transactionRunner.run(async (tx) => {
524
- const redemption = await this.redemptionRepo.findBySubscription(subscriptionId, tx);
525
- if (!redemption || redemption.status !== "ACTIVE") return redemption;
526
- const updated = await this.redemptionRepo.setReversed(redemption.id, tx);
527
- await this.promoRepo.releaseSlot(redemption.promoCodeId, tx);
528
- return updated;
529
- });
530
- }
531
- // ─── Helpers ───────────────────────────────────────────────────────────
532
- checkEligibility(promo, input) {
533
- if (!promo || promo.deletedAt) return "NOT_FOUND";
534
- if (promo.status === "EXPIRED") return "EXPIRED";
535
- if (promo.status === "EXHAUSTED") return "EXHAUSTED";
536
- if (promo.status === "PAUSED") return "PAUSED";
537
- const now = /* @__PURE__ */ new Date();
538
- if (promo.validFrom && promo.validFrom > now) return "EXPIRED";
539
- if (promo.validUntil && promo.validUntil < now) return "EXPIRED";
540
- const blocked = this.config.nonRedeemablePlans ?? [];
541
- if (blocked.includes(input.planId)) {
542
- return "PLAN_MISMATCH";
543
- }
544
- if (promo.appliesToPlans.length > 0 && !promo.appliesToPlans.includes(input.planId)) {
545
- return "PLAN_MISMATCH";
546
- }
547
- if (promo.appliesToBilling && promo.appliesToBilling !== input.billingCycle) {
548
- return "BILLING_MISMATCH";
549
- }
550
- const planGross = getPlanPriceGross(this.planCatalog, input.planId, input.billingCycle);
551
- if (planGross == null) return "PLAN_MISMATCH";
552
- if (promo.minimumPlanAmountGross && planGross < Number(promo.minimumPlanAmountGross)) {
553
- return "BELOW_MINIMUM_AMOUNT";
554
- }
555
- return null;
556
- }
557
- async checkFirstTimeCustomer(promo, email, options = {}) {
558
- if (!promo || !promo.firstTimeCustomersOnly) return null;
559
- if (!email) return options.requireEmail ? "NOT_FIRST_TIME_CUSTOMER" : null;
560
- const existing = await this.firstTimeCheck.hasExistingCustomerForEmail(email);
561
- return existing ? "NOT_FIRST_TIME_CUSTOMER" : null;
562
- }
563
- /**
564
- * Lazy expiry: sets codes whose validUntil has passed to EXPIRED.
565
- * Called before every find/preview — defense-in-depth alongside the cron.
566
- */
567
- async lazyExpire() {
568
- const now = /* @__PURE__ */ new Date();
569
- await this.promoRepo.expireDueCodes(now);
570
- await this.redemptionRepo.expireDueRedemptions(now);
571
- }
572
- /**
573
- * Lowest applicable plan price. With a whitelist it takes the minimum
574
- * from the whitelist, otherwise across all marketed plans of the catalog
575
- * (except non-redeemable).
576
- */
577
- lowestApplicablePlanGross(plans) {
578
- const blocked = new Set(this.config.nonRedeemablePlans ?? []);
579
- const candidates = plans.length > 0 ? plans : (this.planCatalog.plans ?? []).filter((p) => p.marketed !== false && !blocked.has(p.id)).map((p) => p.id);
580
- let min = null;
581
- for (const p of candidates) {
582
- const g = getPlanPriceGross(this.planCatalog, p, "MONTHLY");
583
- if (g == null) continue;
584
- if (min == null || g < min) min = g;
585
- }
586
- return min;
587
- }
588
- };
589
- PromoCodesService = _ts_decorate([
590
- Injectable(),
591
- _ts_param(0, Inject(PROMO_CODE_REPOSITORY_TOKEN)),
592
- _ts_param(1, Inject(PROMO_CODE_REDEMPTION_REPOSITORY_TOKEN)),
593
- _ts_param(2, Inject(PROMO_CODE_VALIDATION_LOG_REPOSITORY_TOKEN)),
594
- _ts_param(3, Inject(PROMO_FIRST_TIME_CUSTOMER_CHECK_TOKEN)),
595
- _ts_param(4, Inject(PROMO_SUBSCRIPTION_LOOKUP_TOKEN)),
596
- _ts_param(5, Inject(PROMO_REVENUE_DEDUCTION_AGGREGATOR_TOKEN)),
597
- _ts_param(6, Inject(PROMO_TRANSACTION_RUNNER_TOKEN)),
598
- _ts_param(7, Inject(PLAN_CATALOG_TOKEN)),
599
- _ts_param(8, Inject(PROMO_SERVICE_CONFIG_TOKEN)),
600
- _ts_metadata("design:type", Function),
601
- _ts_metadata("design:paramtypes", [
602
- typeof PromoCodeRepository === "undefined" ? Object : PromoCodeRepository,
603
- typeof PromoCodeRedemptionRepository === "undefined" ? Object : PromoCodeRedemptionRepository,
604
- typeof PromoCodeValidationLogRepository === "undefined" ? Object : PromoCodeValidationLogRepository,
605
- typeof FirstTimeCustomerCheck === "undefined" ? Object : FirstTimeCustomerCheck,
606
- typeof PromoSubscriptionLookup === "undefined" ? Object : PromoSubscriptionLookup,
607
- typeof PromoRevenueDeductionAggregator === "undefined" ? Object : PromoRevenueDeductionAggregator,
608
- typeof TransactionRunner === "undefined" ? Object : TransactionRunner,
609
- typeof PlanCatalog === "undefined" ? Object : PlanCatalog,
610
- typeof PromoServiceConfig === "undefined" ? Object : PromoServiceConfig
611
- ])
612
- ], PromoCodesService);
613
-
614
- export {
615
- computeDiscountGross,
616
- computeDiscountedGross,
617
- computeRegularStartsAt,
618
- addCycles,
619
- buildLabel,
620
- PROMO_CODE_REPOSITORY_TOKEN,
621
- PROMO_CODE_REDEMPTION_REPOSITORY_TOKEN,
622
- PROMO_CODE_VALIDATION_LOG_REPOSITORY_TOKEN,
623
- PROMO_FIRST_TIME_CUSTOMER_CHECK_TOKEN,
624
- PROMO_SUBSCRIPTION_LOOKUP_TOKEN,
625
- PROMO_REVENUE_DEDUCTION_AGGREGATOR_TOKEN,
626
- PROMO_TRANSACTION_RUNNER_TOKEN,
627
- PROMO_SERVICE_CONFIG_TOKEN,
628
- CODE_MIN_LENGTH,
629
- CODE_MAX_LENGTH,
630
- CODE_PATTERN,
631
- PromoCodesService
632
- };