@saasicat/nest 1.0.0-rc.2 → 1.0.0-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +44 -8
  2. package/dist/.build-stamp +1 -1
  3. package/dist/_entries.cjs +10214 -4796
  4. package/dist/admin/index.d.cts +5 -4
  5. package/dist/admin/index.d.ts +5 -4
  6. package/dist/admin/index.js +13 -9
  7. package/dist/{admin-resources.module-CpvzcfCc.d.cts → admin-resources.module-B4XNvdH-.d.cts} +1 -2
  8. package/dist/{admin-resources.module-DF5_Q7RD.d.ts → admin-resources.module-CWqdI9BV.d.ts} +1 -2
  9. package/dist/{admin-stats.service-TYiSUwIR.d.ts → admin-stats.service-CsLNt19Z.d.ts} +11 -7
  10. package/dist/{admin-stats.service-DfKupywR.d.cts → admin-stats.service-DrZrU8IJ.d.cts} +11 -7
  11. package/dist/{aggregation-B3CD0v_d.d.cts → aggregation-ChpZDh0g.d.ts} +39 -6
  12. package/dist/{aggregation-BFzW07DE.d.ts → aggregation-OpnhwzF5.d.cts} +39 -6
  13. package/dist/billing/index.d.cts +448 -108
  14. package/dist/billing/index.d.ts +448 -108
  15. package/dist/billing/index.js +108 -41
  16. package/dist/catalog/index.d.cts +74 -45
  17. package/dist/catalog/index.d.ts +74 -45
  18. package/dist/catalog/index.js +21 -13
  19. package/dist/catalog.module-C9O7BxRq.d.ts +180 -0
  20. package/dist/catalog.module-T-I5Ricz.d.cts +180 -0
  21. package/dist/checkout-offer/index.d.cts +17 -22
  22. package/dist/checkout-offer/index.d.ts +17 -22
  23. package/dist/checkout-offer/index.js +21 -7
  24. package/dist/checkout-offer.module-DFhGy6JO.d.cts +58 -0
  25. package/dist/checkout-offer.module-x7SP_dX4.d.ts +58 -0
  26. package/dist/checkout-offer.service-BeHgYikI.d.ts +242 -0
  27. package/dist/checkout-offer.service-CCVxG9b2.d.cts +242 -0
  28. package/dist/chunk-34XVPR6P.js +222 -0
  29. package/dist/{chunk-AOQJEYOL.js → chunk-3QVGA6JX.js} +3 -1
  30. package/dist/chunk-3RLWML2S.js +896 -0
  31. package/dist/{chunk-TXC3LHHB.js → chunk-4SDSYV4A.js} +21 -13
  32. package/dist/chunk-55QEDAMX.js +946 -0
  33. package/dist/chunk-5CKC7O52.js +140 -0
  34. package/dist/chunk-6PJE7EWO.js +185 -0
  35. package/dist/chunk-7NJKVC5X.js +353 -0
  36. package/dist/{chunk-RUNZ3X4C.js → chunk-AOS2JPDD.js} +1263 -185
  37. package/dist/{chunk-WOVQPXV4.js → chunk-CQ2ZZMTD.js} +702 -725
  38. package/dist/{chunk-NA6O63B7.js → chunk-F5X66HF5.js} +5 -5
  39. package/dist/chunk-FEQZCWUL.js +174 -0
  40. package/dist/{chunk-VIJ5NJWC.js → chunk-G6EZWECL.js} +107 -31
  41. package/dist/chunk-G7RQO2XO.js +0 -0
  42. package/dist/{chunk-N3L3AICU.js → chunk-GPQWGA6B.js} +5 -6
  43. package/dist/chunk-GZTL64QK.js +7 -0
  44. package/dist/{chunk-SABTXESR.js → chunk-J7NJRK3K.js} +17 -1
  45. package/dist/{chunk-SEPN52AD.js → chunk-JBCC6C3M.js} +566 -307
  46. package/dist/{chunk-I7GTA3RX.js → chunk-JQRA724W.js} +1 -1
  47. package/dist/{chunk-WXYJHZCN.js → chunk-JVAEKTJ4.js} +2 -14
  48. package/dist/chunk-KPEMTBOP.js +26 -0
  49. package/dist/chunk-LF6J4YYN.js +301 -0
  50. package/dist/{chunk-LTT736P3.js → chunk-LLYVYRGJ.js} +228 -387
  51. package/dist/{chunk-6Z7JR4EW.js → chunk-M47BNEY2.js} +1897 -633
  52. package/dist/{chunk-KFT5AIIH.js → chunk-NDLC5GYK.js} +206 -24
  53. package/dist/{chunk-O2J2HDXA.js → chunk-OOTEXP47.js} +3 -8
  54. package/dist/chunk-QPVDCKYS.js +702 -0
  55. package/dist/{chunk-NHVDCYK5.js → chunk-R5YCJBHY.js} +71 -106
  56. package/dist/{chunk-XBYAFEOR.js → chunk-S33SO5XX.js} +1 -1
  57. package/dist/chunk-SZ7RFPXA.js +10 -0
  58. package/dist/chunk-TBBZWZQT.js +98 -0
  59. package/dist/chunk-TOEFN7DN.js +295 -0
  60. package/dist/{chunk-7ZEGFL42.js → chunk-WHJSYKNC.js} +6 -6
  61. package/dist/chunk-WOEJ6K7M.js +55 -0
  62. package/dist/{chunk-AU3OOREM.js → chunk-XSSWYPP5.js} +16 -3
  63. package/dist/contract-line-item-money-B0Z3Ogur.d.cts +43 -0
  64. package/dist/contract-line-item-money-B0Z3Ogur.d.ts +43 -0
  65. package/dist/discovery/index.d.cts +2 -2
  66. package/dist/discovery/index.d.ts +2 -2
  67. package/dist/discovery/index.js +6 -6
  68. package/dist/{discovery.scanner-9cMqF95o.d.cts → discovery.scanner-DXKc6JkV.d.cts} +1 -1
  69. package/dist/{discovery.scanner-9cMqF95o.d.ts → discovery.scanner-DXKc6JkV.d.ts} +1 -1
  70. package/dist/{enforce-quota.interceptor-Df0e3OWe.d.cts → enforce-quota.interceptor-BFyVdWe8.d.cts} +6 -6
  71. package/dist/{enforce-quota.interceptor-DW3etVyI.d.ts → enforce-quota.interceptor-GaBXboGx.d.ts} +6 -6
  72. package/dist/entitlement/index.d.cts +27 -4
  73. package/dist/entitlement/index.d.ts +27 -4
  74. package/dist/entitlement/index.js +13 -8
  75. package/dist/index.d.cts +209 -28
  76. package/dist/index.d.ts +209 -28
  77. package/dist/index.js +285 -163
  78. package/dist/issuer-identity.check-CBO0vcsq.d.cts +197 -0
  79. package/dist/issuer-identity.check-Cz9CEMkZ.d.ts +197 -0
  80. package/dist/{module-options-COSEb0VW.d.ts → module-options-DmQ3G4sZ.d.ts} +135 -41
  81. package/dist/{module-options-BG3MzQva.d.cts → module-options-qAOwinHR.d.cts} +135 -41
  82. package/dist/payment-callback.service-CR84Xw1Z.d.cts +125 -0
  83. package/dist/payment-callback.service-CR84Xw1Z.d.ts +125 -0
  84. package/dist/payments/index.cjs +4 -0
  85. package/dist/payments/index.d.cts +187 -0
  86. package/dist/payments/index.d.ts +187 -0
  87. package/dist/payments/index.js +141 -0
  88. package/dist/payments.module-BdUq9ot_.d.cts +57 -0
  89. package/dist/payments.module-CQGv_VG7.d.ts +57 -0
  90. package/dist/plan-catalog-source-DWe-BGY1.d.cts +21 -0
  91. package/dist/plan-catalog-source-DWe-BGY1.d.ts +21 -0
  92. package/dist/{plan-resolution-Cgo_TR1H.d.cts → plan-resolution-CwUy_OqC.d.cts} +14 -0
  93. package/dist/{plan-resolution-Cgo_TR1H.d.ts → plan-resolution-CwUy_OqC.d.ts} +14 -0
  94. package/dist/{plan-versions.service-DChhbK6h.d.ts → plan-versions.service-C0N-Em2W.d.ts} +38 -64
  95. package/dist/{plan-versions.service-TPEtbN0r.d.cts → plan-versions.service-DARLh-qI.d.cts} +38 -64
  96. package/dist/platform/index.d.cts +52 -23
  97. package/dist/platform/index.d.ts +52 -23
  98. package/dist/platform/index.js +57 -36
  99. package/dist/promo/index.d.cts +23 -8
  100. package/dist/promo/index.d.ts +23 -8
  101. package/dist/promo/index.js +15 -7
  102. package/dist/{promo.module-DtvIycpt.d.cts → promo.module-CKkECmU8.d.cts} +9 -3
  103. package/dist/{promo.module-Z8h1PNX-.d.ts → promo.module-DuIJdK3U.d.ts} +9 -3
  104. package/dist/promo.service-BYBu6dy3.d.cts +215 -0
  105. package/dist/promo.service-CFjfF0Xm.d.ts +215 -0
  106. package/dist/registration/index.d.cts +104 -47
  107. package/dist/registration/index.d.ts +104 -47
  108. package/dist/registration/index.js +15 -9
  109. package/dist/subscriber/index.cjs +4 -0
  110. package/dist/subscriber/index.d.cts +28 -0
  111. package/dist/subscriber/index.d.ts +28 -0
  112. package/dist/subscriber/index.js +16 -0
  113. package/dist/subscriber.service-5W6Gi-1B.d.cts +68 -0
  114. package/dist/subscriber.service-5W6Gi-1B.d.ts +68 -0
  115. package/dist/subscription-contract/index.d.cts +4 -2
  116. package/dist/subscription-contract/index.d.ts +4 -2
  117. package/dist/subscription-contract/index.js +13 -5
  118. package/dist/{subscription-contract.module-fDFdQ0jm.d.ts → subscription-contract.module-BfIRpc1o.d.ts} +8 -2
  119. package/dist/{subscription-contract.module-CEK8HS5-.d.cts → subscription-contract.module-DTMuUJYd.d.cts} +8 -2
  120. package/dist/{subscription-contract.service-hD87MKyg.d.cts → subscription-contract.service-CPZrTM9C.d.ts} +32 -3
  121. package/dist/{subscription-contract.service-hD87MKyg.d.ts → subscription-contract.service-CfNApLuP.d.cts} +32 -3
  122. package/dist/tenant-billing.controller-BLqbsONe.d.ts +812 -0
  123. package/dist/tenant-billing.controller-DKbAqTbM.d.cts +812 -0
  124. package/dist/tenant-billing.module-ClbjzocC.d.cts +364 -0
  125. package/dist/tenant-billing.module-DohgJJ2m.d.ts +364 -0
  126. package/dist/tenant-billing.tokens-G-1xOlMr.d.cts +129 -0
  127. package/dist/tenant-billing.tokens-G-1xOlMr.d.ts +129 -0
  128. package/dist/testing/index.d.cts +44 -17
  129. package/dist/testing/index.d.ts +44 -17
  130. package/dist/testing/index.js +189 -40
  131. package/package.json +24 -4
  132. package/dist/catalog.module-BDDO6iwq.d.cts +0 -103
  133. package/dist/catalog.module-BTnMsE6u.d.ts +0 -103
  134. package/dist/checkout-offer.module-CMtiQJTT.d.ts +0 -39
  135. package/dist/checkout-offer.module-CVpWrzbe.d.cts +0 -39
  136. package/dist/checkout-offer.service-BLOv2HOo.d.cts +0 -49
  137. package/dist/checkout-offer.service-BLOv2HOo.d.ts +0 -49
  138. package/dist/chunk-57V6ZTI6.js +0 -22
  139. package/dist/chunk-BSK6YBLI.js +0 -87
  140. package/dist/chunk-DQKCK7DX.js +0 -217
  141. package/dist/chunk-VXEYLNIB.js +0 -632
  142. package/dist/chunk-WUDYIPYH.js +0 -715
  143. package/dist/define-saasicat-DROBe-b1.d.ts +0 -69
  144. package/dist/define-saasicat-vGBCkFys.d.cts +0 -69
  145. package/dist/promo.service-BJbKAmw3.d.cts +0 -112
  146. package/dist/promo.service-BJbKAmw3.d.ts +0 -112
  147. package/dist/tenant-billing.controller-BQ67mlmI.d.ts +0 -438
  148. package/dist/tenant-billing.controller-DS8kB8a7.d.cts +0 -438
  149. package/dist/tenant-billing.module-BznC3XtW.d.cts +0 -299
  150. package/dist/tenant-billing.module-wUbsQSFG.d.ts +0 -299
  151. /package/dist/{chunk-2FR6ZL7R.js → chunk-BHZOH2DY.js} +0 -0
  152. /package/dist/{chunk-2SLTRKXC.js → chunk-DFOW3JVO.js} +0 -0
  153. /package/dist/{chunk-DBZGV3HC.js → chunk-DU56UKEH.js} +0 -0
  154. /package/dist/{chunk-OYNHOY45.js → chunk-GAUEVIAE.js} +0 -0
