@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.
- package/README.md +44 -8
- package/dist/.build-stamp +1 -1
- package/dist/_entries.cjs +10214 -4796
- package/dist/admin/index.d.cts +5 -4
- package/dist/admin/index.d.ts +5 -4
- package/dist/admin/index.js +13 -9
- package/dist/{admin-resources.module-CpvzcfCc.d.cts → admin-resources.module-B4XNvdH-.d.cts} +1 -2
- package/dist/{admin-resources.module-DF5_Q7RD.d.ts → admin-resources.module-CWqdI9BV.d.ts} +1 -2
- package/dist/{admin-stats.service-TYiSUwIR.d.ts → admin-stats.service-CsLNt19Z.d.ts} +11 -7
- package/dist/{admin-stats.service-DfKupywR.d.cts → admin-stats.service-DrZrU8IJ.d.cts} +11 -7
- package/dist/{aggregation-B3CD0v_d.d.cts → aggregation-ChpZDh0g.d.ts} +39 -6
- package/dist/{aggregation-BFzW07DE.d.ts → aggregation-OpnhwzF5.d.cts} +39 -6
- package/dist/billing/index.d.cts +448 -108
- package/dist/billing/index.d.ts +448 -108
- package/dist/billing/index.js +108 -41
- package/dist/catalog/index.d.cts +74 -45
- package/dist/catalog/index.d.ts +74 -45
- package/dist/catalog/index.js +21 -13
- package/dist/catalog.module-C9O7BxRq.d.ts +180 -0
- package/dist/catalog.module-T-I5Ricz.d.cts +180 -0
- package/dist/checkout-offer/index.d.cts +17 -22
- package/dist/checkout-offer/index.d.ts +17 -22
- package/dist/checkout-offer/index.js +21 -7
- package/dist/checkout-offer.module-DFhGy6JO.d.cts +58 -0
- package/dist/checkout-offer.module-x7SP_dX4.d.ts +58 -0
- package/dist/checkout-offer.service-BeHgYikI.d.ts +242 -0
- package/dist/checkout-offer.service-CCVxG9b2.d.cts +242 -0
- package/dist/chunk-34XVPR6P.js +222 -0
- package/dist/{chunk-AOQJEYOL.js → chunk-3QVGA6JX.js} +3 -1
- package/dist/chunk-3RLWML2S.js +896 -0
- package/dist/{chunk-TXC3LHHB.js → chunk-4SDSYV4A.js} +21 -13
- package/dist/chunk-55QEDAMX.js +946 -0
- package/dist/chunk-5CKC7O52.js +140 -0
- package/dist/chunk-6PJE7EWO.js +185 -0
- package/dist/chunk-7NJKVC5X.js +353 -0
- package/dist/{chunk-RUNZ3X4C.js → chunk-AOS2JPDD.js} +1263 -185
- package/dist/{chunk-WOVQPXV4.js → chunk-CQ2ZZMTD.js} +702 -725
- package/dist/{chunk-NA6O63B7.js → chunk-F5X66HF5.js} +5 -5
- package/dist/chunk-FEQZCWUL.js +174 -0
- package/dist/{chunk-VIJ5NJWC.js → chunk-G6EZWECL.js} +107 -31
- package/dist/chunk-G7RQO2XO.js +0 -0
- package/dist/{chunk-N3L3AICU.js → chunk-GPQWGA6B.js} +5 -6
- package/dist/chunk-GZTL64QK.js +7 -0
- package/dist/{chunk-SABTXESR.js → chunk-J7NJRK3K.js} +17 -1
- package/dist/{chunk-SEPN52AD.js → chunk-JBCC6C3M.js} +566 -307
- package/dist/{chunk-I7GTA3RX.js → chunk-JQRA724W.js} +1 -1
- package/dist/{chunk-WXYJHZCN.js → chunk-JVAEKTJ4.js} +2 -14
- package/dist/chunk-KPEMTBOP.js +26 -0
- package/dist/chunk-LF6J4YYN.js +301 -0
- package/dist/{chunk-LTT736P3.js → chunk-LLYVYRGJ.js} +228 -387
- package/dist/{chunk-6Z7JR4EW.js → chunk-M47BNEY2.js} +1897 -633
- package/dist/{chunk-KFT5AIIH.js → chunk-NDLC5GYK.js} +206 -24
- package/dist/{chunk-O2J2HDXA.js → chunk-OOTEXP47.js} +3 -8
- package/dist/chunk-QPVDCKYS.js +702 -0
- package/dist/{chunk-NHVDCYK5.js → chunk-R5YCJBHY.js} +71 -106
- package/dist/{chunk-XBYAFEOR.js → chunk-S33SO5XX.js} +1 -1
- package/dist/chunk-SZ7RFPXA.js +10 -0
- package/dist/chunk-TBBZWZQT.js +98 -0
- package/dist/chunk-TOEFN7DN.js +295 -0
- package/dist/{chunk-7ZEGFL42.js → chunk-WHJSYKNC.js} +6 -6
- package/dist/chunk-WOEJ6K7M.js +55 -0
- package/dist/{chunk-AU3OOREM.js → chunk-XSSWYPP5.js} +16 -3
- package/dist/contract-line-item-money-B0Z3Ogur.d.cts +43 -0
- package/dist/contract-line-item-money-B0Z3Ogur.d.ts +43 -0
- package/dist/discovery/index.d.cts +2 -2
- package/dist/discovery/index.d.ts +2 -2
- package/dist/discovery/index.js +6 -6
- package/dist/{discovery.scanner-9cMqF95o.d.cts → discovery.scanner-DXKc6JkV.d.cts} +1 -1
- package/dist/{discovery.scanner-9cMqF95o.d.ts → discovery.scanner-DXKc6JkV.d.ts} +1 -1
- package/dist/{enforce-quota.interceptor-Df0e3OWe.d.cts → enforce-quota.interceptor-BFyVdWe8.d.cts} +6 -6
- package/dist/{enforce-quota.interceptor-DW3etVyI.d.ts → enforce-quota.interceptor-GaBXboGx.d.ts} +6 -6
- package/dist/entitlement/index.d.cts +27 -4
- package/dist/entitlement/index.d.ts +27 -4
- package/dist/entitlement/index.js +13 -8
- package/dist/index.d.cts +209 -28
- package/dist/index.d.ts +209 -28
- package/dist/index.js +285 -163
- package/dist/issuer-identity.check-CBO0vcsq.d.cts +197 -0
- package/dist/issuer-identity.check-Cz9CEMkZ.d.ts +197 -0
- package/dist/{module-options-COSEb0VW.d.ts → module-options-DmQ3G4sZ.d.ts} +135 -41
- package/dist/{module-options-BG3MzQva.d.cts → module-options-qAOwinHR.d.cts} +135 -41
- package/dist/payment-callback.service-CR84Xw1Z.d.cts +125 -0
- package/dist/payment-callback.service-CR84Xw1Z.d.ts +125 -0
- package/dist/payments/index.cjs +4 -0
- package/dist/payments/index.d.cts +187 -0
- package/dist/payments/index.d.ts +187 -0
- package/dist/payments/index.js +141 -0
- package/dist/payments.module-BdUq9ot_.d.cts +57 -0
- package/dist/payments.module-CQGv_VG7.d.ts +57 -0
- package/dist/plan-catalog-source-DWe-BGY1.d.cts +21 -0
- package/dist/plan-catalog-source-DWe-BGY1.d.ts +21 -0
- package/dist/{plan-resolution-Cgo_TR1H.d.cts → plan-resolution-CwUy_OqC.d.cts} +14 -0
- package/dist/{plan-resolution-Cgo_TR1H.d.ts → plan-resolution-CwUy_OqC.d.ts} +14 -0
- package/dist/{plan-versions.service-DChhbK6h.d.ts → plan-versions.service-C0N-Em2W.d.ts} +38 -64
- package/dist/{plan-versions.service-TPEtbN0r.d.cts → plan-versions.service-DARLh-qI.d.cts} +38 -64
- package/dist/platform/index.d.cts +52 -23
- package/dist/platform/index.d.ts +52 -23
- package/dist/platform/index.js +57 -36
- package/dist/promo/index.d.cts +23 -8
- package/dist/promo/index.d.ts +23 -8
- package/dist/promo/index.js +15 -7
- package/dist/{promo.module-DtvIycpt.d.cts → promo.module-CKkECmU8.d.cts} +9 -3
- package/dist/{promo.module-Z8h1PNX-.d.ts → promo.module-DuIJdK3U.d.ts} +9 -3
- package/dist/promo.service-BYBu6dy3.d.cts +215 -0
- package/dist/promo.service-CFjfF0Xm.d.ts +215 -0
- package/dist/registration/index.d.cts +104 -47
- package/dist/registration/index.d.ts +104 -47
- package/dist/registration/index.js +15 -9
- package/dist/subscriber/index.cjs +4 -0
- package/dist/subscriber/index.d.cts +28 -0
- package/dist/subscriber/index.d.ts +28 -0
- package/dist/subscriber/index.js +16 -0
- package/dist/subscriber.service-5W6Gi-1B.d.cts +68 -0
- package/dist/subscriber.service-5W6Gi-1B.d.ts +68 -0
- package/dist/subscription-contract/index.d.cts +4 -2
- package/dist/subscription-contract/index.d.ts +4 -2
- package/dist/subscription-contract/index.js +13 -5
- package/dist/{subscription-contract.module-fDFdQ0jm.d.ts → subscription-contract.module-BfIRpc1o.d.ts} +8 -2
- package/dist/{subscription-contract.module-CEK8HS5-.d.cts → subscription-contract.module-DTMuUJYd.d.cts} +8 -2
- package/dist/{subscription-contract.service-hD87MKyg.d.cts → subscription-contract.service-CPZrTM9C.d.ts} +32 -3
- package/dist/{subscription-contract.service-hD87MKyg.d.ts → subscription-contract.service-CfNApLuP.d.cts} +32 -3
- package/dist/tenant-billing.controller-BLqbsONe.d.ts +812 -0
- package/dist/tenant-billing.controller-DKbAqTbM.d.cts +812 -0
- package/dist/tenant-billing.module-ClbjzocC.d.cts +364 -0
- package/dist/tenant-billing.module-DohgJJ2m.d.ts +364 -0
- package/dist/tenant-billing.tokens-G-1xOlMr.d.cts +129 -0
- package/dist/tenant-billing.tokens-G-1xOlMr.d.ts +129 -0
- package/dist/testing/index.d.cts +44 -17
- package/dist/testing/index.d.ts +44 -17
- package/dist/testing/index.js +189 -40
- package/package.json +24 -4
- package/dist/catalog.module-BDDO6iwq.d.cts +0 -103
- package/dist/catalog.module-BTnMsE6u.d.ts +0 -103
- package/dist/checkout-offer.module-CMtiQJTT.d.ts +0 -39
- package/dist/checkout-offer.module-CVpWrzbe.d.cts +0 -39
- package/dist/checkout-offer.service-BLOv2HOo.d.cts +0 -49
- package/dist/checkout-offer.service-BLOv2HOo.d.ts +0 -49
- package/dist/chunk-57V6ZTI6.js +0 -22
- package/dist/chunk-BSK6YBLI.js +0 -87
- package/dist/chunk-DQKCK7DX.js +0 -217
- package/dist/chunk-VXEYLNIB.js +0 -632
- package/dist/chunk-WUDYIPYH.js +0 -715
- package/dist/define-saasicat-DROBe-b1.d.ts +0 -69
- package/dist/define-saasicat-vGBCkFys.d.cts +0 -69
- package/dist/promo.service-BJbKAmw3.d.cts +0 -112
- package/dist/promo.service-BJbKAmw3.d.ts +0 -112
- package/dist/tenant-billing.controller-BQ67mlmI.d.ts +0 -438
- package/dist/tenant-billing.controller-DS8kB8a7.d.cts +0 -438
- package/dist/tenant-billing.module-BznC3XtW.d.cts +0 -299
- package/dist/tenant-billing.module-wUbsQSFG.d.ts +0 -299
- /package/dist/{chunk-2FR6ZL7R.js → chunk-BHZOH2DY.js} +0 -0
- /package/dist/{chunk-2SLTRKXC.js → chunk-DFOW3JVO.js} +0 -0
- /package/dist/{chunk-DBZGV3HC.js → chunk-DU56UKEH.js} +0 -0
- /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
|
+
};
|