@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
@@ -0,0 +1,896 @@
1
+ import {
2
+ getPlanPriceGross
3
+ } from "./chunk-TBBZWZQT.js";
4
+ import {
5
+ computeIncludedVat,
6
+ netFromGross,
7
+ round2
8
+ } from "./chunk-6PJE7EWO.js";
9
+ import {
10
+ PLAN_CATALOG_SOURCE_TOKEN
11
+ } from "./chunk-FEQZCWUL.js";
12
+ import {
13
+ __name
14
+ } from "./chunk-SHUYVCID.js";
15
+
16
+ // src/promo/calculator.ts
17
+ function computeDiscountGross(plan, code) {
18
+ const v = Number(code.value);
19
+ if (code.valueType === "PERCENT") {
20
+ return round2(plan.gross * v / 100);
21
+ }
22
+ return round2(v);
23
+ }
24
+ __name(computeDiscountGross, "computeDiscountGross");
25
+ function computeDiscountedGross(planGross, discountGross) {
26
+ return round2(planGross - discountGross);
27
+ }
28
+ __name(computeDiscountedGross, "computeDiscountedGross");
29
+ function computeRegularStartsAt(startsAt, cycle, durationType, durationValue) {
30
+ const result = new Date(startsAt);
31
+ if (durationType === "ONCE") {
32
+ return addCycles(result, cycle, 1);
33
+ }
34
+ if (durationType === "MONTHS") {
35
+ result.setMonth(result.getMonth() + (durationValue ?? 0));
36
+ return result;
37
+ }
38
+ return addCycles(result, cycle, durationValue ?? 0);
39
+ }
40
+ __name(computeRegularStartsAt, "computeRegularStartsAt");
41
+ function addCycles(date, cycle, n) {
42
+ const r = new Date(date);
43
+ if (cycle === "YEARLY") {
44
+ r.setFullYear(r.getFullYear() + n);
45
+ } else {
46
+ r.setMonth(r.getMonth() + n);
47
+ }
48
+ return r;
49
+ }
50
+ __name(addCycles, "addCycles");
51
+ var LEGACY_LABEL_LOCALE = "de-DE";
52
+ var LEGACY_LABEL_CURRENCY = "EUR";
53
+ var ICU_NON_BREAKING_SPACES = /[\u00A0\u202F]/g;
54
+ function requireSupportedLocale(locale) {
55
+ if (Intl.NumberFormat.supportedLocalesOf([
56
+ locale
57
+ ]).length === 0) {
58
+ 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.`);
59
+ }
60
+ }
61
+ __name(requireSupportedLocale, "requireSupportedLocale");
62
+ function formatPromoValue(value, valueType, { locale = LEGACY_LABEL_LOCALE, currency = LEGACY_LABEL_CURRENCY }) {
63
+ requireSupportedLocale(locale);
64
+ const numberFormat = valueType === "PERCENT" ? {
65
+ style: "unit",
66
+ unit: "percent"
67
+ } : {
68
+ style: "currency",
69
+ currency
70
+ };
71
+ return new Intl.NumberFormat(locale, numberFormat).format(value).replace(ICU_NON_BREAKING_SPACES, " ");
72
+ }
73
+ __name(formatPromoValue, "formatPromoValue");
74
+ function buildLabel(code, cycle, options = {}) {
75
+ const value = Number(code.value);
76
+ const valueStr = formatPromoValue(value, code.valueType, options);
77
+ if (code.durationType === "ONCE") {
78
+ return `${valueStr} once`;
79
+ }
80
+ if (code.durationType === "MONTHS") {
81
+ const m = code.durationValue ?? 0;
82
+ return m === 1 ? `${valueStr} for the first month` : `${valueStr} for ${m} months`;
83
+ }
84
+ const n = code.durationValue ?? 0;
85
+ if (n === 1) return cycle === "YEARLY" ? `${valueStr} for the first year` : `${valueStr} for the first month`;
86
+ return cycle === "YEARLY" ? `${valueStr} for the first ${n} years` : `${valueStr} for the first ${n} months`;
87
+ }
88
+ __name(buildLabel, "buildLabel");
89
+
90
+ // src/promo/promo.tokens.ts
91
+ var PROMO_CODE_REPOSITORY_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PromoCodeRepository");
92
+ var PROMO_CODE_HOLD_REPOSITORY_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PromoCodeHoldRepository");
93
+ var PROMO_CODE_REDEMPTION_REPOSITORY_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PromoCodeRedemptionRepository");
94
+ var PROMO_CODE_VALIDATION_LOG_REPOSITORY_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PromoCodeValidationLogRepository");
95
+ var PROMO_FIRST_TIME_CUSTOMER_CHECK_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PromoFirstTimeCustomerCheck");
96
+ var PROMO_SUBSCRIPTION_LOOKUP_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PromoSubscriptionLookup");
97
+ var PROMO_REVENUE_DEDUCTION_AGGREGATOR_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PromoRevenueDeductionAggregator");
98
+ var PROMO_TRANSACTION_RUNNER_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PromoTransactionRunner");
99
+ var PROMO_SERVICE_CONFIG_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PromoServiceConfig");
100
+
101
+ // src/promo/promo.service.ts
102
+ import { BadRequestException as BadRequestException2, ConflictException, Inject, Injectable, NotFoundException, Optional } from "@nestjs/common";
103
+ import { BILLING_ERROR_CODES, CONTRACT_ERROR_CODES, PROMO_ERROR_CODES as PROMO_ERROR_CODES2 } from "@saasicat/core";
104
+
105
+ // src/promo/code-rules.ts
106
+ import { BadRequestException } from "@nestjs/common";
107
+ import { PROMO_ERROR_CODES } from "@saasicat/core";
108
+ var PERCENT_MIN_EXCLUSIVE = 0;
109
+ var PERCENT_MAX = 100;
110
+ var DURATION_MIN = 1;
111
+ var DURATION_MAX = 24;
112
+ var ALL_TERMS = /* @__PURE__ */ new Set([
113
+ "valueType",
114
+ "value",
115
+ "durationType",
116
+ "durationValue",
117
+ "validFrom",
118
+ "validUntil",
119
+ "appliesToPlans",
120
+ "minimumPlanAmountGross",
121
+ "allowZeroInvoice"
122
+ ]);
123
+ async function assertCodeTerms(terms, changed, context) {
124
+ const touches = /* @__PURE__ */ __name((...fields) => fields.some((field) => changed.has(field)), "touches");
125
+ if (touches("valueType", "value")) assertValue(terms);
126
+ if (touches("durationType", "durationValue")) assertDuration(terms);
127
+ if (touches("validFrom", "validUntil")) assertValidityWindow(terms);
128
+ if (touches("appliesToPlans")) assertPlansDiscountable(terms, context.nonRedeemablePlans);
129
+ if (touches("minimumPlanAmountGross")) assertMinimumAmount(terms);
130
+ if (touches("valueType", "value", "allowZeroInvoice", "appliesToPlans")) {
131
+ await assertNoZeroInvoice(terms, context);
132
+ }
133
+ }
134
+ __name(assertCodeTerms, "assertCodeTerms");
135
+ function discountOnPlan(code, planGross) {
136
+ const discountGross = Math.min(computeDiscountGross({
137
+ gross: planGross
138
+ }, code), planGross);
139
+ const discountedGross = computeDiscountedGross(planGross, discountGross);
140
+ return {
141
+ discountGross,
142
+ discountedGross,
143
+ zeroInvoice: !code.allowZeroInvoice && discountedGross <= 0
144
+ };
145
+ }
146
+ __name(discountOnPlan, "discountOnPlan");
147
+ function appliedValue(code, planGross) {
148
+ const cap = code.valueType === "PERCENT" ? PERCENT_MAX : planGross;
149
+ return Number(code.value) > cap ? cap.toFixed(2) : code.value;
150
+ }
151
+ __name(appliedValue, "appliedValue");
152
+ function assertValue(terms) {
153
+ if (terms.valueType === "PERCENT") {
154
+ if (!(terms.value > PERCENT_MIN_EXCLUSIVE && terms.value <= PERCENT_MAX)) {
155
+ throw new BadRequestException({
156
+ code: PROMO_ERROR_CODES.PROMO_PERCENT_OUT_OF_RANGE,
157
+ message: "The percentage must be between 0 and 100.",
158
+ params: {
159
+ value: terms.value,
160
+ min: PERCENT_MIN_EXCLUSIVE,
161
+ max: PERCENT_MAX
162
+ }
163
+ });
164
+ }
165
+ } else if (!(terms.value > 0)) {
166
+ throw new BadRequestException({
167
+ code: PROMO_ERROR_CODES.PROMO_AMOUNT_NOT_POSITIVE,
168
+ message: "The amount must be positive.",
169
+ params: {
170
+ value: terms.value,
171
+ min: 0
172
+ }
173
+ });
174
+ }
175
+ }
176
+ __name(assertValue, "assertValue");
177
+ function assertDuration(terms) {
178
+ if (terms.durationType === "ONCE") {
179
+ if (terms.durationValue != null) {
180
+ throw new BadRequestException({
181
+ code: PROMO_ERROR_CODES.PROMO_ONE_OFF_WITH_DURATION,
182
+ message: "A one-off discount must not set a duration.",
183
+ params: {
184
+ durationValue: terms.durationValue
185
+ }
186
+ });
187
+ }
188
+ return;
189
+ }
190
+ const v = terms.durationValue;
191
+ if (v == null || v < DURATION_MIN || v > DURATION_MAX) {
192
+ throw new BadRequestException({
193
+ code: PROMO_ERROR_CODES.PROMO_DURATION_INVALID,
194
+ message: "Invalid duration (at most 24 months or billing periods).",
195
+ params: {
196
+ durationValue: v ?? null,
197
+ min: DURATION_MIN,
198
+ max: DURATION_MAX
199
+ }
200
+ });
201
+ }
202
+ }
203
+ __name(assertDuration, "assertDuration");
204
+ function assertValidityWindow(terms) {
205
+ if (terms.validFrom && terms.validUntil && terms.validFrom >= terms.validUntil) {
206
+ throw new BadRequestException({
207
+ code: PROMO_ERROR_CODES.PROMO_VALIDITY_WINDOW_INVALID,
208
+ message: "Invalid validity window.",
209
+ params: {
210
+ validFrom: terms.validFrom.toISOString(),
211
+ validUntil: terms.validUntil.toISOString()
212
+ }
213
+ });
214
+ }
215
+ }
216
+ __name(assertValidityWindow, "assertValidityWindow");
217
+ function assertPlansDiscountable(terms, blocked) {
218
+ for (const plan of terms.appliesToPlans) {
219
+ if (blocked.includes(plan)) {
220
+ throw new BadRequestException({
221
+ code: PROMO_ERROR_CODES.PROMO_PLAN_NOT_DISCOUNTABLE,
222
+ message: `${plan}-Plan cannot be discounted.`,
223
+ params: {
224
+ plan
225
+ }
226
+ });
227
+ }
228
+ }
229
+ }
230
+ __name(assertPlansDiscountable, "assertPlansDiscountable");
231
+ function assertMinimumAmount(terms) {
232
+ if (terms.minimumPlanAmountGross != null && terms.minimumPlanAmountGross <= 0) {
233
+ throw new BadRequestException({
234
+ code: PROMO_ERROR_CODES.PROMO_MIN_AMOUNT_NOT_POSITIVE,
235
+ message: "The minimum plan gross amount must be positive.",
236
+ params: {
237
+ value: terms.minimumPlanAmountGross,
238
+ min: 0
239
+ }
240
+ });
241
+ }
242
+ }
243
+ __name(assertMinimumAmount, "assertMinimumAmount");
244
+ async function assertNoZeroInvoice(terms, context) {
245
+ if (terms.valueType !== "ABSOLUTE" || terms.allowZeroInvoice) return;
246
+ const lowest = await context.lowestApplicablePlanGross(terms.appliesToPlans);
247
+ if (lowest != null && terms.value >= lowest) {
248
+ throw new BadRequestException({
249
+ code: PROMO_ERROR_CODES.PROMO_WOULD_PRODUCE_ZERO_INVOICE,
250
+ message: "For absolute amounts the discount must stay below the lowest applicable plan price, or allowZeroInvoice must be enabled.",
251
+ params: {
252
+ value: terms.value,
253
+ lowestApplicablePlanGross: lowest
254
+ }
255
+ });
256
+ }
257
+ }
258
+ __name(assertNoZeroInvoice, "assertNoZeroInvoice");
259
+
260
+ // src/promo/promo.service.ts
261
+ function _ts_decorate(decorators, target, key, desc) {
262
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
263
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
264
+ 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;
265
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
266
+ }
267
+ __name(_ts_decorate, "_ts_decorate");
268
+ function _ts_metadata(k, v) {
269
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
270
+ }
271
+ __name(_ts_metadata, "_ts_metadata");
272
+ function _ts_param(paramIndex, decorator) {
273
+ return function(target, key) {
274
+ decorator(target, key, paramIndex);
275
+ };
276
+ }
277
+ __name(_ts_param, "_ts_param");
278
+ var CODE_MIN_LENGTH = 4;
279
+ var CODE_MAX_LENGTH = 32;
280
+ var CODE_PATTERN = /^[A-Z0-9_-]{4,32}$/;
281
+ var HOLD_ATTEMPTS = 2;
282
+ function notRedeemable(reason) {
283
+ return new BadRequestException2({
284
+ code: PROMO_ERROR_CODES2.PROMO_CODE_NOT_REDEEMABLE,
285
+ message: `Code cannot be redeemed: ${reason}`,
286
+ params: {
287
+ reason
288
+ }
289
+ });
290
+ }
291
+ __name(notRedeemable, "notRedeemable");
292
+ var PromoCodesService = class {
293
+ static {
294
+ __name(this, "PromoCodesService");
295
+ }
296
+ promoRepo;
297
+ redemptionRepo;
298
+ validationLogRepo;
299
+ firstTimeCheck;
300
+ subscriptionLookup;
301
+ revenueAggregator;
302
+ transactionRunner;
303
+ planCatalogs;
304
+ config;
305
+ holds;
306
+ constructor(promoRepo, redemptionRepo, validationLogRepo, firstTimeCheck, subscriptionLookup, revenueAggregator, transactionRunner, planCatalogs, config, holds = null) {
307
+ this.promoRepo = promoRepo;
308
+ this.redemptionRepo = redemptionRepo;
309
+ this.validationLogRepo = validationLogRepo;
310
+ this.firstTimeCheck = firstTimeCheck;
311
+ this.subscriptionLookup = subscriptionLookup;
312
+ this.revenueAggregator = revenueAggregator;
313
+ this.transactionRunner = transactionRunner;
314
+ this.planCatalogs = planCatalogs;
315
+ this.config = config;
316
+ this.holds = holds;
317
+ }
318
+ // ─── ADMIN: Creation / Editing ─────────────────────────────────────────
319
+ async create(input) {
320
+ const code = input.code.trim().toUpperCase();
321
+ if (!CODE_PATTERN.test(code)) {
322
+ throw new BadRequestException2({
323
+ code: PROMO_ERROR_CODES2.PROMO_CODE_FORMAT_INVALID,
324
+ message: 'The code may only contain upper-case letters, digits, "-" and "_" (4\u201332 characters).',
325
+ params: {
326
+ promoCode: code,
327
+ minLength: CODE_MIN_LENGTH,
328
+ maxLength: CODE_MAX_LENGTH
329
+ }
330
+ });
331
+ }
332
+ await assertCodeTerms({
333
+ valueType: input.valueType,
334
+ value: input.value,
335
+ durationType: input.durationType,
336
+ durationValue: input.durationValue ?? null,
337
+ validFrom: input.validFrom ?? null,
338
+ validUntil: input.validUntil ?? null,
339
+ appliesToPlans: input.appliesToPlans ?? [],
340
+ minimumPlanAmountGross: input.minimumPlanAmountGross ?? null,
341
+ allowZeroInvoice: input.allowZeroInvoice ?? false
342
+ }, ALL_TERMS, this.ruleContext());
343
+ const exists = await this.promoRepo.findByCode(code);
344
+ if (exists) {
345
+ throw new BadRequestException2({
346
+ code: PROMO_ERROR_CODES2.PROMO_CODE_ALREADY_EXISTS,
347
+ message: "The code already exists.",
348
+ params: {
349
+ promoCode: code
350
+ }
351
+ });
352
+ }
353
+ return this.promoRepo.create({
354
+ ...input,
355
+ code
356
+ });
357
+ }
358
+ /**
359
+ * Changes a code under the rules a new one is held to, asking the rules the
360
+ * named fields bear on: the terms as they stand after the change have to be
361
+ * ones `create` would accept. Pausing alone is always possible.
362
+ */
363
+ async update(id, input) {
364
+ const existing = await this.promoRepo.findById(id);
365
+ if (!existing) {
366
+ throw new NotFoundException({
367
+ code: PROMO_ERROR_CODES2.PROMO_CODE_NOT_FOUND,
368
+ message: "Code not found",
369
+ params: {
370
+ promoCodeId: id,
371
+ promoCode: null
372
+ }
373
+ });
374
+ }
375
+ if (input.maxRedemptions != null && existing.maxRedemptions != null) {
376
+ if (input.maxRedemptions < existing.maxRedemptions) {
377
+ throw new BadRequestException2({
378
+ code: PROMO_ERROR_CODES2.PROMO_MAX_REDEMPTIONS_LOWERED,
379
+ message: "maxRedemptions cannot be lowered.",
380
+ params: {
381
+ current: existing.maxRedemptions,
382
+ requested: input.maxRedemptions
383
+ }
384
+ });
385
+ }
386
+ }
387
+ const changed = new Set(Object.keys(input).filter((field) => input[field] !== void 0));
388
+ await assertCodeTerms(termsAfter(existing, input), changed, this.ruleContext());
389
+ return this.promoRepo.update(id, input);
390
+ }
391
+ async softDelete(id) {
392
+ const redemptions = await this.redemptionRepo.countByPromoCode(id);
393
+ const held = await this.liveHeldCount(id);
394
+ if (redemptions > 0 || held > 0) {
395
+ throw new BadRequestException2({
396
+ code: PROMO_ERROR_CODES2.PROMO_CODE_HAS_REDEMPTIONS,
397
+ message: "The code already has redemptions, or checkouts holding one \u2014 it cannot be soft-deleted. Pause it instead.",
398
+ params: {
399
+ promoCodeId: id,
400
+ redemptions,
401
+ held
402
+ }
403
+ });
404
+ }
405
+ await this.promoRepo.softDelete(id);
406
+ }
407
+ async findAll(filter = {}) {
408
+ await this.lazyExpire();
409
+ await this.holds?.expireDue(/* @__PURE__ */ new Date());
410
+ return this.promoRepo.findMany(filter);
411
+ }
412
+ async findOne(id) {
413
+ await this.lazyExpire();
414
+ await this.holds?.expireDue(/* @__PURE__ */ new Date(), id);
415
+ const code = await this.promoRepo.findById(id);
416
+ if (!code || code.deletedAt) {
417
+ throw new NotFoundException({
418
+ code: PROMO_ERROR_CODES2.PROMO_CODE_NOT_FOUND,
419
+ message: "Code not found",
420
+ params: {
421
+ promoCodeId: id,
422
+ promoCode: null
423
+ }
424
+ });
425
+ }
426
+ return code;
427
+ }
428
+ async stats(id) {
429
+ const code = await this.findOne(id);
430
+ const [active, total, reversed, expired, validations, revenueDeductionGross] = await Promise.all([
431
+ this.redemptionRepo.countByPromoCode(id, "ACTIVE"),
432
+ this.redemptionRepo.countByPromoCode(id),
433
+ this.redemptionRepo.countByPromoCode(id, "REVERSED"),
434
+ this.redemptionRepo.countByPromoCode(id, "EXPIRED"),
435
+ this.validationLogRepo.countValid(id),
436
+ this.revenueAggregator.sumGrossForPromoCode(id)
437
+ ]);
438
+ return {
439
+ code,
440
+ validations,
441
+ redemptions: {
442
+ active,
443
+ total,
444
+ reversed,
445
+ expired
446
+ },
447
+ held: code.heldCount,
448
+ revenueDeductionGross
449
+ };
450
+ }
451
+ async listRedemptions(promoCodeId) {
452
+ return this.redemptionRepo.listByPromoCode(promoCodeId);
453
+ }
454
+ // ─── PUBLIC: Preview / Validation ──────────────────────────────────────
455
+ async preview(input, options = {}) {
456
+ const code = input.code.trim().toUpperCase();
457
+ if (!CODE_PATTERN.test(code)) {
458
+ await this.validationLogRepo.log({
459
+ promoCodeId: null,
460
+ codeAttempt: code,
461
+ result: "NOT_FOUND",
462
+ ipHash: input.ipHash,
463
+ sessionId: input.sessionId
464
+ });
465
+ return {
466
+ valid: false,
467
+ reason: "NOT_FOUND"
468
+ };
469
+ }
470
+ await this.lazyExpire();
471
+ const now = /* @__PURE__ */ new Date();
472
+ const promo = await this.findWithLiveHolds(code, now);
473
+ const ownSlot = promo !== null && options.checkoutOfferId !== void 0 && await this.holdsLiveSlot(options.checkoutOfferId, promo, now);
474
+ const verdict = await this.judge(promo, input, {
475
+ ownSlot,
476
+ held: ownSlot && options.concluding === true,
477
+ countSlots: true
478
+ });
479
+ await this.validationLogRepo.log({
480
+ promoCodeId: promo?.id ?? null,
481
+ codeAttempt: code,
482
+ result: verdict.reason ?? "VALID",
483
+ ipHash: input.ipHash,
484
+ sessionId: input.sessionId
485
+ });
486
+ if (verdict.reason !== null) return {
487
+ valid: false,
488
+ reason: verdict.reason
489
+ };
490
+ const { catalog, planGross, discount } = verdict;
491
+ const regularStartsAt = computeRegularStartsAt(now, input.billingCycle, verdict.promo.durationType, verdict.promo.durationValue);
492
+ return {
493
+ valid: true,
494
+ code: verdict.promo.code,
495
+ label: buildLabel(verdict.promo, input.billingCycle),
496
+ discount: {
497
+ valueType: verdict.promo.valueType,
498
+ value: Number(verdict.promo.value).toFixed(2),
499
+ durationType: verdict.promo.durationType,
500
+ durationValue: verdict.promo.durationValue
501
+ },
502
+ price: {
503
+ originalGross: planGross.toFixed(2),
504
+ discountGross: discount.discountGross.toFixed(2),
505
+ discountNet: netFromGross(discount.discountGross, catalog.vatRate).toFixed(2),
506
+ discountedGross: discount.discountedGross.toFixed(2),
507
+ includedVat: computeIncludedVat(discount.discountedGross, catalog.vatRate).toFixed(2),
508
+ nextRegularAmountGross: planGross.toFixed(2),
509
+ regularStartsAt: regularStartsAt ? regularStartsAt.toISOString() : null
510
+ }
511
+ };
512
+ }
513
+ // ─── Checkout: a slot held until the conclusion ────────────────────────
514
+ /**
515
+ * Holds a slot of the code for a checkout offer until `until`, after every
516
+ * check a preview makes — so a customer who reaches the payment form keeps
517
+ * the code however many others redeem it meanwhile. Starting the same
518
+ * checkout again moves the expiry of the slot it holds rather than taking a
519
+ * second — later, never earlier (`PromoCodeHoldRepository.extend`); a
520
+ * checkout that held another code gives that one back.
521
+ *
522
+ * Refused with `PROMO_CODE_NOT_REDEEMABLE` and the reason a preview would
523
+ * give, `EXHAUSTED` when every slot is redeemed or held — also when the
524
+ * code changed between that check and taking the slot. Nothing is held
525
+ * then, and a slot the offer held before stays with it until it expires.
526
+ */
527
+ async holdForCheckout(input) {
528
+ const holds = this.requireHolds();
529
+ const offerId = input.checkoutOfferId;
530
+ await this.lazyExpire();
531
+ const promo = await this.findWithLiveHolds(input.code.trim().toUpperCase(), /* @__PURE__ */ new Date());
532
+ for (let attempt = 0; attempt < HOLD_ATTEMPTS; attempt += 1) {
533
+ const own = await holds.findByCheckoutOffer(offerId);
534
+ const ownSlot = own !== null && own.promoCodeId === promo?.id;
535
+ const held = ownSlot && own.expiresAt > /* @__PURE__ */ new Date();
536
+ const verdict = await this.judge(promo, input, {
537
+ ownSlot,
538
+ held,
539
+ countSlots: true
540
+ });
541
+ if (verdict.reason !== null) throw notRedeemable(verdict.reason);
542
+ const promoCodeId = verdict.promo.id;
543
+ if (ownSlot && await holds.extend(offerId, promoCodeId, input.until)) return;
544
+ if (own && !ownSlot) await holds.release(offerId);
545
+ const taken = await holds.take({
546
+ promoCodeId,
547
+ checkoutOfferId: offerId,
548
+ expiresAt: input.until
549
+ });
550
+ if (taken.outcome === "taken") return;
551
+ if (taken.outcome === "no-slot") throw notRedeemable(await this.whyNoSlot(promoCodeId));
552
+ }
553
+ throw new ConflictException({
554
+ code: CONTRACT_ERROR_CODES.CHECKOUT_OFFER_CHANGED,
555
+ message: `Checkout offer '${offerId}' changed while its promo code was being held. Load it again.`,
556
+ params: {
557
+ offerId
558
+ }
559
+ });
560
+ }
561
+ /** Gives back the slot a checkout offer holds, if it holds one. */
562
+ async releaseCheckoutHold(checkoutOfferId, tx) {
563
+ await this.holds?.release(checkoutOfferId, tx);
564
+ }
565
+ /**
566
+ * Gives back the slot a checkout offer holds only while it still expires at
567
+ * `expiresAt` — the hold as the caller wrote it. A slot another start of the
568
+ * checkout moved since stays with the form that start opened.
569
+ */
570
+ async releaseCheckoutHoldIfUnmoved(checkoutOfferId, expiresAt) {
571
+ await this.holds?.releaseIfUnmoved(checkoutOfferId, expiresAt);
572
+ }
573
+ /**
574
+ * Hands the offer's live hold to the redemption that runs on `tx`: redeeming
575
+ * the code there takes the held slot instead of a free one, whatever
576
+ * happened to the code since the hold was taken. The caller releases it
577
+ * with `releaseCheckoutHold` on the same transaction once the redemption
578
+ * had its chance — a hold the redemption did not take is not held any more.
579
+ * False when the offer holds no live slot.
580
+ */
581
+ async handOverCheckoutHold(checkoutOfferId, tx) {
582
+ return await this.holds?.handOver(checkoutOfferId, /* @__PURE__ */ new Date(), tx) ?? false;
583
+ }
584
+ // ─── Onboarding path: atomic redemption ────────────────────────────────
585
+ /**
586
+ * Wraps `redeemInTransaction` in its own transaction-runner call —
587
+ * the default path when the caller has no external tx context
588
+ * (e.g. POST /billing/promo/redeem as a stand-alone endpoint).
589
+ */
590
+ async redeem(input) {
591
+ return this.transactionRunner.run((tx) => this.redeemInTransaction(input, tx));
592
+ }
593
+ /**
594
+ * Redeems a promo code within an EXTERNAL transaction — the caller
595
+ * (typically `TenantSubscriptionWritePort.applyOnboardingSelection`)
596
+ * already has `prisma.$transaction(...)` open and passes the `tx`
597
+ * context through. This lands plan change, add-on insert, and redemption
598
+ * insert in a single DB transaction (P10.1.1).
599
+ *
600
+ * A checkout that held a slot of the code, and whose conclusion handed it
601
+ * over on this transaction, is redeemed on that slot: its state is not
602
+ * asked again, and no free slot is taken. Otherwise a free one is claimed.
603
+ * Either way the redemption is refused where a preview would refuse on the
604
+ * price — an invoice of zero the code does not allow — and it records at
605
+ * most the price it is redeemed against.
606
+ */
607
+ async redeemInTransaction(input, tx) {
608
+ const code = input.code.trim().toUpperCase();
609
+ const promo = await this.promoRepo.findByCode(code, tx);
610
+ if (!promo || promo.deletedAt) {
611
+ throw new BadRequestException2({
612
+ code: PROMO_ERROR_CODES2.PROMO_CODE_NOT_FOUND,
613
+ // The lookup runs on the code string, so there is no id to report.
614
+ message: "Code not found",
615
+ params: {
616
+ promoCodeId: null,
617
+ promoCode: code
618
+ }
619
+ });
620
+ }
621
+ const sub = await this.subscriptionLookup.findById(input.subscriptionId, tx);
622
+ if (!sub) {
623
+ throw new NotFoundException({
624
+ code: BILLING_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
625
+ message: "Subscription not found",
626
+ params: {
627
+ tenantId: input.tenantId
628
+ }
629
+ });
630
+ }
631
+ if (sub.tenantId !== input.tenantId) {
632
+ throw new BadRequestException2({
633
+ code: BILLING_ERROR_CODES.SUBSCRIPTION_TENANT_MISMATCH,
634
+ message: "Subscription does not belong to the tenant",
635
+ params: {
636
+ subscriptionId: sub.id,
637
+ tenantId: input.tenantId
638
+ }
639
+ });
640
+ }
641
+ const held = await this.holds?.convertHandedOver(promo.id, tx) ?? false;
642
+ const verdict = await this.judge(promo, {
643
+ code,
644
+ planId: sub.plan,
645
+ billingCycle: sub.billingCycle,
646
+ email: input.email
647
+ }, {
648
+ held,
649
+ countSlots: false,
650
+ requireEmail: true
651
+ });
652
+ if (verdict.reason !== null) throw notRedeemable(verdict.reason);
653
+ if (!held) {
654
+ await this.holds?.expireDue(/* @__PURE__ */ new Date(), promo.id, tx);
655
+ if (!await this.promoRepo.claimSlot(promo.id, tx)) {
656
+ throw notRedeemable("EXHAUSTED");
657
+ }
658
+ }
659
+ await this.promoRepo.markExhaustedIfFull(promo.id, tx);
660
+ const startsAt = sub.startedAt ?? /* @__PURE__ */ new Date();
661
+ const endsAt = promo.durationType === "ONCE" ? null : computeRegularStartsAt(startsAt, sub.billingCycle, promo.durationType, promo.durationValue);
662
+ return this.redemptionRepo.create({
663
+ promoCodeId: promo.id,
664
+ subscriptionId: sub.id,
665
+ tenantId: sub.tenantId,
666
+ appliedValueType: promo.valueType,
667
+ appliedValue: appliedValue(promo, verdict.planGross),
668
+ appliedDurationType: promo.durationType,
669
+ appliedDurationValue: promo.durationValue,
670
+ startsAt,
671
+ endsAt
672
+ }, tx);
673
+ }
674
+ async reverse(subscriptionId) {
675
+ return this.transactionRunner.run(async (tx) => {
676
+ const redemption = await this.redemptionRepo.findBySubscription(subscriptionId, tx);
677
+ if (!redemption || redemption.status !== "ACTIVE") return redemption;
678
+ const updated = await this.redemptionRepo.setReversed(redemption.id, tx);
679
+ await this.promoRepo.releaseSlot(redemption.promoCodeId, tx);
680
+ return updated;
681
+ });
682
+ }
683
+ // ─── Helpers ───────────────────────────────────────────────────────────
684
+ /**
685
+ * Why a code does not apply to a plan, or what it takes off it. The plans
686
+ * are read only once the code itself has passed, so somebody trying codes
687
+ * that do not exist costs no read of the plan tables.
688
+ */
689
+ async judge(promo, input, options) {
690
+ const refused = this.checkCode(promo, input, options);
691
+ if (refused) return {
692
+ reason: refused
693
+ };
694
+ const found = promo;
695
+ const catalog = await this.planCatalogs.current();
696
+ const reason = this.checkPlanPrice(found, input, catalog) ?? await this.checkFirstTimeCustomer(found, input.email, options);
697
+ if (reason) return {
698
+ reason
699
+ };
700
+ const planGross = getPlanPriceGross(catalog, input.planId, input.billingCycle);
701
+ const discount = discountOnPlan(found, planGross);
702
+ if (discount.zeroInvoice) return {
703
+ reason: "WOULD_PRODUCE_ZERO_INVOICE"
704
+ };
705
+ return {
706
+ reason: null,
707
+ promo: found,
708
+ catalog,
709
+ planGross,
710
+ discount
711
+ };
712
+ }
713
+ /** Everything about a code that needs no plan price: its state, its slots, and the plans it fits. */
714
+ checkCode(promo, input, options) {
715
+ if (!promo || promo.deletedAt) return "NOT_FOUND";
716
+ if (!options.held) {
717
+ const state = this.checkState(promo) ?? (options.countSlots ? checkFreeSlot(promo, options.ownSlot === true) : null);
718
+ if (state) return state;
719
+ }
720
+ const blocked = this.config.nonRedeemablePlans ?? [];
721
+ if (blocked.includes(input.planId)) {
722
+ return "PLAN_MISMATCH";
723
+ }
724
+ if (promo.appliesToPlans.length > 0 && !promo.appliesToPlans.includes(input.planId)) {
725
+ return "PLAN_MISMATCH";
726
+ }
727
+ if (promo.appliesToBilling && promo.appliesToBilling !== input.billingCycle) {
728
+ return "BILLING_MISMATCH";
729
+ }
730
+ return null;
731
+ }
732
+ /** Whether the code can be used now at all: its status and its validity window. */
733
+ checkState(promo) {
734
+ if (promo.status === "EXPIRED") return "EXPIRED";
735
+ if (promo.status === "EXHAUSTED") return "EXHAUSTED";
736
+ if (promo.status === "PAUSED") return "PAUSED";
737
+ const now = /* @__PURE__ */ new Date();
738
+ if (promo.validFrom && promo.validFrom > now) return "EXPIRED";
739
+ if (promo.validUntil && promo.validUntil < now) return "EXPIRED";
740
+ return null;
741
+ }
742
+ /** Whether the plan is sold at a price in this rhythm, and one the code's minimum allows. */
743
+ checkPlanPrice(promo, input, catalog) {
744
+ const planGross = getPlanPriceGross(catalog, input.planId, input.billingCycle);
745
+ if (planGross == null) return "PLAN_MISMATCH";
746
+ if (promo.minimumPlanAmountGross && planGross < Number(promo.minimumPlanAmountGross)) {
747
+ return "BELOW_MINIMUM_AMOUNT";
748
+ }
749
+ return null;
750
+ }
751
+ async checkFirstTimeCustomer(promo, email, options = {}) {
752
+ if (!promo || !promo.firstTimeCustomersOnly) return null;
753
+ if (!email) return options.requireEmail ? "NOT_FIRST_TIME_CUSTOMER" : null;
754
+ const existing = await this.firstTimeCheck.hasExistingCustomerForEmail(email);
755
+ return existing ? "NOT_FIRST_TIME_CUSTOMER" : null;
756
+ }
757
+ /**
758
+ * Lazy expiry: sets codes whose validUntil has passed to EXPIRED.
759
+ * Called before every find/preview — defense-in-depth alongside the cron.
760
+ */
761
+ async lazyExpire() {
762
+ const now = /* @__PURE__ */ new Date();
763
+ await this.promoRepo.expireDueCodes(now);
764
+ await this.redemptionRepo.expireDueRedemptions(now);
765
+ }
766
+ /**
767
+ * The code with the slots of its expired holds given back, so that what
768
+ * it counts as held is what is held now. One code at a time: this runs on
769
+ * every preview, and a sweep of one code locks one row.
770
+ */
771
+ async findWithLiveHolds(code, now) {
772
+ const promo = await this.promoRepo.findByCode(code);
773
+ if (!promo || !this.holds) return promo;
774
+ const ended = await this.holds.expireDue(now, promo.id);
775
+ return ended > 0 ? this.promoRepo.findByCode(code) : promo;
776
+ }
777
+ /**
778
+ * Why a code the checks passed a moment ago had no slot to take: it may
779
+ * have been paused, deleted or run past its validity in between, and a
780
+ * refusal that says "exhausted" for any of them misleads the customer and
781
+ * the log a support answer is built on.
782
+ */
783
+ async whyNoSlot(promoCodeId) {
784
+ const now = await this.promoRepo.findById(promoCodeId);
785
+ if (!now || now.deletedAt) return "NOT_FOUND";
786
+ return this.checkState(now) ?? "EXHAUSTED";
787
+ }
788
+ /** Whether the checkout offer holds a slot of this code that has not expired. */
789
+ async holdsLiveSlot(checkoutOfferId, promo, now) {
790
+ const hold = await this.holds?.findByCheckoutOffer(checkoutOfferId);
791
+ return hold != null && hold.promoCodeId === promo.id && hold.expiresAt > now;
792
+ }
793
+ /** The slots of a code held right now, its expired holds given back first. */
794
+ async liveHeldCount(promoCodeId) {
795
+ if (!this.holds) return 0;
796
+ await this.holds.expireDue(/* @__PURE__ */ new Date(), promoCodeId);
797
+ return (await this.promoRepo.findById(promoCodeId))?.heldCount ?? 0;
798
+ }
799
+ requireHolds() {
800
+ if (!this.holds) {
801
+ throw new Error("Holding a promo code for a checkout needs a PromoCodeHoldRepository: the shipped persistence bundles provide one as `promo.holdRepository`; pass it to PromoCodesModule.forRoot as `holdRepository` when wiring the module by hand.");
802
+ }
803
+ return this.holds;
804
+ }
805
+ ruleContext() {
806
+ return {
807
+ nonRedeemablePlans: this.config.nonRedeemablePlans ?? [],
808
+ lowestApplicablePlanGross: /* @__PURE__ */ __name(async (plans) => this.lowestApplicablePlanGross(await this.planCatalogs.current(), plans), "lowestApplicablePlanGross")
809
+ };
810
+ }
811
+ /**
812
+ * Lowest applicable plan price. With a whitelist it takes the minimum
813
+ * from the whitelist, otherwise across all marketed plans of the catalog
814
+ * (except non-redeemable).
815
+ */
816
+ lowestApplicablePlanGross(catalog, plans) {
817
+ const blocked = new Set(this.config.nonRedeemablePlans ?? []);
818
+ const candidates = plans.length > 0 ? plans : (catalog.plans ?? []).filter((p) => p.marketed !== false && !blocked.has(p.id)).map((p) => p.id);
819
+ let min = null;
820
+ for (const p of candidates) {
821
+ const g = getPlanPriceGross(catalog, p, "MONTHLY");
822
+ if (g == null) continue;
823
+ if (min == null || g < min) min = g;
824
+ }
825
+ return min;
826
+ }
827
+ };
828
+ PromoCodesService = _ts_decorate([
829
+ Injectable(),
830
+ _ts_param(0, Inject(PROMO_CODE_REPOSITORY_TOKEN)),
831
+ _ts_param(1, Inject(PROMO_CODE_REDEMPTION_REPOSITORY_TOKEN)),
832
+ _ts_param(2, Inject(PROMO_CODE_VALIDATION_LOG_REPOSITORY_TOKEN)),
833
+ _ts_param(3, Inject(PROMO_FIRST_TIME_CUSTOMER_CHECK_TOKEN)),
834
+ _ts_param(4, Inject(PROMO_SUBSCRIPTION_LOOKUP_TOKEN)),
835
+ _ts_param(5, Inject(PROMO_REVENUE_DEDUCTION_AGGREGATOR_TOKEN)),
836
+ _ts_param(6, Inject(PROMO_TRANSACTION_RUNNER_TOKEN)),
837
+ _ts_param(7, Inject(PLAN_CATALOG_SOURCE_TOKEN)),
838
+ _ts_param(8, Inject(PROMO_SERVICE_CONFIG_TOKEN)),
839
+ _ts_param(9, Optional()),
840
+ _ts_param(9, Inject(PROMO_CODE_HOLD_REPOSITORY_TOKEN)),
841
+ _ts_metadata("design:type", Function),
842
+ _ts_metadata("design:paramtypes", [
843
+ typeof PromoCodeRepository === "undefined" ? Object : PromoCodeRepository,
844
+ typeof PromoCodeRedemptionRepository === "undefined" ? Object : PromoCodeRedemptionRepository,
845
+ typeof PromoCodeValidationLogRepository === "undefined" ? Object : PromoCodeValidationLogRepository,
846
+ typeof FirstTimeCustomerCheck === "undefined" ? Object : FirstTimeCustomerCheck,
847
+ typeof PromoSubscriptionLookup === "undefined" ? Object : PromoSubscriptionLookup,
848
+ typeof PromoRevenueDeductionAggregator === "undefined" ? Object : PromoRevenueDeductionAggregator,
849
+ typeof TransactionRunner === "undefined" ? Object : TransactionRunner,
850
+ typeof PlanCatalogSource === "undefined" ? Object : PlanCatalogSource,
851
+ typeof PromoServiceConfig === "undefined" ? Object : PromoServiceConfig,
852
+ Object
853
+ ])
854
+ ], PromoCodesService);
855
+ function checkFreeSlot(promo, ownSlot) {
856
+ if (promo.maxRedemptions == null) return null;
857
+ const taken = promo.redemptionsCount + promo.heldCount - (ownSlot ? 1 : 0);
858
+ return taken >= promo.maxRedemptions ? "EXHAUSTED" : null;
859
+ }
860
+ __name(checkFreeSlot, "checkFreeSlot");
861
+ function termsAfter(existing, change) {
862
+ const kept = /* @__PURE__ */ __name((changed, current) => changed !== void 0 ? changed : current, "kept");
863
+ return {
864
+ valueType: kept(change.valueType, existing.valueType),
865
+ value: kept(change.value, Number(existing.value)),
866
+ durationType: kept(change.durationType, existing.durationType),
867
+ durationValue: kept(change.durationValue, existing.durationValue),
868
+ validFrom: kept(change.validFrom, existing.validFrom),
869
+ validUntil: kept(change.validUntil, existing.validUntil),
870
+ appliesToPlans: kept(change.appliesToPlans, existing.appliesToPlans),
871
+ minimumPlanAmountGross: kept(change.minimumPlanAmountGross, existing.minimumPlanAmountGross == null ? null : Number(existing.minimumPlanAmountGross)),
872
+ allowZeroInvoice: kept(change.allowZeroInvoice, existing.allowZeroInvoice)
873
+ };
874
+ }
875
+ __name(termsAfter, "termsAfter");
876
+
877
+ export {
878
+ computeDiscountGross,
879
+ computeDiscountedGross,
880
+ computeRegularStartsAt,
881
+ addCycles,
882
+ buildLabel,
883
+ PROMO_CODE_REPOSITORY_TOKEN,
884
+ PROMO_CODE_HOLD_REPOSITORY_TOKEN,
885
+ PROMO_CODE_REDEMPTION_REPOSITORY_TOKEN,
886
+ PROMO_CODE_VALIDATION_LOG_REPOSITORY_TOKEN,
887
+ PROMO_FIRST_TIME_CUSTOMER_CHECK_TOKEN,
888
+ PROMO_SUBSCRIPTION_LOOKUP_TOKEN,
889
+ PROMO_REVENUE_DEDUCTION_AGGREGATOR_TOKEN,
890
+ PROMO_TRANSACTION_RUNNER_TOKEN,
891
+ PROMO_SERVICE_CONFIG_TOKEN,
892
+ CODE_MIN_LENGTH,
893
+ CODE_MAX_LENGTH,
894
+ CODE_PATTERN,
895
+ PromoCodesService
896
+ };