@@ -1,75 +1,126 @@
1
+ import {
2
+ AdminAuditService
3
+ } from "./chunk-OOTEXP47.js";
1
4
  import {
2
5
  PromoCodesService
3
- } from "./chunk-VXEYLNIB.js";
6
+ } from "./chunk-3RLWML2S.js";
4
7
  import {
5
8
  SubscriptionContractService
6
- } from "./chunk-KFT5AIIH.js";
9
+ } from "./chunk-NDLC5GYK.js";
7
10
  import {
8
- AUDIT_CONTEXT_RESOLVER_TOKEN,
9
- PENDING_PLAN_QUERY_PORT_TOKEN,
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-VIJ5NJWC.js";
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
- } from "./chunk-DQKCK7DX.js";
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
- codedError
42
- } from "./chunk-57V6ZTI6.js";
43
- import {
44
- ENTITLEMENT_SERVICE_TOKEN
45
- } from "./chunk-OUDSQ3PU.js";
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-AOQJEYOL.js";
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
- asProvider
53
- } from "./chunk-SABTXESR.js";
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
- if (cycle === "YEARLY") {
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 periodEndAfter(startedAt, cycle, after) {
70
- let candidate = new Date(startedAt ?? after);
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
- let candidate = periodEndAfter(startedAt, cycle, now);
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
- planCatalog;
765
+ planCatalogs;
143
766
  featureRegistry;
144
- projectKey;
145
767
  marketingRepo;
146
768
  bundleRepo;
147
769
  catalogEntryRepo;
148
- constructor(planCatalog, featureRegistry, projectKey = null, marketingRepo = null, bundleRepo = null, catalogEntryRepo = null) {
149
- this.planCatalog = planCatalog;
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.planCatalog).map((plan) => ({
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 || !this.projectKey) return this.featureRegistry;
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 || !this.projectKey) return [];
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 || !this.projectKey) return /* @__PURE__ */ new Map();
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 || !this.projectKey) return void 0;
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(PLAN_CATALOG_TOKEN)),
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(PUBLIC_CATALOG_PROJECT_KEY_TOKEN)),
933
+ _ts_param(2, Inject(PUBLIC_CATALOG_MARKETING_REPOSITORY_TOKEN)),
320
934
  _ts_param(3, Optional()),
