@saasicat/core 1.0.0-rc.20 → 1.0.0-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -87,27 +87,33 @@ function pickActivePromo(promotions, targetKey, locale, cycle, today = /* @__PUR
87
87
  return matches[0] ?? null;
88
88
  }
89
89
  function applyPromo(promo, basePrice) {
90
+ const result = boundedPromo(promo, basePrice);
91
+ return result && result.discounted < result.original ? result : null;
92
+ }
93
+ function boundedPromo(promo, basePrice) {
90
94
  if (!promo || basePrice === null || basePrice === void 0) return null;
91
95
  if (promo.type === "percent" && typeof promo.value === "number") {
96
+ const pct = withinBounds(promo.value, 100);
92
97
  return {
93
98
  kind: "percent",
94
- discounted: Math.round(basePrice * (100 - promo.value)) / 100,
99
+ discounted: Math.round(basePrice * (100 - pct)) / 100,
95
100
  original: basePrice,
96
- pct: promo.value
101
+ pct
97
102
  };
98
103
  }
99
104
  if (promo.type === "amount" && typeof promo.value === "number") {
105
+ const saved = withinBounds(promo.value, basePrice);
100
106
  return {
101
107
  kind: "amount",
102
- discounted: Math.max(0, basePrice - promo.value),
108
+ discounted: Math.round((basePrice - saved) * 100) / 100,
103
109
  original: basePrice,
104
- saved: promo.value
110
+ saved
105
111
  };
106
112
  }
107
- if (promo.type === "intro" && typeof promo.value === "object") {
113
+ if (promo.type === "intro" && typeof promo.value === "object" && promo.value !== null) {
108
114
  return {
109
115
  kind: "intro",
110
- discounted: promo.value.price,
116
+ discounted: withinBounds(promo.value.price, basePrice),
111
117
  original: basePrice,
112
118
  months: promo.value.months
113
119
  };
@@ -117,6 +123,14 @@ function applyPromo(promo, basePrice) {
117
123
  }
118
124
  return null;
119
125
  }
126
+ function withinBounds(value, upper) {
127
+ return Math.min(Math.max(value, 0), Math.max(upper, 0));
128
+ }
129
+ function promotionOnPrice(promotions, targetKey, locale, cycle, basePrice, today = /* @__PURE__ */ new Date(), targetType = "PLAN") {
130
+ const promotion = pickActivePromo(promotions, targetKey, locale, cycle, today, targetType);
131
+ const result = applyPromo(promotion, basePrice);
132
+ return promotion && result ? { promotion, result } : null;
133
+ }
120
134
 
121
135
  // src/quota-value.ts
