@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,946 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PromoCodesService,
|
|
3
|
+
computeDiscountGross
|
|
4
|
+
} from "./chunk-3RLWML2S.js";
|
|
5
|
+
import {
|
|
6
|
+
SubscriptionContractService,
|
|
7
|
+
appendImplicitDiscountLineItem
|
|
8
|
+
} from "./chunk-NDLC5GYK.js";
|
|
9
|
+
import {
|
|
10
|
+
resolveBundlePriceNet
|
|
11
|
+
} from "./chunk-KPEMTBOP.js";
|
|
12
|
+
import {
|
|
13
|
+
grossFromNet,
|
|
14
|
+
netFromGross,
|
|
15
|
+
round2
|
|
16
|
+
} from "./chunk-6PJE7EWO.js";
|
|
17
|
+
import {
|
|
18
|
+
BUNDLE_REPOSITORY_TOKEN,
|
|
19
|
+
CATALOG_ENTRY_REPOSITORY_TOKEN,
|
|
20
|
+
PLAN_REPOSITORY_TOKEN,
|
|
21
|
+
PROMOTION_REPOSITORY_TOKEN
|
|
22
|
+
} from "./chunk-3QVGA6JX.js";
|
|
23
|
+
import {
|
|
24
|
+
SubscriberService,
|
|
25
|
+
settleNewSubscriberDetails
|
|
26
|
+
} from "./chunk-LF6J4YYN.js";
|
|
27
|
+
import {
|
|
28
|
+
PLAN_CATALOG_SETTINGS_TOKEN
|
|
29
|
+
} from "./chunk-FEQZCWUL.js";
|
|
30
|
+
import {
|
|
31
|
+
codedError
|
|
32
|
+
} from "./chunk-J7NJRK3K.js";
|
|
33
|
+
import {
|
|
34
|
+
__name
|
|
35
|
+
} from "./chunk-SHUYVCID.js";
|
|
36
|
+
|
|
37
|
+
// src/checkout-offer/checkout-offer-pricing.ts
|
|
38
|
+
import { Inject, Injectable, Optional, UnprocessableEntityException } from "@nestjs/common";
|
|
39
|
+
import { CONTRACT_ERROR_CODES, promotionOnPrice } from "@saasicat/core";
|
|
40
|
+
|
|
41
|
+
// src/checkout-offer/bundle-version-bookable.ts
|
|
42
|
+
import { startOfUtcDay } from "@saasicat/core";
|
|
43
|
+
function bundleVersionNotBookableReason(version, nowMs) {
|
|
44
|
+
if (version.publishedAt === null) return "not_published";
|
|
45
|
+
if (version.supersededAt !== null) return "superseded";
|
|
46
|
+
if (dateIsAfter(version.validFrom, nowMs)) return "not_yet_valid";
|
|
47
|
+
if (isValidUntilExpired(version.validUntil, nowMs)) return "expired";
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
__name(bundleVersionNotBookableReason, "bundleVersionNotBookableReason");
|
|
51
|
+
function dateIsAfter(value, nowMs) {
|
|
52
|
+
if (!value) return false;
|
|
53
|
+
const time = new Date(value).getTime();
|
|
54
|
+
return Number.isNaN(time) || time > nowMs;
|
|
55
|
+
}
|
|
56
|
+
__name(dateIsAfter, "dateIsAfter");
|
|
57
|
+
function isValidUntilExpired(value, nowMs) {
|
|
58
|
+
if (!value) return false;
|
|
59
|
+
const validUntil = new Date(value).getTime();
|
|
60
|
+
if (Number.isNaN(validUntil)) return true;
|
|
61
|
+
return validUntil < startOfUtcDay(new Date(nowMs)).getTime();
|
|
62
|
+
}
|
|
63
|
+
__name(isValidUntilExpired, "isValidUntilExpired");
|
|
64
|
+
|
|
65
|
+
// src/checkout-offer/checkout-offer-pricing.ts
|
|
66
|
+
function _ts_decorate(decorators, target, key, desc) {
|
|
67
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
68
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
69
|
+
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;
|
|
70
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
71
|
+
}
|
|
72
|
+
__name(_ts_decorate, "_ts_decorate");
|
|
73
|
+
function _ts_metadata(k, v) {
|
|
74
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
75
|
+
}
|
|
76
|
+
__name(_ts_metadata, "_ts_metadata");
|
|
77
|
+
function _ts_param(paramIndex, decorator) {
|
|
78
|
+
return function(target, key) {
|
|
79
|
+
decorator(target, key, paramIndex);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
__name(_ts_param, "_ts_param");
|
|
83
|
+
var DEFAULT_LOCALE = "de";
|
|
84
|
+
var CheckoutOfferPricing = class {
|
|
85
|
+
static {
|
|
86
|
+
__name(this, "CheckoutOfferPricing");
|
|
87
|
+
}
|
|
88
|
+
settings;
|
|
89
|
+
plans;
|
|
90
|
+
bundles;
|
|
91
|
+
promotions;
|
|
92
|
+
promoCodes;
|
|
93
|
+
constructor(settings, plans, bundles = null, promotions = null, promoCodes = null) {
|
|
94
|
+
this.settings = settings;
|
|
95
|
+
this.plans = plans;
|
|
96
|
+
this.bundles = bundles;
|
|
97
|
+
this.promotions = promotions;
|
|
98
|
+
this.promoCodes = promoCodes;
|
|
99
|
+
}
|
|
100
|
+
/** Prices a selection against the plan version on sale now. */
|
|
101
|
+
async price(input, asOf = /* @__PURE__ */ new Date()) {
|
|
102
|
+
const planVersion = await this.planVersionOnSale(input.planKey, input.billingCycle, asOf);
|
|
103
|
+
const bundleVersions = await this.bundleVersionsFor(input, asOf, true);
|
|
104
|
+
return this.compute(input, planVersion, bundleVersions, asOf);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Throws unless the offer's stored amounts are what this pricing makes of
|
|
108
|
+
* its own selection: the plan and bundle versions it froze, and the
|
|
109
|
+
* promotions as they stood when it was last priced.
|
|
110
|
+
*/
|
|
111
|
+
async assertPricedByCatalogue(offer) {
|
|
112
|
+
const notCurrent = /* @__PURE__ */ __name(() => new UnprocessableEntityException({
|
|
113
|
+
code: CONTRACT_ERROR_CODES.CHECKOUT_OFFER_PRICE_NOT_CURRENT,
|
|
114
|
+
message: `The prices of checkout offer '${offer.id}' no longer match the catalogue; create a new offer.`,
|
|
115
|
+
params: {
|
|
116
|
+
offerId: offer.id
|
|
117
|
+
}
|
|
118
|
+
}), "notCurrent");
|
|
119
|
+
if (!offer.planVersionId) throw notCurrent();
|
|
120
|
+
const planVersion = await this.plans.findVersionById?.(offer.planVersionId);
|
|
121
|
+
if (!planVersion || planVersion.planId !== offer.planKey) throw notCurrent();
|
|
122
|
+
const input = {
|
|
123
|
+
planKey: offer.planKey,
|
|
124
|
+
billingCycle: offer.billingCycle,
|
|
125
|
+
bundleVersionIds: offer.bundleVersionIds ?? [],
|
|
126
|
+
promoCode: offer.promoCode,
|
|
127
|
+
locale: offer.locale,
|
|
128
|
+
checkoutOfferId: offer.id,
|
|
129
|
+
concluding: true
|
|
130
|
+
};
|
|
131
|
+
const pricedAt = new Date(offer.updatedAt);
|
|
132
|
+
let repriced;
|
|
133
|
+
try {
|
|
134
|
+
this.assertPlanPriced(planVersion, input.billingCycle);
|
|
135
|
+
const bundleVersions = await this.bundleVersionsFor(input, pricedAt, false);
|
|
136
|
+
repriced = await this.compute(input, planVersion, bundleVersions, pricedAt);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (refusesThePromoCode(error)) throw error;
|
|
139
|
+
if (error instanceof UnprocessableEntityException) throw notCurrent();
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
if (moneyOf(repriced) !== moneyOf(offer)) throw notCurrent();
|
|
143
|
+
}
|
|
144
|
+
async compute(input, planVersion, bundleVersions, asOf) {
|
|
145
|
+
const vatRate = this.settings.vatRate;
|
|
146
|
+
const promotions = this.promotions ? await this.promotions.list() : [];
|
|
147
|
+
const plan = await this.plans.findByKey(input.planKey);
|
|
148
|
+
const planLine = this.pricePlan(input, planVersion, plan?.label, promotions, asOf);
|
|
149
|
+
const bundleLines = bundleVersions.map((version) => this.priceBundle(input, version, promotions, asOf));
|
|
150
|
+
const promotionSnapshots = [
|
|
151
|
+
planLine,
|
|
152
|
+
...bundleLines
|
|
153
|
+
].map((priced) => priced.promotion).filter((promotion) => promotion !== null);
|
|
154
|
+
const planNet = planLine.line.priceNet;
|
|
155
|
+
const bundlesNet = round2(bundleLines.reduce((sum, priced) => sum + priced.line.priceNet, 0));
|
|
156
|
+
const regularNet = round2(planNet + bundlesNet);
|
|
157
|
+
const promotionDiscount = round2(promotionSnapshots.reduce((sum, promotion) => sum + promotion.resolvedAmountNet, 0));
|
|
158
|
+
const planNetAfterPromotion = round2(planNet - (planLine.promotion?.resolvedAmountNet ?? 0));
|
|
159
|
+
const promoCodeSnapshot = await this.pricePromoCode(input, planNetAfterPromotion, vatRate);
|
|
160
|
+
const effectiveNet = Math.max(0, round2(regularNet - promotionDiscount - (promoCodeSnapshot?.resolvedAmountNet ?? 0)));
|
|
161
|
+
const priceBreakdown = {
|
|
162
|
+
currency: this.settings.currency,
|
|
163
|
+
billingCycle: input.billingCycle,
|
|
164
|
+
planNet,
|
|
165
|
+
bundlesNet,
|
|
166
|
+
regularNet,
|
|
167
|
+
effectiveNet,
|
|
168
|
+
vatRate,
|
|
169
|
+
effectiveGross: grossFromNet(effectiveNet, vatRate)
|
|
170
|
+
};
|
|
171
|
+
const lineItems = appendImplicitDiscountLineItem({
|
|
172
|
+
billingCycle: input.billingCycle,
|
|
173
|
+
priceBreakdown,
|
|
174
|
+
lineItems: [
|
|
175
|
+
planLine.line,
|
|
176
|
+
...bundleLines.map((priced) => priced.line)
|
|
177
|
+
],
|
|
178
|
+
promotionSnapshots,
|
|
179
|
+
promoCodeSnapshot
|
|
180
|
+
});
|
|
181
|
+
return {
|
|
182
|
+
planVersionId: planVersion.id,
|
|
183
|
+
bundles: bundleVersions.map((version) => version.bundleKey),
|
|
184
|
+
bundleVersionIds: bundleVersions.map((version) => version.id),
|
|
185
|
+
promotionId: planLine.promotion?.id ?? null,
|
|
186
|
+
promoCode: promoCodeSnapshot?.code ?? null,
|
|
187
|
+
priceBreakdown,
|
|
188
|
+
lineItems,
|
|
189
|
+
promotionSnapshots,
|
|
190
|
+
promoCodeSnapshot
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** The plan version on sale for new bookings, as the public catalogue resolves it. */
|
|
194
|
+
async planVersionOnSale(planKey, billingCycle, asOf) {
|
|
195
|
+
const version = await this.plans.findActivePlanVersion?.(planKey, asOf) ?? (this.plans.findActivePlanVersion ? null : await this.plans.findLatestLivePlanVersion?.(planKey));
|
|
196
|
+
if (!version) throw planNotOffered(planKey, billingCycle);
|
|
197
|
+
this.assertPlanPriced(version, billingCycle);
|
|
198
|
+
return version;
|
|
199
|
+
}
|
|
200
|
+
/** A plan without a list price is not sold at one (`SC-PRIC-011`). */
|
|
201
|
+
assertPlanPriced(version, billingCycle) {
|
|
202
|
+
if (version.marketed === false || priceOf(version, billingCycle) === null) {
|
|
203
|
+
throw planNotOffered(version.planId, billingCycle);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async bundleVersionsFor(input, asOf, checkBookable) {
|
|
207
|
+
const ids = input.bundleVersionIds;
|
|
208
|
+
if (ids.length === 0) return [];
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const [index, bundleVersionId] of ids.entries()) {
|
|
211
|
+
if (ids.indexOf(bundleVersionId) !== index) {
|
|
212
|
+
throw bundleNotOffered(bundleVersionId, "duplicate");
|
|
213
|
+
}
|
|
214
|
+
const version = this.bundles ? await this.bundles.findVersionById(bundleVersionId) : null;
|
|
215
|
+
if (!version) throw bundleNotOffered(bundleVersionId, "missing");
|
|
216
|
+
if (checkBookable) {
|
|
217
|
+
const notBookable = bundleVersionNotBookableReason(version, asOf.getTime());
|
|
218
|
+
if (notBookable) throw bundleNotOffered(bundleVersionId, notBookable);
|
|
219
|
+
if (!version.marketed) throw bundleNotOffered(bundleVersionId, "not_marketed");
|
|
220
|
+
}
|
|
221
|
+
const planKeys = version.compatibility?.planIds ?? [];
|
|
222
|
+
if (planKeys.length > 0 && !planKeys.includes(input.planKey)) {
|
|
223
|
+
throw bundleNotOffered(bundleVersionId, "incompatible_with_plan");
|
|
224
|
+
}
|
|
225
|
+
if (resolveBundlePriceNet(version, input.planKey, wireCycle(input.billingCycle)) === null) {
|
|
226
|
+
throw bundleNotOffered(bundleVersionId, "not_priced");
|
|
227
|
+
}
|
|
228
|
+
out.push(version);
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
pricePlan(input, version, label, promotions, asOf) {
|
|
233
|
+
const priceNet = priceOf(version, input.billingCycle);
|
|
234
|
+
return {
|
|
235
|
+
line: this.line({
|
|
236
|
+
kind: "plan",
|
|
237
|
+
sourceKey: input.planKey,
|
|
238
|
+
sourceVersionId: version.id,
|
|
239
|
+
title: label ?? input.planKey,
|
|
240
|
+
priceNet,
|
|
241
|
+
billingCycle: input.billingCycle,
|
|
242
|
+
features: version.features ?? [],
|
|
243
|
+
quotas: version.quotas ?? {}
|
|
244
|
+
}),
|
|
245
|
+
promotion: promotionFor(promotions, input.planKey, "PLAN", input, priceNet, asOf)
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
priceBundle(input, version, promotions, asOf) {
|
|
249
|
+
const priceNet = resolveBundlePriceNet(version, input.planKey, wireCycle(input.billingCycle));
|
|
250
|
+
return {
|
|
251
|
+
line: this.line({
|
|
252
|
+
kind: "bundle",
|
|
253
|
+
sourceKey: version.bundleKey,
|
|
254
|
+
sourceVersionId: version.id,
|
|
255
|
+
title: version.label,
|
|
256
|
+
priceNet,
|
|
257
|
+
billingCycle: input.billingCycle,
|
|
258
|
+
features: version.features ?? [],
|
|
259
|
+
quotas: version.quotas ?? {}
|
|
260
|
+
}),
|
|
261
|
+
promotion: promotionFor(promotions, version.bundleKey, "BUNDLE", input, priceNet, asOf)
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
line(fields) {
|
|
265
|
+
return {
|
|
266
|
+
kind: fields.kind,
|
|
267
|
+
sourceKey: fields.sourceKey,
|
|
268
|
+
sourceVersionId: fields.sourceVersionId,
|
|
269
|
+
titleSnapshot: fields.title,
|
|
270
|
+
descriptionSnapshot: null,
|
|
271
|
+
quantity: 1,
|
|
272
|
+
unit: null,
|
|
273
|
+
priceNet: fields.priceNet,
|
|
274
|
+
priceGross: grossFromNet(fields.priceNet, this.settings.vatRate),
|
|
275
|
+
billingCycle: fields.billingCycle,
|
|
276
|
+
featuresSnapshot: [
|
|
277
|
+
...fields.features
|
|
278
|
+
],
|
|
279
|
+
quotaEffectsSnapshot: {
|
|
280
|
+
...fields.quotas
|
|
281
|
+
},
|
|
282
|
+
metadata: null
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The promo code's discount on the plan, after its promotion.
|
|
287
|
+
*
|
|
288
|
+
* Eligibility is the promo module's to decide, and it decides on its own
|
|
289
|
+
* terms: status, validity, the plan and rhythm the code is limited to. Only
|
|
290
|
+
* the amount is computed here, on the price this offer actually carries.
|
|
291
|
+
*/
|
|
292
|
+
async pricePromoCode(input, planNet, vatRate) {
|
|
293
|
+
if (!input.promoCode) return null;
|
|
294
|
+
if (!this.promoCodes) throw promoCodeNotAccepted("PROMO_CODES_NOT_AVAILABLE");
|
|
295
|
+
const preview = await this.promoCodes.preview({
|
|
296
|
+
code: input.promoCode,
|
|
297
|
+
planId: input.planKey,
|
|
298
|
+
billingCycle: wireCycle(input.billingCycle)
|
|
299
|
+
}, {
|
|
300
|
+
checkoutOfferId: input.checkoutOfferId,
|
|
301
|
+
concluding: input.concluding
|
|
302
|
+
});
|
|
303
|
+
if (!preview.valid) throw promoCodeNotAccepted(preview.reason);
|
|
304
|
+
const planGross = grossFromNet(planNet, vatRate);
|
|
305
|
+
const discountGross = Math.min(computeDiscountGross({
|
|
306
|
+
gross: planGross
|
|
307
|
+
}, preview.discount), planGross);
|
|
308
|
+
const resolvedAmountNet = Math.min(planNet, netFromGross(discountGross, vatRate));
|
|
309
|
+
return {
|
|
310
|
+
code: preview.code,
|
|
311
|
+
label: preview.label,
|
|
312
|
+
valueType: preview.discount.valueType,
|
|
313
|
+
value: Number(preview.discount.value),
|
|
314
|
+
resolvedAmountNet,
|
|
315
|
+
durationType: preview.discount.durationType,
|
|
316
|
+
durationValue: preview.discount.durationValue
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
CheckoutOfferPricing = _ts_decorate([
|
|
321
|
+
Injectable(),
|
|
322
|
+
_ts_param(0, Inject(PLAN_CATALOG_SETTINGS_TOKEN)),
|
|
323
|
+
_ts_param(1, Inject(PLAN_REPOSITORY_TOKEN)),
|
|
324
|
+
_ts_param(2, Optional()),
|
|
325
|
+
_ts_param(2, Inject(BUNDLE_REPOSITORY_TOKEN)),
|
|
326
|
+
_ts_param(3, Optional()),
|
|
327
|
+
_ts_param(3, Inject(PROMOTION_REPOSITORY_TOKEN)),
|
|
328
|
+
_ts_param(4, Optional()),
|
|
329
|
+
_ts_param(4, Inject(PromoCodesService)),
|
|
330
|
+
_ts_metadata("design:type", Function),
|
|
331
|
+
_ts_metadata("design:paramtypes", [
|
|
332
|
+
typeof PlanCatalogSettings === "undefined" ? Object : PlanCatalogSettings,
|
|
333
|
+
typeof PlanRepository === "undefined" ? Object : PlanRepository,
|
|
334
|
+
Object,
|
|
335
|
+
Object,
|
|
336
|
+
Object
|
|
337
|
+
])
|
|
338
|
+
], CheckoutOfferPricing);
|
|
339
|
+
function promotionFor(promotions, targetKey, targetType, input, priceNet, asOf) {
|
|
340
|
+
const shown = promotionOnPrice(promotions, targetKey, input.locale, input.billingCycle, priceNet, asOf, targetType);
|
|
341
|
+
if (!shown) return null;
|
|
342
|
+
const { promotion } = shown;
|
|
343
|
+
const resolvedAmountNet = round2(Math.max(0, priceNet - shown.result.discounted));
|
|
344
|
+
if (resolvedAmountNet <= 0) return null;
|
|
345
|
+
const texts = promotion.i18n?.[input.locale] ?? promotion.i18n?.[DEFAULT_LOCALE] ?? {};
|
|
346
|
+
return {
|
|
347
|
+
id: promotion.id,
|
|
348
|
+
type: promotion.type,
|
|
349
|
+
value: promotion.value,
|
|
350
|
+
label: texts.badge || promotion.internalLabel,
|
|
351
|
+
resolvedAmountNet,
|
|
352
|
+
appliesTo: [
|
|
353
|
+
targetKey
|
|
354
|
+
],
|
|
355
|
+
billingCycle: promotion.billingCycle
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
__name(promotionFor, "promotionFor");
|
|
359
|
+
function priceOf(version, billingCycle) {
|
|
360
|
+
const raw = billingCycle === "yearly" ? version.yearlyNet : version.monthlyNet;
|
|
361
|
+
if (raw === null || raw === void 0) return null;
|
|
362
|
+
const parsed = Number.parseFloat(raw);
|
|
363
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
364
|
+
}
|
|
365
|
+
__name(priceOf, "priceOf");
|
|
366
|
+
function wireCycle(billingCycle) {
|
|
367
|
+
return billingCycle === "yearly" ? "YEARLY" : "MONTHLY";
|
|
368
|
+
}
|
|
369
|
+
__name(wireCycle, "wireCycle");
|
|
370
|
+
function moneyOf(offer) {
|
|
371
|
+
const b = offer.priceBreakdown;
|
|
372
|
+
return JSON.stringify({
|
|
373
|
+
planVersionId: offer.planVersionId,
|
|
374
|
+
bundleVersionIds: offer.bundleVersionIds ?? [],
|
|
375
|
+
promotionId: offer.promotionId,
|
|
376
|
+
promoCode: offer.promoCode,
|
|
377
|
+
breakdown: [
|
|
378
|
+
b.currency,
|
|
379
|
+
b.billingCycle,
|
|
380
|
+
b.planNet,
|
|
381
|
+
b.bundlesNet,
|
|
382
|
+
b.regularNet,
|
|
383
|
+
b.effectiveNet,
|
|
384
|
+
b.vatRate,
|
|
385
|
+
b.effectiveGross
|
|
386
|
+
],
|
|
387
|
+
// A line's gross is not compared: it is its share of the tax on the
|
|
388
|
+
// total, derived from the nets and the breakdown compared here, and the
|
|
389
|
+
// contract derives it again when it is written. So an offer whose stored
|
|
390
|
+
// lines carry a gross rounded another way still concludes, rather than
|
|
391
|
+
// being refused over a cent that moved between two of its lines.
|
|
392
|
+
lines: (offer.lineItems ?? []).map((item) => [
|
|
393
|
+
item.kind,
|
|
394
|
+
item.sourceKey,
|
|
395
|
+
item.sourceVersionId ?? null,
|
|
396
|
+
item.quantity,
|
|
397
|
+
item.priceNet,
|
|
398
|
+
item.billingCycle
|
|
399
|
+
]),
|
|
400
|
+
promotions: (offer.promotionSnapshots ?? []).map((p) => [
|
|
401
|
+
p.id,
|
|
402
|
+
p.resolvedAmountNet
|
|
403
|
+
]),
|
|
404
|
+
promoCodeDiscount: offer.promoCodeSnapshot ? [
|
|
405
|
+
offer.promoCodeSnapshot.code,
|
|
406
|
+
offer.promoCodeSnapshot.resolvedAmountNet
|
|
407
|
+
] : null
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
__name(moneyOf, "moneyOf");
|
|
411
|
+
function planNotOffered(planKey, billingCycle) {
|
|
412
|
+
return new UnprocessableEntityException({
|
|
413
|
+
code: CONTRACT_ERROR_CODES.CHECKOUT_OFFER_PLAN_NOT_OFFERED,
|
|
414
|
+
message: `Plan '${planKey}' is not offered with a ${billingCycle} price at the moment.`,
|
|
415
|
+
params: {
|
|
416
|
+
planKey,
|
|
417
|
+
billingCycle
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
__name(planNotOffered, "planNotOffered");
|
|
422
|
+
function bundleNotOffered(bundleVersionId, reason) {
|
|
423
|
+
return new UnprocessableEntityException({
|
|
424
|
+
code: CONTRACT_ERROR_CODES.CHECKOUT_OFFER_BUNDLE_NOT_OFFERED,
|
|
425
|
+
message: `Bundle version '${bundleVersionId}' cannot be added to this offer (${reason}).`,
|
|
426
|
+
params: {
|
|
427
|
+
bundleVersionId,
|
|
428
|
+
reason
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
__name(bundleNotOffered, "bundleNotOffered");
|
|
433
|
+
function refusesThePromoCode(error) {
|
|
434
|
+
if (!(error instanceof UnprocessableEntityException)) return false;
|
|
435
|
+
const body = error.getResponse();
|
|
436
|
+
return typeof body === "object" && body !== null && body.code === CONTRACT_ERROR_CODES.CHECKOUT_OFFER_PROMO_CODE_NOT_ACCEPTED;
|
|
437
|
+
}
|
|
438
|
+
__name(refusesThePromoCode, "refusesThePromoCode");
|
|
439
|
+
function promoCodeNotAccepted(reason) {
|
|
440
|
+
return new UnprocessableEntityException({
|
|
441
|
+
code: CONTRACT_ERROR_CODES.CHECKOUT_OFFER_PROMO_CODE_NOT_ACCEPTED,
|
|
442
|
+
message: `The promo code cannot be applied to this offer (${reason}).`,
|
|
443
|
+
params: {
|
|
444
|
+
reason
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
__name(promoCodeNotAccepted, "promoCodeNotAccepted");
|
|
449
|
+
|
|
450
|
+
// src/checkout-offer/checkout-offer.tokens.ts
|
|
451
|
+
var CHECKOUT_OFFER_REPOSITORY_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/CheckoutOfferRepository");
|
|
452
|
+
var CHECKOUT_OFFER_TRANSACTION_RUNNER_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/CheckoutOfferTransactionRunner");
|
|
453
|
+
|
|
454
|
+
// src/checkout-offer/checkout-offer.service.ts
|
|
455
|
+
import { isDeepStrictEqual } from "util";
|
|
456
|
+
import { ConflictException, Inject as Inject2, Injectable as Injectable2, NotFoundException, Optional as Optional2, UnprocessableEntityException as UnprocessableEntityException2 } from "@nestjs/common";
|
|
457
|
+
import { CONTRACT_ERROR_CODES as CONTRACT_ERROR_CODES2, SUBSCRIBER_ERROR_CODES, buildFeatureRequiresIndex, collectUnsatisfiedRequires } from "@saasicat/core";
|
|
458
|
+
function _ts_decorate2(decorators, target, key, desc) {
|
|
459
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
460
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
461
|
+
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;
|
|
462
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
463
|
+
}
|
|
464
|
+
__name(_ts_decorate2, "_ts_decorate");
|
|
465
|
+
function _ts_metadata2(k, v) {
|
|
466
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
467
|
+
}
|
|
468
|
+
__name(_ts_metadata2, "_ts_metadata");
|
|
469
|
+
function _ts_param2(paramIndex, decorator) {
|
|
470
|
+
return function(target, key) {
|
|
471
|
+
decorator(target, key, paramIndex);
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
__name(_ts_param2, "_ts_param");
|
|
475
|
+
var DEFAULT_OFFER_LOCALE = "de";
|
|
476
|
+
var HOLD_ATTEMPTS = 3;
|
|
477
|
+
var CheckoutOfferService = class {
|
|
478
|
+
static {
|
|
479
|
+
__name(this, "CheckoutOfferService");
|
|
480
|
+
}
|
|
481
|
+
repo;
|
|
482
|
+
pricing;
|
|
483
|
+
bundles;
|
|
484
|
+
plans;
|
|
485
|
+
catalogEntries;
|
|
486
|
+
contracts;
|
|
487
|
+
transactions;
|
|
488
|
+
subscribers;
|
|
489
|
+
promoCodes;
|
|
490
|
+
constructor(repo, pricing, bundles = null, plans = null, catalogEntries = null, contracts = null, transactions = null, subscribers = null, promoCodes = null) {
|
|
491
|
+
this.repo = repo;
|
|
492
|
+
this.pricing = pricing;
|
|
493
|
+
this.bundles = bundles;
|
|
494
|
+
this.plans = plans;
|
|
495
|
+
this.catalogEntries = catalogEntries;
|
|
496
|
+
this.contracts = contracts;
|
|
497
|
+
this.transactions = transactions;
|
|
498
|
+
this.subscribers = subscribers;
|
|
499
|
+
this.promoCodes = promoCodes;
|
|
500
|
+
}
|
|
501
|
+
list(filter) {
|
|
502
|
+
return this.repo.list(filter);
|
|
503
|
+
}
|
|
504
|
+
async getById(id) {
|
|
505
|
+
const row = await this.repo.findById(id);
|
|
506
|
+
if (!row) {
|
|
507
|
+
throw new NotFoundException({
|
|
508
|
+
code: CONTRACT_ERROR_CODES2.CHECKOUT_OFFER_NOT_FOUND,
|
|
509
|
+
message: `CheckoutOffer '${id}' not found`,
|
|
510
|
+
params: {
|
|
511
|
+
offerId: id
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
return row;
|
|
516
|
+
}
|
|
517
|
+
/** Prices what the caller chose and stores it as an open offer. */
|
|
518
|
+
async create(selection) {
|
|
519
|
+
const locale = selection.locale ?? DEFAULT_OFFER_LOCALE;
|
|
520
|
+
const priced = await this.pricing.price({
|
|
521
|
+
planKey: selection.planKey,
|
|
522
|
+
billingCycle: selection.billingCycle,
|
|
523
|
+
bundleVersionIds: selection.bundleVersionIds ?? [],
|
|
524
|
+
promoCode: selection.promoCode ?? null,
|
|
525
|
+
locale
|
|
526
|
+
});
|
|
527
|
+
await this.assertFeatureRequiresSatisfied({
|
|
528
|
+
planKey: selection.planKey,
|
|
529
|
+
planVersionId: priced.planVersionId,
|
|
530
|
+
bundleVersionIds: priced.bundleVersionIds,
|
|
531
|
+
lineItems: priced.lineItems
|
|
532
|
+
});
|
|
533
|
+
return this.repo.create({
|
|
534
|
+
planKey: selection.planKey,
|
|
535
|
+
billingCycle: selection.billingCycle,
|
|
536
|
+
locale,
|
|
537
|
+
validUntil: selection.validUntil ?? null,
|
|
538
|
+
...priced
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Customization in onboarding — only while the offer is `open`. The plan
|
|
543
|
+
* stays; whatever else changes, the whole offer is priced again. A slot of
|
|
544
|
+
* its promo code held for its checkout stays with it while the code does,
|
|
545
|
+
* and is given back when the code is changed or removed.
|
|
546
|
+
*/
|
|
547
|
+
async update(id, change) {
|
|
548
|
+
const existing = await this.getById(id);
|
|
549
|
+
this.assertOpen(existing, "changed");
|
|
550
|
+
const billingCycle = change.billingCycle ?? existing.billingCycle;
|
|
551
|
+
const locale = change.locale ?? existing.locale;
|
|
552
|
+
const priced = await this.pricing.price({
|
|
553
|
+
planKey: existing.planKey,
|
|
554
|
+
billingCycle,
|
|
555
|
+
bundleVersionIds: change.bundleVersionIds ?? existing.bundleVersionIds ?? [],
|
|
556
|
+
promoCode: change.promoCode !== void 0 ? change.promoCode : existing.promoCode,
|
|
557
|
+
locale,
|
|
558
|
+
checkoutOfferId: id
|
|
559
|
+
});
|
|
560
|
+
await this.assertFeatureRequiresSatisfied({
|
|
561
|
+
planKey: existing.planKey,
|
|
562
|
+
planVersionId: priced.planVersionId,
|
|
563
|
+
bundleVersionIds: priced.bundleVersionIds,
|
|
564
|
+
lineItems: priced.lineItems
|
|
565
|
+
});
|
|
566
|
+
const codeChanges = priced.promoCode !== existing.promoCode;
|
|
567
|
+
const updated = await this.repo.update(id, {
|
|
568
|
+
billingCycle,
|
|
569
|
+
locale,
|
|
570
|
+
...change.validUntil !== void 0 ? {
|
|
571
|
+
validUntil: change.validUntil
|
|
572
|
+
} : {},
|
|
573
|
+
...priced
|
|
574
|
+
});
|
|
575
|
+
if (codeChanges) await this.promoCodes?.releaseCheckoutHold(id);
|
|
576
|
+
return updated;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Holds a slot of the offer's promo code for its checkout until `until` —
|
|
580
|
+
* what a sign-up does as its checkout starts, until a confirmation of its
|
|
581
|
+
* payment form can no longer arrive — so that the code cannot run out
|
|
582
|
+
* before the payment is confirmed and the offer concluded. A live slot the
|
|
583
|
+
* offer holds of that code already moves to `until`, never earlier. The
|
|
584
|
+
* conclusion turns the slot into the redemption; the slot goes back when
|
|
585
|
+
* the offer's code changes or `until` passes.
|
|
586
|
+
*
|
|
587
|
+
* Refused, and nothing held, when the offer is no longer open or its code
|
|
588
|
+
* cannot be held: `PROMO_CODE_NOT_REDEEMABLE` with the reason a preview
|
|
589
|
+
* gives, `EXHAUSTED` when every slot is redeemed or held. An offer without
|
|
590
|
+
* a code holds nothing.
|
|
591
|
+
*/
|
|
592
|
+
async holdPromoCode(id, hold) {
|
|
593
|
+
for (let attempt = 0; attempt < HOLD_ATTEMPTS; attempt += 1) {
|
|
594
|
+
const offer = await this.getById(id);
|
|
595
|
+
this.assertOpen(offer, "held");
|
|
596
|
+
const code = offer.promoCode;
|
|
597
|
+
if (!code) {
|
|
598
|
+
await this.promoCodes?.releaseCheckoutHold(id);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (!this.promoCodes) {
|
|
602
|
+
throw new Error(`Checkout offer '${id}' carries a promo code and no promo module is registered to hold it: enable \`promoCodes\` in SaaSiCatModule.forRoot, or register PromoCodesModule globally beside CheckoutOfferModule.`);
|
|
603
|
+
}
|
|
604
|
+
await this.promoCodes.holdForCheckout({
|
|
605
|
+
checkoutOfferId: id,
|
|
606
|
+
code,
|
|
607
|
+
planId: offer.planKey,
|
|
608
|
+
billingCycle: offer.billingCycle === "yearly" ? "YEARLY" : "MONTHLY",
|
|
609
|
+
email: hold.email,
|
|
610
|
+
until: hold.until
|
|
611
|
+
});
|
|
612
|
+
if ((await this.getById(id)).promoCode === code) return;
|
|
613
|
+
}
|
|
614
|
+
await this.promoCodes?.releaseCheckoutHold(id);
|
|
615
|
+
throw offerChanged(id, "its promo code was being held");
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Gives back the slot held for the offer's checkout while it is still held
|
|
619
|
+
* until `heldUntil`, as the caller wrote it. A slot another start of the
|
|
620
|
+
* checkout moved later stays with the form that start opened.
|
|
621
|
+
*/
|
|
622
|
+
async releasePromoCodeHold(id, heldUntil) {
|
|
623
|
+
await this.promoCodes?.releaseCheckoutHoldIfUnmoved(id, heldUntil);
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Freezes the offer (`status = 'consumed'`). Returns the final
|
|
627
|
+
* snapshot — the caller (registration/billing) writes it
|
|
628
|
+
* as `Subscription.packageSnapshot`.
|
|
629
|
+
*
|
|
630
|
+
* Refused unless every add-on is still bookable and the stored amounts are
|
|
631
|
+
* what the catalogue makes of the offer's own selection, so an offer whose
|
|
632
|
+
* amounts were written by anything but the pricing never becomes a contract.
|
|
633
|
+
*
|
|
634
|
+
* The plan, the add-ons and the promotions are priced as they stood when the
|
|
635
|
+
* offer was priced; a promo code is checked with the promo module as it
|
|
636
|
+
* stands now. A code is redeemed when the contract is concluded, so one that
|
|
637
|
+
* has expired or run out of redemptions since refuses the offer with
|
|
638
|
+
* `CHECKOUT_OFFER_PROMO_CODE_NOT_ACCEPTED`; `CHECKOUT_OFFER_PRICE_NOT_CURRENT`
|
|
639
|
+
* is kept for amounts that no longer match.
|
|
640
|
+
*/
|
|
641
|
+
async consume(id) {
|
|
642
|
+
await this.assertConsumable(id);
|
|
643
|
+
return this.repo.consume(id);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Consumes an offer and creates its contract in one transaction, together
|
|
647
|
+
* with the application's own writes in `within`, so an offer is never
|
|
648
|
+
* consumed without its contract, and no contract is written for an offer
|
|
649
|
+
* that stays open.
|
|
650
|
+
*
|
|
651
|
+
* Everything that can refuse runs before the transaction: what `consume`
|
|
652
|
+
* checks, and the checks the contract the offer becomes is held to. Inside
|
|
653
|
+
* it, the offer is consumed on the condition that it is still open, the
|
|
654
|
+
* contract is written, and `within` runs; an error in any of them undoes
|
|
655
|
+
* the others, and the offer can be concluded again.
|
|
656
|
+
*
|
|
657
|
+
* An offer that is already concluded for the same tenant answers with its
|
|
658
|
+
* contract rather than a refusal, so a caller retrying after a lost answer,
|
|
659
|
+
* or losing a race to another call for that tenant, gets the conclusion that
|
|
660
|
+
* stands; `within` does not run again, its writes committed with it. A
|
|
661
|
+
* conclusion for another tenant is refused with
|
|
662
|
+
* `CHECKOUT_OFFER_ALREADY_CONSUMED`, and an offer changed between the checks
|
|
663
|
+
* and the transaction with `CHECKOUT_OFFER_CHANGED`, nothing written.
|
|
664
|
+
*
|
|
665
|
+
* The contract is between the tenant's subscriber and the configured
|
|
666
|
+
* issuer. A sign-up creates the tenant in `within` and has no subscriber
|
|
667
|
+
* yet, so it passes `subscriber`, which is created on the same transaction
|
|
668
|
+
* before the contract; whether one may be created, or whether the tenant
|
|
669
|
+
* has one, is asked before the transaction with the other checks.
|
|
670
|
+
*
|
|
671
|
+
* A promo code on the offer is redeemed in `within`, with
|
|
672
|
+
* `PromoCodesService.redeemInTransaction` on the same transaction. Before
|
|
673
|
+
* the transaction the code is only checked the way pricing checks it; the
|
|
674
|
+
* redemption checks the rest, the customer and the remaining redemptions,
|
|
675
|
+
* where a refusal still undoes everything. Nothing checks the code again
|
|
676
|
+
* after `within`, so a redemption that takes its last slot does not refuse
|
|
677
|
+
* the offer it was redeemed for.
|
|
678
|
+
*
|
|
679
|
+
* A slot held for the offer's checkout (`holdPromoCode`) is the offer's own
|
|
680
|
+
* throughout: the checks before the transaction do not count it against
|
|
681
|
+
* the code and accept the code as it stood when the slot was held, and the
|
|
682
|
+
* redemption in `within` takes that slot rather than a free one. A slot
|
|
683
|
+
* `within` did not redeem is given back before the conclusion commits.
|
|
684
|
+
*/
|
|
685
|
+
async conclude(id, options, within) {
|
|
686
|
+
const { contracts, transactions, subscribers } = this.conclusionPorts();
|
|
687
|
+
const { subscriber, tx: callersTransaction, ...contractOptions } = options;
|
|
688
|
+
const { tenantId } = contractOptions;
|
|
689
|
+
const standing = await this.standingConclusion(await this.getById(id), contracts, tenantId);
|
|
690
|
+
if (standing) return standing;
|
|
691
|
+
const existing = await this.assertConsumable(id);
|
|
692
|
+
const checked = contracts.prepareFromOffer(existing, contractOptions);
|
|
693
|
+
await this.assertParty(subscribers, contracts, tenantId, subscriber);
|
|
694
|
+
let consumeFailed = false;
|
|
695
|
+
const concludeOn = /* @__PURE__ */ __name(async (tx) => {
|
|
696
|
+
consumeFailed = false;
|
|
697
|
+
let offer;
|
|
698
|
+
try {
|
|
699
|
+
offer = await this.repo.consume(id, tx);
|
|
700
|
+
} catch (error) {
|
|
701
|
+
consumeFailed = true;
|
|
702
|
+
throw error;
|
|
703
|
+
}
|
|
704
|
+
const data = contracts.createDataFromOffer(offer, contractOptions);
|
|
705
|
+
if (!isDeepStrictEqual(data, checked)) throw offerChanged(id);
|
|
706
|
+
if (subscriber) await subscribers.createForTenant(tenantId, subscriber, tx);
|
|
707
|
+
const contract = await contracts.create(data, tx);
|
|
708
|
+
const concluded = {
|
|
709
|
+
offer,
|
|
710
|
+
contract
|
|
711
|
+
};
|
|
712
|
+
const handedOver = await this.promoCodes?.handOverCheckoutHold(id, tx) ?? false;
|
|
713
|
+
if (within) await within(tx, concluded);
|
|
714
|
+
if (handedOver) await this.promoCodes?.releaseCheckoutHold(id, tx);
|
|
715
|
+
return concluded;
|
|
716
|
+
}, "concludeOn");
|
|
717
|
+
try {
|
|
718
|
+
return await (callersTransaction === void 0 ? transactions.run(concludeOn) : concludeOn(callersTransaction));
|
|
719
|
+
} catch (error) {
|
|
720
|
+
if (!consumeFailed) throw error;
|
|
721
|
+
const offer = await this.repo.findById(id).catch(() => null);
|
|
722
|
+
const concludedMeanwhile = await this.standingConclusion(offer, contracts, tenantId).catch(() => null);
|
|
723
|
+
if (concludedMeanwhile) return concludedMeanwhile;
|
|
724
|
+
throw error;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
async assertConsumable(id) {
|
|
728
|
+
const existing = await this.getById(id);
|
|
729
|
+
this.assertOpen(existing, "consumed");
|
|
730
|
+
await this.assertBundleVersionsStillBookable(existing);
|
|
731
|
+
await this.pricing.assertPricedByCatalogue(existing);
|
|
732
|
+
return existing;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* The conclusion that stands for this tenant, if the offer has one. An offer
|
|
736
|
+
* carries no tenant and its id travels in a link, so a conclusion for
|
|
737
|
+
* another tenant is not an answer: that caller is refused as before.
|
|
738
|
+
*/
|
|
739
|
+
async standingConclusion(offer, contracts, tenantId) {
|
|
740
|
+
if (!offer || offer.status !== "consumed") return null;
|
|
741
|
+
const contract = await contracts.findByOriginalOfferId(offer.id);
|
|
742
|
+
return contract && contract.tenantId === tenantId ? {
|
|
743
|
+
offer,
|
|
744
|
+
contract
|
|
745
|
+
} : null;
|
|
746
|
+
}
|
|
747
|
+
conclusionPorts() {
|
|
748
|
+
if (!this.contracts || !this.transactions || !this.subscribers) {
|
|
749
|
+
throw new Error("CheckoutOfferService.conclude needs the subscription contract repository, the subscriber repository and a transaction runner: pass `conclusion` to CheckoutOfferModule.forRoot, or give SaaSiCatModule.forRoot a persistence bundle that has all three.");
|
|
750
|
+
}
|
|
751
|
+
return {
|
|
752
|
+
contracts: this.contracts,
|
|
753
|
+
transactions: this.transactions,
|
|
754
|
+
subscribers: this.subscribers
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Before anything is written: the subscriber to create is one that may be
|
|
759
|
+
* created, or the tenant already has the party the contract is with.
|
|
760
|
+
*/
|
|
761
|
+
async assertParty(subscribers, contracts, tenantId, subscriber) {
|
|
762
|
+
if (!subscriber) {
|
|
763
|
+
await contracts.assertPartyFor(tenantId);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
settleNewSubscriberDetails(subscriber);
|
|
767
|
+
if (await subscribers.findByTenantId(tenantId)) {
|
|
768
|
+
throw new ConflictException(codedError(SUBSCRIBER_ERROR_CODES.SUBSCRIBER_ALREADY_EXISTS, {
|
|
769
|
+
tenantId
|
|
770
|
+
}));
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* #35 P6 — server-side requires validation: the dependencies of all
|
|
775
|
+
* features (plan ∪ selected bundles) must be covered within the
|
|
776
|
+
* selection, otherwise an offer would be created whose features cannot
|
|
777
|
+
* work at the tenant (an app-specific validateModuleDependencies check is
|
|
778
|
+
* thereby replaced on the platform side). The requires source are the
|
|
779
|
+
* curated FeatureCatalogEntries; without CatalogEntryRepository it is
|
|
780
|
+
* skipped (graceful — no requires data available).
|
|
781
|
+
*/
|
|
782
|
+
async assertFeatureRequiresSatisfied(input) {
|
|
783
|
+
if (!this.catalogEntries) return;
|
|
784
|
+
const entries = await this.catalogEntries.listFeatures({});
|
|
785
|
+
const requiresIndex = buildFeatureRequiresIndex(entries);
|
|
786
|
+
if (requiresIndex.size === 0) return;
|
|
787
|
+
const selected = /* @__PURE__ */ new Set([
|
|
788
|
+
...await this.resolvePlanFeatures(input),
|
|
789
|
+
...await this.resolveBundleFeatures(input)
|
|
790
|
+
]);
|
|
791
|
+
const missingRequires = collectUnsatisfiedRequires([
|
|
792
|
+
...selected
|
|
793
|
+
], requiresIndex);
|
|
794
|
+
if (missingRequires.length > 0) {
|
|
795
|
+
throw new UnprocessableEntityException2({
|
|
796
|
+
code: CONTRACT_ERROR_CODES2.CHECKOUT_OFFER_FEATURE_DEPENDENCY_UNSATISFIED,
|
|
797
|
+
message: `The selected plan does not cover all feature dependencies: [${missingRequires.join(", ")}] are missing from the plan + selected bundles.`,
|
|
798
|
+
missingRequires
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Plan features preferably from the server SSOT (PlanRepository); fallback
|
|
804
|
+
* is the frozen featuresSnapshot of the plan LineItem (adapter without
|
|
805
|
+
* PlanRepository wiring).
|
|
806
|
+
*/
|
|
807
|
+
async resolvePlanFeatures(input) {
|
|
808
|
+
if (this.plans) {
|
|
809
|
+
const version = input.planVersionId ? await this.plans.findVersionById?.(input.planVersionId) : null;
|
|
810
|
+
const resolved = version ?? await this.plans.findActivePlanVersion?.(input.planKey) ?? await this.plans.findLatestLivePlanVersion?.(input.planKey);
|
|
811
|
+
if (resolved) return resolved.features ?? [];
|
|
812
|
+
}
|
|
813
|
+
return input.lineItems.filter((item) => item.kind === "plan").flatMap((item) => item.featuresSnapshot ?? []);
|
|
814
|
+
}
|
|
815
|
+
/** Bundle features from the BundleVersions; fallback featuresSnapshot. */
|
|
816
|
+
async resolveBundleFeatures(input) {
|
|
817
|
+
if (this.bundles) {
|
|
818
|
+
const features = [];
|
|
819
|
+
let allResolved = true;
|
|
820
|
+
for (const bundleVersionId of input.bundleVersionIds) {
|
|
821
|
+
const version = await this.bundles.findVersionById(bundleVersionId);
|
|
822
|
+
if (!version) {
|
|
823
|
+
allResolved = false;
|
|
824
|
+
break;
|
|
825
|
+
}
|
|
826
|
+
features.push(...version.features ?? []);
|
|
827
|
+
}
|
|
828
|
+
if (allResolved) return features;
|
|
829
|
+
}
|
|
830
|
+
return input.lineItems.filter((item) => item.kind === "bundle").flatMap((item) => item.featuresSnapshot ?? []);
|
|
831
|
+
}
|
|
832
|
+
async assertBundleVersionsStillBookable(offer) {
|
|
833
|
+
if (!this.bundles || !offer.bundleVersionIds || offer.bundleVersionIds.length === 0) {
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
const now = Date.now();
|
|
837
|
+
const violations = [];
|
|
838
|
+
for (const bundleVersionId of offer.bundleVersionIds) {
|
|
839
|
+
const version = await this.bundles.findVersionById(bundleVersionId);
|
|
840
|
+
if (!version) {
|
|
841
|
+
violations.push({
|
|
842
|
+
bundleVersionId,
|
|
843
|
+
reason: "missing"
|
|
844
|
+
});
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
const reason = bundleVersionNotBookableReason(version, now);
|
|
848
|
+
if (reason) violations.push({
|
|
849
|
+
bundleVersionId,
|
|
850
|
+
reason
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
if (violations.length > 0) {
|
|
854
|
+
throw new UnprocessableEntityException2({
|
|
855
|
+
code: CONTRACT_ERROR_CODES2.CHECKOUT_OFFER_BUNDLE_VERSION_NOT_BOOKABLE,
|
|
856
|
+
message: "At least one bundle version from the checkout offer is no longer bookable.",
|
|
857
|
+
violations
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
assertOpen(existing, action) {
|
|
862
|
+
if (existing.status === "consumed") {
|
|
863
|
+
throw new ConflictException({
|
|
864
|
+
code: CONTRACT_ERROR_CODES2.CHECKOUT_OFFER_ALREADY_CONSUMED,
|
|
865
|
+
message: `Checkout offer '${existing.id}' has already been consumed and cannot be ${action}`,
|
|
866
|
+
params: {
|
|
867
|
+
offerId: existing.id,
|
|
868
|
+
action
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
if (existing.status === "expired") {
|
|
873
|
+
throw new ConflictException({
|
|
874
|
+
code: CONTRACT_ERROR_CODES2.CHECKOUT_OFFER_EXPIRED,
|
|
875
|
+
message: `Checkout offer '${existing.id}' has expired and cannot be ${action}`,
|
|
876
|
+
params: {
|
|
877
|
+
offerId: existing.id,
|
|
878
|
+
action,
|
|
879
|
+
validUntil: existing.validUntil ?? null
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
if (this.isExpired(existing)) {
|
|
884
|
+
throw new ConflictException({
|
|
885
|
+
code: CONTRACT_ERROR_CODES2.CHECKOUT_OFFER_EXPIRED,
|
|
886
|
+
message: `CheckoutOffer '${existing.id}' has expired and cannot be ${action}`,
|
|
887
|
+
params: {
|
|
888
|
+
offerId: existing.id,
|
|
889
|
+
action,
|
|
890
|
+
validUntil: existing.validUntil ?? null
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
isExpired(row) {
|
|
896
|
+
return isValidUntilExpired(row.validUntil, Date.now());
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
CheckoutOfferService = _ts_decorate2([
|
|
900
|
+
Injectable2(),
|
|
901
|
+
_ts_param2(0, Inject2(CHECKOUT_OFFER_REPOSITORY_TOKEN)),
|
|
902
|
+
_ts_param2(1, Inject2(CheckoutOfferPricing)),
|
|
903
|
+
_ts_param2(2, Optional2()),
|
|
904
|
+
_ts_param2(2, Inject2(BUNDLE_REPOSITORY_TOKEN)),
|
|
905
|
+
_ts_param2(3, Optional2()),
|
|
906
|
+
_ts_param2(3, Inject2(PLAN_REPOSITORY_TOKEN)),
|
|
907
|
+
_ts_param2(4, Optional2()),
|
|
908
|
+
_ts_param2(4, Inject2(CATALOG_ENTRY_REPOSITORY_TOKEN)),
|
|
909
|
+
_ts_param2(5, Optional2()),
|
|
910
|
+
_ts_param2(5, Inject2(SubscriptionContractService)),
|
|
911
|
+
_ts_param2(6, Optional2()),
|
|
912
|
+
_ts_param2(6, Inject2(CHECKOUT_OFFER_TRANSACTION_RUNNER_TOKEN)),
|
|
913
|
+
_ts_param2(7, Optional2()),
|
|
914
|
+
_ts_param2(7, Inject2(SubscriberService)),
|
|
915
|
+
_ts_param2(8, Optional2()),
|
|
916
|
+
_ts_param2(8, Inject2(PromoCodesService)),
|
|
917
|
+
_ts_metadata2("design:type", Function),
|
|
918
|
+
_ts_metadata2("design:paramtypes", [
|
|
919
|
+
typeof CheckoutOfferRepository === "undefined" ? Object : CheckoutOfferRepository,
|
|
920
|
+
typeof CheckoutOfferPricing === "undefined" ? Object : CheckoutOfferPricing,
|
|
921
|
+
Object,
|
|
922
|
+
Object,
|
|
923
|
+
Object,
|
|
924
|
+
Object,
|
|
925
|
+
Object,
|
|
926
|
+
Object,
|
|
927
|
+
Object
|
|
928
|
+
])
|
|
929
|
+
], CheckoutOfferService);
|
|
930
|
+
function offerChanged(offerId, during = "it was being concluded") {
|
|
931
|
+
return new ConflictException({
|
|
932
|
+
code: CONTRACT_ERROR_CODES2.CHECKOUT_OFFER_CHANGED,
|
|
933
|
+
message: `Checkout offer '${offerId}' changed while ${during}. Load it again.`,
|
|
934
|
+
params: {
|
|
935
|
+
offerId
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
__name(offerChanged, "offerChanged");
|
|
940
|
+
|
|
941
|
+
export {
|
|
942
|
+
CheckoutOfferPricing,
|
|
943
|
+
CHECKOUT_OFFER_REPOSITORY_TOKEN,
|
|
944
|
+
CHECKOUT_OFFER_TRANSACTION_RUNNER_TOKEN,
|
|
945
|
+
CheckoutOfferService
|
|
946
|
+
};
|