321
- _ts_param(3, Inject(PUBLIC_CATALOG_MARKETING_REPOSITORY_TOKEN)),
935
+ _ts_param(3, Inject(PUBLIC_CATALOG_BUNDLE_REPOSITORY_TOKEN)),
322
936
  _ts_param(4, Optional()),
323
- _ts_param(4, Inject(PUBLIC_CATALOG_BUNDLE_REPOSITORY_TOKEN)),
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 PlanCatalog === "undefined" ? Object : PlanCatalog,
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/composed-tenant-auth.guard.ts
391
- import { ForbiddenException, Inject as Inject2, Injectable, Optional as Optional2 } from "@nestjs/common";
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
- const request = context.switchToHttp().getRequest();
462
- if (!request.user) {
463
- throw new ForbiddenException2({
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 = _ts_decorate4([
479
- Injectable2()
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 DAY_MS = 864e5;
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()) / DAY_MS));
491
- const daysRemaining = Math.max(0, Math.min(daysInPeriod, Math.round((periodEnd.getTime() - now.getTime()) / DAY_MS)));
492
- const prorataDeltaNet = round2((targetPriceNet - currentPriceNet) * daysRemaining / daysInPeriod);
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 Inject3, Injectable as Injectable3, NotFoundException, Optional as Optional3 } from "@nestjs/common";
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 _ts_decorate5(decorators, target, key, desc) {
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(_ts_decorate5, "_ts_decorate");
519
- function _ts_metadata3(k, v) {
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(_ts_metadata3, "_ts_metadata");
523
- function _ts_param3(paramIndex, decorator) {
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(_ts_param3, "_ts_param");
1072
+ __name(_ts_param2, "_ts_param");
529
1073
  var PlanChangePreviewService = class {
530
1074
  static {
531
1075
  __name(this, "PlanChangePreviewService");
532
1076
  }
533
- catalog;
1077
+ catalogs;
534
1078
  entitlements;
535
1079
  subscriptions;
536
1080
  usageSnapshot;
537
1081
  blockedPlans;
538
1082
  trialProjection;
539
- constructor(catalog, entitlements, subscriptions, usageSnapshot, blockedPlans = null, trialProjection = null) {
540
- this.catalog = catalog;
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 targetPlanDef = findPlan(this.catalog, targetPlan);
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 (${this.catalog.projectKey})`,
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(this.catalog, currentLimits.plan);
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(this.catalog, currentLimits.plan, "MONTHLY"),
587
- yearlyNet: getPlanPriceNet(this.catalog, currentLimits.plan, "YEARLY"),
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(this.catalog, targetPlan, "MONTHLY"),
595
- yearlyNet: getPlanPriceNet(this.catalog, targetPlan, "YEARLY"),
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 isImmediate = changeType === "UPGRADE";
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: `${targetPlan}_NOT_SELF_SERVICE`,
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: `${sub.plan}_LOCKED`,
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: `${key.toUpperCase()}_OVER_TARGET`,
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: "FEATURES_LOST",
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: "NO_CHANGE",
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
- classify(currentPlan, currentCycle, targetPlan, targetCycle) {
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 = this.catalog.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
- if (ctx.currentPeriodEnd) return ctx.currentPeriodEnd;
714
- return periodEndAfter(ctx.startedAt, ctx.currentBillingCycle, now);
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 = _ts_decorate5([
729
- Injectable3(),
730
- _ts_param3(0, Inject3(PLAN_CATALOG_TOKEN)),
731
- _ts_param3(1, Inject3(ENTITLEMENT_SERVICE_TOKEN)),
732
- _ts_param3(2, Inject3(SUBSCRIPTION_USAGE_PORT_TOKEN)),
733
- _ts_param3(3, Inject3(USAGE_SNAPSHOT_PORT_TOKEN)),
734
- _ts_param3(4, Optional3()),
735
- _ts_param3(4, Inject3(SELF_SERVICE_BLOCKED_PLANS_TOKEN)),
736
- _ts_param3(5, Optional3()),
737
- _ts_param3(5, Inject3(TRIAL_PROJECTION_PORT_TOKEN)),
738
- _ts_metadata3("design:type", Function),
739
- _ts_metadata3("design:paramtypes", [
740
- typeof PlanCatalog === "undefined" ? Object : PlanCatalog,
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 Inject4, Injectable as Injectable4, Logger, Optional as Optional4 } from "@nestjs/common";
764
- function _ts_decorate6(decorators, target, key, desc) {
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(_ts_decorate6, "_ts_decorate");
771
- function _ts_metadata4(k, v) {
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(_ts_metadata4, "_ts_metadata");
775
- function _ts_param4(paramIndex, decorator) {
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(_ts_param4, "_ts_param");
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 = _ts_decorate6([
836
- Injectable4(),
837
- _ts_param4(0, Inject4(PENDING_PLAN_QUERY_PORT_TOKEN)),
838
- _ts_param4(1, Inject4(SUBSCRIPTION_WRITE_PORT_TOKEN)),
839
- _ts_param4(3, Optional4()),
840
- _ts_param4(3, Inject4(CONTRACT_FREEZE_PORT_TOKEN)),
841
- _ts_metadata4("design:type", Function),
842
- _ts_metadata4("design:paramtypes", [
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 Inject5, Injectable as Injectable5 } from "@nestjs/common";
852
- function _ts_decorate7(decorators, target, key, desc) {
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(_ts_decorate7, "_ts_decorate");
859
- function _ts_metadata5(k, v) {
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(_ts_metadata5, "_ts_metadata");
863
- function _ts_param5(paramIndex, decorator) {
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(_ts_param5, "_ts_param");
1530
+ __name(_ts_param4, "_ts_param");
869
1531
  var SubscriptionContractFreezeService = class {
870
1532
  static {
871
1533
  __name(this, "SubscriptionContractFreezeService");
872
1534
  }
873
- catalog;
1535
+ catalogs;
874
1536
  entitlements;
875
1537
  contracts;
876
- projectKey;
877
1538
  source;
878
- constructor(catalog, entitlements, contracts, projectKey, source) {
879
- this.catalog = catalog;
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 vatRate = this.catalog.vatRate;
888
- const bundles = await this.source.loadBookedBundles(tenantId, cycle, vatRate);
889
- const livePlanVersionId = await this.source.findLivePlanVersionId(newPlan);
890
- const previous = await this.contracts.findActiveByTenantId(tenantId, effectiveFrom);
891
- if (previous) {
892
- await this.contracts.terminate(previous.id, {
893
- effectiveUntil: effectiveFrom,
894
- status: "superseded"
895
- });
896
- }
897
- this.entitlements.invalidateTenant(tenantId);
898
- const limits = await this.entitlements.computeLimits(tenantId, effectiveFrom);
899
- const planDef = findPlan(this.catalog, newPlan);
900
- const planPriceNet = getPlanPriceNet(this.catalog, newPlan, billingCycle) ?? 0;
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: livePlanVersionId,
905
- titleSnapshot: planDef?.name ?? newPlan,
906
- descriptionSnapshot: planDef?.tagline ?? null,
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?.features ?? [],
914
- quotaEffectsSnapshot: planDef?.quotas ?? {},
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
- const subtotalNet = round22(lineItems.reduce((sum, li) => sum + li.priceNet, 0));
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
- effectiveUntil: null,
928
- originalPlanVersionId: livePlanVersionId,
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 = _ts_decorate7([
955
- Injectable5(),
956
- _ts_param5(0, Inject5(PLAN_CATALOG_TOKEN)),
957
- _ts_param5(1, Inject5(ENTITLEMENT_SERVICE_TOKEN)),
958
- _ts_param5(2, Inject5(SubscriptionContractService)),
959
- _ts_param5(3, Inject5(CONTRACT_FREEZE_PROJECT_KEY_TOKEN)),
960
- _ts_param5(4, Inject5(CONTRACT_FREEZE_SOURCE_PORT_TOKEN)),
961
- _ts_metadata5("design:type", Function),
962
- _ts_metadata5("design:paramtypes", [
963
- typeof PlanCatalog === "undefined" ? Object : PlanCatalog,
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
- String,
967
- typeof ContractFreezeSourcePort === "undefined" ? Object : ContractFreezeSourcePort
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 { IsBoolean, IsOptional, IsString, Matches } from "class-validator";
977
- function _ts_decorate8(decorators, target, key, desc) {
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(_ts_decorate8, "_ts_decorate");
984
- function _ts_metadata6(k, v) {
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(_ts_metadata6, "_ts_metadata");
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
- _ts_decorate8([
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
- _ts_metadata6("design:type", String)
1702
+ _ts_metadata5("design:type", String)
1002
1703
  ], PreviewPlanChangeDto.prototype, "plan", void 0);
1003
- _ts_decorate8([
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
- _ts_metadata6("design:type", String)
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
- /** true = cancel immediately (status CANCELED). false/undefined = at period end. */
1027
- immediately;
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
- _ts_decorate8([
1734
+ _ts_decorate7([
1030
1735
  IsOptional(),
1031
- IsBoolean(),
1032
- _ts_metadata6("design:type", Boolean)
1033
- ], CancelSubscriptionDto.prototype, "immediately", void 0);
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 _ts_decorate9(decorators, target, key, desc) {
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(_ts_decorate9, "_ts_decorate");
1044
- function _ts_metadata7(k, v) {
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(_ts_metadata7, "_ts_metadata");
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 (12 months) is set. Bundles are added
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
- _ts_decorate9([
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
- _ts_metadata7("design:type", String)
1777
+ _ts_metadata6("design:type", String)
1073
1778
  ], CompleteOnboardingSubscriptionDto.prototype, "plan", void 0);
1074
- _ts_decorate9([
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
- _ts_metadata7("design:type", String)
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
- IsOptional2(),
1083
- IsString2(),
1084
- Matches2(PROMO_CODE_PATTERN, {
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
- ], CompleteOnboardingSubscriptionDto.prototype, "promoCode", void 0);
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
- IsOptional2(),
1091
- IsArray(),
1092
- ArrayMaxSize(10),
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
- ], CompleteOnboardingSubscriptionDto.prototype, "bundleVersionIds", void 0);
1943
+ ], BundlePriceLookupDto.prototype, "bundleVersionIds", void 0);
1098
1944
 
1099
1945
  // src/billing/subscription-bundles.service.ts
1100
- import { Inject as Inject6, Injectable as Injectable6, NotFoundException as NotFoundException2, Optional as Optional5, UnprocessableEntityException } from "@nestjs/common";
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 _ts_param6(paramIndex, decorator) {
1959
+ function _ts_param5(paramIndex, decorator) {
1114
1960
  return function(target, key) {
1115
1961
  decorator(target, key, paramIndex);
1116
1962
  };
1117
1963
  }
1118
- __name(_ts_param6, "_ts_param");
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 ?? 12;
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
- async listForSubscription(subscriptionId) {
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
- monthlyNet: bv?.monthlyNet ?? null
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 UnprocessableEntityException({
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 UnprocessableEntityException({
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 UnprocessableEntityException({
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 UnprocessableEntityException({
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 UnprocessableEntityException({
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 UnprocessableEntityException({
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
- currentPeriodEnd: input.currentPeriodEnd ?? null,
1240
- minimumTermEndsAt: existing.minimumTermEndsAt
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 UnprocessableEntityException({
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 UnprocessableEntityException({
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
- Injectable6(),
1282
- _ts_param6(0, Inject6(SUBSCRIPTION_BUNDLE_REPOSITORY_TOKEN)),
1283
- _ts_param6(1, Inject6(BUNDLE_REPOSITORY_TOKEN)),
1284
- _ts_param6(2, Optional5()),
1285
- _ts_param6(2, Inject6(SUBSCRIPTION_BUNDLE_CONFIG_TOKEN)),
1286
- _ts_param6(3, Optional5()),
1287
- _ts_param6(3, Inject6(SELF_SERVICE_BLOCKED_BUNDLES_TOKEN)),
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
- return periodEnd.getTime() >= minTermEnd.getTime() ? periodEnd : minTermEnd;
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 ForbiddenException3, Get as Get2, HttpCode, HttpStatus, Inject as Inject7, Logger as Logger2, NotFoundException as NotFoundException3, Optional as Optional6, Post, Req, UseGuards } from "@nestjs/common";
1311
- import { AUTH_ERROR_CODES as AUTH_ERROR_CODES3, BILLING_ERROR_CODES as BILLING_ERROR_CODES3 } from "@saasicat/core";
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 _ts_param7(paramIndex, decorator) {
2259
+ function _ts_param6(paramIndex, decorator) {
1324
2260
  return function(target, key) {
1325
2261
  decorator(target, key, paramIndex);
1326
2262
  };
1327
2263
  }
1328
- __name(_ts_param7, "_ts_param");
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
- 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) {
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 ForbiddenException3({
2392
+ throw new ForbiddenException2({
1438
2393
  code: BILLING_ERROR_CODES3.PLAN_NOT_SELF_SERVICE,
1439
- message: `${dto.plan} is not activated via self-service.`,
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
- const blockers = await this.planPreview.assertChangeAllowed(tenantId, dto.plan, dto.billingCycle);
1456
- if (blockers.length > 0) {
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
- if (dto.effectiveImmediately) {
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(tenantId, dto.plan, dto.billingCycle, wasTrial);
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 = sub.status === "TRIAL" && sub.trialEndsAt ? sub.trialEndsAt : sub.currentPeriodEnd ?? /* @__PURE__ */ new Date();
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 ForbiddenException3({
2539
+ throw new ForbiddenException2({
1530
2540
  code: BILLING_ERROR_CODES3.PLAN_NOT_SELF_SERVICE,
1531
- message: `${dto.plan} is not activated via self-service.`,
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 result = await this.subscriptionWrite.cancelSubscription(tenantId, !!dto.immediately, /* @__PURE__ */ new Date());
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
- immediate: !!dto.immediately,
1710
- canceledAt: result.canceledAt?.toISOString() ?? null,
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
- immediate: !!dto.immediately
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: AUTH_ERROR_CODES3.TENANT_CONTEXT_MISSING,
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: AUTH_ERROR_CODES3.TENANT_CONTEXT_MISSING,
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
- _ts_param7(0, Req()),
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
- _ts_param7(0, Req()),
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
- _ts_param7(0, Req()),
1843
- _ts_param7(1, Body()),
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
- _ts_param7(0, Req()),
1855
- _ts_param7(1, Body()),
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
- _ts_param7(0, Req()),
1867
- _ts_param7(1, Body()),
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
- _ts_param7(0, Req()),
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
- _ts_param7(0, Req()),
1889
- _ts_param7(1, Body()),
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
- _ts_param7(0, Inject7(ENTITLEMENT_SERVICE_TOKEN)),
1901
- _ts_param7(1, Inject7(PlanChangePreviewService)),
1902
- _ts_param7(2, Inject7(SUBSCRIPTION_USAGE_PORT_TOKEN)),
1903
- _ts_param7(3, Inject7(USAGE_SNAPSHOT_PORT_TOKEN)),
1904
- _ts_param7(4, Inject7(SUBSCRIPTION_WRITE_PORT_TOKEN)),
1905
- _ts_param7(5, Optional6()),
1906
- _ts_param7(5, Inject7(TENANT_ID_RESOLVER_TOKEN)),
1907
- _ts_param7(6, Optional6()),
1908
- _ts_param7(6, Inject7(USER_ID_RESOLVER_TOKEN)),
1909
- _ts_param7(7, Optional6()),
1910
- _ts_param7(7, Inject7(SELF_SERVICE_BLOCKED_PLANS_TOKEN)),
1911
- _ts_param7(8, Optional6()),
1912
- _ts_param7(8, Inject7(PromoCodesService)),
1913
- _ts_param7(9, Optional6()),
1914
- _ts_param7(9, Inject7(AdminAuditService)),
1915
- _ts_param7(10, Optional6()),
1916
- _ts_param7(10, Inject7(USER_EMAIL_RESOLVER_TOKEN)),
1917
- _ts_param7(11, Optional6()),
1918
- _ts_param7(11, Inject7(AUDIT_CONTEXT_RESOLVER_TOKEN)),
1919
- _ts_param7(12, Optional6()),
1920
- _ts_param7(12, Inject7(SubscriptionBundlesService)),
1921
- _ts_param7(13, Optional6()),
1922
- _ts_param7(13, Inject7(CONTRACT_FREEZE_PORT_TOKEN)),
1923
- _ts_param7(14, Optional6()),
1924
- _ts_param7(14, Inject7(TRIAL_PROJECTION_PORT_TOKEN)),
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
- if (options.selfServiceBlockedPlans) {
1969
- providers.push({
1970
- provide: SELF_SERVICE_BLOCKED_PLANS_TOKEN,
1971
- useValue: options.selfServiceBlockedPlans
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
- providers.push({
1984
- provide: CONTRACT_FREEZE_PROJECT_KEY_TOKEN,
1985
- useValue: options.contractFreeze.projectKey
1986
- }, asProvider(CONTRACT_FREEZE_SOURCE_PORT_TOKEN, options.contractFreeze.sourcePort), asProvider(SUBSCRIPTION_CONTRACT_REPOSITORY_TOKEN, options.contractFreeze.subscriptionContractRepository), SubscriptionContractService, {
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 Inject8, Injectable as Injectable7, NotFoundException as NotFoundException4, Optional as Optional7 } from "@nestjs/common";
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 _ts_param8(paramIndex, decorator) {
3255
+ function _ts_param7(paramIndex, decorator) {
2061
3256
  return function(target, key) {
2062
3257
  decorator(target, key, paramIndex);
2063
3258
  };
2064
3259
  }
2065
- __name(_ts_param8, "_ts_param");
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 ?? 12;
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: "REDUNDANT_FEATURES",
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: "BUNDLE_FEATURE_DEPENDENCY_UNSATISFIED",
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 priceNet = resolveBundlePriceNet(bundleVersion, ctx.currentPlanKey, ctx.billingCycle);
2121
- const proration = ctx.status !== "TRIAL" && priceNet !== null ? computeProration({
2122
- periodStart: ctx.currentPeriodStart ?? ctx.startedAt ?? now,
2123
- periodEnd: ctx.currentPeriodEnd ?? periodEndAfter(ctx.startedAt, ctx.billingCycle, now),
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: ctx.billingCycle,
3366
+ billingCycle,
2133
3367
  proration,
2134
3368
  nextPeriodPriceNet: priceNet,
2135
3369
  minimumTermMonths,
2136
- minimumTermEndsAt: minimumTermMonths > 0 ? addMonths(now, minimumTermMonths) : null,
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: ctx.currentPeriodEnd,
3417
+ currentPeriodEnd: bookingPeriodEnd,
2175
3418
  minimumTermEndsAt: existing.minimumTermEndsAt
2176
3419
  });
2177
- const periodEnd = ctx.currentPeriodEnd ?? now;
3420
+ const periodEnd = bookingPeriodEnd ?? now;
2178
3421
  if (existing.minimumTermEndsAt && existing.minimumTermEndsAt.getTime() > periodEnd.getTime()) {
2179
3422
  warnings.push({
2180
- code: "MINIMUM_TERM_BINDS",
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: ctx.billingCycle,
3432
+ billingCycle: bookedCycle,
2189
3433
  effectiveAt,
2190
- nextPeriodSavingsNet: resolveBundlePriceNet(bundleVersion, ctx.currentPlanKey, ctx.billingCycle),
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 projectKey = await this.resolveProjectKey(bundleVersion);
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
- Injectable7(),
2283
- _ts_param8(0, Inject8(SUBSCRIPTION_BUNDLE_REPOSITORY_TOKEN)),
2284
- _ts_param8(1, Inject8(BUNDLE_REPOSITORY_TOKEN)),
2285
- _ts_param8(2, Optional7()),
2286
- _ts_param8(2, Inject8(PLAN_REPOSITORY_TOKEN)),
2287
- _ts_param8(3, Optional7()),
2288
- _ts_param8(3, Inject8(CATALOG_ENTRY_REPOSITORY_TOKEN)),
2289
- _ts_param8(4, Optional7()),
2290
- _ts_param8(4, Inject8(SELF_SERVICE_BLOCKED_BUNDLES_TOKEN)),
2291
- _ts_param8(5, Optional7()),
2292
- _ts_param8(5, Inject8(SUBSCRIPTION_BUNDLE_CONFIG_TOKEN)),
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 Inject9, Logger as Logger3, NotFoundException as NotFoundException5, Optional as Optional8, Param, ParseUUIDPipe, Post as Post2, Req as Req2, UseGuards as UseGuards2 } from "@nestjs/common";
2329
- import { AUTH_ERROR_CODES as AUTH_ERROR_CODES4, BILLING_ERROR_CODES as BILLING_ERROR_CODES5 } from "@saasicat/core";
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
- var ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(T.*)?$/;
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(_ts_param9, "_ts_param");
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.requireSubscription(tenantId);
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.requireSubscription(tenantId);
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: AUTH_ERROR_CODES4.TENANT_CONTEXT_MISSING,
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(tenantId, sub.planVersion.planId, sub.billingCycle, /* @__PURE__ */ new Date());
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
- _ts_decorate15([
3787
+ _ts_decorate14([
2564
3788
  Get3(),
2565
- _ts_param9(0, Req2()),
2566
- _ts_metadata12("design:type", Function),
2567
- _ts_metadata12("design:paramtypes", [
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
- _ts_metadata12("design:returntype", Promise)
3794
+ _ts_metadata11("design:returntype", Promise)
2571
3795
  ], GeneratedTenantSubscriptionBundlesController.prototype, "list", null);
2572
- _ts_decorate15([
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
- _ts_param9(0, Req2()),
2575
- _ts_param9(1, Body2()),
2576
- _ts_metadata12("design:type", Function),
2577
- _ts_metadata12("design:paramtypes", [
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
- _ts_metadata12("design:returntype", Promise)
3817
+ _ts_metadata11("design:returntype", Promise)
2582
3818
  ], GeneratedTenantSubscriptionBundlesController.prototype, "add", null);
2583
- _ts_decorate15([
3819
+ _ts_decorate14([
2584
3820
  Post2("preview"),
2585
3821
  HttpCode2(HttpStatus2.OK),
2586
- _ts_param9(0, Req2()),
2587
- _ts_param9(1, Body2()),
2588
- _ts_metadata12("design:type", Function),
2589
- _ts_metadata12("design:paramtypes", [
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
- _ts_metadata12("design:returntype", Promise)
3829
+ _ts_metadata11("design:returntype", Promise)
2594
3830
  ], GeneratedTenantSubscriptionBundlesController.prototype, "preview", null);
2595
- _ts_decorate15([
3831
+ _ts_decorate14([
2596
3832
  Delete(":id"),
3833
+ UseGuards2(TenantAdminGuard),
2597
3834
  HttpCode2(HttpStatus2.OK),
2598
- _ts_param9(0, Req2()),
2599
- _ts_param9(1, Param("id", new ParseUUIDPipe())),
2600
- _ts_param9(2, Body2()),
2601
- _ts_metadata12("design:type", Function),
2602
- _ts_metadata12("design:paramtypes", [
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
- _ts_metadata12("design:returntype", Promise)
3844
+ _ts_metadata11("design:returntype", Promise)
2608
3845
  ], GeneratedTenantSubscriptionBundlesController.prototype, "cancel", null);
2609
- _ts_decorate15([
3846
+ _ts_decorate14([
2610
3847
  Post2(":id/reactivate"),
3848
+ UseGuards2(TenantAdminGuard),
2611
3849
  HttpCode2(HttpStatus2.OK),
2612
- _ts_param9(0, Req2()),
2613
- _ts_param9(1, Param("id", new ParseUUIDPipe())),
2614
- _ts_metadata12("design:type", Function),
2615
- _ts_metadata12("design:paramtypes", [
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
- _ts_metadata12("design:returntype", Promise)
3857
+ _ts_metadata11("design:returntype", Promise)
2620
3858
  ], GeneratedTenantSubscriptionBundlesController.prototype, "reactivate", null);
2621
- GeneratedTenantSubscriptionBundlesController = _ts_decorate15([
3859
+ GeneratedTenantSubscriptionBundlesController = _ts_decorate14([
2622
3860
  Controller3("billing/subscription-bundles"),
2623
3861
  UseGuards2(ComposedTenantAuthGuard, ...extraGuards),
2624
- _ts_param9(0, Inject9(SubscriptionBundlesService)),
2625
- _ts_param9(1, Inject9(SubscriptionBundlePreviewService)),
2626
- _ts_param9(2, Inject9(SUBSCRIPTION_USAGE_PORT_TOKEN)),
2627
- _ts_param9(3, Inject9(TENANT_ID_RESOLVER_TOKEN)),
2628
- _ts_param9(4, Optional8()),
2629
- _ts_param9(4, Inject9(CONTRACT_FREEZE_PORT_TOKEN)),
2630
- _ts_metadata12("design:type", Function),
2631
- _ts_metadata12("design:paramtypes", [
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 _ts_decorate16(decorators, target, key, desc) {
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(_ts_decorate16, "_ts_decorate");
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 ?? 12
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 = _ts_decorate16([
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
  };