122
136
  function readQuotaValue(value) {
@@ -413,6 +427,12 @@ var CATALOG_ERROR_CODES = {
413
427
  FEATURE_NOT_FOUND: "FEATURE_NOT_FOUND",
414
428
  QUOTA_NOT_FOUND: "QUOTA_NOT_FOUND",
415
429
  PROMOTION_NOT_FOUND: "PROMOTION_NOT_FOUND",
430
+ /**
431
+ * A promotion's value is not one its type takes: a percentage above 0 and
432
+ * at most 100, an amount above 0, an intro price of at least 0 for a whole
433
+ * number of months, or a whole number of free months.
434
+ */
435
+ PROMOTION_VALUE_INVALID: "PROMOTION_VALUE_INVALID",
416
436
  MARKETING_PROJECTION_NOT_FOUND: "MARKETING_PROJECTION_NOT_FOUND",
417
437
  // ── already exists ──
418
438
  PLAN_ALREADY_EXISTS: "PLAN_ALREADY_EXISTS",
@@ -572,6 +592,13 @@ var CONTRACT_ERROR_CODES = {
572
592
  SUBSCRIPTION_CONTRACT_LINE_ITEM_TAX_MISMATCH: "SUBSCRIPTION_CONTRACT_LINE_ITEM_TAX_MISMATCH",
573
593
  SUBSCRIPTION_CONTRACT_TAX_RATE_NOT_PERCENT: "SUBSCRIPTION_CONTRACT_TAX_RATE_NOT_PERCENT",
574
594
  SUBSCRIPTION_CONTRACT_LINE_ITEM_CURRENCY_MISMATCH: "SUBSCRIPTION_CONTRACT_LINE_ITEM_CURRENCY_MISMATCH",
595
+ /**
596
+ * The lines do not add up to a total the contract states, counted as often
597
+ * as each falls due in one period. `field` names the total.
598
+ */
599
+ SUBSCRIPTION_CONTRACT_LINES_DO_NOT_ADD_UP: "SUBSCRIPTION_CONTRACT_LINES_DO_NOT_ADD_UP",
600
+ /** Something that takes money off states a negative amount. */
601
+ SUBSCRIPTION_CONTRACT_DISCOUNT_NEGATIVE: "SUBSCRIPTION_CONTRACT_DISCOUNT_NEGATIVE",
575
602
  SUBSCRIPTION_CONTRACT_TERMINATION_BEFORE_START: "SUBSCRIPTION_CONTRACT_TERMINATION_BEFORE_START",
576
603
  // ── stage 3 ──
577
604
  CHECKOUT_OFFER_NOT_FOUND: "CHECKOUT_OFFER_NOT_FOUND",
@@ -1114,6 +1141,30 @@ function oneOf(allowed, value, column, id) {
1114
1141
  }
1115
1142
  return match;
1116
1143
  }
1144
+ var FOREIGN_PAYMENT_METHOD_REFERENCE = "FOREIGN_PAYMENT_METHOD_REFERENCE";
1145
+ var ForeignPaymentMethodReferenceError = class extends Error {
1146
+ constructor(gatewayAccount, paymentMethodRef) {
1147
+ super(
1148
+ `Payment method '${paymentMethodRef}' of account '${gatewayAccount}' belongs to another subscriber. A reference belongs to exactly one, so it is not handed out.`
1149
+ );
1150
+ this.gatewayAccount = gatewayAccount;
1151
+ this.paymentMethodRef = paymentMethodRef;
1152
+ this.name = "ForeignPaymentMethodReferenceError";
1153
+ }
1154
+ gatewayAccount;
1155
+ paymentMethodRef;
1156
+ code = FOREIGN_PAYMENT_METHOD_REFERENCE;
1157
+ };
1158
+ function isForeignPaymentMethodReferenceError(error) {
1159
+ return error instanceof Error && error.code === FOREIGN_PAYMENT_METHOD_REFERENCE;
1160
+ }
1161
+ function refuseForeignPaymentMethodReference(recorded, subscriberId) {
1162
+ if (recorded.subscriberId === subscriberId) return;
1163
+ throw new ForeignPaymentMethodReferenceError(
1164
+ recorded.gatewayAccount,
1165
+ recorded.paymentMethodRef
1166
+ );
1167
+ }
1117
1168
 
1118
1169
  // src/subscription-contract-mapping.ts
1119
1170
  function toSubscriptionContractRecord(row, lineItems) {
@@ -1324,6 +1375,7 @@ var ERROR_MESSAGES_EN = {
1324
1375
  FEATURE_NOT_FOUND: "Feature '{featureKey}' not found",
1325
1376
  QUOTA_NOT_FOUND: "Quota '{quotaKey}' not found",
1326
1377
  PROMOTION_NOT_FOUND: "Promotion '{promotionId}' not found",
1378
+ PROMOTION_VALUE_INVALID: "The value of a '{type}' promotion is not one its type takes: a percentage above 0 and at most 100, an amount above 0, an intro price of at least 0 for a whole number of months, or a whole number of free months.",
1327
1379
  MARKETING_PROJECTION_NOT_FOUND: "MarketingProjection '{projectionId}' not found",
1328
1380
  PLAN_ALREADY_EXISTS: "Plan '{planKey}' already exists",
1329
1381
  BUNDLE_ALREADY_EXISTS: "Bundle '{bundleKey}' already exists",
@@ -1378,7 +1430,7 @@ var ERROR_MESSAGES_EN = {
1378
1430
  // ── promo ──
1379
1431
  PROMO_CODE_NOT_FOUND: "Code not found",
1380
1432
  PROMO_CODE_ALREADY_EXISTS: "The code already exists.",
1381
- PROMO_CODE_HAS_REDEMPTIONS: "The code already has redemptions \u2014 it cannot be soft-deleted. Pause it instead.",
1433
+ PROMO_CODE_HAS_REDEMPTIONS: "The code already has redemptions, or checkouts holding one \u2014 it cannot be soft-deleted. Pause it instead.",
1382
1434
  PROMO_CODE_NOT_REDEEMABLE: "Code cannot be redeemed: {reason}",
1383
1435
  PROMO_CODE_FORMAT_INVALID: 'The code may only contain upper-case letters, digits, "-" and "_" (4\u201332 characters).',
1384
1436
  PROMO_PERCENT_OUT_OF_RANGE: "The percentage must be between 0 and 100.",
@@ -1408,6 +1460,8 @@ var ERROR_MESSAGES_EN = {
1408
1460
  SUBSCRIPTION_CONTRACT_TAX_RATE_NOT_PERCENT: "A subscription contract states a tax rate of {taxRate} at {field}, which is not a percentage: a rate lies from 0 to 100, and a value between 0 and 1 is refused as a fraction.",
1409
1461
  SUBSCRIPTION_CONTRACT_TERMINATION_BEFORE_START: "effectiveUntil must be after the effectiveFrom of the contract.",
1410
1462
  SUBSCRIPTION_CONTRACT_LINE_ITEM_CURRENCY_MISMATCH: "A line item must be booked in the currency its contract was priced in.",
1463
+ SUBSCRIPTION_CONTRACT_LINES_DO_NOT_ADD_UP: "The line items of a subscription contract add up to {lines} for {field}, but the contract states {stated}.",
1464
+ SUBSCRIPTION_CONTRACT_DISCOUNT_NEGATIVE: "A subscription contract states a discount of {amount} at {field}; a discount takes money off and is never negative.",
1411
1465
  CHECKOUT_OFFER_NOT_FOUND: "CheckoutOffer '{offerId}' not found",
1412
1466
  CHECKOUT_OFFER_EXPIRED: "Checkout offer '{offerId}' has expired and cannot be {action}",
1413
1467
  CHECKOUT_OFFER_ALREADY_CONSUMED: "Checkout offer '{offerId}' has already been consumed and cannot be {action}",
@@ -1531,6 +1585,7 @@ var ERROR_MESSAGES_DE = {
1531
1585
  FEATURE_NOT_FOUND: "Feature '{featureKey}' nicht gefunden",
1532
1586
  QUOTA_NOT_FOUND: "Quota '{quotaKey}' nicht gefunden",
1533
1587
  PROMOTION_NOT_FOUND: "Promotion '{promotionId}' nicht gefunden",
1588
+ PROMOTION_VALUE_INVALID: "Der Wert einer Aktion vom Typ '{type}' passt nicht zu ihrem Typ: ein Prozentsatz \xFCber 0 und h\xF6chstens 100, ein Betrag \xFCber 0, ein Einf\xFChrungspreis ab 0 f\xFCr eine ganze Zahl von Monaten oder eine ganze Zahl von Gratismonaten.",
1534
1589
  MARKETING_PROJECTION_NOT_FOUND: "MarketingProjection '{projectionId}' nicht gefunden",
1535
1590
  PLAN_ALREADY_EXISTS: "Plan '{planKey}' gibt es bereits",
1536
1591
  BUNDLE_ALREADY_EXISTS: "Bundle '{bundleKey}' gibt es bereits",
@@ -1585,7 +1640,7 @@ var ERROR_MESSAGES_DE = {
1585
1640
  // ── promo ──
1586
1641
  PROMO_CODE_NOT_FOUND: "Code nicht gefunden",
1587
1642
  PROMO_CODE_ALREADY_EXISTS: "Diesen Code gibt es bereits.",
1588
- PROMO_CODE_HAS_REDEMPTIONS: "Der Code wurde bereits eingel\xF6st \u2014 er l\xE4sst sich nicht per Soft-Delete entfernen. Pausiere ihn stattdessen.",
1643
+ PROMO_CODE_HAS_REDEMPTIONS: "Der Code wurde bereits eingel\xF6st oder ist f\xFCr einen laufenden Checkout reserviert \u2014 er l\xE4sst sich nicht per Soft-Delete entfernen. Pausiere ihn stattdessen.",
1589
1644
  PROMO_CODE_NOT_REDEEMABLE: "Der Code l\xE4sst sich nicht einl\xF6sen: {reason}",
1590
1645
  PROMO_CODE_FORMAT_INVALID: 'Der Code darf nur Gro\xDFbuchstaben, Ziffern, "-" und "_" enthalten (4\u201332 Zeichen).',
1591
1646
  PROMO_PERCENT_OUT_OF_RANGE: "Der Prozentwert muss zwischen 0 und 100 liegen.",
@@ -1614,6 +1669,8 @@ var ERROR_MESSAGES_DE = {
1614
1669
  SUBSCRIPTION_CONTRACT_LINE_ITEM_TAX_MISMATCH: "taxAmount einer Position muss exakt priceGross minus priceNet sein.",
1615
1670
  SUBSCRIPTION_CONTRACT_TAX_RATE_NOT_PERCENT: "Ein Abo-Vertrag nennt bei {field} einen Steuersatz von {taxRate}, der kein Prozentsatz ist: Ein Satz liegt zwischen 0 und 100, und ein Wert zwischen 0 und 1 wird als Bruch abgelehnt.",
1616
1671
  SUBSCRIPTION_CONTRACT_LINE_ITEM_CURRENCY_MISMATCH: "Eine Position muss in der W\xE4hrung gebucht sein, in der ihr Vertrag bepreist ist.",
1672
+ SUBSCRIPTION_CONTRACT_LINES_DO_NOT_ADD_UP: "Die Positionen eines Abo-Vertrags ergeben {lines} f\xFCr {field}, der Vertrag nennt aber {stated}.",
1673
+ SUBSCRIPTION_CONTRACT_DISCOUNT_NEGATIVE: "Ein Abo-Vertrag nennt bei {field} einen Rabatt von {amount}; ein Rabatt zieht Geld ab und ist nie negativ.",
1617
1674
  SUBSCRIPTION_CONTRACT_TERMINATION_BEFORE_START: "effectiveUntil muss nach dem effectiveFrom des Vertrags liegen.",
1618
1675
  CHECKOUT_OFFER_NOT_FOUND: "CheckoutOffer '{offerId}' nicht gefunden",
1619
1676
  CHECKOUT_OFFER_EXPIRED: "Checkout-Angebot '{offerId}' ist abgelaufen \u2014 '{action}' ist nicht mehr m\xF6glich",
@@ -1670,6 +1727,7 @@ export {
1670
1727
  ERROR_MESSAGES_DE,
1671
1728
  ERROR_MESSAGES_EN,
1672
1729
  FEATURE_NOT_LICENSED,
1730
+ ForeignPaymentMethodReferenceError,
1673
1731
  LEGAL_IDENTITY_FIELDS,
1674
1732
  MARKETING_PRIORITY_MAX,
1675
1733
  MARKETING_PRIORITY_MIN,
@@ -1714,6 +1772,7 @@ export {
1714
1772
  formatErrorMessage,
1715
1773
  identityCorrectionDelta,
1716
1774
  isBundleRedundant,
1775
+ isForeignPaymentMethodReferenceError,
1717
1776
  isPaymentCallbackRejectedError,
1718
1777
  isPlatformUserExistsError,
1719
1778
  isVersionEditable,
@@ -1725,9 +1784,11 @@ export {
1725
1784
  planCatalogSettingsOf,
1726
1785
  previousUtcDay,
1727
1786
  promoStatus,
1787
+ promotionOnPrice,
1728
1788
  readQuotaRecord,
1729
1789
  readQuotaValue,
1730
1790
  recordedIssuerIdentity,
1791
+ refuseForeignPaymentMethodReference,
1731
1792
  resolveBundleAvailability,
1732
1793
  resolveErrorMessage,
1733
1794
  sameLegalIdentity,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/core",
3
- "version": "1.0.0-rc.20",
3
+ "version": "1.0.0-rc.21",
4
4
  "description": "TypeScript contracts for SaaSiCat — capabilities, catalogs, subscriptions, entitlements, admin manifests, persistence ports — and the pure domain logic both server and browser run: error catalogues, plan-diff classification, promo arithmetic, feature requirements.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",