@saasicat/nest 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +53 -0
- package/dist/admin/index.cjs +1283 -0
- package/dist/admin/index.d.cts +225 -0
- package/dist/admin/index.d.ts +225 -0
- package/dist/admin/index.js +58 -0
- package/dist/admin-audit.service-9IqXMlZm.d.cts +30 -0
- package/dist/admin-audit.service-9IqXMlZm.d.ts +30 -0
- package/dist/admin-manifest.config-DyrQNT7M.d.cts +10 -0
- package/dist/admin-manifest.config-DyrQNT7M.d.ts +10 -0
- package/dist/aggregation-CJ3qQf92.d.cts +288 -0
- package/dist/aggregation-Dvz9e8X2.d.ts +288 -0
- package/dist/billing/index.cjs +5205 -0
- package/dist/billing/index.d.cts +1326 -0
- package/dist/billing/index.d.ts +1326 -0
- package/dist/billing/index.js +197 -0
- package/dist/catalog/index.cjs +6449 -0
- package/dist/catalog/index.d.cts +1073 -0
- package/dist/catalog/index.d.ts +1073 -0
- package/dist/catalog/index.js +158 -0
- package/dist/checkout-offer/index.cjs +807 -0
- package/dist/checkout-offer/index.d.cts +120 -0
- package/dist/checkout-offer/index.d.ts +120 -0
- package/dist/checkout-offer/index.js +20 -0
- package/dist/chunk-2FR6ZL7R.js +0 -0
- package/dist/chunk-2PNX2QL2.js +347 -0
- package/dist/chunk-2VNZYRDV.js +326 -0
- package/dist/chunk-7MLGHWKO.js +12 -0
- package/dist/chunk-ABB2CRAS.js +31 -0
- package/dist/chunk-AJ3EPELS.js +157 -0
- package/dist/chunk-E56W4U2P.js +90 -0
- package/dist/chunk-HRU3CP57.js +71 -0
- package/dist/chunk-L4SUWH5B.js +14 -0
- package/dist/chunk-M6CZQYPY.js +314 -0
- package/dist/chunk-MDIZUVIK.js +87 -0
- package/dist/chunk-MV6LW5UB.js +217 -0
- package/dist/chunk-NYLON2VC.js +6 -0
- package/dist/chunk-P6MYZMXQ.js +951 -0
- package/dist/chunk-Q53N43LQ.js +13 -0
- package/dist/chunk-QJVPRD3R.js +573 -0
- package/dist/chunk-RIGLQ4BV.js +3367 -0
- package/dist/chunk-SABTXESR.js +24 -0
- package/dist/chunk-SHUYVCID.js +6 -0
- package/dist/chunk-SYEVGPFC.js +686 -0
- package/dist/chunk-V7HUZ6UQ.js +5923 -0
- package/dist/chunk-WPUVJRIN.js +29 -0
- package/dist/chunk-XCZ3TTFK.js +22 -0
- package/dist/chunk-XSGU2Y2O.js +1611 -0
- package/dist/chunk-YNEW3L63.js +190 -0
- package/dist/chunk-YXGHPILG.js +614 -0
- package/dist/chunk-ZL4LLE2Z.js +465 -0
- package/dist/di-CcNeq9v-.d.cts +16 -0
- package/dist/di-CcNeq9v-.d.ts +16 -0
- package/dist/discovery/index.cjs +588 -0
- package/dist/discovery/index.d.cts +299 -0
- package/dist/discovery/index.d.ts +299 -0
- package/dist/discovery/index.js +49 -0
- package/dist/discovery.scanner-CUYLKlYT.d.cts +59 -0
- package/dist/discovery.scanner-CUYLKlYT.d.ts +59 -0
- package/dist/entitlement/index.cjs +805 -0
- package/dist/entitlement/index.d.cts +60 -0
- package/dist/entitlement/index.d.ts +60 -0
- package/dist/entitlement/index.js +57 -0
- package/dist/index.cjs +15918 -0
- package/dist/index.d.cts +85 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.js +956 -0
- package/dist/plan-resolution-CFCoUkrE.d.cts +41 -0
- package/dist/plan-resolution-CFCoUkrE.d.ts +41 -0
- package/dist/platform/index.cjs +2772 -0
- package/dist/platform/index.d.cts +118 -0
- package/dist/platform/index.d.ts +118 -0
- package/dist/platform/index.js +37 -0
- package/dist/promo/index.cjs +995 -0
- package/dist/promo/index.d.cts +121 -0
- package/dist/promo/index.d.ts +121 -0
- package/dist/promo/index.js +58 -0
- package/dist/registration/index.cjs +1687 -0
- package/dist/registration/index.d.cts +444 -0
- package/dist/registration/index.d.ts +444 -0
- package/dist/registration/index.js +82 -0
- package/dist/saas-platform.module-CgZ2omgg.d.cts +180 -0
- package/dist/saas-platform.module-DT9TnCzk.d.ts +180 -0
- package/dist/service-DX8KbGXl.d.cts +109 -0
- package/dist/service-DX8KbGXl.d.ts +109 -0
- package/dist/subscription-contract/index.cjs +471 -0
- package/dist/subscription-contract/index.d.cts +17 -0
- package/dist/subscription-contract/index.d.ts +17 -0
- package/dist/subscription-contract/index.js +21 -0
- package/dist/subscription-contract.service--cm47ZJJ.d.cts +39 -0
- package/dist/subscription-contract.service--cm47ZJJ.d.ts +39 -0
- package/dist/testing/index.cjs +3942 -0
- package/dist/testing/index.d.cts +279 -0
- package/dist/testing/index.d.ts +279 -0
- package/dist/testing/index.js +1195 -0
- package/package.json +185 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PLAN_CATALOG_TOKEN,
|
|
3
|
+
computeIncludedVat,
|
|
4
|
+
getPlanPriceGross,
|
|
5
|
+
round2
|
|
6
|
+
} from "./chunk-MV6LW5UB.js";
|
|
7
|
+
import {
|
|
8
|
+
__name
|
|
9
|
+
} from "./chunk-SHUYVCID.js";
|
|
10
|
+
|
|
11
|
+
// src/promo/calculator.ts
|
|
12
|
+
function computeDiscountGross(plan, code) {
|
|
13
|
+
const v = Number(code.value);
|
|
14
|
+
if (code.valueType === "PERCENT") {
|
|
15
|
+
return round2(plan.gross * v / 100);
|
|
16
|
+
}
|
|
17
|
+
return round2(v);
|
|
18
|
+
}
|
|
19
|
+
__name(computeDiscountGross, "computeDiscountGross");
|
|
20
|
+
function computeDiscountedGross(planGross, discountGross) {
|
|
21
|
+
return round2(planGross - discountGross);
|
|
22
|
+
}
|
|
23
|
+
__name(computeDiscountedGross, "computeDiscountedGross");
|
|
24
|
+
function computeRegularStartsAt(startsAt, cycle, durationType, durationValue) {
|
|
25
|
+
const result = new Date(startsAt);
|
|
26
|
+
if (durationType === "ONCE") {
|
|
27
|
+
return addCycles(result, cycle, 1);
|
|
28
|
+
}
|
|
29
|
+
if (durationType === "MONTHS") {
|
|
30
|
+
result.setMonth(result.getMonth() + (durationValue ?? 0));
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
return addCycles(result, cycle, durationValue ?? 0);
|
|
34
|
+
}
|
|
35
|
+
__name(computeRegularStartsAt, "computeRegularStartsAt");
|
|
36
|
+
function addCycles(date, cycle, n) {
|
|
37
|
+
const r = new Date(date);
|
|
38
|
+
if (cycle === "YEARLY") {
|
|
39
|
+
r.setFullYear(r.getFullYear() + n);
|
|
40
|
+
} else {
|
|
41
|
+
r.setMonth(r.getMonth() + n);
|
|
42
|
+
}
|
|
43
|
+
return r;
|
|
44
|
+
}
|
|
45
|
+
__name(addCycles, "addCycles");
|
|
46
|
+
function buildLabel(code, cycle) {
|
|
47
|
+
const value = Number(code.value);
|
|
48
|
+
const valueStr = code.valueType === "PERCENT" ? `${value.toLocaleString("de-DE")} %` : `${value.toLocaleString("de-DE", {
|
|
49
|
+
minimumFractionDigits: 2,
|
|
50
|
+
maximumFractionDigits: 2
|
|
51
|
+
})} \u20AC`;
|
|
52
|
+
if (code.durationType === "ONCE") {
|
|
53
|
+
return `${valueStr} einmalig`;
|
|
54
|
+
}
|
|
55
|
+
if (code.durationType === "MONTHS") {
|
|
56
|
+
const m = code.durationValue ?? 0;
|
|
57
|
+
return m === 1 ? `${valueStr} im ersten Monat` : `${valueStr} f\xFCr ${m} Monate`;
|
|
58
|
+
}
|
|
59
|
+
const n = code.durationValue ?? 0;
|
|
60
|
+
if (n === 1) return cycle === "YEARLY" ? `${valueStr} im ersten Jahr` : `${valueStr} im ersten Monat`;
|
|
61
|
+
return cycle === "YEARLY" ? `${valueStr} in den ersten ${n} Jahren` : `${valueStr} in den ersten ${n} Monaten`;
|
|
62
|
+
}
|
|
63
|
+
__name(buildLabel, "buildLabel");
|
|
64
|
+
|
|
65
|
+
// src/promo/tokens.ts
|
|
66
|
+
var PROMO_CODE_REPOSITORY_TOKEN = /* @__PURE__ */ Symbol("PROMO_CODE_REPOSITORY");
|
|
67
|
+
var PROMO_CODE_REDEMPTION_REPOSITORY_TOKEN = /* @__PURE__ */ Symbol("PROMO_CODE_REDEMPTION_REPOSITORY");
|
|
68
|
+
var PROMO_CODE_VALIDATION_LOG_REPOSITORY_TOKEN = /* @__PURE__ */ Symbol("PROMO_CODE_VALIDATION_LOG_REPOSITORY");
|
|
69
|
+
var PROMO_FIRST_TIME_CUSTOMER_CHECK_TOKEN = /* @__PURE__ */ Symbol("PROMO_FIRST_TIME_CUSTOMER_CHECK");
|
|
70
|
+
var PROMO_SUBSCRIPTION_LOOKUP_TOKEN = /* @__PURE__ */ Symbol("PROMO_SUBSCRIPTION_LOOKUP");
|
|
71
|
+
var PROMO_REVENUE_DEDUCTION_AGGREGATOR_TOKEN = /* @__PURE__ */ Symbol("PROMO_REVENUE_DEDUCTION_AGGREGATOR");
|
|
72
|
+
var PROMO_TRANSACTION_RUNNER_TOKEN = /* @__PURE__ */ Symbol("PROMO_TRANSACTION_RUNNER");
|
|
73
|
+
var PROMO_SERVICE_CONFIG_TOKEN = /* @__PURE__ */ Symbol("PROMO_SERVICE_CONFIG");
|
|
74
|
+
|
|
75
|
+
// src/promo/service.ts
|
|
76
|
+
import { BadRequestException, Inject, Injectable, NotFoundException } from "@nestjs/common";
|
|
77
|
+
function _ts_decorate(decorators, target, key, desc) {
|
|
78
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
79
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
80
|
+
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;
|
|
81
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
82
|
+
}
|
|
83
|
+
__name(_ts_decorate, "_ts_decorate");
|
|
84
|
+
function _ts_metadata(k, v) {
|
|
85
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
86
|
+
}
|
|
87
|
+
__name(_ts_metadata, "_ts_metadata");
|
|
88
|
+
function _ts_param(paramIndex, decorator) {
|
|
89
|
+
return function(target, key) {
|
|
90
|
+
decorator(target, key, paramIndex);
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
__name(_ts_param, "_ts_param");
|
|
94
|
+
var CODE_PATTERN = /^[A-Z0-9_-]{4,32}$/;
|
|
95
|
+
var PromoCodesService = class {
|
|
96
|
+
static {
|
|
97
|
+
__name(this, "PromoCodesService");
|
|
98
|
+
}
|
|
99
|
+
promoRepo;
|
|
100
|
+
redemptionRepo;
|
|
101
|
+
validationLogRepo;
|
|
102
|
+
firstTimeCheck;
|
|
103
|
+
subscriptionLookup;
|
|
104
|
+
revenueAggregator;
|
|
105
|
+
transactionRunner;
|
|
106
|
+
planCatalog;
|
|
107
|
+
config;
|
|
108
|
+
constructor(promoRepo, redemptionRepo, validationLogRepo, firstTimeCheck, subscriptionLookup, revenueAggregator, transactionRunner, planCatalog, config) {
|
|
109
|
+
this.promoRepo = promoRepo;
|
|
110
|
+
this.redemptionRepo = redemptionRepo;
|
|
111
|
+
this.validationLogRepo = validationLogRepo;
|
|
112
|
+
this.firstTimeCheck = firstTimeCheck;
|
|
113
|
+
this.subscriptionLookup = subscriptionLookup;
|
|
114
|
+
this.revenueAggregator = revenueAggregator;
|
|
115
|
+
this.transactionRunner = transactionRunner;
|
|
116
|
+
this.planCatalog = planCatalog;
|
|
117
|
+
this.config = config;
|
|
118
|
+
}
|
|
119
|
+
// ─── ADMIN: Creation / Editing ─────────────────────────────────────────
|
|
120
|
+
async create(input) {
|
|
121
|
+
const code = input.code.trim().toUpperCase();
|
|
122
|
+
if (!CODE_PATTERN.test(code)) {
|
|
123
|
+
throw new BadRequestException('Code darf nur Gro\xDFbuchstaben, Ziffern, \u201E-" und \u201E_" enthalten (4\u201332 Zeichen).');
|
|
124
|
+
}
|
|
125
|
+
if (input.valueType === "PERCENT") {
|
|
126
|
+
if (!(input.value > 0 && input.value <= 100)) {
|
|
127
|
+
throw new BadRequestException("Prozentsatz muss zwischen 0 und 100 liegen.");
|
|
128
|
+
}
|
|
129
|
+
} else if (!(input.value > 0)) {
|
|
130
|
+
throw new BadRequestException("Betrag muss positiv sein.");
|
|
131
|
+
}
|
|
132
|
+
if (input.durationType === "ONCE") {
|
|
133
|
+
if (input.durationValue != null) {
|
|
134
|
+
throw new BadRequestException("Bei einmaligem Rabatt darf keine Laufzeit gesetzt sein.");
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
const v = input.durationValue;
|
|
138
|
+
if (v == null || v < 1 || v > 24) {
|
|
139
|
+
throw new BadRequestException("Laufzeit ung\xFCltig (max. 24 Monate bzw. Abrechnungsperioden).");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (input.validFrom && input.validUntil && input.validFrom >= input.validUntil) {
|
|
143
|
+
throw new BadRequestException("G\xFCltigkeits-Fenster ung\xFCltig.");
|
|
144
|
+
}
|
|
145
|
+
const plans = input.appliesToPlans ?? [];
|
|
146
|
+
const blocked = this.config.nonRedeemablePlans ?? [];
|
|
147
|
+
for (const p of plans) {
|
|
148
|
+
if (blocked.includes(p)) {
|
|
149
|
+
throw new BadRequestException(`${p}-Plan kann nicht rabattiert werden.`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (input.minimumPlanAmountGross != null && input.minimumPlanAmountGross <= 0) {
|
|
153
|
+
throw new BadRequestException("Mindest-Plan-Brutto muss positiv sein.");
|
|
154
|
+
}
|
|
155
|
+
if (input.valueType === "ABSOLUTE" && !input.allowZeroInvoice) {
|
|
156
|
+
const lowestApplicableGross = this.lowestApplicablePlanGross(plans);
|
|
157
|
+
if (lowestApplicableGross != null && input.value >= lowestApplicableGross) {
|
|
158
|
+
throw new BadRequestException("Bei Absolutbetr\xE4gen muss der Rabatt unter dem niedrigsten anwendbaren Plan-Preis liegen oder allowZeroInvoice aktiviert sein.");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const exists = await this.promoRepo.findByCode(code);
|
|
162
|
+
if (exists) throw new BadRequestException("Code existiert bereits.");
|
|
163
|
+
return this.promoRepo.create({
|
|
164
|
+
...input,
|
|
165
|
+
code
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
async update(id, input) {
|
|
169
|
+
const existing = await this.promoRepo.findById(id);
|
|
170
|
+
if (!existing) throw new NotFoundException("Code nicht gefunden");
|
|
171
|
+
if (input.maxRedemptions != null && existing.maxRedemptions != null) {
|
|
172
|
+
if (input.maxRedemptions < existing.maxRedemptions) {
|
|
173
|
+
throw new BadRequestException("maxRedemptions kann nicht gesenkt werden.");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return this.promoRepo.update(id, input);
|
|
177
|
+
}
|
|
178
|
+
async softDelete(id) {
|
|
179
|
+
const redemptions = await this.redemptionRepo.countByPromoCode(id);
|
|
180
|
+
if (redemptions > 0) {
|
|
181
|
+
throw new BadRequestException("Code hat bereits Einl\xF6sungen \u2014 Soft-Delete nicht m\xF6glich. Stattdessen pausieren.");
|
|
182
|
+
}
|
|
183
|
+
await this.promoRepo.softDelete(id);
|
|
184
|
+
}
|
|
185
|
+
async findAll(filter = {}) {
|
|
186
|
+
await this.lazyExpire();
|
|
187
|
+
return this.promoRepo.findMany(filter);
|
|
188
|
+
}
|
|
189
|
+
async findOne(id) {
|
|
190
|
+
await this.lazyExpire();
|
|
191
|
+
const code = await this.promoRepo.findById(id);
|
|
192
|
+
if (!code || code.deletedAt) throw new NotFoundException("Code nicht gefunden");
|
|
193
|
+
return code;
|
|
194
|
+
}
|
|
195
|
+
async stats(id) {
|
|
196
|
+
const code = await this.findOne(id);
|
|
197
|
+
const [active, total, reversed, expired, validations, revenueDeductionGross] = await Promise.all([
|
|
198
|
+
this.redemptionRepo.countByPromoCode(id, "ACTIVE"),
|
|
199
|
+
this.redemptionRepo.countByPromoCode(id),
|
|
200
|
+
this.redemptionRepo.countByPromoCode(id, "REVERSED"),
|
|
201
|
+
this.redemptionRepo.countByPromoCode(id, "EXPIRED"),
|
|
202
|
+
this.validationLogRepo.countValid(id),
|
|
203
|
+
this.revenueAggregator.sumGrossForPromoCode(id)
|
|
204
|
+
]);
|
|
205
|
+
return {
|
|
206
|
+
code,
|
|
207
|
+
validations,
|
|
208
|
+
redemptions: {
|
|
209
|
+
active,
|
|
210
|
+
total,
|
|
211
|
+
reversed,
|
|
212
|
+
expired
|
|
213
|
+
},
|
|
214
|
+
revenueDeductionGross
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
async listRedemptions(promoCodeId) {
|
|
218
|
+
return this.redemptionRepo.listByPromoCode(promoCodeId);
|
|
219
|
+
}
|
|
220
|
+
// ─── PUBLIC: Preview / Validation ──────────────────────────────────────
|
|
221
|
+
async preview(input) {
|
|
222
|
+
const code = input.code.trim().toUpperCase();
|
|
223
|
+
if (!CODE_PATTERN.test(code)) {
|
|
224
|
+
await this.validationLogRepo.log({
|
|
225
|
+
promoCodeId: null,
|
|
226
|
+
codeAttempt: code,
|
|
227
|
+
result: "NOT_FOUND",
|
|
228
|
+
ipHash: input.ipHash,
|
|
229
|
+
sessionId: input.sessionId
|
|
230
|
+
});
|
|
231
|
+
return {
|
|
232
|
+
valid: false,
|
|
233
|
+
reason: "NOT_FOUND"
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
await this.lazyExpire();
|
|
237
|
+
const promo = await this.promoRepo.findByCode(code);
|
|
238
|
+
const reason = this.checkEligibility(promo, input) ?? await this.checkFirstTimeCustomer(promo, input.email);
|
|
239
|
+
if (reason) {
|
|
240
|
+
await this.validationLogRepo.log({
|
|
241
|
+
promoCodeId: promo?.id ?? null,
|
|
242
|
+
codeAttempt: code,
|
|
243
|
+
result: reason,
|
|
244
|
+
ipHash: input.ipHash,
|
|
245
|
+
sessionId: input.sessionId
|
|
246
|
+
});
|
|
247
|
+
return {
|
|
248
|
+
valid: false,
|
|
249
|
+
reason
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
const planGross = getPlanPriceGross(this.planCatalog, input.planId, input.billingCycle);
|
|
253
|
+
const discountGross = Math.min(computeDiscountGross({
|
|
254
|
+
gross: planGross
|
|
255
|
+
}, promo), planGross);
|
|
256
|
+
const discountedGross = computeDiscountedGross(planGross, discountGross);
|
|
257
|
+
const includedVat = computeIncludedVat(discountedGross, this.planCatalog.vatRate);
|
|
258
|
+
if (!promo.allowZeroInvoice && discountedGross <= 0) {
|
|
259
|
+
await this.validationLogRepo.log({
|
|
260
|
+
promoCodeId: promo.id,
|
|
261
|
+
codeAttempt: code,
|
|
262
|
+
result: "WOULD_PRODUCE_ZERO_INVOICE",
|
|
263
|
+
ipHash: input.ipHash,
|
|
264
|
+
sessionId: input.sessionId
|
|
265
|
+
});
|
|
266
|
+
return {
|
|
267
|
+
valid: false,
|
|
268
|
+
reason: "WOULD_PRODUCE_ZERO_INVOICE"
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
const startsAt = /* @__PURE__ */ new Date();
|
|
272
|
+
const regularStartsAt = computeRegularStartsAt(startsAt, input.billingCycle, promo.durationType, promo.durationValue);
|
|
273
|
+
await this.validationLogRepo.log({
|
|
274
|
+
promoCodeId: promo.id,
|
|
275
|
+
codeAttempt: code,
|
|
276
|
+
result: "VALID",
|
|
277
|
+
ipHash: input.ipHash,
|
|
278
|
+
sessionId: input.sessionId
|
|
279
|
+
});
|
|
280
|
+
return {
|
|
281
|
+
valid: true,
|
|
282
|
+
code: promo.code,
|
|
283
|
+
label: buildLabel(promo, input.billingCycle),
|
|
284
|
+
discount: {
|
|
285
|
+
valueType: promo.valueType,
|
|
286
|
+
value: Number(promo.value).toFixed(2),
|
|
287
|
+
durationType: promo.durationType,
|
|
288
|
+
durationValue: promo.durationValue
|
|
289
|
+
},
|
|
290
|
+
price: {
|
|
291
|
+
originalGross: planGross.toFixed(2),
|
|
292
|
+
discountGross: discountGross.toFixed(2),
|
|
293
|
+
discountedGross: discountedGross.toFixed(2),
|
|
294
|
+
includedVat: includedVat.toFixed(2),
|
|
295
|
+
nextRegularAmountGross: planGross.toFixed(2),
|
|
296
|
+
regularStartsAt: regularStartsAt ? regularStartsAt.toISOString() : null
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
// ─── Onboarding path: atomic redemption ────────────────────────────────
|
|
301
|
+
/**
|
|
302
|
+
* Wraps `redeemInTransaction` in its own transaction-runner call —
|
|
303
|
+
* the default path when the caller has no external tx context
|
|
304
|
+
* (e.g. POST /billing/promo/redeem as a stand-alone endpoint).
|
|
305
|
+
*/
|
|
306
|
+
async redeem(input) {
|
|
307
|
+
return this.transactionRunner.run((tx) => this.redeemInTransaction(input, tx));
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Redeems a promo code within an EXTERNAL transaction — the caller
|
|
311
|
+
* (typically `TenantSubscriptionWritePort.applyOnboardingSelection`)
|
|
312
|
+
* already has `prisma.$transaction(...)` open and passes the `tx`
|
|
313
|
+
* context through. This lands plan change, add-on insert, and redemption
|
|
314
|
+
* insert in a single DB transaction (P10.1.1).
|
|
315
|
+
*/
|
|
316
|
+
async redeemInTransaction(input, tx) {
|
|
317
|
+
const code = input.code.trim().toUpperCase();
|
|
318
|
+
const promo = await this.promoRepo.findByCode(code, tx);
|
|
319
|
+
if (!promo || promo.deletedAt) {
|
|
320
|
+
throw new BadRequestException("Code nicht gefunden");
|
|
321
|
+
}
|
|
322
|
+
const sub = await this.subscriptionLookup.findById(input.subscriptionId, tx);
|
|
323
|
+
if (!sub) throw new NotFoundException("Subscription nicht gefunden");
|
|
324
|
+
if (sub.tenantId !== input.tenantId) {
|
|
325
|
+
throw new BadRequestException("Subscription geh\xF6rt nicht zum Tenant");
|
|
326
|
+
}
|
|
327
|
+
const reason = this.checkEligibility(promo, {
|
|
328
|
+
code,
|
|
329
|
+
planId: sub.plan,
|
|
330
|
+
billingCycle: sub.billingCycle
|
|
331
|
+
});
|
|
332
|
+
if (reason) throw new BadRequestException(`Code nicht einl\xF6sbar: ${reason}`);
|
|
333
|
+
const firstTimeReason = await this.checkFirstTimeCustomer(promo, input.email, {
|
|
334
|
+
requireEmail: true
|
|
335
|
+
});
|
|
336
|
+
if (firstTimeReason) {
|
|
337
|
+
throw new BadRequestException(`Code nicht einl\xF6sbar: ${firstTimeReason}`);
|
|
338
|
+
}
|
|
339
|
+
const claimed = await this.promoRepo.claimSlot(promo.id, tx);
|
|
340
|
+
if (!claimed) {
|
|
341
|
+
throw new BadRequestException("Code nicht einl\xF6sbar: EXHAUSTED");
|
|
342
|
+
}
|
|
343
|
+
await this.promoRepo.markExhaustedIfFull(promo.id, tx);
|
|
344
|
+
const startsAt = sub.startedAt ?? /* @__PURE__ */ new Date();
|
|
345
|
+
const endsAt = promo.durationType === "ONCE" ? null : computeRegularStartsAt(startsAt, sub.billingCycle, promo.durationType, promo.durationValue);
|
|
346
|
+
return this.redemptionRepo.create({
|
|
347
|
+
promoCodeId: promo.id,
|
|
348
|
+
subscriptionId: sub.id,
|
|
349
|
+
tenantId: sub.tenantId,
|
|
350
|
+
appliedValueType: promo.valueType,
|
|
351
|
+
appliedValue: promo.value,
|
|
352
|
+
appliedDurationType: promo.durationType,
|
|
353
|
+
appliedDurationValue: promo.durationValue,
|
|
354
|
+
startsAt,
|
|
355
|
+
endsAt
|
|
356
|
+
}, tx);
|
|
357
|
+
}
|
|
358
|
+
async reverse(subscriptionId) {
|
|
359
|
+
return this.transactionRunner.run(async (tx) => {
|
|
360
|
+
const redemption = await this.redemptionRepo.findBySubscription(subscriptionId, tx);
|
|
361
|
+
if (!redemption || redemption.status !== "ACTIVE") return redemption;
|
|
362
|
+
const updated = await this.redemptionRepo.setReversed(redemption.id, tx);
|
|
363
|
+
await this.promoRepo.releaseSlot(redemption.promoCodeId, tx);
|
|
364
|
+
return updated;
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
// ─── Helpers ───────────────────────────────────────────────────────────
|
|
368
|
+
checkEligibility(promo, input) {
|
|
369
|
+
if (!promo || promo.deletedAt) return "NOT_FOUND";
|
|
370
|
+
if (promo.status === "EXPIRED") return "EXPIRED";
|
|
371
|
+
if (promo.status === "EXHAUSTED") return "EXHAUSTED";
|
|
372
|
+
if (promo.status === "PAUSED") return "PAUSED";
|
|
373
|
+
const now = /* @__PURE__ */ new Date();
|
|
374
|
+
if (promo.validFrom && promo.validFrom > now) return "EXPIRED";
|
|
375
|
+
if (promo.validUntil && promo.validUntil < now) return "EXPIRED";
|
|
376
|
+
const blocked = this.config.nonRedeemablePlans ?? [];
|
|
377
|
+
if (blocked.includes(input.planId)) {
|
|
378
|
+
return "PLAN_MISMATCH";
|
|
379
|
+
}
|
|
380
|
+
if (promo.appliesToPlans.length > 0 && !promo.appliesToPlans.includes(input.planId)) {
|
|
381
|
+
return "PLAN_MISMATCH";
|
|
382
|
+
}
|
|
383
|
+
if (promo.appliesToBilling && promo.appliesToBilling !== input.billingCycle) {
|
|
384
|
+
return "BILLING_MISMATCH";
|
|
385
|
+
}
|
|
386
|
+
const planGross = getPlanPriceGross(this.planCatalog, input.planId, input.billingCycle);
|
|
387
|
+
if (planGross == null) return "PLAN_MISMATCH";
|
|
388
|
+
if (promo.minimumPlanAmountGross && planGross < Number(promo.minimumPlanAmountGross)) {
|
|
389
|
+
return "BELOW_MINIMUM_AMOUNT";
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
async checkFirstTimeCustomer(promo, email, options = {}) {
|
|
394
|
+
if (!promo || !promo.firstTimeCustomersOnly) return null;
|
|
395
|
+
if (!email) return options.requireEmail ? "NOT_FIRST_TIME_CUSTOMER" : null;
|
|
396
|
+
const existing = await this.firstTimeCheck.hasExistingCustomerForEmail(email);
|
|
397
|
+
return existing ? "NOT_FIRST_TIME_CUSTOMER" : null;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Lazy expiry: sets codes whose validUntil has passed to EXPIRED.
|
|
401
|
+
* Called before every find/preview — defense-in-depth alongside the cron.
|
|
402
|
+
*/
|
|
403
|
+
async lazyExpire() {
|
|
404
|
+
const now = /* @__PURE__ */ new Date();
|
|
405
|
+
await this.promoRepo.expireDueCodes(now);
|
|
406
|
+
await this.redemptionRepo.expireDueRedemptions(now);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Lowest applicable plan price. With a whitelist it takes the minimum
|
|
410
|
+
* from the whitelist, otherwise across all marketed plans of the catalog
|
|
411
|
+
* (except non-redeemable).
|
|
412
|
+
*/
|
|
413
|
+
lowestApplicablePlanGross(plans) {
|
|
414
|
+
const blocked = new Set(this.config.nonRedeemablePlans ?? []);
|
|
415
|
+
const candidates = plans.length > 0 ? plans : (this.planCatalog.plans ?? []).filter((p) => p.marketed !== false && !blocked.has(p.id)).map((p) => p.id);
|
|
416
|
+
let min = null;
|
|
417
|
+
for (const p of candidates) {
|
|
418
|
+
const g = getPlanPriceGross(this.planCatalog, p, "MONTHLY");
|
|
419
|
+
if (g == null) continue;
|
|
420
|
+
if (min == null || g < min) min = g;
|
|
421
|
+
}
|
|
422
|
+
return min;
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
PromoCodesService = _ts_decorate([
|
|
426
|
+
Injectable(),
|
|
427
|
+
_ts_param(0, Inject(PROMO_CODE_REPOSITORY_TOKEN)),
|
|
428
|
+
_ts_param(1, Inject(PROMO_CODE_REDEMPTION_REPOSITORY_TOKEN)),
|
|
429
|
+
_ts_param(2, Inject(PROMO_CODE_VALIDATION_LOG_REPOSITORY_TOKEN)),
|
|
430
|
+
_ts_param(3, Inject(PROMO_FIRST_TIME_CUSTOMER_CHECK_TOKEN)),
|
|
431
|
+
_ts_param(4, Inject(PROMO_SUBSCRIPTION_LOOKUP_TOKEN)),
|
|
432
|
+
_ts_param(5, Inject(PROMO_REVENUE_DEDUCTION_AGGREGATOR_TOKEN)),
|
|
433
|
+
_ts_param(6, Inject(PROMO_TRANSACTION_RUNNER_TOKEN)),
|
|
434
|
+
_ts_param(7, Inject(PLAN_CATALOG_TOKEN)),
|
|
435
|
+
_ts_param(8, Inject(PROMO_SERVICE_CONFIG_TOKEN)),
|
|
436
|
+
_ts_metadata("design:type", Function),
|
|
437
|
+
_ts_metadata("design:paramtypes", [
|
|
438
|
+
typeof PromoCodeRepository === "undefined" ? Object : PromoCodeRepository,
|
|
439
|
+
typeof PromoCodeRedemptionRepository === "undefined" ? Object : PromoCodeRedemptionRepository,
|
|
440
|
+
typeof PromoCodeValidationLogRepository === "undefined" ? Object : PromoCodeValidationLogRepository,
|
|
441
|
+
typeof FirstTimeCustomerCheck === "undefined" ? Object : FirstTimeCustomerCheck,
|
|
442
|
+
typeof PromoSubscriptionLookup === "undefined" ? Object : PromoSubscriptionLookup,
|
|
443
|
+
typeof PromoRevenueDeductionAggregator === "undefined" ? Object : PromoRevenueDeductionAggregator,
|
|
444
|
+
typeof TransactionRunner === "undefined" ? Object : TransactionRunner,
|
|
445
|
+
typeof PlanCatalog === "undefined" ? Object : PlanCatalog,
|
|
446
|
+
typeof PromoServiceConfig === "undefined" ? Object : PromoServiceConfig
|
|
447
|
+
])
|
|
448
|
+
], PromoCodesService);
|
|
449
|
+
|
|
450
|
+
export {
|
|
451
|
+
computeDiscountGross,
|
|
452
|
+
computeDiscountedGross,
|
|
453
|
+
computeRegularStartsAt,
|
|
454
|
+
addCycles,
|
|
455
|
+
buildLabel,
|
|
456
|
+
PROMO_CODE_REPOSITORY_TOKEN,
|
|
457
|
+
PROMO_CODE_REDEMPTION_REPOSITORY_TOKEN,
|
|
458
|
+
PROMO_CODE_VALIDATION_LOG_REPOSITORY_TOKEN,
|
|
459
|
+
PROMO_FIRST_TIME_CUSTOMER_CHECK_TOKEN,
|
|
460
|
+
PROMO_SUBSCRIPTION_LOOKUP_TOKEN,
|
|
461
|
+
PROMO_REVENUE_DEDUCTION_AGGREGATOR_TOKEN,
|
|
462
|
+
PROMO_TRANSACTION_RUNNER_TOKEN,
|
|
463
|
+
PROMO_SERVICE_CONFIG_TOKEN,
|
|
464
|
+
PromoCodesService
|
|
465
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { FactoryProvider, Provider } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A port implementation can be passed as a ready-made value OR as a factory
|
|
5
|
+
* (`{ useFactory, inject }`) — this way consumers supply their adapters
|
|
6
|
+
* without needing to know the instantiation themselves.
|
|
7
|
+
*/
|
|
8
|
+
type ProviderSpec<T> = T | Pick<FactoryProvider, 'useFactory' | 'inject'>;
|
|
9
|
+
/**
|
|
10
|
+
* Normalizes a `ProviderSpec<T>` into a Nest `Provider` for `token`:
|
|
11
|
+
* a `{ useFactory, inject }` form becomes a factory provider, everything else
|
|
12
|
+
* a `useValue` provider.
|
|
13
|
+
*/
|
|
14
|
+
declare function asProvider<T>(token: symbol, impl: ProviderSpec<T>): Provider;
|
|
15
|
+
|
|
16
|
+
export { type ProviderSpec as P, asProvider as a };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { FactoryProvider, Provider } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A port implementation can be passed as a ready-made value OR as a factory
|
|
5
|
+
* (`{ useFactory, inject }`) — this way consumers supply their adapters
|
|
6
|
+
* without needing to know the instantiation themselves.
|
|
7
|
+
*/
|
|
8
|
+
type ProviderSpec<T> = T | Pick<FactoryProvider, 'useFactory' | 'inject'>;
|
|
9
|
+
/**
|
|
10
|
+
* Normalizes a `ProviderSpec<T>` into a Nest `Provider` for `token`:
|
|
11
|
+
* a `{ useFactory, inject }` form becomes a factory provider, everything else
|
|
12
|
+
* a `useValue` provider.
|
|
13
|
+
*/
|
|
14
|
+
declare function asProvider<T>(token: symbol, impl: ProviderSpec<T>): Provider;
|
|
15
|
+
|
|
16
|
+
export { type ProviderSpec as P, asProvider as a };
|