@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
|
@@ -1,75 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AdminAuditService
|
|
3
|
+
} from "./chunk-OOTEXP47.js";
|
|
1
4
|
import {
|
|
2
5
|
PromoCodesService
|
|
3
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-3RLWML2S.js";
|
|
4
7
|
import {
|
|
5
8
|
SubscriptionContractService
|
|
6
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-NDLC5GYK.js";
|
|
7
10
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
SUBSCRIPTION_USAGE_PORT_TOKEN,
|
|
11
|
-
SUBSCRIPTION_WRITE_PORT_TOKEN,
|
|
12
|
-
TENANT_AUTH_GUARDS_TOKEN,
|
|
13
|
-
TENANT_ID_RESOLVER_TOKEN,
|
|
14
|
-
TRIAL_PROJECTION_PORT_TOKEN,
|
|
15
|
-
USAGE_SNAPSHOT_PORT_TOKEN,
|
|
16
|
-
USER_EMAIL_RESOLVER_TOKEN,
|
|
17
|
-
USER_ID_RESOLVER_TOKEN
|
|
18
|
-
} from "./chunk-N3L3AICU.js";
|
|
19
|
-
import {
|
|
20
|
-
AdminAuditService
|
|
21
|
-
} from "./chunk-O2J2HDXA.js";
|
|
22
|
-
import {
|
|
23
|
-
SaaSiCatPublicRoute
|
|
24
|
-
} from "./chunk-YSC46BGM.js";
|
|
11
|
+
resolveBundlePriceNet
|
|
12
|
+
} from "./chunk-KPEMTBOP.js";
|
|
25
13
|
import {
|
|
26
14
|
EntitlementService,
|
|
27
15
|
SUBSCRIPTION_BUNDLE_CONFIG_TOKEN,
|
|
28
16
|
SUBSCRIPTION_BUNDLE_REPOSITORY_TOKEN,
|
|
17
|
+
cancellationHasLanded,
|
|
29
18
|
toEffectiveLimitsSnapshot
|
|
30
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-G6EZWECL.js";
|
|
20
|
+
import {
|
|
21
|
+
ENTITLEMENT_SERVICE_TOKEN
|
|
22
|
+
} from "./chunk-OUDSQ3PU.js";
|
|
31
23
|
import {
|
|
32
|
-
PLAN_CATALOG_TOKEN,
|
|
33
24
|
findPlan,
|
|
34
25
|
getMarketedPlans,
|
|
35
|
-
getPlanPriceNet
|
|
36
|
-
|
|
26
|
+
getPlanPriceNet,
|
|
27
|
+
isPlanNotSoldInCycle,
|
|
28
|
+
listPriceNet,
|
|
29
|
+
planNotSoldInCycle
|
|
30
|
+
} from "./chunk-TBBZWZQT.js";
|
|
37
31
|
import {
|
|
38
32
|
SUBSCRIPTION_CONTRACT_REPOSITORY_TOKEN
|
|
39
33
|
} from "./chunk-VBRYPU7V.js";
|
|
40
34
|
import {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
35
|
+
assertContractWindow,
|
|
36
|
+
assertNoNegativeDiscount,
|
|
37
|
+
assertOnePlanLine,
|
|
38
|
+
assertTaxRatePercent,
|
|
39
|
+
contractTotalsOf,
|
|
40
|
+
recordContractLinesMoney
|
|
41
|
+
} from "./chunk-6PJE7EWO.js";
|
|
46
42
|
import {
|
|
47
43
|
BUNDLE_REPOSITORY_TOKEN,
|
|
48
44
|
CATALOG_ENTRY_REPOSITORY_TOKEN,
|
|
49
45
|
PLAN_REPOSITORY_TOKEN
|
|
50
|
-
} from "./chunk-
|
|
46
|
+
} from "./chunk-3QVGA6JX.js";
|
|
47
|
+
import {
|
|
48
|
+
ComposedTenantAuthGuard,
|
|
49
|
+
holdsTenantAdminRole
|
|
50
|
+
} from "./chunk-5CKC7O52.js";
|
|
51
|
+
import {
|
|
52
|
+
AUDIT_CONTEXT_RESOLVER_TOKEN,
|
|
53
|
+
CANCELLATION_NOTICE_DAYS_TOKEN,
|
|
54
|
+
PENDING_PLAN_QUERY_PORT_TOKEN,
|
|
55
|
+
SUBSCRIPTION_USAGE_PORT_TOKEN,
|
|
56
|
+
SUBSCRIPTION_WRITE_PORT_TOKEN,
|
|
57
|
+
TENANT_AUTH_GUARDS_TOKEN,
|
|
58
|
+
TENANT_ID_RESOLVER_TOKEN,
|
|
59
|
+
TRIAL_PROJECTION_PORT_TOKEN,
|
|
60
|
+
USAGE_SNAPSHOT_PORT_TOKEN,
|
|
61
|
+
USER_EMAIL_RESOLVER_TOKEN,
|
|
62
|
+
USER_ID_RESOLVER_TOKEN
|
|
63
|
+
} from "./chunk-GPQWGA6B.js";
|
|
51
64
|
import {
|
|
52
|
-
|
|
53
|
-
} from "./chunk-
|
|
65
|
+
subscriberProviders
|
|
66
|
+
} from "./chunk-WOEJ6K7M.js";
|
|
67
|
+
import {
|
|
68
|
+
SaaSiCatPublicRoute
|
|
69
|
+
} from "./chunk-YSC46BGM.js";
|
|
70
|
+
import {
|
|
71
|
+
PLAN_CATALOG_SETTINGS_TOKEN,
|
|
72
|
+
PLAN_CATALOG_SOURCE_TOKEN,
|
|
73
|
+
planDefFromVersion
|
|
74
|
+
} from "./chunk-FEQZCWUL.js";
|
|
75
|
+
import {
|
|
76
|
+
asProvider,
|
|
77
|
+
codedError
|
|
78
|
+
} from "./chunk-J7NJRK3K.js";
|
|
54
79
|
import {
|
|
55
80
|
__name
|
|
56
81
|
} from "./chunk-SHUYVCID.js";
|
|
57
82
|
|
|
58
83
|
// src/billing/billing-period.ts
|
|
59
|
-
function advanceOneCycle(d, cycle) {
|
|
84
|
+
function advanceOneCycle(d, cycle, anchorDay) {
|
|
85
|
+
const year = d.getUTCFullYear() + (cycle === "YEARLY" ? 1 : 0);
|
|
86
|
+
const month = d.getUTCMonth() + (cycle === "YEARLY" ? 0 : 1);
|
|
87
|
+
const day = Math.min(usableAnchor(anchorDay) ?? d.getUTCDate(), daysInMonth(year, month));
|
|
60
88
|
const out = new Date(d);
|
|
61
|
-
|
|
62
|
-
out.setUTCFullYear(out.getUTCFullYear() + 1);
|
|
63
|
-
} else {
|
|
64
|
-
out.setUTCMonth(out.getUTCMonth() + 1);
|
|
65
|
-
}
|
|
89
|
+
out.setUTCFullYear(year, month, day);
|
|
66
90
|
return out;
|
|
67
91
|
}
|
|
68
92
|
__name(advanceOneCycle, "advanceOneCycle");
|
|
69
|
-
function
|
|
70
|
-
|
|
93
|
+
function retreatOneCycle(d, cycle, anchorDay) {
|
|
94
|
+
const year = d.getUTCFullYear() - (cycle === "YEARLY" ? 1 : 0);
|
|
95
|
+
const month = d.getUTCMonth() - (cycle === "YEARLY" ? 0 : 1);
|
|
96
|
+
const normalisedYear = year + Math.floor(month / 12);
|
|
97
|
+
const normalisedMonth = (month % 12 + 12) % 12;
|
|
98
|
+
const day = Math.min(usableAnchor(anchorDay) ?? d.getUTCDate(), daysInMonth(normalisedYear, normalisedMonth));
|
|
99
|
+
const out = new Date(d);
|
|
100
|
+
out.setUTCFullYear(normalisedYear, normalisedMonth, day);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
__name(retreatOneCycle, "retreatOneCycle");
|
|
104
|
+
function billingAnchorDay(windowStart) {
|
|
105
|
+
return windowStart.getUTCDate();
|
|
106
|
+
}
|
|
107
|
+
__name(billingAnchorDay, "billingAnchorDay");
|
|
108
|
+
function usableAnchor(anchorDay) {
|
|
109
|
+
if (anchorDay === void 0) return void 0;
|
|
110
|
+
if (!Number.isInteger(anchorDay) || anchorDay < 1 || anchorDay > 31) return void 0;
|
|
111
|
+
return anchorDay;
|
|
112
|
+
}
|
|
113
|
+
__name(usableAnchor, "usableAnchor");
|
|
114
|
+
function daysInMonth(year, month) {
|
|
115
|
+
return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
|
116
|
+
}
|
|
117
|
+
__name(daysInMonth, "daysInMonth");
|
|
118
|
+
function periodEndAfter(startedAt, cycle, after, anchorDay) {
|
|
119
|
+
const start = new Date(startedAt ?? after);
|
|
120
|
+
const anchor = usableAnchor(anchorDay) ?? billingAnchorDay(start);
|
|
121
|
+
let candidate = start;
|
|
71
122
|
while (candidate <= after) {
|
|
72
|
-
candidate = advanceOneCycle(candidate, cycle);
|
|
123
|
+
candidate = advanceOneCycle(candidate, cycle, anchor);
|
|
73
124
|
}
|
|
74
125
|
return candidate;
|
|
75
126
|
}
|
|
@@ -81,16 +132,182 @@ function initialPeriodWindow(startedAt, cycle) {
|
|
|
81
132
|
};
|
|
82
133
|
}
|
|
83
134
|
__name(initialPeriodWindow, "initialPeriodWindow");
|
|
84
|
-
function periodEndWithMinLead(startedAt, cycle, now, minLeadDays = 42) {
|
|
135
|
+
function periodEndWithMinLead(startedAt, cycle, now, minLeadDays = 42, anchorDay) {
|
|
85
136
|
const minLeadMs = minLeadDays * 864e5;
|
|
86
|
-
|
|
137
|
+
const anchor = usableAnchor(anchorDay) ?? billingAnchorDay(new Date(startedAt ?? now));
|
|
138
|
+
let candidate = periodEndAfter(startedAt, cycle, now, anchor);
|
|
87
139
|
while (candidate.getTime() - now.getTime() < minLeadMs) {
|
|
88
|
-
candidate = advanceOneCycle(candidate, cycle);
|
|
140
|
+
candidate = advanceOneCycle(candidate, cycle, anchor);
|
|
89
141
|
}
|
|
90
142
|
return candidate;
|
|
91
143
|
}
|
|
92
144
|
__name(periodEndWithMinLead, "periodEndWithMinLead");
|
|
93
145
|
|
|
146
|
+
// src/billing/bundle-period.ts
|
|
147
|
+
function daysInMonth2(year, month) {
|
|
148
|
+
return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
|
149
|
+
}
|
|
150
|
+
__name(daysInMonth2, "daysInMonth");
|
|
151
|
+
function onDayOfMonth(reference, day, timeOfDay) {
|
|
152
|
+
const year = reference.getUTCFullYear();
|
|
153
|
+
const month = reference.getUTCMonth();
|
|
154
|
+
const out = new Date(reference);
|
|
155
|
+
out.setUTCFullYear(year, month, Math.min(day, daysInMonth2(year, month)));
|
|
156
|
+
out.setUTCHours(timeOfDay.getUTCHours(), timeOfDay.getUTCMinutes(), timeOfDay.getUTCSeconds(), timeOfDay.getUTCMilliseconds());
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
__name(onDayOfMonth, "onDayOfMonth");
|
|
160
|
+
function resolvePlanAnchorDay(source) {
|
|
161
|
+
const stored = source.billingAnchorDay;
|
|
162
|
+
if (stored != null && Number.isInteger(stored) && stored >= 1 && stored <= 31) {
|
|
163
|
+
return stored;
|
|
164
|
+
}
|
|
165
|
+
const windowStart = source.currentPeriodStart ?? source.startedAt;
|
|
166
|
+
return windowStart ? windowStart.getUTCDate() : null;
|
|
167
|
+
}
|
|
168
|
+
__name(resolvePlanAnchorDay, "resolvePlanAnchorDay");
|
|
169
|
+
function bundleFirstPeriodEnd(input) {
|
|
170
|
+
const { startedAt, cycle, planPeriodEnd, planAnchorDay } = input;
|
|
171
|
+
if (planPeriodEnd == null) return null;
|
|
172
|
+
if (cycle === "YEARLY") {
|
|
173
|
+
return planPeriodEnd > startedAt ? planPeriodEnd : advanceOneCycle(planPeriodEnd, "YEARLY");
|
|
174
|
+
}
|
|
175
|
+
const anchor = planAnchorDay ?? planPeriodEnd.getUTCDate();
|
|
176
|
+
const thisMonth = onDayOfMonth(startedAt, anchor, planPeriodEnd);
|
|
177
|
+
if (thisMonth > startedAt) return thisMonth;
|
|
178
|
+
return advanceOneCycle(thisMonth, "MONTHLY", anchor);
|
|
179
|
+
}
|
|
180
|
+
__name(bundleFirstPeriodEnd, "bundleFirstPeriodEnd");
|
|
181
|
+
function bundleFirstPeriodStart(firstPeriodEnd, cycle, planAnchorDay) {
|
|
182
|
+
return retreatOneCycle(firstPeriodEnd, cycle, planAnchorDay ?? void 0);
|
|
183
|
+
}
|
|
184
|
+
__name(bundleFirstPeriodStart, "bundleFirstPeriodStart");
|
|
185
|
+
function bundleNextPeriodEnd(currentPeriodEnd, cycle, planAnchorDay) {
|
|
186
|
+
return advanceOneCycle(currentPeriodEnd, cycle, planAnchorDay ?? void 0);
|
|
187
|
+
}
|
|
188
|
+
__name(bundleNextPeriodEnd, "bundleNextPeriodEnd");
|
|
189
|
+
var DEFAULT_BUNDLE_MINIMUM_TERM_MONTHS = 0;
|
|
190
|
+
function bundleCycleFitsPlan(bundleCycle, planCycle) {
|
|
191
|
+
return !(bundleCycle === "YEARLY" && planCycle === "MONTHLY");
|
|
192
|
+
}
|
|
193
|
+
__name(bundleCycleFitsPlan, "bundleCycleFitsPlan");
|
|
194
|
+
function computeNextBundlePeriod(booking, plan, now) {
|
|
195
|
+
const endsAt = earliest(plan.endsAt, booking.canceledEffectiveAt ?? booking.canceledAt);
|
|
196
|
+
if (endsAt !== null && endsAt <= now) return null;
|
|
197
|
+
const window = booking.currentPeriodEnd === null ? openFirstWindow(booking, plan, now) : rollNextWindow(booking, plan, now);
|
|
198
|
+
if (window === null) return null;
|
|
199
|
+
if (endsAt !== null && endsAt <= window.currentPeriodStart) return null;
|
|
200
|
+
return endsAt !== null && endsAt < window.currentPeriodEnd ? {
|
|
201
|
+
currentPeriodStart: window.currentPeriodStart,
|
|
202
|
+
currentPeriodEnd: endsAt
|
|
203
|
+
} : window;
|
|
204
|
+
}
|
|
205
|
+
__name(computeNextBundlePeriod, "computeNextBundlePeriod");
|
|
206
|
+
function earliest(a, b) {
|
|
207
|
+
if (a === null) return b;
|
|
208
|
+
if (b === null) return a;
|
|
209
|
+
return a <= b ? a : b;
|
|
210
|
+
}
|
|
211
|
+
__name(earliest, "earliest");
|
|
212
|
+
function openFirstWindow(booking, plan, now) {
|
|
213
|
+
if (booking.billingCycle === null) return null;
|
|
214
|
+
const planPeriodEnd = plan.currentPeriodEnd ?? null;
|
|
215
|
+
if (planPeriodEnd === null) return null;
|
|
216
|
+
const currentPeriodStart = plan.currentPeriodStart ?? now;
|
|
217
|
+
const firstEnd = bundleFirstPeriodEnd({
|
|
218
|
+
startedAt: currentPeriodStart,
|
|
219
|
+
cycle: booking.billingCycle,
|
|
220
|
+
planPeriodEnd,
|
|
221
|
+
planAnchorDay: plan.billingAnchorDay
|
|
222
|
+
});
|
|
223
|
+
if (firstEnd === null) return null;
|
|
224
|
+
const currentPeriodEnd = firstEnd > now ? firstEnd : periodEndAfter(firstEnd, booking.billingCycle, now, plan.billingAnchorDay ?? void 0);
|
|
225
|
+
return {
|
|
226
|
+
currentPeriodStart,
|
|
227
|
+
currentPeriodEnd
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
__name(openFirstWindow, "openFirstWindow");
|
|
231
|
+
function rollNextWindow(booking, plan, now) {
|
|
232
|
+
const currentPeriodStart = booking.currentPeriodEnd;
|
|
233
|
+
if (currentPeriodStart === null || currentPeriodStart > now) return null;
|
|
234
|
+
const cycle = booking.billingCycle ?? plan.billingCycle;
|
|
235
|
+
return {
|
|
236
|
+
currentPeriodStart,
|
|
237
|
+
currentPeriodEnd: periodEndAfter(currentPeriodStart, cycle, now, plan.billingAnchorDay ?? void 0)
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
__name(rollNextWindow, "rollNextWindow");
|
|
241
|
+
|
|
242
|
+
// src/billing/cancellation.ts
|
|
243
|
+
var DAY_MS = 864e5;
|
|
244
|
+
function decideCancellation(input) {
|
|
245
|
+
const { now, currentPeriodEnd, minimumTermUntil, billingCycle, noticePeriodDays } = input;
|
|
246
|
+
const anchorDay = input.billingAnchorDay ?? void 0;
|
|
247
|
+
const candidates = [
|
|
248
|
+
minimumTermUntil,
|
|
249
|
+
currentPeriodEnd
|
|
250
|
+
].filter((date) => date !== null);
|
|
251
|
+
const termEndsAt = candidates.length ? new Date(Math.max(...candidates.map((date) => date.getTime()))) : now;
|
|
252
|
+
if (termEndsAt <= now) {
|
|
253
|
+
return {
|
|
254
|
+
effectiveAt: now,
|
|
255
|
+
termEndsAt,
|
|
256
|
+
afterNoticeDeadline: false,
|
|
257
|
+
noticeDeadline: null
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (noticePeriodDays <= 0) {
|
|
261
|
+
return {
|
|
262
|
+
effectiveAt: termEndsAt,
|
|
263
|
+
termEndsAt,
|
|
264
|
+
afterNoticeDeadline: false,
|
|
265
|
+
noticeDeadline: null
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const noticeDeadline = new Date(termEndsAt.getTime() - noticePeriodDays * DAY_MS);
|
|
269
|
+
if (now <= noticeDeadline) {
|
|
270
|
+
return {
|
|
271
|
+
effectiveAt: termEndsAt,
|
|
272
|
+
termEndsAt,
|
|
273
|
+
afterNoticeDeadline: false,
|
|
274
|
+
noticeDeadline
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const steppingAnchor = anchorDay ?? billingAnchorDay(termEndsAt);
|
|
278
|
+
let effectiveAt = advanceOneCycle(termEndsAt, billingCycle, steppingAnchor);
|
|
279
|
+
while (effectiveAt.getTime() - now.getTime() < noticePeriodDays * DAY_MS) {
|
|
280
|
+
effectiveAt = advanceOneCycle(effectiveAt, billingCycle, steppingAnchor);
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
effectiveAt,
|
|
284
|
+
termEndsAt,
|
|
285
|
+
afterNoticeDeadline: true,
|
|
286
|
+
noticeDeadline
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
__name(decideCancellation, "decideCancellation");
|
|
290
|
+
var NO_NOTICE_PERIOD = {
|
|
291
|
+
monthly: 0,
|
|
292
|
+
yearly: 0
|
|
293
|
+
};
|
|
294
|
+
function noticeDaysFor(periods, billingCycle) {
|
|
295
|
+
return billingCycle === "YEARLY" ? periods.yearly : periods.monthly;
|
|
296
|
+
}
|
|
297
|
+
__name(noticeDaysFor, "noticeDaysFor");
|
|
298
|
+
function decideCancellationFor(sub, now, noticePeriodDays) {
|
|
299
|
+
const isTrial = sub.status === "TRIAL";
|
|
300
|
+
return decideCancellation({
|
|
301
|
+
now,
|
|
302
|
+
currentPeriodEnd: isTrial ? sub.trialEndsAt ?? sub.currentPeriodEnd : sub.currentPeriodEnd,
|
|
303
|
+
minimumTermUntil: isTrial ? null : sub.minimumTermUntil,
|
|
304
|
+
billingCycle: sub.billingCycle,
|
|
305
|
+
noticePeriodDays: isTrial ? 0 : noticePeriodDays,
|
|
306
|
+
billingAnchorDay: sub.billingAnchorDay
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
__name(decideCancellationFor, "decideCancellationFor");
|
|
310
|
+
|
|
94
311
|
// src/billing/feature-guard-marker.ts
|
|
95
312
|
var FEATURE_GUARD_MARKER = /* @__PURE__ */ Symbol.for("saasicat/nest/feature-guard");
|
|
96
313
|
function isPlatformFeatureGuard(target) {
|
|
@@ -107,11 +324,417 @@ var REQUIRE_FEATURE_KEY = "require-feature";
|
|
|
107
324
|
var RequireFeature = /* @__PURE__ */ __name((...features) => SetMetadata(REQUIRE_FEATURE_KEY, features), "RequireFeature");
|
|
108
325
|
|
|
109
326
|
// src/billing/public-catalog.tokens.ts
|
|
110
|
-
var PUBLIC_CATALOG_PROJECT_KEY_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PublicCatalogProjectKey");
|
|
111
327
|
var PUBLIC_CATALOG_BUNDLE_REPOSITORY_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PublicCatalogBundleRepository");
|
|
112
328
|
var PUBLIC_CATALOG_MARKETING_REPOSITORY_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PublicCatalogMarketingRepository");
|
|
113
329
|
var PUBLIC_CATALOG_CATALOG_ENTRY_REPOSITORY_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/PublicCatalogCatalogEntryRepository");
|
|
114
330
|
|
|
331
|
+
// src/billing/plan-catalog-loader.ts
|
|
332
|
+
import { readFileSync } from "fs";
|
|
333
|
+
import { resolve as resolvePath } from "path";
|
|
334
|
+
import yaml from "js-yaml";
|
|
335
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
336
|
+
import addFormats from "ajv-formats";
|
|
337
|
+
import { planCatalogSchema } from "@saasicat/spec";
|
|
338
|
+
|
|
339
|
+
// src/billing/plan-catalog-environment.ts
|
|
340
|
+
var EnvironmentResolutionFailure = class extends Error {
|
|
341
|
+
static {
|
|
342
|
+
__name(this, "EnvironmentResolutionFailure");
|
|
343
|
+
}
|
|
344
|
+
problems;
|
|
345
|
+
constructor(problems) {
|
|
346
|
+
super(problems.map((p) => p.message).join("\n")), this.problems = problems;
|
|
347
|
+
this.name = "EnvironmentResolutionFailure";
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
var CREDENTIAL_WORDS = /* @__PURE__ */ new Set([
|
|
351
|
+
"SECRET",
|
|
352
|
+
"SECRETS",
|
|
353
|
+
"TOKEN",
|
|
354
|
+
"TOKENS",
|
|
355
|
+
"PASSWORD",
|
|
356
|
+
"PASSWORDS",
|
|
357
|
+
"PASSWD",
|
|
358
|
+
"PWD",
|
|
359
|
+
"CREDENTIAL",
|
|
360
|
+
"CREDENTIALS"
|
|
361
|
+
]);
|
|
362
|
+
var CREDENTIAL_KEY_QUALIFIERS = /* @__PURE__ */ new Set([
|
|
363
|
+
"PRIVATE",
|
|
364
|
+
"API",
|
|
365
|
+
"ACCESS",
|
|
366
|
+
"SIGNING",
|
|
367
|
+
"SECRET",
|
|
368
|
+
"AUTH"
|
|
369
|
+
]);
|
|
370
|
+
function namesACredential(variable) {
|
|
371
|
+
const parts = variable.toUpperCase().split("_");
|
|
372
|
+
if (parts.some((part) => CREDENTIAL_WORDS.has(part))) return true;
|
|
373
|
+
return parts.some((part, index) => part === "KEY" && CREDENTIAL_KEY_QUALIFIERS.has(parts[index - 1] ?? ""));
|
|
374
|
+
}
|
|
375
|
+
__name(namesACredential, "namesACredential");
|
|
376
|
+
function isNameStart(ch) {
|
|
377
|
+
return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
|
|
378
|
+
}
|
|
379
|
+
__name(isNameStart, "isNameStart");
|
|
380
|
+
function isNameChar(ch) {
|
|
381
|
+
return isNameStart(ch) || ch >= "0" && ch <= "9";
|
|
382
|
+
}
|
|
383
|
+
__name(isNameChar, "isNameChar");
|
|
384
|
+
function splitReferences(value) {
|
|
385
|
+
const segments = [];
|
|
386
|
+
let literal = "";
|
|
387
|
+
let index = 0;
|
|
388
|
+
while (index < value.length) {
|
|
389
|
+
const ch = value[index];
|
|
390
|
+
if (ch !== "$") {
|
|
391
|
+
literal += ch;
|
|
392
|
+
index += 1;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const reference = readReference(value, index);
|
|
396
|
+
if (reference === null) {
|
|
397
|
+
literal += ch;
|
|
398
|
+
index += 1;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (literal !== "") segments.push({
|
|
402
|
+
text: literal
|
|
403
|
+
});
|
|
404
|
+
literal = "";
|
|
405
|
+
segments.push({
|
|
406
|
+
reference: reference.reference
|
|
407
|
+
});
|
|
408
|
+
index = reference.end;
|
|
409
|
+
}
|
|
410
|
+
if (literal !== "") segments.push({
|
|
411
|
+
text: literal
|
|
412
|
+
});
|
|
413
|
+
return segments;
|
|
414
|
+
}
|
|
415
|
+
__name(splitReferences, "splitReferences");
|
|
416
|
+
function readReference(value, start) {
|
|
417
|
+
if (value[start + 1] !== "{") return null;
|
|
418
|
+
let cursor = start + 2;
|
|
419
|
+
if (!isNameStart(value[cursor] ?? "")) return null;
|
|
420
|
+
while (cursor < value.length && isNameChar(value[cursor])) cursor += 1;
|
|
421
|
+
const name = value.slice(start + 2, cursor);
|
|
422
|
+
if (value[cursor] === "}") {
|
|
423
|
+
return {
|
|
424
|
+
reference: {
|
|
425
|
+
name,
|
|
426
|
+
fallback: null
|
|
427
|
+
},
|
|
428
|
+
end: cursor + 1
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
if (value.startsWith(":-", cursor)) {
|
|
432
|
+
const close = value.indexOf("}", cursor + 2);
|
|
433
|
+
if (close === -1) return null;
|
|
434
|
+
return {
|
|
435
|
+
reference: {
|
|
436
|
+
name,
|
|
437
|
+
fallback: value.slice(cursor + 2, close)
|
|
438
|
+
},
|
|
439
|
+
end: close + 1
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
__name(readReference, "readReference");
|
|
445
|
+
function schemaAt(root, path) {
|
|
446
|
+
let node = root;
|
|
447
|
+
for (const segment of path) {
|
|
448
|
+
if (node === null) return null;
|
|
449
|
+
node = deref(root, node);
|
|
450
|
+
if (node.properties && segment in node.properties) {
|
|
451
|
+
node = node.properties[segment];
|
|
452
|
+
} else if (node.items && /^\d+$/.test(segment)) {
|
|
453
|
+
node = node.items;
|
|
454
|
+
} else if (node.patternProperties) {
|
|
455
|
+
node = Object.values(node.patternProperties)[0] ?? null;
|
|
456
|
+
} else if (typeof node.additionalProperties === "object") {
|
|
457
|
+
node = node.additionalProperties;
|
|
458
|
+
} else {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return node === null ? null : deref(root, node);
|
|
463
|
+
}
|
|
464
|
+
__name(schemaAt, "schemaAt");
|
|
465
|
+
function deref(root, node) {
|
|
466
|
+
if (!node.$ref) return node;
|
|
467
|
+
const prefix = "#/$defs/";
|
|
468
|
+
if (!node.$ref.startsWith(prefix)) {
|
|
469
|
+
throw new Error(`plan-catalog.schema.json uses a $ref this resolver cannot follow: ${node.$ref}`);
|
|
470
|
+
}
|
|
471
|
+
const target = root.$defs?.[node.$ref.slice(prefix.length)];
|
|
472
|
+
if (!target) {
|
|
473
|
+
throw new Error(`plan-catalog.schema.json refers to a missing definition: ${node.$ref}`);
|
|
474
|
+
}
|
|
475
|
+
return target;
|
|
476
|
+
}
|
|
477
|
+
__name(deref, "deref");
|
|
478
|
+
function declaredTypes(node) {
|
|
479
|
+
if (!node?.type) return /* @__PURE__ */ new Set();
|
|
480
|
+
return new Set(Array.isArray(node.type) ? node.type : [
|
|
481
|
+
node.type
|
|
482
|
+
]);
|
|
483
|
+
}
|
|
484
|
+
__name(declaredTypes, "declaredTypes");
|
|
485
|
+
var INTEGER_TEXT = /^-?\d+$/;
|
|
486
|
+
var NUMBER_TEXT = /^-?\d+(\.\d+)?$/;
|
|
487
|
+
function readAs(text, types) {
|
|
488
|
+
if (types.has("string") || types.size === 0) return text;
|
|
489
|
+
if (types.has("integer") && INTEGER_TEXT.test(text)) return finiteOrUndefined(Number(text));
|
|
490
|
+
if (types.has("number") && NUMBER_TEXT.test(text)) return finiteOrUndefined(Number(text));
|
|
491
|
+
if (types.has("boolean") && (text === "true" || text === "false")) return text === "true";
|
|
492
|
+
if (types.has("null") && text === "null") return null;
|
|
493
|
+
return void 0;
|
|
494
|
+
}
|
|
495
|
+
__name(readAs, "readAs");
|
|
496
|
+
function finiteOrUndefined(value) {
|
|
497
|
+
return Number.isFinite(value) ? value : void 0;
|
|
498
|
+
}
|
|
499
|
+
__name(finiteOrUndefined, "finiteOrUndefined");
|
|
500
|
+
function describeTypes(types) {
|
|
501
|
+
return [
|
|
502
|
+
...types
|
|
503
|
+
].join(" or ");
|
|
504
|
+
}
|
|
505
|
+
__name(describeTypes, "describeTypes");
|
|
506
|
+
function describeStructure(types) {
|
|
507
|
+
const shapes = [
|
|
508
|
+
types.has("array") ? "a list" : null,
|
|
509
|
+
types.has("object") ? "a block" : null
|
|
510
|
+
];
|
|
511
|
+
return shapes.filter((shape) => shape !== null).join(" or ");
|
|
512
|
+
}
|
|
513
|
+
__name(describeStructure, "describeStructure");
|
|
514
|
+
function pointer(path) {
|
|
515
|
+
return path.length === 0 ? "" : `/${path.join("/")}`;
|
|
516
|
+
}
|
|
517
|
+
__name(pointer, "pointer");
|
|
518
|
+
function resolveEnvironmentReferences(document, schema, env) {
|
|
519
|
+
const problems = [];
|
|
520
|
+
const resolved = walk(document, [], schema, env, problems);
|
|
521
|
+
if (problems.length > 0) throw new EnvironmentResolutionFailure(problems);
|
|
522
|
+
return resolved;
|
|
523
|
+
}
|
|
524
|
+
__name(resolveEnvironmentReferences, "resolveEnvironmentReferences");
|
|
525
|
+
function walk(value, path, schema, env, problems) {
|
|
526
|
+
if (typeof value === "string") {
|
|
527
|
+
return resolveString(value, path, schema, env, problems);
|
|
528
|
+
}
|
|
529
|
+
if (Array.isArray(value)) {
|
|
530
|
+
return value.map((item, index) => walk(item, [
|
|
531
|
+
...path,
|
|
532
|
+
String(index)
|
|
533
|
+
], schema, env, problems));
|
|
534
|
+
}
|
|
535
|
+
if (value !== null && typeof value === "object") {
|
|
536
|
+
const out = {};
|
|
537
|
+
for (const [key, member] of Object.entries(value)) {
|
|
538
|
+
Object.defineProperty(out, key, {
|
|
539
|
+
value: walk(member, [
|
|
540
|
+
...path,
|
|
541
|
+
key
|
|
542
|
+
], schema, env, problems),
|
|
543
|
+
enumerable: true,
|
|
544
|
+
writable: true,
|
|
545
|
+
configurable: true
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
return value;
|
|
551
|
+
}
|
|
552
|
+
__name(walk, "walk");
|
|
553
|
+
function resolveString(value, path, schema, env, problems) {
|
|
554
|
+
const segments = splitReferences(value);
|
|
555
|
+
const references = segments.flatMap((segment) => "reference" in segment ? [
|
|
556
|
+
segment.reference
|
|
557
|
+
] : []);
|
|
558
|
+
if (references.length === 0) return value;
|
|
559
|
+
const instancePath = pointer(path);
|
|
560
|
+
const named = references.map((r) => `\${${r.name}}`).join(" and ");
|
|
561
|
+
if (env === null) {
|
|
562
|
+
for (const { name } of references) {
|
|
563
|
+
problems.push({
|
|
564
|
+
instancePath,
|
|
565
|
+
params: {
|
|
566
|
+
envVar: name
|
|
567
|
+
},
|
|
568
|
+
message: `refers to \${${name}}. References to the environment are resolved only when the platform reads its own configuration file, and this document arrived another way.`
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
return value;
|
|
572
|
+
}
|
|
573
|
+
const types = declaredTypes(schemaAt(schema, path));
|
|
574
|
+
let text = "";
|
|
575
|
+
let complete = true;
|
|
576
|
+
for (const segment of segments) {
|
|
577
|
+
if ("text" in segment) {
|
|
578
|
+
text += segment.text;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const { name, fallback } = segment.reference;
|
|
582
|
+
if (namesACredential(name)) {
|
|
583
|
+
problems.push({
|
|
584
|
+
instancePath,
|
|
585
|
+
params: {
|
|
586
|
+
envVar: name
|
|
587
|
+
},
|
|
588
|
+
message: `\${${name}} names a credential, and a value the file resolves becomes part of the catalogue \u2014 shown, logged and recorded. Credentials stay in the environment and are read where they are used. Rename the variable if it holds no secret.`
|
|
589
|
+
});
|
|
590
|
+
complete = false;
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const fromEnv = Object.hasOwn(env, name) ? env[name] : void 0;
|
|
594
|
+
if (fromEnv !== void 0 && (fromEnv !== "" || fallback === null)) {
|
|
595
|
+
text += fromEnv;
|
|
596
|
+
} else if (fallback !== null) {
|
|
597
|
+
text += fallback;
|
|
598
|
+
} else {
|
|
599
|
+
problems.push({
|
|
600
|
+
instancePath,
|
|
601
|
+
params: {
|
|
602
|
+
envVar: name
|
|
603
|
+
},
|
|
604
|
+
message: `refers to \${${name}}, which is not set. Set it in the environment, or write a default into the file as \${${name}:-<value>}.`
|
|
605
|
+
});
|
|
606
|
+
complete = false;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (!complete) return value;
|
|
610
|
+
const envVar = references[0].name;
|
|
611
|
+
if (types.has("array") || types.has("object")) {
|
|
612
|
+
problems.push({
|
|
613
|
+
instancePath,
|
|
614
|
+
params: {
|
|
615
|
+
envVar
|
|
616
|
+
},
|
|
617
|
+
message: `${named} stands where the field takes ${describeStructure(types)}. A variable stands in for a single value: write the structure in the file and refer to a variable per entry.`
|
|
618
|
+
});
|
|
619
|
+
return value;
|
|
620
|
+
}
|
|
621
|
+
const converted = readAs(text, types);
|
|
622
|
+
if (converted === void 0) {
|
|
623
|
+
problems.push({
|
|
624
|
+
instancePath,
|
|
625
|
+
params: {
|
|
626
|
+
envVar
|
|
627
|
+
},
|
|
628
|
+
message: `${named} resolves to ${JSON.stringify(text)}, and the field takes ${describeTypes(types)}.`
|
|
629
|
+
});
|
|
630
|
+
return value;
|
|
631
|
+
}
|
|
632
|
+
return converted;
|
|
633
|
+
}
|
|
634
|
+
__name(resolveString, "resolveString");
|
|
635
|
+
|
|
636
|
+
// src/billing/plan-catalog-loader.ts
|
|
637
|
+
function locate(error) {
|
|
638
|
+
const segments = (error.instancePath ?? "").split("/").filter(Boolean);
|
|
639
|
+
const named = error.params?.missingProperty ?? error.params?.additionalProperty;
|
|
640
|
+
if (named) segments.push(named);
|
|
641
|
+
return segments.length > 0 ? segments.join(".") : "(document root)";
|
|
642
|
+
}
|
|
643
|
+
__name(locate, "locate");
|
|
644
|
+
var PLAN_CATALOG_UNREADABLE_ERROR = "PlanCatalogUnreadableError";
|
|
645
|
+
var PlanCatalogValidationError = class extends Error {
|
|
646
|
+
static {
|
|
647
|
+
__name(this, "PlanCatalogValidationError");
|
|
648
|
+
}
|
|
649
|
+
source;
|
|
650
|
+
errors;
|
|
651
|
+
constructor(source, errors) {
|
|
652
|
+
const messages = errors.map((e) => `${locate(e)}: ${e.message ?? "unknown"}`).join("\n ");
|
|
653
|
+
super(`Plan catalog validation failed for ${source}:
|
|
654
|
+
${messages}`), this.source = source, this.errors = errors;
|
|
655
|
+
this.name = "PlanCatalogValidationError";
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
function loadPlanCatalogFromFile(opts) {
|
|
659
|
+
const absolutePath = resolvePath(opts.path);
|
|
660
|
+
const raw = readFileSync(absolutePath, "utf-8");
|
|
661
|
+
const catalog = loadPlanCatalogFromString(raw, {
|
|
662
|
+
source: absolutePath,
|
|
663
|
+
crossFieldChecks: opts.crossFieldChecks ?? true,
|
|
664
|
+
// `??` would turn an explicit `null` — refuse every reference — into
|
|
665
|
+
// the process environment, the one thing that value asks not to read.
|
|
666
|
+
env: opts.env === void 0 ? process.env : opts.env
|
|
667
|
+
});
|
|
668
|
+
SOURCES.set(catalog, absolutePath);
|
|
669
|
+
return catalog;
|
|
670
|
+
}
|
|
671
|
+
__name(loadPlanCatalogFromFile, "loadPlanCatalogFromFile");
|
|
672
|
+
var SOURCES = /* @__PURE__ */ new WeakMap();
|
|
673
|
+
function catalogSource(catalog) {
|
|
674
|
+
return SOURCES.get(catalog) ?? null;
|
|
675
|
+
}
|
|
676
|
+
__name(catalogSource, "catalogSource");
|
|
677
|
+
function loadPlanCatalogFromString(yamlContent, opts) {
|
|
678
|
+
const parsed = yaml.load(yamlContent);
|
|
679
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
680
|
+
const error = new Error(`The YAML content of ${opts.source} is not an object`);
|
|
681
|
+
error.name = PLAN_CATALOG_UNREADABLE_ERROR;
|
|
682
|
+
throw error;
|
|
683
|
+
}
|
|
684
|
+
const resolved = resolveReferences(parsed, opts.source, opts.env ?? null);
|
|
685
|
+
const ajv = new Ajv2020({
|
|
686
|
+
strict: false,
|
|
687
|
+
allErrors: true
|
|
688
|
+
});
|
|
689
|
+
addFormats.default(ajv);
|
|
690
|
+
const validate = ajv.compile(planCatalogSchema);
|
|
691
|
+
if (!validate(resolved)) {
|
|
692
|
+
throw new PlanCatalogValidationError(opts.source, validate.errors ?? []);
|
|
693
|
+
}
|
|
694
|
+
const catalog = resolved;
|
|
695
|
+
if (opts.crossFieldChecks ?? true) {
|
|
696
|
+
validateConsistency(catalog, opts.source);
|
|
697
|
+
}
|
|
698
|
+
return catalog;
|
|
699
|
+
}
|
|
700
|
+
__name(loadPlanCatalogFromString, "loadPlanCatalogFromString");
|
|
701
|
+
function resolveReferences(parsed, source, env) {
|
|
702
|
+
try {
|
|
703
|
+
return resolveEnvironmentReferences(parsed, planCatalogSchema, env);
|
|
704
|
+
} catch (error) {
|
|
705
|
+
if (error instanceof EnvironmentResolutionFailure) {
|
|
706
|
+
throw new PlanCatalogValidationError(source, error.problems);
|
|
707
|
+
}
|
|
708
|
+
throw error;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
__name(resolveReferences, "resolveReferences");
|
|
712
|
+
function validateConsistency(catalog, source) {
|
|
713
|
+
const errors = [];
|
|
714
|
+
const declaredFeatureKeys = new Set((catalog.features ?? []).map((f) => f.key));
|
|
715
|
+
const planIds = /* @__PURE__ */ new Set();
|
|
716
|
+
for (const plan of catalog.plans ?? []) {
|
|
717
|
+
if (planIds.has(plan.id)) {
|
|
718
|
+
errors.push(`plans[].id: duplicate plan id "${plan.id}"`);
|
|
719
|
+
}
|
|
720
|
+
planIds.add(plan.id);
|
|
721
|
+
if (catalog.features) {
|
|
722
|
+
for (const fk of plan.features) {
|
|
723
|
+
if (!declaredFeatureKeys.has(fk)) {
|
|
724
|
+
errors.push(`plans[id=${plan.id}].features: Unknown featureKey "${fk}" \u2014 not declared in catalog.features`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (errors.length > 0) {
|
|
730
|
+
const err = new Error(`Plan catalog consistency error in ${source}:
|
|
731
|
+
${errors.join("\n ")}`);
|
|
732
|
+
err.name = "PlanCatalogConsistencyError";
|
|
733
|
+
throw err;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
__name(validateConsistency, "validateConsistency");
|
|
737
|
+
|
|
115
738
|
// src/billing/feature-ui-registry.tokens.ts
|
|
116
739
|
var BILLING_FEATURE_UI_REGISTRY_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/BillingFeatureUiRegistry");
|
|
117
740
|
|
|
@@ -139,23 +762,21 @@ var PublicCatalogController = class {
|
|
|
139
762
|
static {
|
|
140
763
|
__name(this, "PublicCatalogController");
|
|
141
764
|
}
|
|
142
|
-
|
|
765
|
+
planCatalogs;
|
|
143
766
|
featureRegistry;
|
|
144
|
-
projectKey;
|
|
145
767
|
marketingRepo;
|
|
146
768
|
bundleRepo;
|
|
147
769
|
catalogEntryRepo;
|
|
148
|
-
constructor(
|
|
149
|
-
this.
|
|
770
|
+
constructor(planCatalogs, featureRegistry, marketingRepo = null, bundleRepo = null, catalogEntryRepo = null) {
|
|
771
|
+
this.planCatalogs = planCatalogs;
|
|
150
772
|
this.featureRegistry = featureRegistry;
|
|
151
|
-
this.projectKey = projectKey;
|
|
152
773
|
this.marketingRepo = marketingRepo;
|
|
153
774
|
this.bundleRepo = bundleRepo;
|
|
154
775
|
this.catalogEntryRepo = catalogEntryRepo;
|
|
155
776
|
}
|
|
156
777
|
async listPlans(lang, localeParam = "de") {
|
|
157
778
|
const locale = lang || localeParam;
|
|
158
|
-
const plans = getMarketedPlans(this.
|
|
779
|
+
const plans = getMarketedPlans(await this.planCatalogs.current()).map((plan) => ({
|
|
159
780
|
id: plan.id,
|
|
160
781
|
name: plan.name ?? plan.id,
|
|
161
782
|
tagline: plan.tagline ?? "",
|
|
@@ -169,12 +790,10 @@ var PublicCatalogController = class {
|
|
|
169
790
|
return plans;
|
|
170
791
|
}
|
|
171
792
|
async listFeatureRegistry() {
|
|
172
|
-
if (!this.catalogEntryRepo
|
|
793
|
+
if (!this.catalogEntryRepo) return this.featureRegistry;
|
|
173
794
|
let rows;
|
|
174
795
|
try {
|
|
175
|
-
rows = await this.catalogEntryRepo.listFeatures({
|
|
176
|
-
projectKey: this.projectKey
|
|
177
|
-
});
|
|
796
|
+
rows = await this.catalogEntryRepo.listFeatures({});
|
|
178
797
|
} catch {
|
|
179
798
|
return this.featureRegistry;
|
|
180
799
|
}
|
|
@@ -210,10 +829,8 @@ var PublicCatalogController = class {
|
|
|
210
829
|
*/
|
|
211
830
|
async listBundles(lang, localeParam = "de") {
|
|
212
831
|
const locale = lang || localeParam;
|
|
213
|
-
if (!this.bundleRepo
|
|
214
|
-
const stems = await this.bundleRepo.list({
|
|
215
|
-
projectKey: this.projectKey
|
|
216
|
-
});
|
|
832
|
+
if (!this.bundleRepo) return [];
|
|
833
|
+
const stems = await this.bundleRepo.list({});
|
|
217
834
|
const requiresIndex = await this.loadFeatureRequiresIndex();
|
|
218
835
|
const out = [];
|
|
219
836
|
for (const stem of stems) {
|
|
@@ -232,20 +849,17 @@ var PublicCatalogController = class {
|
|
|
232
849
|
* `requiresFeatures` then stays conservatively empty.
|
|
233
850
|
*/
|
|
234
851
|
async loadFeatureRequiresIndex() {
|
|
235
|
-
if (!this.catalogEntryRepo
|
|
852
|
+
if (!this.catalogEntryRepo) return /* @__PURE__ */ new Map();
|
|
236
853
|
try {
|
|
237
|
-
const rows = await this.catalogEntryRepo.listFeatures({
|
|
238
|
-
projectKey: this.projectKey
|
|
239
|
-
});
|
|
854
|
+
const rows = await this.catalogEntryRepo.listFeatures({});
|
|
240
855
|
return buildFeatureRequiresIndex(rows);
|
|
241
856
|
} catch {
|
|
242
857
|
return /* @__PURE__ */ new Map();
|
|
243
858
|
}
|
|
244
859
|
}
|
|
245
860
|
async lookupMarketing(targetType, targetVersionId, locale) {
|
|
246
|
-
if (!this.marketingRepo
|
|
861
|
+
if (!this.marketingRepo) return void 0;
|
|
247
862
|
const rows = await this.marketingRepo.list({
|
|
248
|
-
projectKey: this.projectKey,
|
|
249
863
|
targetType,
|
|
250
864
|
targetVersionId,
|
|
251
865
|
locale
|
|
@@ -313,23 +927,20 @@ _ts_decorate([
|
|
|
313
927
|
PublicCatalogController = _ts_decorate([
|
|
314
928
|
Controller("billing"),
|
|
315
929
|
SaaSiCatPublicRoute(),
|
|
316
|
-
_ts_param(0, Inject(
|
|
930
|
+
_ts_param(0, Inject(PLAN_CATALOG_SOURCE_TOKEN)),
|
|
317
931
|
_ts_param(1, Inject(BILLING_FEATURE_UI_REGISTRY_TOKEN)),
|
|
318
932
|
_ts_param(2, Optional()),
|
|
319
|
-
_ts_param(2, Inject(
|
|
933
|
+
_ts_param(2, Inject(PUBLIC_CATALOG_MARKETING_REPOSITORY_TOKEN)),
|
|
320
934
|
_ts_param(3, Optional()),
|
|
321
|
-
_ts_param(3, Inject(
|
|
935
|
+
_ts_param(3, Inject(PUBLIC_CATALOG_BUNDLE_REPOSITORY_TOKEN)),
|
|
322
936
|
_ts_param(4, Optional()),
|
|
323
|
-
_ts_param(4, Inject(
|
|
324
|
-
_ts_param(5, Optional()),
|
|
325
|
-
_ts_param(5, Inject(PUBLIC_CATALOG_CATALOG_ENTRY_REPOSITORY_TOKEN)),
|
|
937
|
+
_ts_param(4, Inject(PUBLIC_CATALOG_CATALOG_ENTRY_REPOSITORY_TOKEN)),
|
|
326
938
|
_ts_metadata("design:type", Function),
|
|
327
939
|
_ts_metadata("design:paramtypes", [
|
|
328
|
-
typeof
|
|
940
|
+
typeof PlanCatalogSource === "undefined" ? Object : PlanCatalogSource,
|
|
329
941
|
typeof FeatureUiRegistry === "undefined" ? Object : FeatureUiRegistry,
|
|
330
942
|
Object,
|
|
331
943
|
Object,
|
|
332
|
-
Object,
|
|
333
944
|
Object
|
|
334
945
|
])
|
|
335
946
|
], PublicCatalogController);
|
|
@@ -355,12 +966,6 @@ var PublicCatalogModule = class _PublicCatalogModule {
|
|
|
355
966
|
useValue: options.featureUiRegistry
|
|
356
967
|
}
|
|
357
968
|
];
|
|
358
|
-
if (options.projectKey !== void 0) {
|
|
359
|
-
providers.push({
|
|
360
|
-
provide: PUBLIC_CATALOG_PROJECT_KEY_TOKEN,
|
|
361
|
-
useValue: options.projectKey
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
969
|
if (options.bundleRepository) {
|
|
365
970
|
providers.push(asProvider(PUBLIC_CATALOG_BUNDLE_REPOSITORY_TOKEN, options.bundleRepository));
|
|
366
971
|
}
|
|
@@ -387,8 +992,8 @@ PublicCatalogModule = _ts_decorate2([
|
|
|
387
992
|
Module({})
|
|
388
993
|
], PublicCatalogModule);
|
|
389
994
|
|
|
390
|
-
// src/billing/
|
|
391
|
-
import { ForbiddenException,
|
|
995
|
+
// src/billing/tenant-admin.guard.ts
|
|
996
|
+
import { ForbiddenException, Injectable } from "@nestjs/common";
|
|
392
997
|
import { AUTH_ERROR_CODES } from "@saasicat/core";
|
|
393
998
|
function _ts_decorate3(decorators, target, key, desc) {
|
|
394
999
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
@@ -397,86 +1002,22 @@ function _ts_decorate3(decorators, target, key, desc) {
|
|
|
397
1002
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
398
1003
|
}
|
|
399
1004
|
__name(_ts_decorate3, "_ts_decorate");
|
|
400
|
-
function _ts_metadata2(k, v) {
|
|
401
|
-
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
402
|
-
}
|
|
403
|
-
__name(_ts_metadata2, "_ts_metadata");
|
|
404
|
-
function _ts_param2(paramIndex, decorator) {
|
|
405
|
-
return function(target, key) {
|
|
406
|
-
decorator(target, key, paramIndex);
|
|
407
|
-
};
|
|
408
|
-
}
|
|
409
|
-
__name(_ts_param2, "_ts_param");
|
|
410
|
-
var ComposedTenantAuthGuard = class {
|
|
411
|
-
static {
|
|
412
|
-
__name(this, "ComposedTenantAuthGuard");
|
|
413
|
-
}
|
|
414
|
-
guards;
|
|
415
|
-
constructor(guards = null) {
|
|
416
|
-
this.guards = guards;
|
|
417
|
-
}
|
|
418
|
-
async canActivate(context) {
|
|
419
|
-
if (!this.guards || this.guards.length === 0) {
|
|
420
|
-
throw new ForbiddenException({
|
|
421
|
-
code: AUTH_ERROR_CODES.AUTH_GUARDS_NOT_CONFIGURED,
|
|
422
|
-
message: "TenantBillingModule.forRoot.authGuards is not configured."
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
for (const guard of this.guards) {
|
|
426
|
-
const result = await Promise.resolve(guard.canActivate(context));
|
|
427
|
-
if (!result) return false;
|
|
428
|
-
}
|
|
429
|
-
return true;
|
|
430
|
-
}
|
|
431
|
-
};
|
|
432
|
-
ComposedTenantAuthGuard = _ts_decorate3([
|
|
433
|
-
Injectable(),
|
|
434
|
-
_ts_param2(0, Optional2()),
|
|
435
|
-
_ts_param2(0, Inject2(TENANT_AUTH_GUARDS_TOKEN)),
|
|
436
|
-
_ts_metadata2("design:type", Function),
|
|
437
|
-
_ts_metadata2("design:paramtypes", [
|
|
438
|
-
Object
|
|
439
|
-
])
|
|
440
|
-
], ComposedTenantAuthGuard);
|
|
441
|
-
|
|
442
|
-
// src/billing/tenant-admin.guard.ts
|
|
443
|
-
import { ForbiddenException as ForbiddenException2, Injectable as Injectable2 } from "@nestjs/common";
|
|
444
|
-
import { AUTH_ERROR_CODES as AUTH_ERROR_CODES2 } from "@saasicat/core";
|
|
445
|
-
function _ts_decorate4(decorators, target, key, desc) {
|
|
446
|
-
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
447
|
-
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
448
|
-
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;
|
|
449
|
-
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
450
|
-
}
|
|
451
|
-
__name(_ts_decorate4, "_ts_decorate");
|
|
452
|
-
var ADMIN_ROLES = /* @__PURE__ */ new Set([
|
|
453
|
-
"TENANT_ADMIN",
|
|
454
|
-
"SUPER_ADMIN"
|
|
455
|
-
]);
|
|
456
1005
|
var TenantAdminGuard = class {
|
|
457
1006
|
static {
|
|
458
1007
|
__name(this, "TenantAdminGuard");
|
|
459
1008
|
}
|
|
460
1009
|
canActivate(context) {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
code: AUTH_ERROR_CODES2.NOT_AUTHENTICATED,
|
|
465
|
-
message: "Not authenticated"
|
|
466
|
-
});
|
|
467
|
-
}
|
|
468
|
-
const role = request.user.platformRole ?? request.user.role;
|
|
469
|
-
if (!role || !ADMIN_ROLES.has(role)) {
|
|
470
|
-
throw new ForbiddenException2({
|
|
471
|
-
code: AUTH_ERROR_CODES2.TENANT_ADMIN_REQUIRED,
|
|
1010
|
+
if (!holdsTenantAdminRole(context)) {
|
|
1011
|
+
throw new ForbiddenException({
|
|
1012
|
+
code: AUTH_ERROR_CODES.TENANT_ADMIN_REQUIRED,
|
|
472
1013
|
message: "This action requires the TENANT_ADMIN role."
|
|
473
1014
|
});
|
|
474
1015
|
}
|
|
475
1016
|
return true;
|
|
476
1017
|
}
|
|
477
1018
|
};
|
|
478
|
-
TenantAdminGuard =
|
|
479
|
-
|
|
1019
|
+
TenantAdminGuard = _ts_decorate3([
|
|
1020
|
+
Injectable()
|
|
480
1021
|
], TenantAdminGuard);
|
|
481
1022
|
|
|
482
1023
|
// src/billing/self-service-policy.ts
|
|
@@ -484,12 +1025,13 @@ var SELF_SERVICE_BLOCKED_PLANS_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest
|
|
|
484
1025
|
var SELF_SERVICE_BLOCKED_BUNDLES_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/SelfServiceBlockedBundles");
|
|
485
1026
|
|
|
486
1027
|
// src/billing/proration.ts
|
|
487
|
-
var
|
|
1028
|
+
var DAY_MS2 = 864e5;
|
|
488
1029
|
function computeProration(input) {
|
|
489
1030
|
const { periodStart, periodEnd, now, currentPriceNet, targetPriceNet } = input;
|
|
490
|
-
const daysInPeriod = Math.max(1, Math.round((periodEnd.getTime() - periodStart.getTime()) /
|
|
491
|
-
const daysRemaining = Math.max(0, Math.min(daysInPeriod, Math.round((periodEnd.getTime() - now.getTime()) /
|
|
492
|
-
const
|
|
1031
|
+
const daysInPeriod = Math.max(1, Math.round((periodEnd.getTime() - periodStart.getTime()) / DAY_MS2));
|
|
1032
|
+
const daysRemaining = Math.max(0, Math.min(daysInPeriod, Math.round((periodEnd.getTime() - now.getTime()) / DAY_MS2)));
|
|
1033
|
+
const rawDeltaNet = round2((targetPriceNet - currentPriceNet) * daysRemaining / daysInPeriod);
|
|
1034
|
+
const prorataDeltaNet = Math.max(0, rawDeltaNet);
|
|
493
1035
|
return {
|
|
494
1036
|
daysRemainingInPeriod: daysRemaining,
|
|
495
1037
|
daysInPeriod,
|
|
@@ -497,7 +1039,9 @@ function computeProration(input) {
|
|
|
497
1039
|
periodEnd,
|
|
498
1040
|
currentPriceNet,
|
|
499
1041
|
targetPriceNet,
|
|
500
|
-
prorataDeltaNet
|
|
1042
|
+
prorataDeltaNet,
|
|
1043
|
+
rawDeltaNet,
|
|
1044
|
+
isFree: rawDeltaNet < 0
|
|
501
1045
|
};
|
|
502
1046
|
}
|
|
503
1047
|
__name(computeProration, "computeProration");
|
|
@@ -507,42 +1051,44 @@ function round2(n) {
|
|
|
507
1051
|
__name(round2, "round2");
|
|
508
1052
|
|
|
509
1053
|
// src/billing/plan-change-preview.service.ts
|
|
510
|
-
import { Inject as
|
|
1054
|
+
import { Inject as Inject2, Injectable as Injectable2, NotFoundException, Optional as Optional2 } from "@nestjs/common";
|
|
511
1055
|
import { BILLING_ERROR_CODES } from "@saasicat/core";
|
|
512
|
-
function
|
|
1056
|
+
function _ts_decorate4(decorators, target, key, desc) {
|
|
513
1057
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
514
1058
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
515
1059
|
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;
|
|
516
1060
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
517
1061
|
}
|
|
518
|
-
__name(
|
|
519
|
-
function
|
|
1062
|
+
__name(_ts_decorate4, "_ts_decorate");
|
|
1063
|
+
function _ts_metadata2(k, v) {
|
|
520
1064
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
521
1065
|
}
|
|
522
|
-
__name(
|
|
523
|
-
function
|
|
1066
|
+
__name(_ts_metadata2, "_ts_metadata");
|
|
1067
|
+
function _ts_param2(paramIndex, decorator) {
|
|
524
1068
|
return function(target, key) {
|
|
525
1069
|
decorator(target, key, paramIndex);
|
|
526
1070
|
};
|
|
527
1071
|
}
|
|
528
|
-
__name(
|
|
1072
|
+
__name(_ts_param2, "_ts_param");
|
|
529
1073
|
var PlanChangePreviewService = class {
|
|
530
1074
|
static {
|
|
531
1075
|
__name(this, "PlanChangePreviewService");
|
|
532
1076
|
}
|
|
533
|
-
|
|
1077
|
+
catalogs;
|
|
534
1078
|
entitlements;
|
|
535
1079
|
subscriptions;
|
|
536
1080
|
usageSnapshot;
|
|
537
1081
|
blockedPlans;
|
|
538
1082
|
trialProjection;
|
|
539
|
-
|
|
540
|
-
|
|
1083
|
+
subscriptionBundles;
|
|
1084
|
+
constructor(catalogs, entitlements, subscriptions, usageSnapshot, blockedPlans = null, trialProjection = null, subscriptionBundles = null) {
|
|
1085
|
+
this.catalogs = catalogs;
|
|
541
1086
|
this.entitlements = entitlements;
|
|
542
1087
|
this.subscriptions = subscriptions;
|
|
543
1088
|
this.usageSnapshot = usageSnapshot;
|
|
544
1089
|
this.blockedPlans = blockedPlans;
|
|
545
1090
|
this.trialProjection = trialProjection;
|
|
1091
|
+
this.subscriptionBundles = subscriptionBundles;
|
|
546
1092
|
}
|
|
547
1093
|
async preview(tenantId, targetPlan, targetCycle, now = /* @__PURE__ */ new Date()) {
|
|
548
1094
|
const sub = await this.subscriptions.findForTenant(tenantId);
|
|
@@ -555,21 +1101,24 @@ var PlanChangePreviewService = class {
|
|
|
555
1101
|
}
|
|
556
1102
|
});
|
|
557
1103
|
}
|
|
558
|
-
const
|
|
1104
|
+
const catalog = await this.catalogs.current();
|
|
1105
|
+
const targetPlanDef = findPlan(catalog, targetPlan);
|
|
559
1106
|
if (!targetPlanDef) {
|
|
560
1107
|
throw new NotFoundException({
|
|
561
1108
|
code: BILLING_ERROR_CODES.PLAN_NOT_IN_CATALOG,
|
|
562
|
-
message: `Plan "${targetPlan}" is not in the catalog
|
|
1109
|
+
message: `Plan "${targetPlan}" is not in the catalog`,
|
|
563
1110
|
params: {
|
|
564
|
-
planKey: targetPlan
|
|
565
|
-
projectKey: this.catalog.projectKey
|
|
1111
|
+
planKey: targetPlan
|
|
566
1112
|
}
|
|
567
1113
|
});
|
|
568
1114
|
}
|
|
569
1115
|
const ctx = {
|
|
570
1116
|
currentPeriodStart: sub.currentPeriodStart,
|
|
571
1117
|
currentPeriodEnd: sub.currentPeriodEnd,
|
|
1118
|
+
minimumTermUntil: sub.minimumTermUntil ?? null,
|
|
572
1119
|
trialEndsAt: sub.trialEndsAt,
|
|
1120
|
+
canceledAt: sub.canceledAt ?? null,
|
|
1121
|
+
canceledEffectiveAt: sub.canceledEffectiveAt ?? null,
|
|
573
1122
|
status: sub.status,
|
|
574
1123
|
currentBillingCycle: sub.billingCycle,
|
|
575
1124
|
currentPlan: sub.plan,
|
|
@@ -579,24 +1128,24 @@ var PlanChangePreviewService = class {
|
|
|
579
1128
|
this.entitlements.computeLimits(tenantId, now),
|
|
580
1129
|
this.usageSnapshot.snapshot(tenantId)
|
|
581
1130
|
]);
|
|
582
|
-
const currentPlanDef = findPlan(
|
|
1131
|
+
const currentPlanDef = findPlan(catalog, currentLimits.plan);
|
|
583
1132
|
const currentSnap = {
|
|
584
1133
|
id: currentLimits.plan,
|
|
585
1134
|
name: currentPlanDef?.name ?? currentLimits.plan,
|
|
586
|
-
monthlyNet: getPlanPriceNet(
|
|
587
|
-
yearlyNet: getPlanPriceNet(
|
|
1135
|
+
monthlyNet: getPlanPriceNet(catalog, currentLimits.plan, "MONTHLY"),
|
|
1136
|
+
yearlyNet: getPlanPriceNet(catalog, currentLimits.plan, "YEARLY"),
|
|
588
1137
|
quotas: currentLimits.quotas,
|
|
589
1138
|
features: Array.from(currentLimits.features).sort()
|
|
590
1139
|
};
|
|
591
1140
|
const targetSnap = {
|
|
592
1141
|
id: targetPlanDef.id,
|
|
593
1142
|
name: targetPlanDef.name ?? targetPlanDef.id,
|
|
594
|
-
monthlyNet: getPlanPriceNet(
|
|
595
|
-
yearlyNet: getPlanPriceNet(
|
|
1143
|
+
monthlyNet: getPlanPriceNet(catalog, targetPlan, "MONTHLY"),
|
|
1144
|
+
yearlyNet: getPlanPriceNet(catalog, targetPlan, "YEARLY"),
|
|
596
1145
|
quotas: targetPlanDef.quotas,
|
|
597
1146
|
features: targetPlanDef.features.slice().sort()
|
|
598
1147
|
};
|
|
599
|
-
const changeType = this.classify(sub.plan, sub.billingCycle, targetPlan, targetCycle);
|
|
1148
|
+
const changeType = this.classify(catalog, sub.plan, sub.billingCycle, targetPlan, targetCycle);
|
|
600
1149
|
const limitsCheck = {};
|
|
601
1150
|
const quotaKeys = /* @__PURE__ */ new Set([
|
|
602
1151
|
...Object.keys(currentLimits.quotas),
|
|
@@ -619,45 +1168,103 @@ var PlanChangePreviewService = class {
|
|
|
619
1168
|
const targetFeatureSet = new Set(targetSnap.features);
|
|
620
1169
|
const featuresLost = Array.from(currentFeatureSet).filter((f) => !targetFeatureSet.has(f)).sort();
|
|
621
1170
|
const featuresGained = targetSnap.features.filter((f) => !currentFeatureSet.has(f));
|
|
622
|
-
const
|
|
1171
|
+
const planDirection = this.planDirection(catalog, sub.plan, targetPlan);
|
|
1172
|
+
const cycleDirection = this.cycleDirection(sub.billingCycle, targetCycle);
|
|
1173
|
+
const commits = ctx.status !== "TRIAL";
|
|
1174
|
+
const isImmediate = planDirection === "UP" && (!commits || cycleDirection !== "SHORTER");
|
|
623
1175
|
const effectiveAt = isImmediate ? null : this.resolveEffectiveAt(ctx, now);
|
|
624
1176
|
const proration = isImmediate && ctx.status !== "TRIAL" ? this.computeProration(ctx, now, currentSnap, targetSnap, sub.billingCycle, targetCycle) : null;
|
|
625
1177
|
const blockers = [];
|
|
626
1178
|
const warnings = [];
|
|
1179
|
+
const landsAt = ctx.canceledEffectiveAt ?? ctx.canceledAt;
|
|
1180
|
+
if (landsAt !== null && landsAt <= now) {
|
|
1181
|
+
blockers.push({
|
|
1182
|
+
code: BILLING_ERROR_CODES.SUBSCRIPTION_ENDED,
|
|
1183
|
+
message: "This subscription has ended. Its plan can no longer be changed."
|
|
1184
|
+
});
|
|
1185
|
+
} else if (landsAt !== null && cycleDirection !== "SAME") {
|
|
1186
|
+
blockers.push({
|
|
1187
|
+
code: BILLING_ERROR_CODES.CANCELLATION_LOCKS_THE_CYCLE,
|
|
1188
|
+
message: "This subscription is cancelled, so its billing cycle cannot change. The plan can."
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
627
1191
|
const blockedTargets = this.blockedPlans?.asTarget ?? [];
|
|
628
1192
|
const blockedSources = this.blockedPlans?.asSource ?? [];
|
|
1193
|
+
if (isPlanNotSoldInCycle(targetPlanDef, targetCycle)) {
|
|
1194
|
+
blockers.push(planNotSoldInCycle(targetPlanDef, targetCycle));
|
|
1195
|
+
}
|
|
629
1196
|
if (blockedTargets.includes(targetPlan)) {
|
|
630
1197
|
blockers.push({
|
|
631
|
-
code:
|
|
632
|
-
message: `${targetSnap.name} is only activated via a special contract. Please contact the contract manager
|
|
1198
|
+
code: BILLING_ERROR_CODES.PLAN_NOT_SELF_SERVICE,
|
|
1199
|
+
message: `${targetSnap.name} is only activated via a special contract. Please contact the contract manager.`,
|
|
1200
|
+
params: {
|
|
1201
|
+
planName: targetSnap.name,
|
|
1202
|
+
planKey: targetPlan
|
|
1203
|
+
}
|
|
633
1204
|
});
|
|
634
1205
|
}
|
|
635
1206
|
if (blockedSources.includes(sub.plan)) {
|
|
636
1207
|
blockers.push({
|
|
637
|
-
code:
|
|
638
|
-
message: `Active ${currentSnap.name} special contract \u2014 please contact the contract manager to change plans
|
|
1208
|
+
code: BILLING_ERROR_CODES.PLAN_LOCKED,
|
|
1209
|
+
message: `Active ${currentSnap.name} special contract \u2014 please contact the contract manager to change plans.`,
|
|
1210
|
+
params: {
|
|
1211
|
+
planName: currentSnap.name,
|
|
1212
|
+
planKey: sub.plan
|
|
1213
|
+
}
|
|
639
1214
|
});
|
|
640
1215
|
}
|
|
641
1216
|
for (const [key, row] of Object.entries(limitsCheck)) {
|
|
642
1217
|
if (!row.exceeded) continue;
|
|
643
1218
|
const usedDisplay = isFloatQuota(key) ? row.used.toFixed(1) : row.used.toString();
|
|
644
1219
|
blockers.push({
|
|
645
|
-
code:
|
|
646
|
-
message: `Current usage ${usedDisplay} exceeds the target limit ${row.targetMax} (${key}) in the ${targetSnap.name} plan. Please reduce usage first
|
|
1220
|
+
code: BILLING_ERROR_CODES.QUOTA_OVER_TARGET,
|
|
1221
|
+
message: `Current usage ${usedDisplay} exceeds the target limit ${row.targetMax} (${key}) in the ${targetSnap.name} plan. Please reduce usage first.`,
|
|
1222
|
+
params: {
|
|
1223
|
+
used: usedDisplay,
|
|
1224
|
+
targetMax: row.targetMax,
|
|
1225
|
+
quotaKey: key,
|
|
1226
|
+
planName: targetSnap.name
|
|
1227
|
+
}
|
|
647
1228
|
});
|
|
648
1229
|
}
|
|
649
1230
|
if (featuresLost.length > 0) {
|
|
650
1231
|
warnings.push({
|
|
651
|
-
code:
|
|
652
|
-
message: `Switching means losing access to ${featuresLost.length} feature${featuresLost.length === 1 ? "" : "s"}. Existing data is retained and never deleted \u2014 upgrading again unlocks it
|
|
1232
|
+
code: featuresLost.length === 1 ? BILLING_ERROR_CODES.FEATURE_LOST : BILLING_ERROR_CODES.FEATURES_LOST,
|
|
1233
|
+
message: `Switching means losing access to ${featuresLost.length} feature${featuresLost.length === 1 ? "" : "s"}. Existing data is retained and never deleted \u2014 upgrading again unlocks it.`,
|
|
1234
|
+
params: {
|
|
1235
|
+
count: featuresLost.length
|
|
1236
|
+
}
|
|
653
1237
|
});
|
|
654
1238
|
}
|
|
655
1239
|
if (changeType === "NOOP") {
|
|
656
1240
|
warnings.push({
|
|
657
|
-
code:
|
|
1241
|
+
code: BILLING_ERROR_CODES.NO_CHANGE,
|
|
658
1242
|
message: "Target plan and billing cycle already match the current state."
|
|
659
1243
|
});
|
|
660
1244
|
}
|
|
1245
|
+
if (planDirection === "UP" && cycleDirection === "SHORTER") {
|
|
1246
|
+
warnings.push({
|
|
1247
|
+
code: BILLING_ERROR_CODES.CYCLE_SHORTENS_AT_TERM_END,
|
|
1248
|
+
message: `A ${targetCycle.toLowerCase()} ${targetSnap.name} cannot start inside the ${sub.billingCycle.toLowerCase()} term you are in. The upgrade takes effect when that term ends; to have it today, keep the ${sub.billingCycle.toLowerCase()} cycle.`,
|
|
1249
|
+
params: {
|
|
1250
|
+
targetCycle: targetCycle.toLowerCase(),
|
|
1251
|
+
currentCycle: sub.billingCycle.toLowerCase(),
|
|
1252
|
+
planName: targetSnap.name
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
const changeLandsAt = effectiveAt ?? now;
|
|
1257
|
+
for (const booking of await this.bookingsOutlastingCycle(sub, targetCycle, changeLandsAt)) {
|
|
1258
|
+
blockers.push({
|
|
1259
|
+
code: BILLING_ERROR_CODES.BUNDLE_BOOKING_OUTLASTS_TARGET_CYCLE,
|
|
1260
|
+
message: `A yearly bundle is booked until ${booking.until}. A monthly plan cannot carry it \u2014 cancel the bundle first, or keep the yearly cycle.`,
|
|
1261
|
+
params: {
|
|
1262
|
+
billingCycle: "yearly",
|
|
1263
|
+
planCycle: targetCycle.toLowerCase(),
|
|
1264
|
+
until: booking.until
|
|
1265
|
+
}
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
661
1268
|
const projectedTrialEndsAt = this.trialProjection && sub.status === "TRIAL" ? await this.trialProjection.projectTrialEndsAt({
|
|
662
1269
|
currentPlan: sub.plan,
|
|
663
1270
|
targetPlan,
|
|
@@ -667,6 +1274,8 @@ var PlanChangePreviewService = class {
|
|
|
667
1274
|
}) : null;
|
|
668
1275
|
return {
|
|
669
1276
|
changeType,
|
|
1277
|
+
planDirection,
|
|
1278
|
+
cycleDirection,
|
|
670
1279
|
current: {
|
|
671
1280
|
plan: currentSnap,
|
|
672
1281
|
billingCycle: sub.billingCycle
|
|
@@ -692,16 +1301,53 @@ var PlanChangePreviewService = class {
|
|
|
692
1301
|
const dto = await this.preview(tenantId, targetPlan, targetCycle, now);
|
|
693
1302
|
return dto.blockers;
|
|
694
1303
|
}
|
|
695
|
-
|
|
1304
|
+
/** Catalog order decides which plan is higher; equal keys are `SAME`. */
|
|
1305
|
+
planDirection(catalog, currentPlan, targetPlan) {
|
|
1306
|
+
if (currentPlan === targetPlan) return "SAME";
|
|
1307
|
+
return this.planRank(catalog, targetPlan) > this.planRank(catalog, currentPlan) ? "UP" : "DOWN";
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* YEARLY is the longer commitment; anything else is compared against it.
|
|
1311
|
+
*
|
|
1312
|
+
* Written as a comparison rather than a pair of equality checks so a third
|
|
1313
|
+
* cycle — quarterly is the one that keeps being asked for — orders itself
|
|
1314
|
+
* instead of falling into `SAME` and quietly becoming immediate.
|
|
1315
|
+
*/
|
|
1316
|
+
cycleDirection(currentCycle, targetCycle) {
|
|
1317
|
+
const rank = /* @__PURE__ */ __name((cycle) => cycle === "YEARLY" ? 1 : 0, "rank");
|
|
1318
|
+
const [from, to] = [
|
|
1319
|
+
rank(currentCycle),
|
|
1320
|
+
rank(targetCycle)
|
|
1321
|
+
];
|
|
1322
|
+
if (to === from) return "SAME";
|
|
1323
|
+
return to > from ? "LONGER" : "SHORTER";
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Active bookings whose own rhythm would not fit `targetCycle`.
|
|
1327
|
+
*
|
|
1328
|
+
* Reads the booking's stored rhythm, not the plan's: a booking with none
|
|
1329
|
+
* follows the plan and therefore fits any plan by construction. Empty
|
|
1330
|
+
* without the bundle module, which is a consumer that has no bookings at
|
|
1331
|
+
* all rather than one whose bookings are being ignored.
|
|
1332
|
+
*/
|
|
1333
|
+
async bookingsOutlastingCycle(sub, targetCycle, now) {
|
|
1334
|
+
const subscriptionId = sub.id;
|
|
1335
|
+
if (!this.subscriptionBundles || !subscriptionId) return [];
|
|
1336
|
+
const active = await this.subscriptionBundles.listActiveBySubscription(subscriptionId, now);
|
|
1337
|
+
return active.filter((booking) => booking.billingCycle != null && !bundleCycleFitsPlan(booking.billingCycle, targetCycle)).map((booking) => ({
|
|
1338
|
+
until: (booking.currentPeriodEnd ?? booking.minimumTermEndsAt ?? now).toISOString().slice(0, 10)
|
|
1339
|
+
}));
|
|
1340
|
+
}
|
|
1341
|
+
classify(catalog, currentPlan, currentCycle, targetPlan, targetCycle) {
|
|
696
1342
|
if (currentPlan === targetPlan && currentCycle === targetCycle) return "NOOP";
|
|
697
1343
|
if (currentPlan === targetPlan) return "CYCLE_CHANGE";
|
|
698
|
-
const currentRank = this.planRank(currentPlan);
|
|
699
|
-
const targetRank = this.planRank(targetPlan);
|
|
1344
|
+
const currentRank = this.planRank(catalog, currentPlan);
|
|
1345
|
+
const targetRank = this.planRank(catalog, targetPlan);
|
|
700
1346
|
return targetRank > currentRank ? "UPGRADE" : "DOWNGRADE";
|
|
701
1347
|
}
|
|
702
1348
|
/** Catalog order = rank. Non-marketed plans go to the end. */
|
|
703
|
-
planRank(planId) {
|
|
704
|
-
const plans =
|
|
1349
|
+
planRank(catalog, planId) {
|
|
1350
|
+
const plans = catalog.plans ?? [];
|
|
705
1351
|
const idx = plans.findIndex((p) => p.id === planId);
|
|
706
1352
|
if (idx === -1) return Number.POSITIVE_INFINITY;
|
|
707
1353
|
const plan = plans[idx];
|
|
@@ -710,8 +1356,9 @@ var PlanChangePreviewService = class {
|
|
|
710
1356
|
}
|
|
711
1357
|
resolveEffectiveAt(ctx, now) {
|
|
712
1358
|
if (ctx.status === "TRIAL" && ctx.trialEndsAt) return ctx.trialEndsAt;
|
|
713
|
-
|
|
714
|
-
|
|
1359
|
+
const periodEnd = ctx.currentPeriodEnd ?? periodEndAfter(ctx.startedAt, ctx.currentBillingCycle, now);
|
|
1360
|
+
if (ctx.minimumTermUntil && ctx.minimumTermUntil > periodEnd) return ctx.minimumTermUntil;
|
|
1361
|
+
return periodEnd;
|
|
715
1362
|
}
|
|
716
1363
|
computeProration(ctx, now, current, target, currentCycle, targetCycle) {
|
|
717
1364
|
const periodStart = ctx.currentPeriodStart ?? ctx.startedAt ?? now;
|
|
@@ -725,23 +1372,26 @@ var PlanChangePreviewService = class {
|
|
|
725
1372
|
});
|
|
726
1373
|
}
|
|
727
1374
|
};
|
|
728
|
-
PlanChangePreviewService =
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
1375
|
+
PlanChangePreviewService = _ts_decorate4([
|
|
1376
|
+
Injectable2(),
|
|
1377
|
+
_ts_param2(0, Inject2(PLAN_CATALOG_SOURCE_TOKEN)),
|
|
1378
|
+
_ts_param2(1, Inject2(ENTITLEMENT_SERVICE_TOKEN)),
|
|
1379
|
+
_ts_param2(2, Inject2(SUBSCRIPTION_USAGE_PORT_TOKEN)),
|
|
1380
|
+
_ts_param2(3, Inject2(USAGE_SNAPSHOT_PORT_TOKEN)),
|
|
1381
|
+
_ts_param2(4, Optional2()),
|
|
1382
|
+
_ts_param2(4, Inject2(SELF_SERVICE_BLOCKED_PLANS_TOKEN)),
|
|
1383
|
+
_ts_param2(5, Optional2()),
|
|
1384
|
+
_ts_param2(5, Inject2(TRIAL_PROJECTION_PORT_TOKEN)),
|
|
1385
|
+
_ts_param2(6, Optional2()),
|
|
1386
|
+
_ts_param2(6, Inject2(SUBSCRIPTION_BUNDLE_REPOSITORY_TOKEN)),
|
|
1387
|
+
_ts_metadata2("design:type", Function),
|
|
1388
|
+
_ts_metadata2("design:paramtypes", [
|
|
1389
|
+
typeof PlanCatalogSource === "undefined" ? Object : PlanCatalogSource,
|
|
741
1390
|
typeof EntitlementService === "undefined" ? Object : EntitlementService,
|
|
742
1391
|
typeof SubscriptionUsagePort === "undefined" ? Object : SubscriptionUsagePort,
|
|
743
1392
|
typeof UsageSnapshotPort === "undefined" ? Object : UsageSnapshotPort,
|
|
744
1393
|
Object,
|
|
1394
|
+
Object,
|
|
745
1395
|
Object
|
|
746
1396
|
])
|
|
747
1397
|
], PlanChangePreviewService);
|
|
@@ -757,27 +1407,26 @@ __name(isFloatQuota, "isFloatQuota");
|
|
|
757
1407
|
// src/billing/contract-freeze.tokens.ts
|
|
758
1408
|
var CONTRACT_FREEZE_PORT_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/ContractFreezePort");
|
|
759
1409
|
var CONTRACT_FREEZE_SOURCE_PORT_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/ContractFreezeSourcePort");
|
|
760
|
-
var CONTRACT_FREEZE_PROJECT_KEY_TOKEN = /* @__PURE__ */ Symbol.for("saasicat/nest/ContractFreezeProjectKey");
|
|
761
1410
|
|
|
762
1411
|
// src/billing/pending-plan-materialization.service.ts
|
|
763
|
-
import { Inject as
|
|
764
|
-
function
|
|
1412
|
+
import { Inject as Inject3, Injectable as Injectable3, Logger, Optional as Optional3 } from "@nestjs/common";
|
|
1413
|
+
function _ts_decorate5(decorators, target, key, desc) {
|
|
765
1414
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
766
1415
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
767
1416
|
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;
|
|
768
1417
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
769
1418
|
}
|
|
770
|
-
__name(
|
|
771
|
-
function
|
|
1419
|
+
__name(_ts_decorate5, "_ts_decorate");
|
|
1420
|
+
function _ts_metadata3(k, v) {
|
|
772
1421
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
773
1422
|
}
|
|
774
|
-
__name(
|
|
775
|
-
function
|
|
1423
|
+
__name(_ts_metadata3, "_ts_metadata");
|
|
1424
|
+
function _ts_param3(paramIndex, decorator) {
|
|
776
1425
|
return function(target, key) {
|
|
777
1426
|
decorator(target, key, paramIndex);
|
|
778
1427
|
};
|
|
779
1428
|
}
|
|
780
|
-
__name(
|
|
1429
|
+
__name(_ts_param3, "_ts_param");
|
|
781
1430
|
var PendingPlanMaterializationService = class _PendingPlanMaterializationService {
|
|
782
1431
|
static {
|
|
783
1432
|
__name(this, "PendingPlanMaterializationService");
|
|
@@ -796,50 +1445,63 @@ var PendingPlanMaterializationService = class _PendingPlanMaterializationService
|
|
|
796
1445
|
async materializeDuePlanChanges(now = /* @__PURE__ */ new Date()) {
|
|
797
1446
|
const due = await this.query.findDuePendingPlanChanges(now);
|
|
798
1447
|
let applied = 0;
|
|
1448
|
+
let declined = 0;
|
|
799
1449
|
for (const change of due) {
|
|
1450
|
+
if (cancellationHasLanded(change, now)) {
|
|
1451
|
+
declined += 1;
|
|
1452
|
+
continue;
|
|
1453
|
+
}
|
|
800
1454
|
const cycle = change.pendingBillingCycle ?? "MONTHLY";
|
|
801
1455
|
const period = initialPeriodWindow(now, cycle);
|
|
802
1456
|
try {
|
|
803
|
-
await this.subscriptionWrite.changePlanImmediate(change.tenantId, {
|
|
1457
|
+
const result = await this.subscriptionWrite.changePlanImmediate(change.tenantId, {
|
|
804
1458
|
planId: change.pendingPlan,
|
|
805
1459
|
cycle,
|
|
806
1460
|
periodStart: period.start,
|
|
807
1461
|
periodEnd: period.end,
|
|
808
1462
|
// Status stays (ACTIVE/PAST_DUE etc.) — only the plan is materialized.
|
|
809
|
-
nextStatus: null
|
|
1463
|
+
nextStatus: null,
|
|
1464
|
+
expectedCanceledAt: change.canceledAt
|
|
810
1465
|
});
|
|
1466
|
+
if (!result.claimed) {
|
|
1467
|
+
declined += 1;
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
811
1470
|
this.entitlements.invalidateTenant(change.tenantId);
|
|
812
1471
|
applied += 1;
|
|
813
1472
|
} catch (err) {
|
|
814
1473
|
this.logger.error(`Pending plan materialisation failed (tenant ${change.tenantId}): ${String(err)}`);
|
|
815
1474
|
continue;
|
|
816
1475
|
}
|
|
817
|
-
await this.tryFreeze(change.tenantId, change.pendingPlan, cycle, now);
|
|
1476
|
+
await this.tryFreeze(change.tenantId, change.pendingPlan, cycle, now, change.canceledEffectiveAt ?? change.canceledAt);
|
|
818
1477
|
}
|
|
819
1478
|
if (applied > 0) {
|
|
820
1479
|
this.logger.log(`Pending plan materialisation: applied ${applied} scheduled plan change(s).`);
|
|
821
1480
|
}
|
|
1481
|
+
if (declined > 0) {
|
|
1482
|
+
this.logger.log(`Pending plan materialisation: declined ${declined} change(s) on subscriptions whose cancellation has taken effect.`);
|
|
1483
|
+
}
|
|
822
1484
|
return {
|
|
823
1485
|
applied
|
|
824
1486
|
};
|
|
825
1487
|
}
|
|
826
|
-
async tryFreeze(tenantId, plan, cycle, now) {
|
|
1488
|
+
async tryFreeze(tenantId, plan, cycle, now, endsAt) {
|
|
827
1489
|
if (!this.contractFreeze) return;
|
|
828
1490
|
try {
|
|
829
|
-
await this.contractFreeze.freezeOnPlanChange(tenantId, plan, cycle, now);
|
|
1491
|
+
await this.contractFreeze.freezeOnPlanChange(tenantId, plan, cycle, now, endsAt);
|
|
830
1492
|
} catch (err) {
|
|
831
1493
|
this.logger.error(`Contract freeze after materialisation failed (tenant ${tenantId}): ${String(err)}`);
|
|
832
1494
|
}
|
|
833
1495
|
}
|
|
834
1496
|
};
|
|
835
|
-
PendingPlanMaterializationService =
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1497
|
+
PendingPlanMaterializationService = _ts_decorate5([
|
|
1498
|
+
Injectable3(),
|
|
1499
|
+
_ts_param3(0, Inject3(PENDING_PLAN_QUERY_PORT_TOKEN)),
|
|
1500
|
+
_ts_param3(1, Inject3(SUBSCRIPTION_WRITE_PORT_TOKEN)),
|
|
1501
|
+
_ts_param3(3, Optional3()),
|
|
1502
|
+
_ts_param3(3, Inject3(CONTRACT_FREEZE_PORT_TOKEN)),
|
|
1503
|
+
_ts_metadata3("design:type", Function),
|
|
1504
|
+
_ts_metadata3("design:paramtypes", [
|
|
843
1505
|
typeof PendingPlanQueryPort === "undefined" ? Object : PendingPlanQueryPort,
|
|
844
1506
|
typeof TenantSubscriptionWritePort === "undefined" ? Object : TenantSubscriptionWritePort,
|
|
845
1507
|
typeof EntitlementService === "undefined" ? Object : EntitlementService,
|
|
@@ -848,84 +1510,134 @@ PendingPlanMaterializationService = _ts_decorate6([
|
|
|
848
1510
|
], PendingPlanMaterializationService);
|
|
849
1511
|
|
|
850
1512
|
// src/billing/subscription-contract-freeze.service.ts
|
|
851
|
-
import { Inject as
|
|
852
|
-
function
|
|
1513
|
+
import { Inject as Inject4, Injectable as Injectable4, Optional as Optional4, UnprocessableEntityException } from "@nestjs/common";
|
|
1514
|
+
function _ts_decorate6(decorators, target, key, desc) {
|
|
853
1515
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
854
1516
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
855
1517
|
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;
|
|
856
1518
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
857
1519
|
}
|
|
858
|
-
__name(
|
|
859
|
-
function
|
|
1520
|
+
__name(_ts_decorate6, "_ts_decorate");
|
|
1521
|
+
function _ts_metadata4(k, v) {
|
|
860
1522
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
861
1523
|
}
|
|
862
|
-
__name(
|
|
863
|
-
function
|
|
1524
|
+
__name(_ts_metadata4, "_ts_metadata");
|
|
1525
|
+
function _ts_param4(paramIndex, decorator) {
|
|
864
1526
|
return function(target, key) {
|
|
865
1527
|
decorator(target, key, paramIndex);
|
|
866
1528
|
};
|
|
867
1529
|
}
|
|
868
|
-
__name(
|
|
1530
|
+
__name(_ts_param4, "_ts_param");
|
|
869
1531
|
var SubscriptionContractFreezeService = class {
|
|
870
1532
|
static {
|
|
871
1533
|
__name(this, "SubscriptionContractFreezeService");
|
|
872
1534
|
}
|
|
873
|
-
|
|
1535
|
+
catalogs;
|
|
874
1536
|
entitlements;
|
|
875
1537
|
contracts;
|
|
876
|
-
projectKey;
|
|
877
1538
|
source;
|
|
878
|
-
constructor(
|
|
879
|
-
this.
|
|
1539
|
+
constructor(catalogs, entitlements, contracts, source, writes = null) {
|
|
1540
|
+
this.catalogs = catalogs;
|
|
880
1541
|
this.entitlements = entitlements;
|
|
881
1542
|
this.contracts = contracts;
|
|
882
|
-
this.projectKey = projectKey;
|
|
883
1543
|
this.source = source;
|
|
1544
|
+
if (writes?.bindsPlanVersion === false) {
|
|
1545
|
+
throw new Error("The contract freeze records the plan version a subscription is bound to, and this installation's subscription write does not bind it on a plan change. With @saasicat/adapter-prisma, leave `tenantSubscription.synchronizePlanVersion` at its default of true \u2014 or do not configure `contractFreeze`.");
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
assertPartyFor(tenantId) {
|
|
1549
|
+
return this.contracts.assertPartyFor(tenantId);
|
|
1550
|
+
}
|
|
1551
|
+
async endOnCancellation(tenantId, effectiveAt) {
|
|
1552
|
+
const active = await this.contracts.findActiveByTenantId(tenantId, effectiveAt);
|
|
1553
|
+
if (!active) return;
|
|
1554
|
+
await this.contracts.terminate(active.id, {
|
|
1555
|
+
effectiveUntil: effectiveAt,
|
|
1556
|
+
// Only when it is already over. An ordinary cancellation lands at
|
|
1557
|
+
// the term end, months out, and the customer is under this contract
|
|
1558
|
+
// until then — the invoice side has to keep finding it, and the
|
|
1559
|
+
// window in `findActiveByTenantId` is what stops it afterwards.
|
|
1560
|
+
status: effectiveAt <= /* @__PURE__ */ new Date() ? "terminated" : null
|
|
1561
|
+
});
|
|
1562
|
+
this.entitlements.invalidateTenant(tenantId);
|
|
884
1563
|
}
|
|
885
|
-
async freezeOnPlanChange(tenantId, newPlan, billingCycle, effectiveFrom) {
|
|
1564
|
+
async freezeOnPlanChange(tenantId, newPlan, billingCycle, effectiveFrom, endsAt = null) {
|
|
886
1565
|
const cycle = billingCycle === "YEARLY" ? "yearly" : "monthly";
|
|
887
|
-
const
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1566
|
+
const catalog = await this.catalogs.current();
|
|
1567
|
+
const vatRate = catalog.vatRate;
|
|
1568
|
+
assertTaxRatePercent("catalog.vatRate", vatRate);
|
|
1569
|
+
assertContractWindow(effectiveFrom, endsAt);
|
|
1570
|
+
const bound = await this.source.findBoundPlanVersion(tenantId);
|
|
1571
|
+
if (!bound || bound.planId !== newPlan) {
|
|
1572
|
+
throw new Error(`The contract for tenant ${tenantId} was asked to record plan '${newPlan}', but the subscription is bound to ${bound ? `'${bound.planId}' (version ${bound.id})` : "no plan version"}. Bind the plan version on the subscription before the contract is frozen.`);
|
|
1573
|
+
}
|
|
1574
|
+
const stem = findPlan(catalog, newPlan);
|
|
1575
|
+
const planDef = planDefFromVersion({
|
|
1576
|
+
id: newPlan,
|
|
1577
|
+
name: stem?.name ?? newPlan,
|
|
1578
|
+
tagline: stem?.tagline
|
|
1579
|
+
}, bound);
|
|
1580
|
+
if (isPlanNotSoldInCycle(planDef, billingCycle)) {
|
|
1581
|
+
throw new UnprocessableEntityException(planNotSoldInCycle(planDef, billingCycle));
|
|
1582
|
+
}
|
|
1583
|
+
await this.contracts.assertPartyFor(tenantId);
|
|
1584
|
+
const bundles = await this.source.loadBookedBundles(tenantId, cycle);
|
|
1585
|
+
const planPriceNet = listPriceNet(planDef, billingCycle) ?? 0;
|
|
901
1586
|
const planLineItem = {
|
|
902
1587
|
kind: "plan",
|
|
903
1588
|
sourceKey: newPlan,
|
|
904
|
-
sourceVersionId:
|
|
905
|
-
titleSnapshot: planDef
|
|
906
|
-
descriptionSnapshot: planDef
|
|
1589
|
+
sourceVersionId: bound.id,
|
|
1590
|
+
titleSnapshot: planDef.name ?? newPlan,
|
|
1591
|
+
descriptionSnapshot: planDef.tagline ?? null,
|
|
907
1592
|
quantity: 1,
|
|
908
1593
|
unit: null,
|
|
909
1594
|
priceNet: planPriceNet,
|
|
910
|
-
priceGross: round22(planPriceNet * (1 + vatRate / 100)),
|
|
911
1595
|
billingCycle: cycle,
|
|
912
1596
|
minimumTermUntil: null,
|
|
913
|
-
featuresSnapshot: planDef
|
|
914
|
-
quotaEffectsSnapshot: planDef
|
|
1597
|
+
featuresSnapshot: planDef.features,
|
|
1598
|
+
quotaEffectsSnapshot: planDef.quotas,
|
|
915
1599
|
metadata: null
|
|
916
1600
|
};
|
|
917
|
-
const lineItems = [
|
|
1601
|
+
const lineItems = recordContractLinesMoney([
|
|
918
1602
|
planLineItem,
|
|
919
1603
|
...bundles.lineItems
|
|
920
|
-
]
|
|
921
|
-
|
|
1604
|
+
], {
|
|
1605
|
+
currency: catalog.currency,
|
|
1606
|
+
taxRate: vatRate
|
|
1607
|
+
});
|
|
1608
|
+
const totals = contractTotalsOf(lineItems, cycle);
|
|
1609
|
+
const priceSnapshot = {
|
|
1610
|
+
currency: catalog.currency,
|
|
1611
|
+
billingCycle: cycle,
|
|
1612
|
+
subtotalNet: totals.subtotalNet,
|
|
1613
|
+
discountNet: totals.discountNet,
|
|
1614
|
+
totalNet: totals.totalNet,
|
|
1615
|
+
vatRate,
|
|
1616
|
+
totalGross: totals.totalGross
|
|
1617
|
+
};
|
|
1618
|
+
assertOnePlanLine(lineItems);
|
|
1619
|
+
assertNoNegativeDiscount({
|
|
1620
|
+
priceSnapshot
|
|
1621
|
+
});
|
|
1622
|
+
const previous = await this.contracts.findActiveByTenantId(tenantId, effectiveFrom);
|
|
1623
|
+
if (previous) {
|
|
1624
|
+
await this.contracts.terminate(previous.id, {
|
|
1625
|
+
effectiveUntil: effectiveFrom,
|
|
1626
|
+
status: "superseded"
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
this.entitlements.invalidateTenant(tenantId);
|
|
1630
|
+
const limits = await this.entitlements.computeLimits(tenantId, effectiveFrom, catalog);
|
|
922
1631
|
const data = {
|
|
923
|
-
projectKey: this.projectKey,
|
|
924
1632
|
tenantId,
|
|
925
1633
|
status: "active",
|
|
926
1634
|
effectiveFrom,
|
|
927
|
-
|
|
928
|
-
|
|
1635
|
+
// The successor inherits the ending. A cancellation capped the
|
|
1636
|
+
// contract that was running when it was declared; every contract
|
|
1637
|
+
// after it ends on the same date, or the repair lasts exactly until
|
|
1638
|
+
// the next plan change.
|
|
1639
|
+
effectiveUntil: endsAt,
|
|
1640
|
+
originalPlanVersionId: bound.id,
|
|
929
1641
|
originalBundleVersionIds: bundles.bundleVersionIds,
|
|
930
1642
|
entitlementSnapshot: {
|
|
931
1643
|
plan: limits.plan,
|
|
@@ -936,55 +1648,44 @@ var SubscriptionContractFreezeService = class {
|
|
|
936
1648
|
...limits.features
|
|
937
1649
|
]
|
|
938
1650
|
},
|
|
939
|
-
priceSnapshot
|
|
940
|
-
currency: this.catalog.currency,
|
|
941
|
-
billingCycle: cycle,
|
|
942
|
-
subtotalNet,
|
|
943
|
-
discountNet: 0,
|
|
944
|
-
totalNet: subtotalNet,
|
|
945
|
-
vatRate,
|
|
946
|
-
totalGross: round22(subtotalNet * (1 + vatRate / 100))
|
|
947
|
-
},
|
|
1651
|
+
priceSnapshot,
|
|
948
1652
|
lineItems
|
|
949
1653
|
};
|
|
950
1654
|
await this.contracts.create(data);
|
|
951
1655
|
this.entitlements.invalidateTenant(tenantId);
|
|
952
1656
|
}
|
|
953
1657
|
};
|
|
954
|
-
SubscriptionContractFreezeService =
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1658
|
+
SubscriptionContractFreezeService = _ts_decorate6([
|
|
1659
|
+
Injectable4(),
|
|
1660
|
+
_ts_param4(0, Inject4(PLAN_CATALOG_SOURCE_TOKEN)),
|
|
1661
|
+
_ts_param4(1, Inject4(ENTITLEMENT_SERVICE_TOKEN)),
|
|
1662
|
+
_ts_param4(2, Inject4(SubscriptionContractService)),
|
|
1663
|
+
_ts_param4(3, Inject4(CONTRACT_FREEZE_SOURCE_PORT_TOKEN)),
|
|
1664
|
+
_ts_param4(4, Optional4()),
|
|
1665
|
+
_ts_param4(4, Inject4(SUBSCRIPTION_WRITE_PORT_TOKEN)),
|
|
1666
|
+
_ts_metadata4("design:type", Function),
|
|
1667
|
+
_ts_metadata4("design:paramtypes", [
|
|
1668
|
+
typeof PlanCatalogSource === "undefined" ? Object : PlanCatalogSource,
|
|
964
1669
|
typeof EntitlementService === "undefined" ? Object : EntitlementService,
|
|
965
1670
|
typeof SubscriptionContractService === "undefined" ? Object : SubscriptionContractService,
|
|
966
|
-
|
|
967
|
-
|
|
1671
|
+
typeof ContractFreezeSourcePort === "undefined" ? Object : ContractFreezeSourcePort,
|
|
1672
|
+
Object
|
|
968
1673
|
])
|
|
969
1674
|
], SubscriptionContractFreezeService);
|
|
970
|
-
function round22(value) {
|
|
971
|
-
return Math.round(value * 100) / 100;
|
|
972
|
-
}
|
|
973
|
-
__name(round22, "round2");
|
|
974
1675
|
|
|
975
1676
|
// src/billing/dto/tenant-billing.dto.ts
|
|
976
|
-
import {
|
|
977
|
-
function
|
|
1677
|
+
import { IsISO8601, IsOptional, IsString, Matches } from "class-validator";
|
|
1678
|
+
function _ts_decorate7(decorators, target, key, desc) {
|
|
978
1679
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
979
1680
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
980
1681
|
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;
|
|
981
1682
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
982
1683
|
}
|
|
983
|
-
__name(
|
|
984
|
-
function
|
|
1684
|
+
__name(_ts_decorate7, "_ts_decorate");
|
|
1685
|
+
function _ts_metadata5(k, v) {
|
|
985
1686
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
986
1687
|
}
|
|
987
|
-
__name(
|
|
1688
|
+
__name(_ts_metadata5, "_ts_metadata");
|
|
988
1689
|
var PLAN_OR_CYCLE_PATTERN = /^[A-Z][A-Z0-9_]*$/;
|
|
989
1690
|
var PreviewPlanChangeDto = class {
|
|
990
1691
|
static {
|
|
@@ -993,58 +1694,62 @@ var PreviewPlanChangeDto = class {
|
|
|
993
1694
|
plan;
|
|
994
1695
|
billingCycle;
|
|
995
1696
|
};
|
|
996
|
-
|
|
1697
|
+
_ts_decorate7([
|
|
997
1698
|
IsString(),
|
|
998
1699
|
Matches(PLAN_OR_CYCLE_PATTERN, {
|
|
999
1700
|
message: "plan must be SCREAMING_SNAKE_CASE"
|
|
1000
1701
|
}),
|
|
1001
|
-
|
|
1702
|
+
_ts_metadata5("design:type", String)
|
|
1002
1703
|
], PreviewPlanChangeDto.prototype, "plan", void 0);
|
|
1003
|
-
|
|
1704
|
+
_ts_decorate7([
|
|
1004
1705
|
IsString(),
|
|
1005
1706
|
Matches(PLAN_OR_CYCLE_PATTERN, {
|
|
1006
1707
|
message: "billingCycle must be SCREAMING_SNAKE_CASE (e.g. MONTHLY, YEARLY)"
|
|
1007
1708
|
}),
|
|
1008
|
-
|
|
1709
|
+
_ts_metadata5("design:type", String)
|
|
1009
1710
|
], PreviewPlanChangeDto.prototype, "billingCycle", void 0);
|
|
1010
1711
|
var ChangePlanDto = class extends PreviewPlanChangeDto {
|
|
1011
1712
|
static {
|
|
1012
1713
|
__name(this, "ChangePlanDto");
|
|
1013
1714
|
}
|
|
1014
|
-
/** Immediate change (true) vs. change at period end (false/undefined). */
|
|
1015
|
-
effectiveImmediately;
|
|
1016
1715
|
};
|
|
1017
|
-
_ts_decorate8([
|
|
1018
|
-
IsOptional(),
|
|
1019
|
-
IsBoolean(),
|
|
1020
|
-
_ts_metadata6("design:type", Boolean)
|
|
1021
|
-
], ChangePlanDto.prototype, "effectiveImmediately", void 0);
|
|
1022
1716
|
var CancelSubscriptionDto = class {
|
|
1023
1717
|
static {
|
|
1024
1718
|
__name(this, "CancelSubscriptionDto");
|
|
1025
1719
|
}
|
|
1026
|
-
/**
|
|
1027
|
-
|
|
1720
|
+
/**
|
|
1721
|
+
* The effective date the page showed before the customer confirmed.
|
|
1722
|
+
*
|
|
1723
|
+
* Optional, and checked rather than trusted: if the server's own decision
|
|
1724
|
+
* differs, the request is refused with the new date so the page can ask
|
|
1725
|
+
* again. The window is small and the consequence is not — a dialog opened
|
|
1726
|
+
* before a notice deadline and confirmed after it promises January 2027 and
|
|
1727
|
+
* would deliver January 2028.
|
|
1728
|
+
*
|
|
1729
|
+
* This is the caller telling the server what only the caller knows: what
|
|
1730
|
+
* the human actually agreed to.
|
|
1731
|
+
*/
|
|
1732
|
+
expectedEffectiveAt;
|
|
1028
1733
|
};
|
|
1029
|
-
|
|
1734
|
+
_ts_decorate7([
|
|
1030
1735
|
IsOptional(),
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
], CancelSubscriptionDto.prototype, "
|
|
1736
|
+
IsISO8601(),
|
|
1737
|
+
_ts_metadata5("design:type", String)
|
|
1738
|
+
], CancelSubscriptionDto.prototype, "expectedEffectiveAt", void 0);
|
|
1034
1739
|
|
|
1035
1740
|
// src/billing/dto/onboarding-subscription.dto.ts
|
|
1036
1741
|
import { ArrayMaxSize, IsArray, IsOptional as IsOptional2, IsString as IsString2, IsUUID, Matches as Matches2 } from "class-validator";
|
|
1037
|
-
function
|
|
1742
|
+
function _ts_decorate8(decorators, target, key, desc) {
|
|
1038
1743
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
1039
1744
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
1040
1745
|
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;
|
|
1041
1746
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
1042
1747
|
}
|
|
1043
|
-
__name(
|
|
1044
|
-
function
|
|
1748
|
+
__name(_ts_decorate8, "_ts_decorate");
|
|
1749
|
+
function _ts_metadata6(k, v) {
|
|
1045
1750
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
1046
1751
|
}
|
|
1047
|
-
__name(
|
|
1752
|
+
__name(_ts_metadata6, "_ts_metadata");
|
|
1048
1753
|
var PLAN_OR_CYCLE_PATTERN2 = /^[A-Z][A-Z0-9_]*$/;
|
|
1049
1754
|
var PROMO_CODE_PATTERN = /^[A-Z0-9_-]{4,32}$/i;
|
|
1050
1755
|
var CompleteOnboardingSubscriptionDto = class {
|
|
@@ -1057,47 +1762,188 @@ var CompleteOnboardingSubscriptionDto = class {
|
|
|
1057
1762
|
/**
|
|
1058
1763
|
* Optional — UUIDs of the BundleVersions that should be booked
|
|
1059
1764
|
* together with the plan (P11.7.3). Per bundle, the platform
|
|
1060
|
-
* default minimum term (
|
|
1765
|
+
* default minimum term (none) is set. Bundles are added
|
|
1061
1766
|
* best-effort **after** the plan change — an error on an individual
|
|
1062
1767
|
* bundle (e.g. incompatible with the chosen plan) lands as a
|
|
1063
1768
|
* warning in the response, without rolling back the plan change.
|
|
1064
1769
|
*/
|
|
1065
1770
|
bundleVersionIds;
|
|
1066
1771
|
};
|
|
1067
|
-
|
|
1772
|
+
_ts_decorate8([
|
|
1068
1773
|
IsString2(),
|
|
1069
1774
|
Matches2(PLAN_OR_CYCLE_PATTERN2, {
|
|
1070
1775
|
message: "plan must be SCREAMING_SNAKE_CASE"
|
|
1071
1776
|
}),
|
|
1072
|
-
|
|
1777
|
+
_ts_metadata6("design:type", String)
|
|
1073
1778
|
], CompleteOnboardingSubscriptionDto.prototype, "plan", void 0);
|
|
1074
|
-
|
|
1779
|
+
_ts_decorate8([
|
|
1075
1780
|
IsString2(),
|
|
1076
1781
|
Matches2(PLAN_OR_CYCLE_PATTERN2, {
|
|
1077
1782
|
message: "billingCycle must be SCREAMING_SNAKE_CASE (e.g. MONTHLY, YEARLY)"
|
|
1078
1783
|
}),
|
|
1079
|
-
|
|
1784
|
+
_ts_metadata6("design:type", String)
|
|
1080
1785
|
], CompleteOnboardingSubscriptionDto.prototype, "billingCycle", void 0);
|
|
1786
|
+
_ts_decorate8([
|
|
1787
|
+
IsOptional2(),
|
|
1788
|
+
IsString2(),
|
|
1789
|
+
Matches2(PROMO_CODE_PATTERN, {
|
|
1790
|
+
message: 'promoCode must consist of A\u2013Z, 0\u20139, "-", "_" (4\u201332 characters)'
|
|
1791
|
+
}),
|
|
1792
|
+
_ts_metadata6("design:type", String)
|
|
1793
|
+
], CompleteOnboardingSubscriptionDto.prototype, "promoCode", void 0);
|
|
1794
|
+
_ts_decorate8([
|
|
1795
|
+
IsOptional2(),
|
|
1796
|
+
IsArray(),
|
|
1797
|
+
ArrayMaxSize(10),
|
|
1798
|
+
IsUUID("all", {
|
|
1799
|
+
each: true
|
|
1800
|
+
}),
|
|
1801
|
+
_ts_metadata6("design:type", Array)
|
|
1802
|
+
], CompleteOnboardingSubscriptionDto.prototype, "bundleVersionIds", void 0);
|
|
1803
|
+
|
|
1804
|
+
// src/billing/dto/subscription-bundles.dto.ts
|
|
1805
|
+
import { ArrayMaxSize as ArrayMaxSize2, IsArray as IsArray2, IsIn, IsInt, IsOptional as IsOptional3, IsUUID as IsUUID2, Matches as Matches3, Max, Min } from "class-validator";
|
|
1806
|
+
import { BUNDLE_PRICE_LOOKUP_LIMIT } from "@saasicat/core";
|
|
1807
|
+
function _ts_decorate9(decorators, target, key, desc) {
|
|
1808
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
1809
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
1810
|
+
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;
|
|
1811
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
1812
|
+
}
|
|
1813
|
+
__name(_ts_decorate9, "_ts_decorate");
|
|
1814
|
+
function _ts_metadata7(k, v) {
|
|
1815
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
1816
|
+
}
|
|
1817
|
+
__name(_ts_metadata7, "_ts_metadata");
|
|
1818
|
+
var ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(T.*)?$/;
|
|
1819
|
+
var AddSubscriptionBundleDto = class {
|
|
1820
|
+
static {
|
|
1821
|
+
__name(this, "AddSubscriptionBundleDto");
|
|
1822
|
+
}
|
|
1823
|
+
bundleVersionId;
|
|
1824
|
+
/**
|
|
1825
|
+
* Optional — override for the minimum term (months). `0` = no
|
|
1826
|
+
* minimum term. Default comes from the `SubscriptionBundleConfig`
|
|
1827
|
+
* (platform = no commitment).
|
|
1828
|
+
*/
|
|
1829
|
+
minimumTermMonths;
|
|
1830
|
+
/**
|
|
1831
|
+
* The rhythm to bill this bundle in. Defaults to the plan's.
|
|
1832
|
+
*
|
|
1833
|
+
* A bundle may run in a shorter rhythm than its plan — monthly beside a
|
|
1834
|
+
* yearly plan is the interesting case — but never a longer one. A yearly
|
|
1835
|
+
* bundle on a monthly plan is refused rather than modelled: it has no
|
|
1836
|
+
* boundary to meet, and every one of the plan's twelve period ends is a
|
|
1837
|
+
* moment the plan could stop and leave the bundle committed with nothing to
|
|
1838
|
+
* grant.
|
|
1839
|
+
*/
|
|
1840
|
+
billingCycle;
|
|
1841
|
+
};
|
|
1842
|
+
_ts_decorate9([
|
|
1843
|
+
IsUUID2(),
|
|
1844
|
+
_ts_metadata7("design:type", String)
|
|
1845
|
+
], AddSubscriptionBundleDto.prototype, "bundleVersionId", void 0);
|
|
1846
|
+
_ts_decorate9([
|
|
1847
|
+
IsOptional3(),
|
|
1848
|
+
IsInt(),
|
|
1849
|
+
Min(0),
|
|
1850
|
+
Max(120),
|
|
1851
|
+
_ts_metadata7("design:type", Number)
|
|
1852
|
+
], AddSubscriptionBundleDto.prototype, "minimumTermMonths", void 0);
|
|
1853
|
+
_ts_decorate9([
|
|
1854
|
+
IsOptional3(),
|
|
1855
|
+
IsIn([
|
|
1856
|
+
"MONTHLY",
|
|
1857
|
+
"YEARLY"
|
|
1858
|
+
]),
|
|
1859
|
+
_ts_metadata7("design:type", String)
|
|
1860
|
+
], AddSubscriptionBundleDto.prototype, "billingCycle", void 0);
|
|
1861
|
+
var PreviewSubscriptionBundleDto = class {
|
|
1862
|
+
static {
|
|
1863
|
+
__name(this, "PreviewSubscriptionBundleDto");
|
|
1864
|
+
}
|
|
1865
|
+
/** Add preview: BundleVersion to be booked. */
|
|
1866
|
+
bundleVersionId;
|
|
1867
|
+
/** Cancel preview: existing Bundle booking. */
|
|
1868
|
+
subscriptionBundleId;
|
|
1869
|
+
/** Add preview only — override analogous to `AddSubscriptionBundleDto`. */
|
|
1870
|
+
minimumTermMonths;
|
|
1871
|
+
/**
|
|
1872
|
+
* Add preview only — the rhythm to quote, analogous to
|
|
1873
|
+
* `AddSubscriptionBundleDto`. Defaults to the plan's.
|
|
1874
|
+
*
|
|
1875
|
+
* The booking has taken this since bundles gained a rhythm of their own.
|
|
1876
|
+
* The preview not taking it meant a tenant asking for a monthly bundle on a
|
|
1877
|
+
* yearly plan was quoted the yearly one and then charged the monthly one —
|
|
1878
|
+
* a preview describing a different contract from the one written, which is
|
|
1879
|
+
* the one thing a preview may never do.
|
|
1880
|
+
*/
|
|
1881
|
+
billingCycle;
|
|
1882
|
+
};
|
|
1883
|
+
_ts_decorate9([
|
|
1884
|
+
IsOptional3(),
|
|
1885
|
+
IsUUID2(),
|
|
1886
|
+
_ts_metadata7("design:type", String)
|
|
1887
|
+
], PreviewSubscriptionBundleDto.prototype, "bundleVersionId", void 0);
|
|
1888
|
+
_ts_decorate9([
|
|
1889
|
+
IsOptional3(),
|
|
1890
|
+
IsUUID2(),
|
|
1891
|
+
_ts_metadata7("design:type", String)
|
|
1892
|
+
], PreviewSubscriptionBundleDto.prototype, "subscriptionBundleId", void 0);
|
|
1893
|
+
_ts_decorate9([
|
|
1894
|
+
IsOptional3(),
|
|
1895
|
+
IsInt(),
|
|
1896
|
+
Min(0),
|
|
1897
|
+
Max(120),
|
|
1898
|
+
_ts_metadata7("design:type", Number)
|
|
1899
|
+
], PreviewSubscriptionBundleDto.prototype, "minimumTermMonths", void 0);
|
|
1900
|
+
_ts_decorate9([
|
|
1901
|
+
IsOptional3(),
|
|
1902
|
+
IsIn([
|
|
1903
|
+
"MONTHLY",
|
|
1904
|
+
"YEARLY"
|
|
1905
|
+
]),
|
|
1906
|
+
_ts_metadata7("design:type", String)
|
|
1907
|
+
], PreviewSubscriptionBundleDto.prototype, "billingCycle", void 0);
|
|
1908
|
+
var CancelSubscriptionBundleDto = class {
|
|
1909
|
+
static {
|
|
1910
|
+
__name(this, "CancelSubscriptionBundleDto");
|
|
1911
|
+
}
|
|
1912
|
+
/**
|
|
1913
|
+
* Optional — default = `new Date()` server-side. Format: ISO-8601
|
|
1914
|
+
* (`YYYY-MM-DD` or full timestamp). Usually not set by the tenant
|
|
1915
|
+
* self-service.
|
|
1916
|
+
*/
|
|
1917
|
+
canceledAt;
|
|
1918
|
+
};
|
|
1081
1919
|
_ts_decorate9([
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
message: 'promoCode must consist of A\u2013Z, 0\u20139, "-", "_" (4\u201332 characters)'
|
|
1920
|
+
IsOptional3(),
|
|
1921
|
+
Matches3(ISO_DATE_PATTERN, {
|
|
1922
|
+
message: "canceledAt must be an ISO date (YYYY-MM-DD)"
|
|
1086
1923
|
}),
|
|
1087
1924
|
_ts_metadata7("design:type", String)
|
|
1088
|
-
],
|
|
1925
|
+
], CancelSubscriptionBundleDto.prototype, "canceledAt", void 0);
|
|
1926
|
+
var BundlePriceLookupDto = class {
|
|
1927
|
+
static {
|
|
1928
|
+
__name(this, "BundlePriceLookupDto");
|
|
1929
|
+
}
|
|
1930
|
+
/**
|
|
1931
|
+
* Capped rather than unbounded: the caller names what it is displaying, and
|
|
1932
|
+
* a page shows a catalogue, not a database. Each id costs a lookup.
|
|
1933
|
+
*/
|
|
1934
|
+
bundleVersionIds;
|
|
1935
|
+
};
|
|
1089
1936
|
_ts_decorate9([
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
IsUUID("all", {
|
|
1937
|
+
IsArray2(),
|
|
1938
|
+
ArrayMaxSize2(BUNDLE_PRICE_LOOKUP_LIMIT),
|
|
1939
|
+
IsUUID2("4", {
|
|
1094
1940
|
each: true
|
|
1095
1941
|
}),
|
|
1096
1942
|
_ts_metadata7("design:type", Array)
|
|
1097
|
-
],
|
|
1943
|
+
], BundlePriceLookupDto.prototype, "bundleVersionIds", void 0);
|
|
1098
1944
|
|
|
1099
1945
|
// src/billing/subscription-bundles.service.ts
|
|
1100
|
-
import { Inject as
|
|
1946
|
+
import { Inject as Inject5, Injectable as Injectable5, NotFoundException as NotFoundException2, Optional as Optional5, UnprocessableEntityException as UnprocessableEntityException2 } from "@nestjs/common";
|
|
1101
1947
|
import { BILLING_ERROR_CODES as BILLING_ERROR_CODES2, CATALOG_ERROR_CODES } from "@saasicat/core";
|
|
1102
1948
|
function _ts_decorate10(decorators, target, key, desc) {
|
|
1103
1949
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
@@ -1110,12 +1956,12 @@ function _ts_metadata8(k, v) {
|
|
|
1110
1956
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
1111
1957
|
}
|
|
1112
1958
|
__name(_ts_metadata8, "_ts_metadata");
|
|
1113
|
-
function
|
|
1959
|
+
function _ts_param5(paramIndex, decorator) {
|
|
1114
1960
|
return function(target, key) {
|
|
1115
1961
|
decorator(target, key, paramIndex);
|
|
1116
1962
|
};
|
|
1117
1963
|
}
|
|
1118
|
-
__name(
|
|
1964
|
+
__name(_ts_param5, "_ts_param");
|
|
1119
1965
|
var SubscriptionBundlesService = class {
|
|
1120
1966
|
static {
|
|
1121
1967
|
__name(this, "SubscriptionBundlesService");
|
|
@@ -1128,21 +1974,58 @@ var SubscriptionBundlesService = class {
|
|
|
1128
1974
|
this.repo = repo;
|
|
1129
1975
|
this.bundles = bundles;
|
|
1130
1976
|
this.blockedBundles = blockedBundles;
|
|
1131
|
-
this.defaultMinTermMonths = config.defaultMinimumTermMonths ??
|
|
1977
|
+
this.defaultMinTermMonths = config.defaultMinimumTermMonths ?? DEFAULT_BUNDLE_MINIMUM_TERM_MONTHS;
|
|
1132
1978
|
}
|
|
1133
1979
|
/** All bundle bookings of a subscription (for the "My Bundles" page). */
|
|
1134
|
-
|
|
1980
|
+
/**
|
|
1981
|
+
* The tenant's bookings, with the price each one is actually billed at.
|
|
1982
|
+
*
|
|
1983
|
+
* `planKey` is required because a price is not a property of a bundle
|
|
1984
|
+
* alone: a `BundlePricingOverride` can set a different one per plan, and
|
|
1985
|
+
* the rhythm decides which of the two figures applies. Returning the base
|
|
1986
|
+
* monthly price regardless — which this did until 2026-08-27 — puts a
|
|
1987
|
+
* number on the tenant's screen that nobody is charged, and on a yearly
|
|
1988
|
+
* booking it was out by whatever the yearly price is.
|
|
1989
|
+
*/
|
|
1990
|
+
async listForSubscription(subscriptionId, planKey, planCycle) {
|
|
1135
1991
|
const records = await this.repo.listBySubscription(subscriptionId);
|
|
1136
1992
|
return Promise.all(records.map(async (r) => {
|
|
1137
1993
|
const bv = await this.bundles.findVersionById(r.bundleVersionId);
|
|
1994
|
+
const cycle = r.billingCycle ?? planCycle;
|
|
1138
1995
|
return {
|
|
1139
1996
|
...r,
|
|
1140
1997
|
bundleKey: bv?.bundleKey ?? null,
|
|
1141
1998
|
label: bv?.label ?? null,
|
|
1142
|
-
|
|
1999
|
+
priceNet: bv ? resolveBundlePriceNet(bv, planKey, cycle) : null
|
|
1143
2000
|
};
|
|
1144
2001
|
}));
|
|
1145
2002
|
}
|
|
2003
|
+
/**
|
|
2004
|
+
* List prices for the given bundle versions, in both rhythms, resolved for
|
|
2005
|
+
* one plan.
|
|
2006
|
+
*
|
|
2007
|
+
* The public catalogue cannot answer this: it has no tenant and therefore
|
|
2008
|
+
* no plan, so it serves the base prices and a bundle priced only through an
|
|
2009
|
+
* override reads as having no price at all. A tenant UI that treated those
|
|
2010
|
+
* fields as final hid such a bundle behind "not available in this rhythm"
|
|
2011
|
+
* while the booking would have gone through.
|
|
2012
|
+
*/
|
|
2013
|
+
async resolvePricesFor(planKey, bundleVersionIds) {
|
|
2014
|
+
const entries = await Promise.all(bundleVersionIds.map(async (id) => {
|
|
2015
|
+
const bv = await this.bundles.findVersionById(id);
|
|
2016
|
+
if (!bv || !isLive(bv)) return null;
|
|
2017
|
+
const stem = await this.bundles.findById(bv.bundleId);
|
|
2018
|
+
if (!stem || stem.deletedAt !== null) return null;
|
|
2019
|
+
return [
|
|
2020
|
+
id,
|
|
2021
|
+
{
|
|
2022
|
+
monthlyNet: resolveBundlePriceNet(bv, planKey, "MONTHLY"),
|
|
2023
|
+
yearlyNet: resolveBundlePriceNet(bv, planKey, "YEARLY")
|
|
2024
|
+
}
|
|
2025
|
+
];
|
|
2026
|
+
}));
|
|
2027
|
+
return Object.fromEntries(entries.filter((entry) => entry !== null));
|
|
2028
|
+
}
|
|
1146
2029
|
async addBundleToSubscription(input) {
|
|
1147
2030
|
const bundleVersion = await this.bundles.findVersionById(input.bundleVersionId);
|
|
1148
2031
|
if (!bundleVersion) {
|
|
@@ -1155,7 +2038,7 @@ var SubscriptionBundlesService = class {
|
|
|
1155
2038
|
});
|
|
1156
2039
|
}
|
|
1157
2040
|
if (bundleVersion.publishedAt === null) {
|
|
1158
|
-
throw new
|
|
2041
|
+
throw new UnprocessableEntityException2({
|
|
1159
2042
|
code: CATALOG_ERROR_CODES.BUNDLE_VERSION_NOT_PUBLISHED,
|
|
1160
2043
|
message: `BundleVersion '${input.bundleVersionId}' is not published and cannot be booked.`,
|
|
1161
2044
|
params: {
|
|
@@ -1164,7 +2047,7 @@ var SubscriptionBundlesService = class {
|
|
|
1164
2047
|
});
|
|
1165
2048
|
}
|
|
1166
2049
|
if (bundleVersion.supersededAt !== null) {
|
|
1167
|
-
throw new
|
|
2050
|
+
throw new UnprocessableEntityException2({
|
|
1168
2051
|
code: CATALOG_ERROR_CODES.BUNDLE_VERSION_SUPERSEDED,
|
|
1169
2052
|
message: `BundleVersion '${input.bundleVersionId}' has been superseded by a newer version.`,
|
|
1170
2053
|
params: {
|
|
@@ -1173,7 +2056,7 @@ var SubscriptionBundlesService = class {
|
|
|
1173
2056
|
});
|
|
1174
2057
|
}
|
|
1175
2058
|
if (this.blockedBundles?.bundleKeys?.includes(bundleVersion.bundleKey)) {
|
|
1176
|
-
throw new
|
|
2059
|
+
throw new UnprocessableEntityException2({
|
|
1177
2060
|
code: BILLING_ERROR_CODES2.BUNDLE_NOT_SELF_SERVICE,
|
|
1178
2061
|
message: `Bundle '${bundleVersion.bundleKey}' is only activated via a special contract. Please contact the contract manager.`,
|
|
1179
2062
|
params: {
|
|
@@ -1183,7 +2066,7 @@ var SubscriptionBundlesService = class {
|
|
|
1183
2066
|
}
|
|
1184
2067
|
const planIds = bundleVersion.compatibility?.planIds ?? [];
|
|
1185
2068
|
if (planIds.length > 0 && !planIds.includes(input.currentPlanKey)) {
|
|
1186
|
-
throw new
|
|
2069
|
+
throw new UnprocessableEntityException2({
|
|
1187
2070
|
code: BILLING_ERROR_CODES2.BUNDLE_INCOMPATIBLE_WITH_PLAN,
|
|
1188
2071
|
message: `BundleVersion '${input.bundleVersionId}' is not compatible with plan '${input.currentPlanKey}'. Allowed: [${planIds.join(", ")}].`,
|
|
1189
2072
|
params: {
|
|
@@ -1195,7 +2078,7 @@ var SubscriptionBundlesService = class {
|
|
|
1195
2078
|
}
|
|
1196
2079
|
const active = await this.repo.listActiveBySubscription(input.subscriptionId);
|
|
1197
2080
|
if (active.some((b) => b.bundleVersionId === input.bundleVersionId)) {
|
|
1198
|
-
throw new
|
|
2081
|
+
throw new UnprocessableEntityException2({
|
|
1199
2082
|
code: BILLING_ERROR_CODES2.BUNDLE_ALREADY_SUBSCRIBED,
|
|
1200
2083
|
message: `Subscription '${input.subscriptionId}' has already actively booked this bundle.`,
|
|
1201
2084
|
params: {
|
|
@@ -1204,13 +2087,48 @@ var SubscriptionBundlesService = class {
|
|
|
1204
2087
|
});
|
|
1205
2088
|
}
|
|
1206
2089
|
const startedAt = input.startedAt ?? /* @__PURE__ */ new Date();
|
|
2090
|
+
const billingCycle = input.billingCycle ?? input.planCycle;
|
|
2091
|
+
if (resolveBundlePriceNet(bundleVersion, input.currentPlanKey, billingCycle) === null) {
|
|
2092
|
+
throw new UnprocessableEntityException2({
|
|
2093
|
+
code: BILLING_ERROR_CODES2.BUNDLE_NOT_PRICED_FOR_THIS_PLAN,
|
|
2094
|
+
message: `This bundle has no ${billingCycle.toLowerCase()} price for the ${input.currentPlanKey} plan, so it cannot be booked.`,
|
|
2095
|
+
params: {
|
|
2096
|
+
billingCycle,
|
|
2097
|
+
planKey: input.currentPlanKey
|
|
2098
|
+
}
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
if (!bundleCycleFitsPlan(billingCycle, input.planCycle)) {
|
|
2102
|
+
throw new UnprocessableEntityException2({
|
|
2103
|
+
code: BILLING_ERROR_CODES2.BUNDLE_CYCLE_EXCEEDS_PLAN,
|
|
2104
|
+
message: `A ${billingCycle.toLowerCase()} bundle cannot be booked on a ${input.planCycle.toLowerCase()} plan: its term would outlast the plan that pays for it.`,
|
|
2105
|
+
params: {
|
|
2106
|
+
billingCycle,
|
|
2107
|
+
planCycle: input.planCycle
|
|
2108
|
+
}
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
const currentPeriodEnd = bundleFirstPeriodEnd({
|
|
2112
|
+
startedAt,
|
|
2113
|
+
cycle: billingCycle,
|
|
2114
|
+
planPeriodEnd: input.planPeriodEnd,
|
|
2115
|
+
planAnchorDay: input.planAnchorDay
|
|
2116
|
+
});
|
|
1207
2117
|
const minimumTermMonths = input.minimumTermMonths ?? this.defaultMinTermMonths;
|
|
1208
|
-
const minimumTermEndsAt = minimumTermMonths > 0 ? addMonths(startedAt, minimumTermMonths) : null;
|
|
2118
|
+
const minimumTermEndsAt = clampToParent(minimumTermMonths > 0 ? addMonths(startedAt, minimumTermMonths) : null, input.parentEndsAt);
|
|
1209
2119
|
return this.repo.add({
|
|
1210
2120
|
subscriptionId: input.subscriptionId,
|
|
1211
2121
|
bundleVersionId: input.bundleVersionId,
|
|
1212
2122
|
startedAt,
|
|
1213
|
-
minimumTermEndsAt
|
|
2123
|
+
minimumTermEndsAt,
|
|
2124
|
+
billingCycle,
|
|
2125
|
+
// Both ends together or neither. A start without an end is a window
|
|
2126
|
+
// that cannot be reasoned about: it is not running (nothing says
|
|
2127
|
+
// when it stops) and it is not absent (something is written), and
|
|
2128
|
+
// every reader would have to pick one. A booking made while its plan
|
|
2129
|
+
// had no period has no period of its own either, and says so.
|
|
2130
|
+
currentPeriodStart: currentPeriodEnd === null ? null : startedAt,
|
|
2131
|
+
currentPeriodEnd
|
|
1214
2132
|
});
|
|
1215
2133
|
}
|
|
1216
2134
|
async cancelBundleFromSubscription(input) {
|
|
@@ -1225,7 +2143,7 @@ var SubscriptionBundlesService = class {
|
|
|
1225
2143
|
});
|
|
1226
2144
|
}
|
|
1227
2145
|
if (existing.canceledAt !== null) {
|
|
1228
|
-
throw new
|
|
2146
|
+
throw new UnprocessableEntityException2({
|
|
1229
2147
|
code: BILLING_ERROR_CODES2.SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED,
|
|
1230
2148
|
message: `SubscriptionBundle '${input.subscriptionBundleId}' is already cancelled.`,
|
|
1231
2149
|
params: {
|
|
@@ -1236,8 +2154,15 @@ var SubscriptionBundlesService = class {
|
|
|
1236
2154
|
const canceledAt = input.canceledAt ?? /* @__PURE__ */ new Date();
|
|
1237
2155
|
const canceledEffectiveAt = resolveBundleCancelEffectiveAt({
|
|
1238
2156
|
canceledAt,
|
|
1239
|
-
|
|
1240
|
-
|
|
2157
|
+
// The booking's own period, not the plan's. A monthly bundle beside
|
|
2158
|
+
// a yearly plan ends its month long before the plan ends its year,
|
|
2159
|
+
// and reading the plan's boundary kept such a booking committed —
|
|
2160
|
+
// and billed — until the annual renewal. The plan's boundary is the
|
|
2161
|
+
// fallback for a booking made before bundles had periods, which is
|
|
2162
|
+
// what those were billed against.
|
|
2163
|
+
currentPeriodEnd: existing.currentPeriodEnd ?? input.currentPeriodEnd ?? null,
|
|
2164
|
+
minimumTermEndsAt: existing.minimumTermEndsAt,
|
|
2165
|
+
parentEndsAt: input.parentEndsAt
|
|
1241
2166
|
});
|
|
1242
2167
|
return this.repo.cancel(input.subscriptionBundleId, {
|
|
1243
2168
|
canceledAt,
|
|
@@ -1260,7 +2185,7 @@ var SubscriptionBundlesService = class {
|
|
|
1260
2185
|
});
|
|
1261
2186
|
}
|
|
1262
2187
|
if (existing.canceledAt === null) {
|
|
1263
|
-
throw new
|
|
2188
|
+
throw new UnprocessableEntityException2({
|
|
1264
2189
|
code: BILLING_ERROR_CODES2.SUBSCRIPTION_BUNDLE_NOT_CANCELLED,
|
|
1265
2190
|
message: `SubscriptionBundle '${subscriptionBundleId}' is not cancelled.`,
|
|
1266
2191
|
params: {
|
|
@@ -1269,7 +2194,7 @@ var SubscriptionBundlesService = class {
|
|
|
1269
2194
|
});
|
|
1270
2195
|
}
|
|
1271
2196
|
if (existing.canceledEffectiveAt !== null && existing.canceledEffectiveAt <= /* @__PURE__ */ new Date()) {
|
|
1272
|
-
throw new
|
|
2197
|
+
throw new UnprocessableEntityException2({
|
|
1273
2198
|
code: BILLING_ERROR_CODES2.SUBSCRIPTION_BUNDLE_CANCELLATION_EFFECTIVE,
|
|
1274
2199
|
message: "Cancellation already in effect \u2014 book the bundle again."
|
|
1275
2200
|
});
|
|
@@ -1278,13 +2203,13 @@ var SubscriptionBundlesService = class {
|
|
|
1278
2203
|
}
|
|
1279
2204
|
};
|
|
1280
2205
|
SubscriptionBundlesService = _ts_decorate10([
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
2206
|
+
Injectable5(),
|
|
2207
|
+
_ts_param5(0, Inject5(SUBSCRIPTION_BUNDLE_REPOSITORY_TOKEN)),
|
|
2208
|
+
_ts_param5(1, Inject5(BUNDLE_REPOSITORY_TOKEN)),
|
|
2209
|
+
_ts_param5(2, Optional5()),
|
|
2210
|
+
_ts_param5(2, Inject5(SUBSCRIPTION_BUNDLE_CONFIG_TOKEN)),
|
|
2211
|
+
_ts_param5(3, Optional5()),
|
|
2212
|
+
_ts_param5(3, Inject5(SELF_SERVICE_BLOCKED_BUNDLES_TOKEN)),
|
|
1288
2213
|
_ts_metadata8("design:type", Function),
|
|
1289
2214
|
_ts_metadata8("design:paramtypes", [
|
|
1290
2215
|
typeof SubscriptionBundleRepository === "undefined" ? Object : SubscriptionBundleRepository,
|
|
@@ -1296,7 +2221,9 @@ SubscriptionBundlesService = _ts_decorate10([
|
|
|
1296
2221
|
function resolveBundleCancelEffectiveAt(input) {
|
|
1297
2222
|
const periodEnd = input.currentPeriodEnd ?? input.canceledAt;
|
|
1298
2223
|
const minTermEnd = input.minimumTermEndsAt ?? input.canceledAt;
|
|
1299
|
-
|
|
2224
|
+
const later = periodEnd.getTime() >= minTermEnd.getTime() ? periodEnd : minTermEnd;
|
|
2225
|
+
if (input.parentEndsAt === null) return later;
|
|
2226
|
+
return later.getTime() <= input.parentEndsAt.getTime() ? later : input.parentEndsAt;
|
|
1300
2227
|
}
|
|
1301
2228
|
__name(resolveBundleCancelEffectiveAt, "resolveBundleCancelEffectiveAt");
|
|
1302
2229
|
function addMonths(date, months) {
|
|
@@ -1305,10 +2232,19 @@ function addMonths(date, months) {
|
|
|
1305
2232
|
return out;
|
|
1306
2233
|
}
|
|
1307
2234
|
__name(addMonths, "addMonths");
|
|
2235
|
+
function isLive(version) {
|
|
2236
|
+
return version.publishedAt !== null && version.supersededAt === null;
|
|
2237
|
+
}
|
|
2238
|
+
__name(isLive, "isLive");
|
|
2239
|
+
function clampToParent(ownTermEndsAt, parentEndsAt) {
|
|
2240
|
+
if (ownTermEndsAt === null || parentEndsAt === null) return ownTermEndsAt;
|
|
2241
|
+
return ownTermEndsAt < parentEndsAt ? ownTermEndsAt : parentEndsAt;
|
|
2242
|
+
}
|
|
2243
|
+
__name(clampToParent, "clampToParent");
|
|
1308
2244
|
|
|
1309
2245
|
// src/billing/tenant-billing.controller.ts
|
|
1310
|
-
import { BadRequestException, Body, Controller as Controller2, ForbiddenException as
|
|
1311
|
-
import { AUTH_ERROR_CODES as
|
|
2246
|
+
import { BadRequestException, Body, Controller as Controller2, ForbiddenException as ForbiddenException2, Get as Get2, HttpCode, HttpStatus, Inject as Inject6, Logger as Logger2, ConflictException, NotFoundException as NotFoundException3, Optional as Optional6, Post, Req, UseGuards } from "@nestjs/common";
|
|
2247
|
+
import { AUTH_ERROR_CODES as AUTH_ERROR_CODES2, BILLING_ERROR_CODES as BILLING_ERROR_CODES3 } from "@saasicat/core";
|
|
1312
2248
|
function _ts_decorate11(decorators, target, key, desc) {
|
|
1313
2249
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
1314
2250
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1320,12 +2256,12 @@ function _ts_metadata9(k, v) {
|
|
|
1320
2256
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
1321
2257
|
}
|
|
1322
2258
|
__name(_ts_metadata9, "_ts_metadata");
|
|
1323
|
-
function
|
|
2259
|
+
function _ts_param6(paramIndex, decorator) {
|
|
1324
2260
|
return function(target, key) {
|
|
1325
2261
|
decorator(target, key, paramIndex);
|
|
1326
2262
|
};
|
|
1327
2263
|
}
|
|
1328
|
-
__name(
|
|
2264
|
+
__name(_ts_param6, "_ts_param");
|
|
1329
2265
|
var TenantBillingController = class _TenantBillingController {
|
|
1330
2266
|
static {
|
|
1331
2267
|
__name(this, "TenantBillingController");
|
|
@@ -1345,7 +2281,8 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1345
2281
|
subscriptionBundles;
|
|
1346
2282
|
contractFreeze;
|
|
1347
2283
|
trialProjection;
|
|
1348
|
-
|
|
2284
|
+
cancellationNoticeDays;
|
|
2285
|
+
constructor(entitlements, planPreview, subscriptionUsage, usageSnapshot, subscriptionWrite, tenantIdResolver = null, userIdResolver = null, blockedPlans = null, promoCodes = null, auditService = null, userEmailResolver = null, auditContextResolver = null, subscriptionBundles = null, contractFreeze = null, trialProjection = null, cancellationNoticeDays = NO_NOTICE_PERIOD) {
|
|
1349
2286
|
this.entitlements = entitlements;
|
|
1350
2287
|
this.planPreview = planPreview;
|
|
1351
2288
|
this.subscriptionUsage = subscriptionUsage;
|
|
@@ -1361,6 +2298,7 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1361
2298
|
this.subscriptionBundles = subscriptionBundles;
|
|
1362
2299
|
this.contractFreeze = contractFreeze;
|
|
1363
2300
|
this.trialProjection = trialProjection;
|
|
2301
|
+
this.cancellationNoticeDays = cancellationNoticeDays;
|
|
1364
2302
|
}
|
|
1365
2303
|
logger = new Logger2(_TenantBillingController.name);
|
|
1366
2304
|
// ---------------------------------------------------------------------
|
|
@@ -1413,6 +2351,23 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1413
2351
|
pendingPlanVersionEffectiveAt: sub.pendingPlanVersionEffectiveAt,
|
|
1414
2352
|
pendingPlanVersionAccepted: sub.pendingPlanVersionAccepted,
|
|
1415
2353
|
pendingPlanVersionAcceptedAt: sub.pendingPlanVersionAcceptedAt,
|
|
2354
|
+
canceledAt: sub.canceledAt ?? null,
|
|
2355
|
+
// The same fallback the renewal and the cancel route apply, for the
|
|
2356
|
+
// same reason: on a row written before the two fields separated,
|
|
2357
|
+
// `canceledAt` IS the effective date and the second column is
|
|
2358
|
+
// genuinely null. Reading it strictly here told the page the
|
|
2359
|
+
// subscription was never cancelled — so it hid the end date and
|
|
2360
|
+
// went on offering to cancel something that already had been.
|
|
2361
|
+
canceledEffectiveAt: sub.canceledEffectiveAt ?? sub.canceledAt ?? null,
|
|
2362
|
+
// What a cancellation declared right now would do.
|
|
2363
|
+
//
|
|
2364
|
+
// Here rather than behind a second route, because the page has to
|
|
2365
|
+
// state the date BEFORE the customer confirms, and the rule that
|
|
2366
|
+
// produces it lives on this side: the minimum term and the notice
|
|
2367
|
+
// period are not visible to a browser. With a window configured,
|
|
2368
|
+
// four days of delay cost a whole period — a sentence that has to
|
|
2369
|
+
// be read in the confirmation, not discovered in the receipt.
|
|
2370
|
+
cancellation: this.projectCancellation(sub, /* @__PURE__ */ new Date()),
|
|
1416
2371
|
limits: toEffectiveLimitsSnapshot(limits),
|
|
1417
2372
|
usage,
|
|
1418
2373
|
packageSnapshot: sub.packageSnapshot ?? null,
|
|
@@ -1434,10 +2389,21 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1434
2389
|
const userId = this.requireUserId(req);
|
|
1435
2390
|
const blockedTargets = this.blockedPlans?.asTarget ?? [];
|
|
1436
2391
|
if (blockedTargets.includes(dto.plan)) {
|
|
1437
|
-
throw new
|
|
2392
|
+
throw new ForbiddenException2({
|
|
1438
2393
|
code: BILLING_ERROR_CODES3.PLAN_NOT_SELF_SERVICE,
|
|
1439
|
-
|
|
2394
|
+
// Says what the shipped catalogue says, so an app that has not
|
|
2395
|
+
// translated this code and one that has do not refuse in two
|
|
2396
|
+
// different sentences.
|
|
2397
|
+
message: `${dto.plan} is only activated via a special contract. Please contact the contract manager.`,
|
|
2398
|
+
// `planName` is the key here, and that is the best label this
|
|
2399
|
+
// check has: it decides before any catalogue is read, from
|
|
2400
|
+
// nothing but the key the request sent. The plan-change
|
|
2401
|
+
// preview does have a catalogue and passes the real name,
|
|
2402
|
+
// which is the one a tenant recognises. Either way the
|
|
2403
|
+
// template's `{planName}` resolves to a word rather than a
|
|
2404
|
+
// brace — `error-params-contract.test.js` holds that.
|
|
1440
2405
|
params: {
|
|
2406
|
+
planName: dto.plan,
|
|
1441
2407
|
planKey: dto.plan
|
|
1442
2408
|
}
|
|
1443
2409
|
});
|
|
@@ -1452,17 +2418,45 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1452
2418
|
}
|
|
1453
2419
|
});
|
|
1454
2420
|
}
|
|
1455
|
-
|
|
1456
|
-
if (
|
|
2421
|
+
await this.contractFreeze?.assertPartyFor(tenantId);
|
|
2422
|
+
if (cancellationHasLanded(sub, /* @__PURE__ */ new Date())) {
|
|
2423
|
+
throw new ConflictException({
|
|
2424
|
+
code: "SUBSCRIPTION_ENDED",
|
|
2425
|
+
message: "This subscription has ended. Its plan can no longer be changed.",
|
|
2426
|
+
canceledEffectiveAt: sub.canceledEffectiveAt ?? sub.canceledAt ?? null
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
2429
|
+
const cancellationOutstanding = (sub.canceledEffectiveAt ?? sub.canceledAt) !== null;
|
|
2430
|
+
if (cancellationOutstanding && dto.billingCycle !== sub.billingCycle) {
|
|
2431
|
+
throw new ConflictException({
|
|
2432
|
+
code: "CANCELLATION_LOCKS_THE_CYCLE",
|
|
2433
|
+
message: "This subscription is cancelled, so its billing cycle cannot change. The plan can.",
|
|
2434
|
+
canceledEffectiveAt: sub.canceledEffectiveAt ?? sub.canceledAt ?? null,
|
|
2435
|
+
billingCycle: sub.billingCycle
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
const decision = await this.planPreview.preview(tenantId, dto.plan, dto.billingCycle);
|
|
2439
|
+
if (decision.blockers.length > 0) {
|
|
1457
2440
|
throw new BadRequestException({
|
|
1458
2441
|
code: BILLING_ERROR_CODES3.PLAN_CHANGE_BLOCKED,
|
|
1459
2442
|
message: "Plan change is blocked.",
|
|
1460
|
-
blockers
|
|
2443
|
+
blockers: decision.blockers
|
|
2444
|
+
});
|
|
2445
|
+
}
|
|
2446
|
+
if (cancellationHasLanded(sub, /* @__PURE__ */ new Date())) {
|
|
2447
|
+
throw new ConflictException({
|
|
2448
|
+
code: "SUBSCRIPTION_ENDED",
|
|
2449
|
+
message: "This subscription ended while the request was being decided.",
|
|
2450
|
+
canceledEffectiveAt: sub.canceledEffectiveAt ?? sub.canceledAt ?? null
|
|
1461
2451
|
});
|
|
1462
2452
|
}
|
|
1463
|
-
|
|
2453
|
+
const changedUnderneath = {
|
|
2454
|
+
code: BILLING_ERROR_CODES3.SUBSCRIPTION_CHANGED,
|
|
2455
|
+
message: "This subscription changed while the request was being decided. Reload it."
|
|
2456
|
+
};
|
|
2457
|
+
if (decision.isImmediate) {
|
|
1464
2458
|
const wasTrial = sub.status === "TRIAL";
|
|
1465
|
-
const period = wasTrial ? null : initialPeriodWindow(/* @__PURE__ */ new Date(), dto.billingCycle);
|
|
2459
|
+
const period = wasTrial || cancellationOutstanding ? null : initialPeriodWindow(/* @__PURE__ */ new Date(), dto.billingCycle);
|
|
1466
2460
|
const trialEndsAt = wasTrial && this.trialProjection ? await this.trialProjection.projectTrialEndsAt({
|
|
1467
2461
|
currentPlan: sub.plan,
|
|
1468
2462
|
targetPlan: dto.plan,
|
|
@@ -1476,10 +2470,22 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1476
2470
|
periodStart: period?.start ?? null,
|
|
1477
2471
|
periodEnd: period?.end ?? null,
|
|
1478
2472
|
nextStatus: wasTrial ? null : "ACTIVE",
|
|
1479
|
-
trialEndsAt
|
|
2473
|
+
trialEndsAt,
|
|
2474
|
+
expectedCanceledAt: sub.canceledAt ?? null
|
|
1480
2475
|
});
|
|
2476
|
+
if (!result.claimed) {
|
|
2477
|
+
throw new ConflictException(changedUnderneath);
|
|
2478
|
+
}
|
|
1481
2479
|
this.entitlements.invalidateTenant(tenantId);
|
|
1482
|
-
await this.tryFreezeOnPlanChange(
|
|
2480
|
+
await this.tryFreezeOnPlanChange(
|
|
2481
|
+
tenantId,
|
|
2482
|
+
dto.plan,
|
|
2483
|
+
dto.billingCycle,
|
|
2484
|
+
wasTrial,
|
|
2485
|
+
// A plan change on a cancelled subscription is allowed; the
|
|
2486
|
+
// contract it freezes still ends when the subscription does.
|
|
2487
|
+
sub.canceledEffectiveAt ?? sub.canceledAt ?? null
|
|
2488
|
+
);
|
|
1483
2489
|
await this.auditLog(req, userId, "Subscription", tenantId, "CHANGE_PLAN", {
|
|
1484
2490
|
fromPlan: sub.plan,
|
|
1485
2491
|
fromCycle: sub.billingCycle,
|
|
@@ -1493,12 +2499,16 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1493
2499
|
immediate: true
|
|
1494
2500
|
};
|
|
1495
2501
|
}
|
|
1496
|
-
const effectiveAt =
|
|
1497
|
-
await this.subscriptionWrite.schedulePlanChange(tenantId, {
|
|
2502
|
+
const effectiveAt = decision.effectiveAt ?? sub.currentPeriodEnd ?? /* @__PURE__ */ new Date();
|
|
2503
|
+
const scheduled = await this.subscriptionWrite.schedulePlanChange(tenantId, {
|
|
1498
2504
|
pendingPlan: dto.plan,
|
|
1499
2505
|
pendingBillingCycle: dto.billingCycle,
|
|
1500
|
-
pendingEffectiveAt: effectiveAt
|
|
2506
|
+
pendingEffectiveAt: effectiveAt,
|
|
2507
|
+
expectedCanceledAt: sub.canceledAt ?? null
|
|
1501
2508
|
});
|
|
2509
|
+
if (!scheduled.claimed) {
|
|
2510
|
+
throw new ConflictException(changedUnderneath);
|
|
2511
|
+
}
|
|
1502
2512
|
this.entitlements.invalidateTenant(tenantId);
|
|
1503
2513
|
await this.auditLog(req, userId, "Subscription", tenantId, "SCHEDULE_PLAN_CHANGE", {
|
|
1504
2514
|
fromPlan: sub.plan,
|
|
@@ -1526,10 +2536,12 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1526
2536
|
const warnings = [];
|
|
1527
2537
|
const blockedTargets = this.blockedPlans?.asTarget ?? [];
|
|
1528
2538
|
if (blockedTargets.includes(dto.plan)) {
|
|
1529
|
-
throw new
|
|
2539
|
+
throw new ForbiddenException2({
|
|
1530
2540
|
code: BILLING_ERROR_CODES3.PLAN_NOT_SELF_SERVICE,
|
|
1531
|
-
message: `${dto.plan} is
|
|
2541
|
+
message: `${dto.plan} is only activated via a special contract. Please contact the contract manager.`,
|
|
2542
|
+
// The key as the label — see the note on the plan-change route.
|
|
1532
2543
|
params: {
|
|
2544
|
+
planName: dto.plan,
|
|
1533
2545
|
planKey: dto.plan
|
|
1534
2546
|
}
|
|
1535
2547
|
});
|
|
@@ -1544,6 +2556,13 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1544
2556
|
}
|
|
1545
2557
|
});
|
|
1546
2558
|
}
|
|
2559
|
+
if (cancellationHasLanded(sub, /* @__PURE__ */ new Date())) {
|
|
2560
|
+
throw new ConflictException({
|
|
2561
|
+
code: "SUBSCRIPTION_ENDED",
|
|
2562
|
+
message: "This subscription has ended and cannot be activated again.",
|
|
2563
|
+
canceledEffectiveAt: sub.canceledEffectiveAt ?? sub.canceledAt ?? null
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
1547
2566
|
const blockers = await this.planPreview.assertChangeAllowed(tenantId, dto.plan, dto.billingCycle);
|
|
1548
2567
|
if (blockers.length > 0) {
|
|
1549
2568
|
throw new BadRequestException({
|
|
@@ -1562,6 +2581,7 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1562
2581
|
email: userEmail ?? void 0
|
|
1563
2582
|
}, tx) : null;
|
|
1564
2583
|
let planResult;
|
|
2584
|
+
let claimLost;
|
|
1565
2585
|
let promoRedemption = null;
|
|
1566
2586
|
if (this.subscriptionWrite.applyOnboardingSelection) {
|
|
1567
2587
|
try {
|
|
@@ -1570,8 +2590,10 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1570
2590
|
cycle: dto.billingCycle,
|
|
1571
2591
|
periodStart: period?.start ?? null,
|
|
1572
2592
|
periodEnd: period?.end ?? null,
|
|
1573
|
-
nextStatus: wasTrial ? null : "ACTIVE"
|
|
2593
|
+
nextStatus: wasTrial ? null : "ACTIVE",
|
|
2594
|
+
expectedCanceledAt: sub.canceledAt ?? null
|
|
1574
2595
|
}, redeemPromoCallback);
|
|
2596
|
+
claimLost = !result.claimed;
|
|
1575
2597
|
planResult = {
|
|
1576
2598
|
plan: result.plan,
|
|
1577
2599
|
billingCycle: result.billingCycle
|
|
@@ -1591,14 +2613,30 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1591
2613
|
}
|
|
1592
2614
|
});
|
|
1593
2615
|
}
|
|
2616
|
+
if (claimLost) {
|
|
2617
|
+
throw new ConflictException({
|
|
2618
|
+
code: BILLING_ERROR_CODES3.SUBSCRIPTION_CHANGED,
|
|
2619
|
+
message: "This subscription changed while onboarding was being applied. Reload it."
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
1594
2622
|
} else {
|
|
1595
2623
|
planResult = await this.subscriptionWrite.changePlanImmediate(tenantId, {
|
|
1596
2624
|
planId: dto.plan,
|
|
1597
2625
|
cycle: dto.billingCycle,
|
|
1598
2626
|
periodStart: period?.start ?? null,
|
|
1599
2627
|
periodEnd: period?.end ?? null,
|
|
1600
|
-
nextStatus: wasTrial ? null : "ACTIVE"
|
|
2628
|
+
nextStatus: wasTrial ? null : "ACTIVE",
|
|
2629
|
+
// What this route read, so a cancellation declared since then
|
|
2630
|
+
// takes the row instead of being written over. A landed one was
|
|
2631
|
+
// refused above; a pending one is legitimate and claims fine.
|
|
2632
|
+
expectedCanceledAt: sub.canceledAt ?? null
|
|
1601
2633
|
});
|
|
2634
|
+
if (!planResult.claimed) {
|
|
2635
|
+
throw new ConflictException({
|
|
2636
|
+
code: BILLING_ERROR_CODES3.SUBSCRIPTION_CHANGED,
|
|
2637
|
+
message: "This subscription changed while onboarding was being applied. Reload it."
|
|
2638
|
+
});
|
|
2639
|
+
}
|
|
1602
2640
|
if (dto.promoCode) {
|
|
1603
2641
|
if (!this.promoCodes) {
|
|
1604
2642
|
warnings.push("A promo code was sent, but PromoCodesModule is not loaded \u2014 the code was NOT redeemed.");
|
|
@@ -1637,7 +2675,20 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1637
2675
|
await this.subscriptionBundles.addBundleToSubscription({
|
|
1638
2676
|
subscriptionId: sub.id,
|
|
1639
2677
|
bundleVersionId,
|
|
1640
|
-
currentPlanKey: planResult.plan
|
|
2678
|
+
currentPlanKey: planResult.plan,
|
|
2679
|
+
// Onboarding refuses an ended subscription above, and a
|
|
2680
|
+
// cancellation still outstanding still caps the term.
|
|
2681
|
+
parentEndsAt: sub.canceledEffectiveAt ?? sub.canceledAt ?? null,
|
|
2682
|
+
// The bundle runs in step with the plan it is booked
|
|
2683
|
+
// beside — the plan whose window onboarding just opened.
|
|
2684
|
+
planCycle: dto.billingCycle,
|
|
2685
|
+
planPeriodEnd: period?.end ?? null,
|
|
2686
|
+
// The window onboarding just opened decides, not any
|
|
2687
|
+
// anchor stored from the subscription's previous life —
|
|
2688
|
+
// the plan change reset the rhythm.
|
|
2689
|
+
planAnchorDay: resolvePlanAnchorDay({
|
|
2690
|
+
currentPeriodStart: period?.start ?? null
|
|
2691
|
+
})
|
|
1641
2692
|
});
|
|
1642
2693
|
bundlesAdded += 1;
|
|
1643
2694
|
} catch (err) {
|
|
@@ -1674,6 +2725,13 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1674
2725
|
}
|
|
1675
2726
|
});
|
|
1676
2727
|
}
|
|
2728
|
+
if (cancellationHasLanded(sub, /* @__PURE__ */ new Date())) {
|
|
2729
|
+
throw new ConflictException({
|
|
2730
|
+
code: "SUBSCRIPTION_ENDED",
|
|
2731
|
+
message: "This subscription has ended. There is nothing left to accept.",
|
|
2732
|
+
canceledEffectiveAt: sub.canceledEffectiveAt ?? sub.canceledAt ?? null
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
1677
2735
|
if (!sub.pendingPlanVersion) {
|
|
1678
2736
|
throw new BadRequestException({
|
|
1679
2737
|
code: BILLING_ERROR_CODES3.NO_PENDING_PLAN_VERSION,
|
|
@@ -1703,28 +2761,129 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1703
2761
|
async cancelSubscription(req, dto) {
|
|
1704
2762
|
const tenantId = this.requireTenantId(req);
|
|
1705
2763
|
const userId = this.requireUserId(req);
|
|
1706
|
-
const
|
|
2764
|
+
const now = /* @__PURE__ */ new Date();
|
|
2765
|
+
const sub = await this.subscriptionUsage.findForTenant(tenantId);
|
|
2766
|
+
if (!sub) {
|
|
2767
|
+
throw new NotFoundException3({
|
|
2768
|
+
code: BILLING_ERROR_CODES3.NO_SUBSCRIPTION,
|
|
2769
|
+
message: "This tenant has no subscription to cancel."
|
|
2770
|
+
});
|
|
2771
|
+
}
|
|
2772
|
+
const existing = sub.canceledEffectiveAt ?? sub.canceledAt ?? null;
|
|
2773
|
+
if (existing) {
|
|
2774
|
+
if (this.contractFreeze) {
|
|
2775
|
+
try {
|
|
2776
|
+
await this.contractFreeze.endOnCancellation(tenantId, existing);
|
|
2777
|
+
} catch (err) {
|
|
2778
|
+
this.logger.error(`Ending the contract for an existing cancellation failed (tenant ${tenantId}): ${String(err)}`);
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
return {
|
|
2782
|
+
canceledAt: sub.canceledAt ?? null,
|
|
2783
|
+
canceledEffectiveAt: existing,
|
|
2784
|
+
status: sub.status,
|
|
2785
|
+
// Null rather than recomputed, because the three below explain a
|
|
2786
|
+
// decision that was taken once and is not stored. Deriving them
|
|
2787
|
+
// from the effective date tells the wrong story exactly where it
|
|
2788
|
+
// matters: a declaration that landed a period late has an
|
|
2789
|
+
// earlier term end and `afterNoticeDeadline: true`, and a retry
|
|
2790
|
+
// would report the effective date as the term end and the late
|
|
2791
|
+
// declaration as on time. Null says "not recomputed", which is
|
|
2792
|
+
// the truth; the date, which is stored, is above.
|
|
2793
|
+
termEndsAt: null,
|
|
2794
|
+
noticeDeadline: null,
|
|
2795
|
+
afterNoticeDeadline: null,
|
|
2796
|
+
alreadyCanceled: true
|
|
2797
|
+
};
|
|
2798
|
+
}
|
|
2799
|
+
const decision = this.projectCancellation(sub, now);
|
|
2800
|
+
const expected = dto.expectedEffectiveAt ? new Date(dto.expectedEffectiveAt) : null;
|
|
2801
|
+
const landsImmediately = decision.effectiveAt <= now;
|
|
2802
|
+
const disagrees = expected !== null && (landsImmediately ? expected.getTime() > decision.effectiveAt.getTime() : expected.getTime() !== decision.effectiveAt.getTime());
|
|
2803
|
+
if (disagrees) {
|
|
2804
|
+
throw new ConflictException({
|
|
2805
|
+
code: BILLING_ERROR_CODES3.CANCELLATION_TERMS_CHANGED,
|
|
2806
|
+
message: "The effective date changed since it was shown. Confirm the new one.",
|
|
2807
|
+
effectiveAt: decision.effectiveAt,
|
|
2808
|
+
termEndsAt: decision.termEndsAt,
|
|
2809
|
+
noticeDeadline: decision.noticeDeadline,
|
|
2810
|
+
afterNoticeDeadline: decision.afterNoticeDeadline
|
|
2811
|
+
});
|
|
2812
|
+
}
|
|
2813
|
+
const result = await this.subscriptionWrite.cancelSubscription(tenantId, {
|
|
2814
|
+
canceledAt: now,
|
|
2815
|
+
effectiveAt: decision.effectiveAt,
|
|
2816
|
+
terminateNow: decision.effectiveAt <= now,
|
|
2817
|
+
minimumTermUntil: decision.afterNoticeDeadline ? decision.effectiveAt : void 0
|
|
2818
|
+
});
|
|
2819
|
+
if (result.alreadyCanceled) {
|
|
2820
|
+
return {
|
|
2821
|
+
canceledAt: result.canceledAt,
|
|
2822
|
+
canceledEffectiveAt: result.canceledEffectiveAt,
|
|
2823
|
+
status: result.status,
|
|
2824
|
+
termEndsAt: null,
|
|
2825
|
+
noticeDeadline: null,
|
|
2826
|
+
afterNoticeDeadline: null,
|
|
2827
|
+
alreadyCanceled: true
|
|
2828
|
+
};
|
|
2829
|
+
}
|
|
1707
2830
|
this.entitlements.invalidateTenant(tenantId);
|
|
2831
|
+
if (this.contractFreeze) {
|
|
2832
|
+
try {
|
|
2833
|
+
await this.contractFreeze.endOnCancellation(tenantId, decision.effectiveAt);
|
|
2834
|
+
} catch (err) {
|
|
2835
|
+
this.logger.error(`Ending the contract after a cancellation failed (tenant ${tenantId}): ${String(err)}`);
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
1708
2838
|
await this.auditLog(req, userId, "Subscription", tenantId, "CANCEL_SUBSCRIPTION", {
|
|
1709
|
-
|
|
1710
|
-
|
|
2839
|
+
canceledAt: now.toISOString(),
|
|
2840
|
+
effectiveAt: decision.effectiveAt.toISOString(),
|
|
2841
|
+
afterNoticeDeadline: decision.afterNoticeDeadline,
|
|
1711
2842
|
status: result.status
|
|
1712
2843
|
});
|
|
1713
2844
|
return {
|
|
1714
2845
|
canceledAt: result.canceledAt,
|
|
2846
|
+
canceledEffectiveAt: result.canceledEffectiveAt,
|
|
1715
2847
|
status: result.status,
|
|
1716
|
-
|
|
2848
|
+
// What the page needs to say which period it landed in, and why.
|
|
2849
|
+
termEndsAt: decision.termEndsAt,
|
|
2850
|
+
noticeDeadline: decision.noticeDeadline,
|
|
2851
|
+
afterNoticeDeadline: decision.afterNoticeDeadline,
|
|
2852
|
+
alreadyCanceled: false
|
|
1717
2853
|
};
|
|
1718
2854
|
}
|
|
1719
2855
|
// ---------------------------------------------------------------------
|
|
1720
2856
|
// Helpers
|
|
1721
2857
|
// ---------------------------------------------------------------------
|
|
2858
|
+
/**
|
|
2859
|
+
* What a cancellation declared at `now` would do. One method, because the
|
|
2860
|
+
* page states the date and this route applies it, and two constructions of
|
|
2861
|
+
* the same input are two chances to disagree about a rule the customer
|
|
2862
|
+
* meets once.
|
|
2863
|
+
*/
|
|
2864
|
+
projectCancellation(sub, now) {
|
|
2865
|
+
return decideCancellationFor(
|
|
2866
|
+
{
|
|
2867
|
+
status: sub.status,
|
|
2868
|
+
billingCycle: sub.billingCycle,
|
|
2869
|
+
currentPeriodEnd: sub.currentPeriodEnd ?? null,
|
|
2870
|
+
minimumTermUntil: sub.minimumTermUntil ?? null,
|
|
2871
|
+
trialEndsAt: sub.trialEndsAt ?? null,
|
|
2872
|
+
billingAnchorDay: sub.billingAnchorDay ?? null
|
|
2873
|
+
},
|
|
2874
|
+
now,
|
|
2875
|
+
// The rhythm of the CONTRACT, not of the plan somebody is looking
|
|
2876
|
+
// at: a customer on a yearly subscription is owed the yearly
|
|
2877
|
+
// notice even where the same plan is also sold monthly.
|
|
2878
|
+
noticeDaysFor(this.cancellationNoticeDays, sub.billingCycle)
|
|
2879
|
+
);
|
|
2880
|
+
}
|
|
1722
2881
|
requireTenantId(req) {
|
|
1723
2882
|
const resolver = this.tenantIdResolver ?? ((r) => r.user?.tenantId ?? null);
|
|
1724
2883
|
const tenantId = resolver(req);
|
|
1725
2884
|
if (!tenantId) {
|
|
1726
2885
|
throw new NotFoundException3({
|
|
1727
|
-
code:
|
|
2886
|
+
code: AUTH_ERROR_CODES2.TENANT_CONTEXT_MISSING,
|
|
1728
2887
|
message: "No tenant ID found on the request"
|
|
1729
2888
|
});
|
|
1730
2889
|
}
|
|
@@ -1735,7 +2894,7 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1735
2894
|
const userId = resolver(req);
|
|
1736
2895
|
if (!userId) {
|
|
1737
2896
|
throw new NotFoundException3({
|
|
1738
|
-
code:
|
|
2897
|
+
code: AUTH_ERROR_CODES2.TENANT_CONTEXT_MISSING,
|
|
1739
2898
|
message: "No user ID found on the request"
|
|
1740
2899
|
});
|
|
1741
2900
|
}
|
|
@@ -1790,10 +2949,10 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1790
2949
|
* the freeze happens on the transition to ACTIVE, i.e. on materialization).
|
|
1791
2950
|
* Without a configured `contractFreeze` hook the call is a no-op.
|
|
1792
2951
|
*/
|
|
1793
|
-
async tryFreezeOnPlanChange(tenantId, plan, cycle, wasTrial) {
|
|
2952
|
+
async tryFreezeOnPlanChange(tenantId, plan, cycle, wasTrial, endsAt = null) {
|
|
1794
2953
|
if (wasTrial || !this.contractFreeze) return;
|
|
1795
2954
|
try {
|
|
1796
|
-
await this.contractFreeze.freezeOnPlanChange(tenantId, plan, cycle, /* @__PURE__ */ new Date());
|
|
2955
|
+
await this.contractFreeze.freezeOnPlanChange(tenantId, plan, cycle, /* @__PURE__ */ new Date(), endsAt);
|
|
1797
2956
|
} catch (err) {
|
|
1798
2957
|
this.logger.error(`Contract freeze after plan change failed (tenant ${tenantId}): ${String(err)}`);
|
|
1799
2958
|
}
|
|
@@ -1819,7 +2978,7 @@ var TenantBillingController = class _TenantBillingController {
|
|
|
1819
2978
|
};
|
|
1820
2979
|
_ts_decorate11([
|
|
1821
2980
|
Get2("entitlement"),
|
|
1822
|
-
|
|
2981
|
+
_ts_param6(0, Req()),
|
|
1823
2982
|
_ts_metadata9("design:type", Function),
|
|
1824
2983
|
_ts_metadata9("design:paramtypes", [
|
|
1825
2984
|
typeof RequestLike === "undefined" ? Object : RequestLike
|
|
@@ -1828,7 +2987,7 @@ _ts_decorate11([
|
|
|
1828
2987
|
], TenantBillingController.prototype, "getEntitlement", null);
|
|
1829
2988
|
_ts_decorate11([
|
|
1830
2989
|
Get2("usage"),
|
|
1831
|
-
|
|
2990
|
+
_ts_param6(0, Req()),
|
|
1832
2991
|
_ts_metadata9("design:type", Function),
|
|
1833
2992
|
_ts_metadata9("design:paramtypes", [
|
|
1834
2993
|
typeof RequestLike === "undefined" ? Object : RequestLike
|
|
@@ -1839,8 +2998,8 @@ _ts_decorate11([
|
|
|
1839
2998
|
Post("plan/preview"),
|
|
1840
2999
|
HttpCode(HttpStatus.OK),
|
|
1841
3000
|
UseGuards(TenantAdminGuard),
|
|
1842
|
-
|
|
1843
|
-
|
|
3001
|
+
_ts_param6(0, Req()),
|
|
3002
|
+
_ts_param6(1, Body()),
|
|
1844
3003
|
_ts_metadata9("design:type", Function),
|
|
1845
3004
|
_ts_metadata9("design:paramtypes", [
|
|
1846
3005
|
typeof RequestLike === "undefined" ? Object : RequestLike,
|
|
@@ -1851,8 +3010,8 @@ _ts_decorate11([
|
|
|
1851
3010
|
_ts_decorate11([
|
|
1852
3011
|
Post("plan"),
|
|
1853
3012
|
UseGuards(TenantAdminGuard),
|
|
1854
|
-
|
|
1855
|
-
|
|
3013
|
+
_ts_param6(0, Req()),
|
|
3014
|
+
_ts_param6(1, Body()),
|
|
1856
3015
|
_ts_metadata9("design:type", Function),
|
|
1857
3016
|
_ts_metadata9("design:paramtypes", [
|
|
1858
3017
|
typeof RequestLike === "undefined" ? Object : RequestLike,
|
|
@@ -1863,8 +3022,8 @@ _ts_decorate11([
|
|
|
1863
3022
|
_ts_decorate11([
|
|
1864
3023
|
Post("onboarding/initial-subscription"),
|
|
1865
3024
|
UseGuards(TenantAdminGuard),
|
|
1866
|
-
|
|
1867
|
-
|
|
3025
|
+
_ts_param6(0, Req()),
|
|
3026
|
+
_ts_param6(1, Body()),
|
|
1868
3027
|
_ts_metadata9("design:type", Function),
|
|
1869
3028
|
_ts_metadata9("design:paramtypes", [
|
|
1870
3029
|
typeof RequestLike === "undefined" ? Object : RequestLike,
|
|
@@ -1875,7 +3034,7 @@ _ts_decorate11([
|
|
|
1875
3034
|
_ts_decorate11([
|
|
1876
3035
|
Post("subscription/accept-pending-version"),
|
|
1877
3036
|
UseGuards(TenantAdminGuard),
|
|
1878
|
-
|
|
3037
|
+
_ts_param6(0, Req()),
|
|
1879
3038
|
_ts_metadata9("design:type", Function),
|
|
1880
3039
|
_ts_metadata9("design:paramtypes", [
|
|
1881
3040
|
typeof RequestLike === "undefined" ? Object : RequestLike
|
|
@@ -1885,8 +3044,8 @@ _ts_decorate11([
|
|
|
1885
3044
|
_ts_decorate11([
|
|
1886
3045
|
Post("cancel"),
|
|
1887
3046
|
UseGuards(TenantAdminGuard),
|
|
1888
|
-
|
|
1889
|
-
|
|
3047
|
+
_ts_param6(0, Req()),
|
|
3048
|
+
_ts_param6(1, Body()),
|
|
1890
3049
|
_ts_metadata9("design:type", Function),
|
|
1891
3050
|
_ts_metadata9("design:paramtypes", [
|
|
1892
3051
|
typeof RequestLike === "undefined" ? Object : RequestLike,
|
|
@@ -1897,31 +3056,33 @@ _ts_decorate11([
|
|
|
1897
3056
|
TenantBillingController = _ts_decorate11([
|
|
1898
3057
|
Controller2("billing"),
|
|
1899
3058
|
UseGuards(ComposedTenantAuthGuard),
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
3059
|
+
_ts_param6(0, Inject6(ENTITLEMENT_SERVICE_TOKEN)),
|
|
3060
|
+
_ts_param6(1, Inject6(PlanChangePreviewService)),
|
|
3061
|
+
_ts_param6(2, Inject6(SUBSCRIPTION_USAGE_PORT_TOKEN)),
|
|
3062
|
+
_ts_param6(3, Inject6(USAGE_SNAPSHOT_PORT_TOKEN)),
|
|
3063
|
+
_ts_param6(4, Inject6(SUBSCRIPTION_WRITE_PORT_TOKEN)),
|
|
3064
|
+
_ts_param6(5, Optional6()),
|
|
3065
|
+
_ts_param6(5, Inject6(TENANT_ID_RESOLVER_TOKEN)),
|
|
3066
|
+
_ts_param6(6, Optional6()),
|
|
3067
|
+
_ts_param6(6, Inject6(USER_ID_RESOLVER_TOKEN)),
|
|
3068
|
+
_ts_param6(7, Optional6()),
|
|
3069
|
+
_ts_param6(7, Inject6(SELF_SERVICE_BLOCKED_PLANS_TOKEN)),
|
|
3070
|
+
_ts_param6(8, Optional6()),
|
|
3071
|
+
_ts_param6(8, Inject6(PromoCodesService)),
|
|
3072
|
+
_ts_param6(9, Optional6()),
|
|
3073
|
+
_ts_param6(9, Inject6(AdminAuditService)),
|
|
3074
|
+
_ts_param6(10, Optional6()),
|
|
3075
|
+
_ts_param6(10, Inject6(USER_EMAIL_RESOLVER_TOKEN)),
|
|
3076
|
+
_ts_param6(11, Optional6()),
|
|
3077
|
+
_ts_param6(11, Inject6(AUDIT_CONTEXT_RESOLVER_TOKEN)),
|
|
3078
|
+
_ts_param6(12, Optional6()),
|
|
3079
|
+
_ts_param6(12, Inject6(SubscriptionBundlesService)),
|
|
3080
|
+
_ts_param6(13, Optional6()),
|
|
3081
|
+
_ts_param6(13, Inject6(CONTRACT_FREEZE_PORT_TOKEN)),
|
|
3082
|
+
_ts_param6(14, Optional6()),
|
|
3083
|
+
_ts_param6(14, Inject6(TRIAL_PROJECTION_PORT_TOKEN)),
|
|
3084
|
+
_ts_param6(15, Optional6()),
|
|
3085
|
+
_ts_param6(15, Inject6(CANCELLATION_NOTICE_DAYS_TOKEN)),
|
|
1925
3086
|
_ts_metadata9("design:type", Function),
|
|
1926
3087
|
_ts_metadata9("design:paramtypes", [
|
|
1927
3088
|
typeof EntitlementService === "undefined" ? Object : EntitlementService,
|
|
@@ -1938,12 +3099,14 @@ TenantBillingController = _ts_decorate11([
|
|
|
1938
3099
|
Object,
|
|
1939
3100
|
Object,
|
|
1940
3101
|
Object,
|
|
1941
|
-
Object
|
|
3102
|
+
Object,
|
|
3103
|
+
typeof CancellationNoticePeriods === "undefined" ? Object : CancellationNoticePeriods
|
|
1942
3104
|
])
|
|
1943
3105
|
], TenantBillingController);
|
|
1944
3106
|
|
|
1945
3107
|
// src/billing/tenant-billing.module.ts
|
|
1946
3108
|
import { Module as Module2 } from "@nestjs/common";
|
|
3109
|
+
import { planCatalogSchema as planCatalogSchema2 } from "@saasicat/spec";
|
|
1947
3110
|
function _ts_decorate12(decorators, target, key, desc) {
|
|
1948
3111
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
1949
3112
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1951,6 +3114,27 @@ function _ts_decorate12(decorators, target, key, desc) {
|
|
|
1951
3114
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
1952
3115
|
}
|
|
1953
3116
|
__name(_ts_decorate12, "_ts_decorate");
|
|
3117
|
+
function settingFromCatalog(catalog, key) {
|
|
3118
|
+
const settings = catalog.tenantBilling;
|
|
3119
|
+
const value = settings?.[key];
|
|
3120
|
+
if (value === void 0) {
|
|
3121
|
+
throw new Error(`The plan catalogue carries no tenantBilling.${key}. It is a required section of config/saas.yaml and the loader refuses a file without it, so a catalogue that reaches here without one was built in code: check the object passed to \`planCatalog\`.`);
|
|
3122
|
+
}
|
|
3123
|
+
return value;
|
|
3124
|
+
}
|
|
3125
|
+
__name(settingFromCatalog, "settingFromCatalog");
|
|
3126
|
+
var MOVED_TO_THE_FILE = Object.keys(planCatalogSchema2.properties?.tenantBilling?.properties ?? // Refuses to guess rather than falling back to an empty list: an empty
|
|
3127
|
+
// one would turn this guard off without saying so.
|
|
3128
|
+
(() => {
|
|
3129
|
+
throw new Error("plan-catalog.schema.json declares no tenantBilling properties \u2014 @saasicat/spec and @saasicat/nest are out of step.");
|
|
3130
|
+
})());
|
|
3131
|
+
function assertNoMovedOptions(options) {
|
|
3132
|
+
const given = options;
|
|
3133
|
+
const passed = MOVED_TO_THE_FILE.filter((key) => given[key] !== void 0);
|
|
3134
|
+
if (passed.length === 0) return;
|
|
3135
|
+
throw new Error(`TenantBillingModule.forRoot() no longer accepts ${passed.join(" and ")} \u2014 this moved to config/saas.yaml under \`tenantBilling:\`. Move the value there (both members of each setting are required) and delete it here. The file is the one place these live, so a fallback here would be the second home that change exists to close. See docs/guides/upgrade-to-1.0.md.`);
|
|
3136
|
+
}
|
|
3137
|
+
__name(assertNoMovedOptions, "assertNoMovedOptions");
|
|
1954
3138
|
var TenantBillingModule = class _TenantBillingModule {
|
|
1955
3139
|
static {
|
|
1956
3140
|
__name(this, "TenantBillingModule");
|
|
@@ -1965,11 +3149,22 @@ var TenantBillingModule = class _TenantBillingModule {
|
|
|
1965
3149
|
TenantAdminGuard,
|
|
1966
3150
|
PlanChangePreviewService
|
|
1967
3151
|
];
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
3152
|
+
assertNoMovedOptions(options);
|
|
3153
|
+
providers.push({
|
|
3154
|
+
provide: CANCELLATION_NOTICE_DAYS_TOKEN,
|
|
3155
|
+
useFactory: /* @__PURE__ */ __name((catalog) => settingFromCatalog(catalog, "cancellationNoticeDays"), "useFactory"),
|
|
3156
|
+
inject: [
|
|
3157
|
+
PLAN_CATALOG_SETTINGS_TOKEN
|
|
3158
|
+
]
|
|
3159
|
+
}, {
|
|
3160
|
+
provide: SELF_SERVICE_BLOCKED_PLANS_TOKEN,
|
|
3161
|
+
useFactory: /* @__PURE__ */ __name((catalog) => settingFromCatalog(catalog, "selfServiceBlockedPlans"), "useFactory"),
|
|
3162
|
+
inject: [
|
|
3163
|
+
PLAN_CATALOG_SETTINGS_TOKEN
|
|
3164
|
+
]
|
|
3165
|
+
});
|
|
3166
|
+
if (options.subscriptionBundleRepository) {
|
|
3167
|
+
providers.push(asProvider(SUBSCRIPTION_BUNDLE_REPOSITORY_TOKEN, options.subscriptionBundleRepository));
|
|
1973
3168
|
}
|
|
1974
3169
|
if (options.trialProjectionPort) {
|
|
1975
3170
|
providers.push(asProvider(TRIAL_PROJECTION_PORT_TOKEN, options.trialProjectionPort));
|
|
@@ -1980,10 +3175,10 @@ var TenantBillingModule = class _TenantBillingModule {
|
|
|
1980
3175
|
}
|
|
1981
3176
|
const hasContractFreeze = Boolean(options.contractFreeze);
|
|
1982
3177
|
if (options.contractFreeze) {
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
3178
|
+
if (!options.contractFreeze.subscriberRepository) {
|
|
3179
|
+
throw new Error("TenantBillingModule: `contractFreeze` needs `subscriberRepository` \u2014 a frozen contract names the subscriber it is concluded with, and a plan change for a tenant without one is refused before anything changes.");
|
|
3180
|
+
}
|
|
3181
|
+
providers.push(asProvider(CONTRACT_FREEZE_SOURCE_PORT_TOKEN, options.contractFreeze.sourcePort), asProvider(SUBSCRIPTION_CONTRACT_REPOSITORY_TOKEN, options.contractFreeze.subscriptionContractRepository), ...subscriberProviders(options.contractFreeze.subscriberRepository), SubscriptionContractService, {
|
|
1987
3182
|
provide: CONTRACT_FREEZE_PORT_TOKEN,
|
|
1988
3183
|
useClass: SubscriptionContractFreezeService
|
|
1989
3184
|
});
|
|
@@ -2044,7 +3239,7 @@ TenantBillingModule = _ts_decorate12([
|
|
|
2044
3239
|
], TenantBillingModule);
|
|
2045
3240
|
|
|
2046
3241
|
// src/billing/subscription-bundle-preview.service.ts
|
|
2047
|
-
import { Inject as
|
|
3242
|
+
import { Inject as Inject7, Injectable as Injectable6, NotFoundException as NotFoundException4, Optional as Optional7 } from "@nestjs/common";
|
|
2048
3243
|
import { BILLING_ERROR_CODES as BILLING_ERROR_CODES4, CATALOG_ERROR_CODES as CATALOG_ERROR_CODES2, buildFeatureRequiresIndex as buildFeatureRequiresIndex2, collectUnsatisfiedRequires as collectUnsatisfiedRequires2 } from "@saasicat/core";
|
|
2049
3244
|
function _ts_decorate13(decorators, target, key, desc) {
|
|
2050
3245
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
@@ -2057,12 +3252,12 @@ function _ts_metadata10(k, v) {
|
|
|
2057
3252
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
2058
3253
|
}
|
|
2059
3254
|
__name(_ts_metadata10, "_ts_metadata");
|
|
2060
|
-
function
|
|
3255
|
+
function _ts_param7(paramIndex, decorator) {
|
|
2061
3256
|
return function(target, key) {
|
|
2062
3257
|
decorator(target, key, paramIndex);
|
|
2063
3258
|
};
|
|
2064
3259
|
}
|
|
2065
|
-
__name(
|
|
3260
|
+
__name(_ts_param7, "_ts_param");
|
|
2066
3261
|
var SubscriptionBundlePreviewService = class {
|
|
2067
3262
|
static {
|
|
2068
3263
|
__name(this, "SubscriptionBundlePreviewService");
|
|
@@ -2079,7 +3274,7 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2079
3274
|
this.plans = plans;
|
|
2080
3275
|
this.catalogEntries = catalogEntries;
|
|
2081
3276
|
this.blockedBundles = blockedBundles;
|
|
2082
|
-
this.defaultMinTermMonths = config.defaultMinimumTermMonths ??
|
|
3277
|
+
this.defaultMinTermMonths = config.defaultMinimumTermMonths ?? DEFAULT_BUNDLE_MINIMUM_TERM_MONTHS;
|
|
2083
3278
|
}
|
|
2084
3279
|
async previewAdd(ctx, input, now = /* @__PURE__ */ new Date()) {
|
|
2085
3280
|
const bundleVersion = await this.bundles.findVersionById(input.bundleVersionId);
|
|
@@ -2094,33 +3289,72 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2094
3289
|
}
|
|
2095
3290
|
const blockers = [];
|
|
2096
3291
|
const warnings = [];
|
|
3292
|
+
const planCycle = ctx.billingCycle;
|
|
3293
|
+
const billingCycle = input.billingCycle ?? planCycle;
|
|
3294
|
+
if (!bundleCycleFitsPlan(billingCycle, planCycle)) {
|
|
3295
|
+
blockers.push({
|
|
3296
|
+
code: BILLING_ERROR_CODES4.BUNDLE_CYCLE_EXCEEDS_PLAN,
|
|
3297
|
+
message: `A ${billingCycle.toLowerCase()} bundle cannot run beside a ${planCycle.toLowerCase()} plan: it would still be committed on every day the plan could end.`
|
|
3298
|
+
});
|
|
3299
|
+
}
|
|
2097
3300
|
this.collectBookabilityBlockers(bundleVersion, ctx.currentPlanKey, blockers);
|
|
2098
3301
|
const activeBundleVersions = await this.loadActiveBundleVersions(ctx.subscriptionId);
|
|
2099
3302
|
if (activeBundleVersions.some((bv) => bv.id === bundleVersion.id)) {
|
|
2100
3303
|
blockers.push({
|
|
2101
3304
|
code: "BUNDLE_ALREADY_SUBSCRIBED",
|
|
2102
|
-
message: "This bundle is already actively booked."
|
|
3305
|
+
message: "This bundle is already actively booked.",
|
|
3306
|
+
params: {
|
|
3307
|
+
subscriptionId: ctx.subscriptionId
|
|
3308
|
+
}
|
|
2103
3309
|
});
|
|
2104
3310
|
}
|
|
2105
3311
|
const planFeatures = new Set(await this.resolvePlanFeatures(ctx.currentPlanKey, now));
|
|
2106
3312
|
const redundantFeatures = this.collectRedundantFeatures(bundleVersion, planFeatures, ctx.currentPlanKey, activeBundleVersions);
|
|
2107
3313
|
if (redundantFeatures.length > 0) {
|
|
2108
3314
|
warnings.push({
|
|
2109
|
-
code:
|
|
2110
|
-
message: `${redundantFeatures.length} feature${redundantFeatures.length === 1 ? " is" : "s are"} already included in the plan or another booked bundle \u2014 the bundle would be paid for twice
|
|
3315
|
+
code: BILLING_ERROR_CODES4.REDUNDANT_FEATURES,
|
|
3316
|
+
message: `${redundantFeatures.length} feature${redundantFeatures.length === 1 ? " is" : "s are"} already included in the plan or another booked bundle \u2014 the bundle would be paid for twice.`,
|
|
3317
|
+
params: {
|
|
3318
|
+
count: redundantFeatures.length
|
|
3319
|
+
}
|
|
2111
3320
|
});
|
|
2112
3321
|
}
|
|
2113
3322
|
const missingRequires = await this.collectMissingRequires(bundleVersion, planFeatures, activeBundleVersions);
|
|
2114
3323
|
if (missingRequires.length > 0) {
|
|
2115
3324
|
blockers.push({
|
|
2116
|
-
code:
|
|
2117
|
-
message: `The bundle requires [${missingRequires.join(", ")}] \u2014 present neither in the plan nor in the active bundles
|
|
3325
|
+
code: BILLING_ERROR_CODES4.BUNDLE_FEATURE_DEPENDENCY_UNSATISFIED,
|
|
3326
|
+
message: `The bundle requires [${missingRequires.join(", ")}] \u2014 present neither in the plan nor in the active bundles.`,
|
|
3327
|
+
params: {
|
|
3328
|
+
features: missingRequires.join(", ")
|
|
3329
|
+
}
|
|
3330
|
+
});
|
|
3331
|
+
}
|
|
3332
|
+
const priceNet = resolveBundlePriceNet(bundleVersion, ctx.currentPlanKey, billingCycle);
|
|
3333
|
+
if (priceNet === null) {
|
|
3334
|
+
blockers.push({
|
|
3335
|
+
code: BILLING_ERROR_CODES4.BUNDLE_NOT_PRICED_FOR_THIS_PLAN,
|
|
3336
|
+
message: `This bundle has no ${billingCycle.toLowerCase()} price for the ${ctx.currentPlanKey} plan, so it cannot be booked from here.`,
|
|
3337
|
+
params: {
|
|
3338
|
+
billingCycle,
|
|
3339
|
+
planKey: ctx.currentPlanKey
|
|
3340
|
+
}
|
|
2118
3341
|
});
|
|
2119
3342
|
}
|
|
2120
|
-
const
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
3343
|
+
const planAnchorDay = resolvePlanAnchorDay({
|
|
3344
|
+
billingAnchorDay: ctx.planAnchorDay,
|
|
3345
|
+
currentPeriodStart: ctx.currentPeriodStart,
|
|
3346
|
+
startedAt: ctx.startedAt
|
|
3347
|
+
});
|
|
3348
|
+
const planPeriodEnd = ctx.currentPeriodEnd;
|
|
3349
|
+
const firstPeriodEnd = bundleFirstPeriodEnd({
|
|
3350
|
+
startedAt: now,
|
|
3351
|
+
cycle: billingCycle,
|
|
3352
|
+
planPeriodEnd,
|
|
3353
|
+
planAnchorDay
|
|
3354
|
+
});
|
|
3355
|
+
const proration = ctx.status !== "TRIAL" && priceNet !== null && firstPeriodEnd !== null ? computeProration({
|
|
3356
|
+
periodStart: bundleFirstPeriodStart(firstPeriodEnd, billingCycle, planAnchorDay),
|
|
3357
|
+
periodEnd: firstPeriodEnd,
|
|
2124
3358
|
now,
|
|
2125
3359
|
currentPriceNet: 0,
|
|
2126
3360
|
targetPriceNet: priceNet
|
|
@@ -2129,11 +3363,15 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2129
3363
|
return {
|
|
2130
3364
|
action: "add",
|
|
2131
3365
|
bundle: toSnapshot(bundleVersion),
|
|
2132
|
-
billingCycle
|
|
3366
|
+
billingCycle,
|
|
2133
3367
|
proration,
|
|
2134
3368
|
nextPeriodPriceNet: priceNet,
|
|
2135
3369
|
minimumTermMonths,
|
|
2136
|
-
|
|
3370
|
+
// The same cap the mutation applies, for the same reason and by the
|
|
3371
|
+
// same rule: a zero-month term stays uncommitted.
|
|
3372
|
+
minimumTermEndsAt: clampToParent(minimumTermMonths > 0 ? addMonths(now, minimumTermMonths) : null, ctx.parentEndsAt),
|
|
3373
|
+
firstPeriodEnd,
|
|
3374
|
+
endsWithPlanAt: ctx.parentEndsAt,
|
|
2137
3375
|
redundantFeatures,
|
|
2138
3376
|
missingRequires,
|
|
2139
3377
|
blockers,
|
|
@@ -2166,28 +3404,34 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2166
3404
|
if (existing.canceledAt !== null) {
|
|
2167
3405
|
blockers.push({
|
|
2168
3406
|
code: BILLING_ERROR_CODES4.SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED,
|
|
2169
|
-
message: "This bundle booking is already cancelled."
|
|
3407
|
+
message: "This bundle booking is already cancelled.",
|
|
3408
|
+
params: {
|
|
3409
|
+
subscriptionBundleId: existing.id
|
|
3410
|
+
}
|
|
2170
3411
|
});
|
|
2171
3412
|
}
|
|
3413
|
+
const bookingPeriodEnd = existing.currentPeriodEnd ?? ctx.currentPeriodEnd;
|
|
2172
3414
|
const effectiveAt = resolveBundleCancelEffectiveAt({
|
|
3415
|
+
parentEndsAt: ctx.parentEndsAt,
|
|
2173
3416
|
canceledAt: now,
|
|
2174
|
-
currentPeriodEnd:
|
|
3417
|
+
currentPeriodEnd: bookingPeriodEnd,
|
|
2175
3418
|
minimumTermEndsAt: existing.minimumTermEndsAt
|
|
2176
3419
|
});
|
|
2177
|
-
const periodEnd =
|
|
3420
|
+
const periodEnd = bookingPeriodEnd ?? now;
|
|
2178
3421
|
if (existing.minimumTermEndsAt && existing.minimumTermEndsAt.getTime() > periodEnd.getTime()) {
|
|
2179
3422
|
warnings.push({
|
|
2180
|
-
code:
|
|
3423
|
+
code: BILLING_ERROR_CODES4.MINIMUM_TERM_BINDS,
|
|
2181
3424
|
message: "The minimum term extends beyond the end of the period \u2014 the cancellation only takes effect when the minimum term ends."
|
|
2182
3425
|
});
|
|
2183
3426
|
}
|
|
3427
|
+
const bookedCycle = existing.billingCycle ?? ctx.billingCycle;
|
|
2184
3428
|
return {
|
|
2185
3429
|
action: "cancel",
|
|
2186
3430
|
subscriptionBundleId: existing.id,
|
|
2187
3431
|
bundle: toSnapshot(bundleVersion),
|
|
2188
|
-
billingCycle:
|
|
3432
|
+
billingCycle: bookedCycle,
|
|
2189
3433
|
effectiveAt,
|
|
2190
|
-
nextPeriodSavingsNet: resolveBundlePriceNet(bundleVersion, ctx.currentPlanKey,
|
|
3434
|
+
nextPeriodSavingsNet: resolveBundlePriceNet(bundleVersion, ctx.currentPlanKey, bookedCycle),
|
|
2191
3435
|
blockers,
|
|
2192
3436
|
warnings
|
|
2193
3437
|
};
|
|
@@ -2197,26 +3441,40 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2197
3441
|
if (bundleVersion.publishedAt === null) {
|
|
2198
3442
|
blockers.push({
|
|
2199
3443
|
code: "BUNDLE_VERSION_NOT_PUBLISHED",
|
|
2200
|
-
message: "This bundle version is not published and cannot be booked."
|
|
3444
|
+
message: "This bundle version is not published and cannot be booked.",
|
|
3445
|
+
params: {
|
|
3446
|
+
bundleVersionId: bundleVersion.id
|
|
3447
|
+
}
|
|
2201
3448
|
});
|
|
2202
3449
|
}
|
|
2203
3450
|
if (bundleVersion.supersededAt !== null) {
|
|
2204
3451
|
blockers.push({
|
|
2205
3452
|
code: "BUNDLE_VERSION_SUPERSEDED",
|
|
2206
|
-
message: "This bundle version has been superseded by a newer one."
|
|
3453
|
+
message: "This bundle version has been superseded by a newer one.",
|
|
3454
|
+
params: {
|
|
3455
|
+
bundleVersionId: bundleVersion.id
|
|
3456
|
+
}
|
|
2207
3457
|
});
|
|
2208
3458
|
}
|
|
2209
3459
|
const planIds = bundleVersion.compatibility?.planIds ?? [];
|
|
2210
3460
|
if (planIds.length > 0 && !planIds.includes(currentPlanKey)) {
|
|
2211
3461
|
blockers.push({
|
|
2212
3462
|
code: "BUNDLE_INCOMPATIBLE_WITH_PLAN",
|
|
2213
|
-
message: `The bundle is not compatible with plan '${currentPlanKey}'. Allowed: [${planIds.join(", ")}]
|
|
3463
|
+
message: `The bundle is not compatible with plan '${currentPlanKey}'. Allowed: [${planIds.join(", ")}].`,
|
|
3464
|
+
params: {
|
|
3465
|
+
bundleVersionId: bundleVersion.id,
|
|
3466
|
+
planKey: currentPlanKey,
|
|
3467
|
+
allowedPlanKeys: planIds.join(", ")
|
|
3468
|
+
}
|
|
2214
3469
|
});
|
|
2215
3470
|
}
|
|
2216
3471
|
if (this.blockedBundles?.bundleKeys?.includes(bundleVersion.bundleKey)) {
|
|
2217
3472
|
blockers.push({
|
|
2218
3473
|
code: "BUNDLE_NOT_SELF_SERVICE",
|
|
2219
|
-
message: `Bundle '${bundleVersion.bundleKey}' is only activated via a special contract. Please contact the contract manager
|
|
3474
|
+
message: `Bundle '${bundleVersion.bundleKey}' is only activated via a special contract. Please contact the contract manager.`,
|
|
3475
|
+
params: {
|
|
3476
|
+
bundleKey: bundleVersion.bundleKey
|
|
3477
|
+
}
|
|
2220
3478
|
});
|
|
2221
3479
|
}
|
|
2222
3480
|
}
|
|
@@ -2260,11 +3518,7 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2260
3518
|
*/
|
|
2261
3519
|
async collectMissingRequires(bundleVersion, planFeatures, activeBundleVersions) {
|
|
2262
3520
|
if (!this.catalogEntries) return [];
|
|
2263
|
-
const
|
|
2264
|
-
if (!projectKey) return [];
|
|
2265
|
-
const entries = await this.catalogEntries.listFeatures({
|
|
2266
|
-
projectKey
|
|
2267
|
-
});
|
|
3521
|
+
const entries = await this.catalogEntries.listFeatures({});
|
|
2268
3522
|
const requiresIndex = buildFeatureRequiresIndex2(entries);
|
|
2269
3523
|
const covered = /* @__PURE__ */ new Set([
|
|
2270
3524
|
...planFeatures,
|
|
@@ -2272,24 +3526,19 @@ var SubscriptionBundlePreviewService = class {
|
|
|
2272
3526
|
]);
|
|
2273
3527
|
return collectUnsatisfiedRequires2(bundleVersion.features ?? [], requiresIndex).filter((key) => !covered.has(key));
|
|
2274
3528
|
}
|
|
2275
|
-
/** `projectKey` lives on the bundle stem, not on the version. */
|
|
2276
|
-
async resolveProjectKey(bundleVersion) {
|
|
2277
|
-
const stem = await this.bundles.findById(bundleVersion.bundleId);
|
|
2278
|
-
return stem?.projectKey ?? null;
|
|
2279
|
-
}
|
|
2280
3529
|
};
|
|
2281
3530
|
SubscriptionBundlePreviewService = _ts_decorate13([
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
3531
|
+
Injectable6(),
|
|
3532
|
+
_ts_param7(0, Inject7(SUBSCRIPTION_BUNDLE_REPOSITORY_TOKEN)),
|
|
3533
|
+
_ts_param7(1, Inject7(BUNDLE_REPOSITORY_TOKEN)),
|
|
3534
|
+
_ts_param7(2, Optional7()),
|
|
3535
|
+
_ts_param7(2, Inject7(PLAN_REPOSITORY_TOKEN)),
|
|
3536
|
+
_ts_param7(3, Optional7()),
|
|
3537
|
+
_ts_param7(3, Inject7(CATALOG_ENTRY_REPOSITORY_TOKEN)),
|
|
3538
|
+
_ts_param7(4, Optional7()),
|
|
3539
|
+
_ts_param7(4, Inject7(SELF_SERVICE_BLOCKED_BUNDLES_TOKEN)),
|
|
3540
|
+
_ts_param7(5, Optional7()),
|
|
3541
|
+
_ts_param7(5, Inject7(SUBSCRIPTION_BUNDLE_CONFIG_TOKEN)),
|
|
2293
3542
|
_ts_metadata10("design:type", Function),
|
|
2294
3543
|
_ts_metadata10("design:paramtypes", [
|
|
2295
3544
|
typeof SubscriptionBundleRepository === "undefined" ? Object : SubscriptionBundleRepository,
|
|
@@ -2300,15 +3549,6 @@ SubscriptionBundlePreviewService = _ts_decorate13([
|
|
|
2300
3549
|
typeof SubscriptionBundleConfig === "undefined" ? Object : SubscriptionBundleConfig
|
|
2301
3550
|
])
|
|
2302
3551
|
], SubscriptionBundlePreviewService);
|
|
2303
|
-
function resolveBundlePriceNet(bundleVersion, planKey, billingCycle) {
|
|
2304
|
-
const override = (bundleVersion.pricingOverrides ?? []).find((o) => o.planId === planKey);
|
|
2305
|
-
const yearly = billingCycle === "YEARLY";
|
|
2306
|
-
const raw = yearly ? override?.yearlyNet !== void 0 ? override.yearlyNet : bundleVersion.yearlyNet : override?.monthlyNet !== void 0 ? override.monthlyNet : bundleVersion.monthlyNet;
|
|
2307
|
-
if (raw === null || raw === void 0) return null;
|
|
2308
|
-
const parsed = Number.parseFloat(raw);
|
|
2309
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
2310
|
-
}
|
|
2311
|
-
__name(resolveBundlePriceNet, "resolveBundlePriceNet");
|
|
2312
3552
|
function toSnapshot(bundleVersion) {
|
|
2313
3553
|
return {
|
|
2314
3554
|
bundleKey: bundleVersion.bundleKey,
|
|
@@ -2325,11 +3565,8 @@ function toSnapshot(bundleVersion) {
|
|
|
2325
3565
|
__name(toSnapshot, "toSnapshot");
|
|
2326
3566
|
|
|
2327
3567
|
// src/billing/tenant-subscription-bundles.controller.ts
|
|
2328
|
-
import { BadRequestException as BadRequestException2, Body as Body2, Controller as Controller3, Delete, Get as Get3, HttpCode as HttpCode2, HttpStatus as HttpStatus2, Inject as
|
|
2329
|
-
import { AUTH_ERROR_CODES as
|
|
2330
|
-
|
|
2331
|
-
// src/billing/dto/subscription-bundles.dto.ts
|
|
2332
|
-
import { IsInt, IsOptional as IsOptional3, IsUUID as IsUUID2, Matches as Matches3, Max, Min } from "class-validator";
|
|
3568
|
+
import { BadRequestException as BadRequestException2, Body as Body2, Controller as Controller3, Delete, Get as Get3, HttpCode as HttpCode2, HttpStatus as HttpStatus2, Inject as Inject8, Logger as Logger3, ConflictException as ConflictException2, NotFoundException as NotFoundException5, Optional as Optional8, Param, ParseUUIDPipe, Post as Post2, Req as Req2, UseGuards as UseGuards2 } from "@nestjs/common";
|
|
3569
|
+
import { AUTH_ERROR_CODES as AUTH_ERROR_CODES3, BILLING_ERROR_CODES as BILLING_ERROR_CODES5 } from "@saasicat/core";
|
|
2333
3570
|
function _ts_decorate14(decorators, target, key, desc) {
|
|
2334
3571
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
2335
3572
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -2341,95 +3578,12 @@ function _ts_metadata11(k, v) {
|
|
|
2341
3578
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
2342
3579
|
}
|
|
2343
3580
|
__name(_ts_metadata11, "_ts_metadata");
|
|
2344
|
-
|
|
2345
|
-
var AddSubscriptionBundleDto = class {
|
|
2346
|
-
static {
|
|
2347
|
-
__name(this, "AddSubscriptionBundleDto");
|
|
2348
|
-
}
|
|
2349
|
-
bundleVersionId;
|
|
2350
|
-
/**
|
|
2351
|
-
* Optional — override for the minimum term (months). `0` = no
|
|
2352
|
-
* minimum term. Default comes from the `SubscriptionBundleConfig`
|
|
2353
|
-
* (platform = 12 months).
|
|
2354
|
-
*/
|
|
2355
|
-
minimumTermMonths;
|
|
2356
|
-
};
|
|
2357
|
-
_ts_decorate14([
|
|
2358
|
-
IsUUID2(),
|
|
2359
|
-
_ts_metadata11("design:type", String)
|
|
2360
|
-
], AddSubscriptionBundleDto.prototype, "bundleVersionId", void 0);
|
|
2361
|
-
_ts_decorate14([
|
|
2362
|
-
IsOptional3(),
|
|
2363
|
-
IsInt(),
|
|
2364
|
-
Min(0),
|
|
2365
|
-
Max(120),
|
|
2366
|
-
_ts_metadata11("design:type", Number)
|
|
2367
|
-
], AddSubscriptionBundleDto.prototype, "minimumTermMonths", void 0);
|
|
2368
|
-
var PreviewSubscriptionBundleDto = class {
|
|
2369
|
-
static {
|
|
2370
|
-
__name(this, "PreviewSubscriptionBundleDto");
|
|
2371
|
-
}
|
|
2372
|
-
/** Add preview: BundleVersion to be booked. */
|
|
2373
|
-
bundleVersionId;
|
|
2374
|
-
/** Cancel preview: existing Bundle booking. */
|
|
2375
|
-
subscriptionBundleId;
|
|
2376
|
-
/** Add preview only — override analogous to `AddSubscriptionBundleDto`. */
|
|
2377
|
-
minimumTermMonths;
|
|
2378
|
-
};
|
|
2379
|
-
_ts_decorate14([
|
|
2380
|
-
IsOptional3(),
|
|
2381
|
-
IsUUID2(),
|
|
2382
|
-
_ts_metadata11("design:type", String)
|
|
2383
|
-
], PreviewSubscriptionBundleDto.prototype, "bundleVersionId", void 0);
|
|
2384
|
-
_ts_decorate14([
|
|
2385
|
-
IsOptional3(),
|
|
2386
|
-
IsUUID2(),
|
|
2387
|
-
_ts_metadata11("design:type", String)
|
|
2388
|
-
], PreviewSubscriptionBundleDto.prototype, "subscriptionBundleId", void 0);
|
|
2389
|
-
_ts_decorate14([
|
|
2390
|
-
IsOptional3(),
|
|
2391
|
-
IsInt(),
|
|
2392
|
-
Min(0),
|
|
2393
|
-
Max(120),
|
|
2394
|
-
_ts_metadata11("design:type", Number)
|
|
2395
|
-
], PreviewSubscriptionBundleDto.prototype, "minimumTermMonths", void 0);
|
|
2396
|
-
var CancelSubscriptionBundleDto = class {
|
|
2397
|
-
static {
|
|
2398
|
-
__name(this, "CancelSubscriptionBundleDto");
|
|
2399
|
-
}
|
|
2400
|
-
/**
|
|
2401
|
-
* Optional — default = `new Date()` server-side. Format: ISO-8601
|
|
2402
|
-
* (`YYYY-MM-DD` or full timestamp). Usually not set by the tenant
|
|
2403
|
-
* self-service.
|
|
2404
|
-
*/
|
|
2405
|
-
canceledAt;
|
|
2406
|
-
};
|
|
2407
|
-
_ts_decorate14([
|
|
2408
|
-
IsOptional3(),
|
|
2409
|
-
Matches3(ISO_DATE_PATTERN, {
|
|
2410
|
-
message: "canceledAt must be an ISO date (YYYY-MM-DD)"
|
|
2411
|
-
}),
|
|
2412
|
-
_ts_metadata11("design:type", String)
|
|
2413
|
-
], CancelSubscriptionBundleDto.prototype, "canceledAt", void 0);
|
|
2414
|
-
|
|
2415
|
-
// src/billing/tenant-subscription-bundles.controller.ts
|
|
2416
|
-
function _ts_decorate15(decorators, target, key, desc) {
|
|
2417
|
-
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
2418
|
-
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
2419
|
-
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;
|
|
2420
|
-
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
2421
|
-
}
|
|
2422
|
-
__name(_ts_decorate15, "_ts_decorate");
|
|
2423
|
-
function _ts_metadata12(k, v) {
|
|
2424
|
-
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
2425
|
-
}
|
|
2426
|
-
__name(_ts_metadata12, "_ts_metadata");
|
|
2427
|
-
function _ts_param9(paramIndex, decorator) {
|
|
3581
|
+
function _ts_param8(paramIndex, decorator) {
|
|
2428
3582
|
return function(target, key) {
|
|
2429
3583
|
decorator(target, key, paramIndex);
|
|
2430
3584
|
};
|
|
2431
3585
|
}
|
|
2432
|
-
__name(
|
|
3586
|
+
__name(_ts_param8, "_ts_param");
|
|
2433
3587
|
function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
2434
3588
|
let GeneratedTenantSubscriptionBundlesController = class GeneratedTenantSubscriptionBundlesController2 {
|
|
2435
3589
|
static {
|
|
@@ -2452,17 +3606,44 @@ function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
|
2452
3606
|
}
|
|
2453
3607
|
async list(req) {
|
|
2454
3608
|
const sub = await this.requireSubscription(this.requireTenantId(req));
|
|
2455
|
-
return this.service.listForSubscription(this.requireSubscriptionPk(sub));
|
|
3609
|
+
return this.service.listForSubscription(this.requireSubscriptionPk(sub), sub.plan, sub.billingCycle);
|
|
3610
|
+
}
|
|
3611
|
+
/**
|
|
3612
|
+
* List prices for the bundles the store is showing, resolved for this
|
|
3613
|
+
* tenant's plan.
|
|
3614
|
+
*
|
|
3615
|
+
* A read with a body, like `preview` beside it: the caller names the
|
|
3616
|
+
* bundle versions it is displaying. The public catalogue serves base
|
|
3617
|
+
* prices and has no plan to resolve an override against, so a tenant UI
|
|
3618
|
+
* pricing from it quotes a figure that may not be the one charged — and
|
|
3619
|
+
* reads a bundle priced only through an override as having no price at
|
|
3620
|
+
* all.
|
|
3621
|
+
*/
|
|
3622
|
+
async prices(req, dto) {
|
|
3623
|
+
const sub = await this.requireSubscription(this.requireTenantId(req));
|
|
3624
|
+
return this.service.resolvePricesFor(sub.plan, dto.bundleVersionIds);
|
|
2456
3625
|
}
|
|
2457
3626
|
async add(req, dto) {
|
|
2458
3627
|
const tenantId = this.requireTenantId(req);
|
|
2459
|
-
const sub = await this.
|
|
3628
|
+
const sub = await this.requireRunningSubscription(tenantId);
|
|
3629
|
+
await this.contractFreeze?.assertPartyFor(tenantId);
|
|
2460
3630
|
const result = await this.service.addBundleToSubscription({
|
|
2461
3631
|
subscriptionId: this.requireSubscriptionPk(sub),
|
|
2462
3632
|
bundleVersionId: dto.bundleVersionId,
|
|
2463
3633
|
// Plan KEY (compatibility.planIds is key-based) — not the planVersion UUID.
|
|
2464
3634
|
currentPlanKey: sub.plan,
|
|
2465
|
-
minimumTermMonths: dto.minimumTermMonths
|
|
3635
|
+
minimumTermMonths: dto.minimumTermMonths,
|
|
3636
|
+
// A bundle cannot commit past the subscription paying for it.
|
|
3637
|
+
parentEndsAt: sub.canceledEffectiveAt ?? sub.canceledAt ?? null,
|
|
3638
|
+
// And it runs in the plan's rhythm, on the plan's day.
|
|
3639
|
+
planCycle: sub.billingCycle,
|
|
3640
|
+
planPeriodEnd: sub.currentPeriodEnd ?? null,
|
|
3641
|
+
// Resolved rather than passed through: a null here would let
|
|
3642
|
+
// the arithmetic read the period END, which a short month has
|
|
3643
|
+
// already clamped — and the preview, which resolves, would then
|
|
3644
|
+
// quote a different day from the one booked.
|
|
3645
|
+
planAnchorDay: resolvePlanAnchorDay(sub),
|
|
3646
|
+
billingCycle: dto.billingCycle
|
|
2466
3647
|
});
|
|
2467
3648
|
await this.refreezeContract(tenantId, sub);
|
|
2468
3649
|
return result;
|
|
@@ -2476,7 +3657,7 @@ function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
|
2476
3657
|
message: "Exactly one of bundleVersionId (add preview) or subscriptionBundleId (cancel preview) must be given."
|
|
2477
3658
|
});
|
|
2478
3659
|
}
|
|
2479
|
-
const sub = await this.requireSubscription(this.requireTenantId(req));
|
|
3660
|
+
const sub = hasAdd ? await this.requireRunningSubscription(this.requireTenantId(req)) : await this.requireSubscription(this.requireTenantId(req));
|
|
2480
3661
|
const ctx = {
|
|
2481
3662
|
subscriptionId: this.requireSubscriptionPk(sub),
|
|
2482
3663
|
// Plan KEY (compatibility.planIds is key-based) — not the planVersion UUID.
|
|
@@ -2485,11 +3666,17 @@ function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
|
2485
3666
|
status: sub.status,
|
|
2486
3667
|
startedAt: sub.startedAt,
|
|
2487
3668
|
currentPeriodStart: sub.currentPeriodStart,
|
|
2488
|
-
currentPeriodEnd: sub.currentPeriodEnd
|
|
3669
|
+
currentPeriodEnd: sub.currentPeriodEnd,
|
|
3670
|
+
// The dialog states the term it will be committed to, and the
|
|
3671
|
+
// booking caps that at the parent's end. Both, or the two
|
|
3672
|
+
// describe different contracts.
|
|
3673
|
+
parentEndsAt: sub.canceledEffectiveAt ?? sub.canceledAt ?? null,
|
|
3674
|
+
planAnchorDay: resolvePlanAnchorDay(sub)
|
|
2489
3675
|
};
|
|
2490
3676
|
return hasAdd ? this.previewService.previewAdd(ctx, {
|
|
2491
3677
|
bundleVersionId: dto.bundleVersionId,
|
|
2492
|
-
minimumTermMonths: dto.minimumTermMonths
|
|
3678
|
+
minimumTermMonths: dto.minimumTermMonths,
|
|
3679
|
+
billingCycle: dto.billingCycle
|
|
2493
3680
|
}) : this.previewService.previewCancel(ctx, {
|
|
2494
3681
|
subscriptionBundleId: dto.subscriptionBundleId
|
|
2495
3682
|
});
|
|
@@ -2500,6 +3687,9 @@ function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
|
2500
3687
|
const result = await this.service.cancelBundleFromSubscription({
|
|
2501
3688
|
subscriptionBundleId,
|
|
2502
3689
|
canceledAt: dto.canceledAt ? new Date(dto.canceledAt) : void 0,
|
|
3690
|
+
// A bundle cannot be held past the plan that pays for it, and
|
|
3691
|
+
// the plan may have been cancelled since this was booked.
|
|
3692
|
+
parentEndsAt: sub.canceledEffectiveAt ?? sub.canceledAt ?? null,
|
|
2503
3693
|
currentPeriodEnd: sub.currentPeriodEnd ?? void 0
|
|
2504
3694
|
});
|
|
2505
3695
|
await this.refreezeContract(tenantId, sub);
|
|
@@ -2507,7 +3697,8 @@ function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
|
2507
3697
|
}
|
|
2508
3698
|
async reactivate(req, subscriptionBundleId) {
|
|
2509
3699
|
const tenantId = this.requireTenantId(req);
|
|
2510
|
-
const sub = await this.
|
|
3700
|
+
const sub = await this.requireRunningSubscription(tenantId);
|
|
3701
|
+
await this.contractFreeze?.assertPartyFor(tenantId);
|
|
2511
3702
|
const result = await this.service.reactivateBundle(subscriptionBundleId);
|
|
2512
3703
|
await this.refreezeContract(tenantId, sub);
|
|
2513
3704
|
return result;
|
|
@@ -2522,7 +3713,7 @@ function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
|
2522
3713
|
const tenantId = this.tenantIdResolver(req);
|
|
2523
3714
|
if (!tenantId) {
|
|
2524
3715
|
throw new NotFoundException5({
|
|
2525
|
-
code:
|
|
3716
|
+
code: AUTH_ERROR_CODES3.TENANT_CONTEXT_MISSING,
|
|
2526
3717
|
message: "No tenant ID found on the request"
|
|
2527
3718
|
});
|
|
2528
3719
|
}
|
|
@@ -2541,6 +3732,29 @@ function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
|
2541
3732
|
}
|
|
2542
3733
|
return sub;
|
|
2543
3734
|
}
|
|
3735
|
+
/**
|
|
3736
|
+
* The same subscription, refused once its cancellation has landed.
|
|
3737
|
+
*
|
|
3738
|
+
* A bundle is bought, priced and given a minimum term, and it grants
|
|
3739
|
+
* its features through the parent subscription — which grants nothing
|
|
3740
|
+
* once it has ended. Sold there, it is a commitment that can never
|
|
3741
|
+
* deliver: charged, listed, and inert.
|
|
3742
|
+
*
|
|
3743
|
+
* Reading and cancelling stay open on purpose. A tenant whose
|
|
3744
|
+
* subscription ended must still be able to see what they booked and
|
|
3745
|
+
* tidy it up; what closes is the till.
|
|
3746
|
+
*/
|
|
3747
|
+
async requireRunningSubscription(tenantId) {
|
|
3748
|
+
const sub = await this.requireSubscription(tenantId);
|
|
3749
|
+
if (cancellationHasLanded(sub, /* @__PURE__ */ new Date())) {
|
|
3750
|
+
throw new ConflictException2({
|
|
3751
|
+
code: "SUBSCRIPTION_ENDED",
|
|
3752
|
+
message: "This subscription has ended. Bundles can no longer be booked for it.",
|
|
3753
|
+
canceledEffectiveAt: sub.canceledEffectiveAt ?? sub.canceledAt ?? null
|
|
3754
|
+
});
|
|
3755
|
+
}
|
|
3756
|
+
return sub;
|
|
3757
|
+
}
|
|
2544
3758
|
/**
|
|
2545
3759
|
* Re-freeze with the unchanged plan after bundle add/cancel (#61) —
|
|
2546
3760
|
* only if the consumer has wired the ContractFreezePort.
|
|
@@ -2553,82 +3767,106 @@ function buildTenantSubscriptionBundlesController(extraGuards = []) {
|
|
|
2553
3767
|
}
|
|
2554
3768
|
return;
|
|
2555
3769
|
}
|
|
3770
|
+
if (cancellationHasLanded(sub, /* @__PURE__ */ new Date())) return;
|
|
2556
3771
|
try {
|
|
2557
|
-
await this.contractFreeze.freezeOnPlanChange(
|
|
3772
|
+
await this.contractFreeze.freezeOnPlanChange(
|
|
3773
|
+
tenantId,
|
|
3774
|
+
sub.planVersion.planId,
|
|
3775
|
+
sub.billingCycle,
|
|
3776
|
+
/* @__PURE__ */ new Date(),
|
|
3777
|
+
// Cancelling a bundle stays open on a cancelled
|
|
3778
|
+
// subscription, and it re-freezes: the replacement contract
|
|
3779
|
+
// ends when the subscription does, like the one it succeeds.
|
|
3780
|
+
sub.canceledEffectiveAt ?? sub.canceledAt ?? null
|
|
3781
|
+
);
|
|
2558
3782
|
} catch (err) {
|
|
2559
3783
|
this.logger.error(`Contract freeze after bundle mutation failed (tenant ${tenantId}): ${String(err)}`);
|
|
2560
3784
|
}
|
|
2561
3785
|
}
|
|
2562
3786
|
};
|
|
2563
|
-
|
|
3787
|
+
_ts_decorate14([
|
|
2564
3788
|
Get3(),
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
3789
|
+
_ts_param8(0, Req2()),
|
|
3790
|
+
_ts_metadata11("design:type", Function),
|
|
3791
|
+
_ts_metadata11("design:paramtypes", [
|
|
2568
3792
|
typeof RequestLike === "undefined" ? Object : RequestLike
|
|
2569
3793
|
]),
|
|
2570
|
-
|
|
3794
|
+
_ts_metadata11("design:returntype", Promise)
|
|
2571
3795
|
], GeneratedTenantSubscriptionBundlesController.prototype, "list", null);
|
|
2572
|
-
|
|
3796
|
+
_ts_decorate14([
|
|
3797
|
+
Post2("prices"),
|
|
3798
|
+
_ts_param8(0, Req2()),
|
|
3799
|
+
_ts_param8(1, Body2()),
|
|
3800
|
+
_ts_metadata11("design:type", Function),
|
|
3801
|
+
_ts_metadata11("design:paramtypes", [
|
|
3802
|
+
typeof RequestLike === "undefined" ? Object : RequestLike,
|
|
3803
|
+
typeof BundlePriceLookupDto === "undefined" ? Object : BundlePriceLookupDto
|
|
3804
|
+
]),
|
|
3805
|
+
_ts_metadata11("design:returntype", Promise)
|
|
3806
|
+
], GeneratedTenantSubscriptionBundlesController.prototype, "prices", null);
|
|
3807
|
+
_ts_decorate14([
|
|
2573
3808
|
Post2(),
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
3809
|
+
UseGuards2(TenantAdminGuard),
|
|
3810
|
+
_ts_param8(0, Req2()),
|
|
3811
|
+
_ts_param8(1, Body2()),
|
|
3812
|
+
_ts_metadata11("design:type", Function),
|
|
3813
|
+
_ts_metadata11("design:paramtypes", [
|
|
2578
3814
|
typeof RequestLike === "undefined" ? Object : RequestLike,
|
|
2579
3815
|
typeof AddSubscriptionBundleDto === "undefined" ? Object : AddSubscriptionBundleDto
|
|
2580
3816
|
]),
|
|
2581
|
-
|
|
3817
|
+
_ts_metadata11("design:returntype", Promise)
|
|
2582
3818
|
], GeneratedTenantSubscriptionBundlesController.prototype, "add", null);
|
|
2583
|
-
|
|
3819
|
+
_ts_decorate14([
|
|
2584
3820
|
Post2("preview"),
|
|
2585
3821
|
HttpCode2(HttpStatus2.OK),
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
3822
|
+
_ts_param8(0, Req2()),
|
|
3823
|
+
_ts_param8(1, Body2()),
|
|
3824
|
+
_ts_metadata11("design:type", Function),
|
|
3825
|
+
_ts_metadata11("design:paramtypes", [
|
|
2590
3826
|
typeof RequestLike === "undefined" ? Object : RequestLike,
|
|
2591
3827
|
typeof PreviewSubscriptionBundleDto === "undefined" ? Object : PreviewSubscriptionBundleDto
|
|
2592
3828
|
]),
|
|
2593
|
-
|
|
3829
|
+
_ts_metadata11("design:returntype", Promise)
|
|
2594
3830
|
], GeneratedTenantSubscriptionBundlesController.prototype, "preview", null);
|
|
2595
|
-
|
|
3831
|
+
_ts_decorate14([
|
|
2596
3832
|
Delete(":id"),
|
|
3833
|
+
UseGuards2(TenantAdminGuard),
|
|
2597
3834
|
HttpCode2(HttpStatus2.OK),
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
3835
|
+
_ts_param8(0, Req2()),
|
|
3836
|
+
_ts_param8(1, Param("id", new ParseUUIDPipe())),
|
|
3837
|
+
_ts_param8(2, Body2()),
|
|
3838
|
+
_ts_metadata11("design:type", Function),
|
|
3839
|
+
_ts_metadata11("design:paramtypes", [
|
|
2603
3840
|
typeof RequestLike === "undefined" ? Object : RequestLike,
|
|
2604
3841
|
String,
|
|
2605
3842
|
typeof CancelSubscriptionBundleDto === "undefined" ? Object : CancelSubscriptionBundleDto
|
|
2606
3843
|
]),
|
|
2607
|
-
|
|
3844
|
+
_ts_metadata11("design:returntype", Promise)
|
|
2608
3845
|
], GeneratedTenantSubscriptionBundlesController.prototype, "cancel", null);
|
|
2609
|
-
|
|
3846
|
+
_ts_decorate14([
|
|
2610
3847
|
Post2(":id/reactivate"),
|
|
3848
|
+
UseGuards2(TenantAdminGuard),
|
|
2611
3849
|
HttpCode2(HttpStatus2.OK),
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
3850
|
+
_ts_param8(0, Req2()),
|
|
3851
|
+
_ts_param8(1, Param("id", new ParseUUIDPipe())),
|
|
3852
|
+
_ts_metadata11("design:type", Function),
|
|
3853
|
+
_ts_metadata11("design:paramtypes", [
|
|
2616
3854
|
typeof RequestLike === "undefined" ? Object : RequestLike,
|
|
2617
3855
|
String
|
|
2618
3856
|
]),
|
|
2619
|
-
|
|
3857
|
+
_ts_metadata11("design:returntype", Promise)
|
|
2620
3858
|
], GeneratedTenantSubscriptionBundlesController.prototype, "reactivate", null);
|
|
2621
|
-
GeneratedTenantSubscriptionBundlesController =
|
|
3859
|
+
GeneratedTenantSubscriptionBundlesController = _ts_decorate14([
|
|
2622
3860
|
Controller3("billing/subscription-bundles"),
|
|
2623
3861
|
UseGuards2(ComposedTenantAuthGuard, ...extraGuards),
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
3862
|
+
_ts_param8(0, Inject8(SubscriptionBundlesService)),
|
|
3863
|
+
_ts_param8(1, Inject8(SubscriptionBundlePreviewService)),
|
|
3864
|
+
_ts_param8(2, Inject8(SUBSCRIPTION_USAGE_PORT_TOKEN)),
|
|
3865
|
+
_ts_param8(3, Inject8(TENANT_ID_RESOLVER_TOKEN)),
|
|
3866
|
+
_ts_param8(4, Optional8()),
|
|
3867
|
+
_ts_param8(4, Inject8(CONTRACT_FREEZE_PORT_TOKEN)),
|
|
3868
|
+
_ts_metadata11("design:type", Function),
|
|
3869
|
+
_ts_metadata11("design:paramtypes", [
|
|
2632
3870
|
typeof SubscriptionBundlesService === "undefined" ? Object : SubscriptionBundlesService,
|
|
2633
3871
|
typeof SubscriptionBundlePreviewService === "undefined" ? Object : SubscriptionBundlePreviewService,
|
|
2634
3872
|
typeof SubscriptionUsagePort === "undefined" ? Object : SubscriptionUsagePort,
|
|
@@ -2642,13 +3880,13 @@ __name(buildTenantSubscriptionBundlesController, "buildTenantSubscriptionBundles
|
|
|
2642
3880
|
|
|
2643
3881
|
// src/billing/subscription-bundles.module.ts
|
|
2644
3882
|
import { Module as Module3 } from "@nestjs/common";
|
|
2645
|
-
function
|
|
3883
|
+
function _ts_decorate15(decorators, target, key, desc) {
|
|
2646
3884
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
2647
3885
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
2648
3886
|
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;
|
|
2649
3887
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
2650
3888
|
}
|
|
2651
|
-
__name(
|
|
3889
|
+
__name(_ts_decorate15, "_ts_decorate");
|
|
2652
3890
|
var SubscriptionBundleModule = class _SubscriptionBundleModule {
|
|
2653
3891
|
static {
|
|
2654
3892
|
__name(this, "SubscriptionBundleModule");
|
|
@@ -2661,8 +3899,14 @@ var SubscriptionBundleModule = class _SubscriptionBundleModule {
|
|
|
2661
3899
|
] : [],
|
|
2662
3900
|
{
|
|
2663
3901
|
provide: SUBSCRIPTION_BUNDLE_CONFIG_TOKEN,
|
|
3902
|
+
// Handed over, not decided. The default belongs to the
|
|
3903
|
+
// services, because a consumer can construct those directly and
|
|
3904
|
+
// both paths have to answer the same — and a module filling the
|
|
3905
|
+
// value in makes their default unreachable, which is what kept
|
|
3906
|
+
// every add-on committed for twelve months after the default
|
|
3907
|
+
// had already been changed to none.
|
|
2664
3908
|
useValue: {
|
|
2665
|
-
defaultMinimumTermMonths: options.defaultMinimumTermMonths
|
|
3909
|
+
defaultMinimumTermMonths: options.defaultMinimumTermMonths
|
|
2666
3910
|
}
|
|
2667
3911
|
},
|
|
2668
3912
|
...options.selfServiceBlockedBundles ? [
|
|
@@ -2704,26 +3948,43 @@ var SubscriptionBundleModule = class _SubscriptionBundleModule {
|
|
|
2704
3948
|
};
|
|
2705
3949
|
}
|
|
2706
3950
|
};
|
|
2707
|
-
SubscriptionBundleModule =
|
|
3951
|
+
SubscriptionBundleModule = _ts_decorate15([
|
|
2708
3952
|
Module3({})
|
|
2709
3953
|
], SubscriptionBundleModule);
|
|
2710
3954
|
|
|
2711
3955
|
export {
|
|
3956
|
+
advanceOneCycle,
|
|
3957
|
+
retreatOneCycle,
|
|
3958
|
+
billingAnchorDay,
|
|
2712
3959
|
periodEndAfter,
|
|
2713
3960
|
initialPeriodWindow,
|
|
2714
3961
|
periodEndWithMinLead,
|
|
3962
|
+
resolvePlanAnchorDay,
|
|
3963
|
+
bundleFirstPeriodEnd,
|
|
3964
|
+
bundleFirstPeriodStart,
|
|
3965
|
+
bundleNextPeriodEnd,
|
|
3966
|
+
DEFAULT_BUNDLE_MINIMUM_TERM_MONTHS,
|
|
3967
|
+
bundleCycleFitsPlan,
|
|
3968
|
+
computeNextBundlePeriod,
|
|
3969
|
+
decideCancellation,
|
|
3970
|
+
NO_NOTICE_PERIOD,
|
|
3971
|
+
noticeDaysFor,
|
|
3972
|
+
decideCancellationFor,
|
|
2715
3973
|
FEATURE_GUARD_MARKER,
|
|
2716
3974
|
isPlatformFeatureGuard,
|
|
2717
3975
|
REQUIRE_FEATURE_KEY,
|
|
2718
3976
|
RequireFeature,
|
|
2719
|
-
PUBLIC_CATALOG_PROJECT_KEY_TOKEN,
|
|
2720
3977
|
PUBLIC_CATALOG_BUNDLE_REPOSITORY_TOKEN,
|
|
2721
3978
|
PUBLIC_CATALOG_MARKETING_REPOSITORY_TOKEN,
|
|
2722
3979
|
PUBLIC_CATALOG_CATALOG_ENTRY_REPOSITORY_TOKEN,
|
|
3980
|
+
PLAN_CATALOG_UNREADABLE_ERROR,
|
|
3981
|
+
PlanCatalogValidationError,
|
|
3982
|
+
loadPlanCatalogFromFile,
|
|
3983
|
+
catalogSource,
|
|
3984
|
+
loadPlanCatalogFromString,
|
|
2723
3985
|
BILLING_FEATURE_UI_REGISTRY_TOKEN,
|
|
2724
3986
|
PublicCatalogController,
|
|
2725
3987
|
PublicCatalogModule,
|
|
2726
|
-
ComposedTenantAuthGuard,
|
|
2727
3988
|
TenantAdminGuard,
|
|
2728
3989
|
SELF_SERVICE_BLOCKED_PLANS_TOKEN,
|
|
2729
3990
|
SELF_SERVICE_BLOCKED_BUNDLES_TOKEN,
|
|
@@ -2731,20 +3992,23 @@ export {
|
|
|
2731
3992
|
PlanChangePreviewService,
|
|
2732
3993
|
CONTRACT_FREEZE_PORT_TOKEN,
|
|
2733
3994
|
CONTRACT_FREEZE_SOURCE_PORT_TOKEN,
|
|
2734
|
-
CONTRACT_FREEZE_PROJECT_KEY_TOKEN,
|
|
2735
3995
|
PendingPlanMaterializationService,
|
|
2736
3996
|
SubscriptionContractFreezeService,
|
|
2737
3997
|
PreviewPlanChangeDto,
|
|
2738
3998
|
ChangePlanDto,
|
|
2739
3999
|
CancelSubscriptionDto,
|
|
2740
4000
|
CompleteOnboardingSubscriptionDto,
|
|
4001
|
+
AddSubscriptionBundleDto,
|
|
4002
|
+
PreviewSubscriptionBundleDto,
|
|
4003
|
+
CancelSubscriptionBundleDto,
|
|
4004
|
+
BundlePriceLookupDto,
|
|
2741
4005
|
SubscriptionBundlesService,
|
|
2742
4006
|
resolveBundleCancelEffectiveAt,
|
|
2743
4007
|
addMonths,
|
|
4008
|
+
clampToParent,
|
|
2744
4009
|
TenantBillingController,
|
|
2745
4010
|
TenantBillingModule,
|
|
2746
4011
|
SubscriptionBundlePreviewService,
|
|
2747
|
-
resolveBundlePriceNet,
|
|
2748
4012
|
buildTenantSubscriptionBundlesController,
|
|
2749
4013
|
SubscriptionBundleModule
|
|
2750
4014
|
};
|