@saasicat/core 1.0.0-rc.4 → 1.0.0-rc.6

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/.build-stamp CHANGED
@@ -1 +1 @@
1
- 17dd1f2fcb075de1137e722910bc05e0dba58797081591fbec1a97466de011b5
1
+ 0c786c82cbbbdc8c75e264412f0eb8783769f233ca44e937e497a3d7992a54da
package/dist/index.d.cts CHANGED
@@ -2505,7 +2505,20 @@ interface CreateSubscriptionContractData {
2505
2505
  }
2506
2506
  interface TerminateSubscriptionContractData {
2507
2507
  effectiveUntil: Date;
2508
- status: Extract<SubscriptionContractStatus, 'terminated' | 'superseded'>;
2508
+ /**
2509
+ * The terminal status, or `null` to end the contract by date alone.
2510
+ *
2511
+ * `findActiveByTenantId` already asks its question as a window —
2512
+ * `effectiveFrom <= asOf` and `effectiveUntil` null or after it — so a
2513
+ * contract given an end in the FUTURE is found until that moment and not
2514
+ * afterwards, with no scheduled job to flip anything.
2515
+ *
2516
+ * Writing a terminal status instead makes the contract disappear from that
2517
+ * lookup at once, which for a cancellation declared months ahead removes an
2518
+ * agreement the customer is still under. Null is how a caller says "it ends
2519
+ * then", and a status is how it says "it is over now".
2520
+ */
2521
+ status: Extract<SubscriptionContractStatus, 'terminated' | 'superseded'> | null;
2509
2522
  }
2510
2523
  interface SubscriptionContractFilter {
2511
2524
  projectKey?: string;
@@ -2564,6 +2577,24 @@ interface SubscriptionRecord {
2564
2577
  } | null;
2565
2578
  planVersionId: string;
2566
2579
  planVersion: PlanVersionRecord;
2580
+ /**
2581
+ * When a cancellation was declared, and when it takes effect.
2582
+ *
2583
+ * Required, and required together, because entitlement resolution ends a
2584
+ * subscription by reading them: without the second date it cannot tell a
2585
+ * subscription that ends next January from one that ended last January, and
2586
+ * it grants the latter everything. Nothing else in the platform would
2587
+ * notice — no repository filters a cancelled subscription out, and stopping
2588
+ * the billing period is a different decision from ending what a tenant may
2589
+ * do.
2590
+ *
2591
+ * `null` on both means no cancellation. On a row written before the two
2592
+ * fields separated, `canceledAt` carries the effective date and
2593
+ * `canceledEffectiveAt` is genuinely null; every reader in the platform
2594
+ * applies `canceledEffectiveAt ?? canceledAt` for that reason.
2595
+ */
2596
+ canceledAt: Date | null;
2597
+ canceledEffectiveAt: Date | null;
2567
2598
  }
2568
2599
  /** Snapshot of a `PlanVersion` row. */
2569
2600
  interface PlanVersionRecord {
@@ -2724,6 +2755,27 @@ interface SubscriptionUsageRecord {
2724
2755
  /** Current period window — for proration and change-effective date. */
2725
2756
  currentPeriodStart: Date | null;
2726
2757
  currentPeriodEnd: Date | null;
2758
+ /**
2759
+ * End of what was committed to, which the period end need not equal.
2760
+ *
2761
+ * The cancellation rules measure against this: a subscription cancelled
2762
+ * inside its term keeps running until the term ends, not until the period
2763
+ * does. Null on a trial, and on any subscription written before the field
2764
+ * existed — readers treat that as "the period end is the answer".
2765
+ */
2766
+ minimumTermUntil?: Date | null;
2767
+ /**
2768
+ * When a cancellation was declared, and when it lands.
2769
+ *
2770
+ * Required for the reason the same pair is required on
2771
+ * `SubscriptionRecord`: the tenant billing route reads them to refuse a
2772
+ * plan change on a subscription that has ended, and a record that omits
2773
+ * them answers "not cancelled" — so the change is applied and prorated
2774
+ * while entitlement resolution, which reads a record that does carry them,
2775
+ * grants nothing.
2776
+ */
2777
+ canceledAt: Date | null;
2778
+ canceledEffectiveAt: Date | null;
2727
2779
  pendingPlan: string | null;
2728
2780
  pendingBillingCycle: string | null;
2729
2781
  pendingEffectiveAt: Date | null;
@@ -2799,12 +2851,29 @@ interface ImmediatePlanChangeInput {
2799
2851
  * change, or target package without trial). A `Date` is persisted.
2800
2852
  */
2801
2853
  trialEndsAt?: Date | null;
2854
+ /**
2855
+ * `canceledAt` as the caller read it, so the write can claim the row only
2856
+ * while that is still true.
2857
+ *
2858
+ * Three of the plan route's decisions depend on the cancellation — whether
2859
+ * the change is refused at all, whether the billing cycle may move, and
2860
+ * whether a fresh period is opened — and a read and a write are two
2861
+ * moments. A cancellation declared in between made every one of them answer
2862
+ * about a state that no longer existed, and the write went ahead anyway: a
2863
+ * plan term recorded past the date the subscription ends.
2864
+ *
2865
+ * `null` is a value here rather than an absence. It claims a row that has
2866
+ * no cancellation, and loses against one that has acquired one.
2867
+ */
2868
+ expectedCanceledAt: Date | null;
2802
2869
  }
2803
2870
  /** Input for `schedulePlanChange` (change at period end). */
2804
2871
  interface ScheduledPlanChangeInput {
2805
2872
  pendingPlan: string;
2806
2873
  pendingBillingCycle: string;
2807
2874
  pendingEffectiveAt: Date;
2875
+ /** See `ImmediatePlanChangeInput.expectedCanceledAt`. */
2876
+ expectedCanceledAt: Date | null;
2808
2877
  }
2809
2878
  /**
2810
2879
  * Input for `applyOnboardingSelection`. Plan-change fields that the
@@ -2813,6 +2882,12 @@ interface ScheduledPlanChangeInput {
2813
2882
  interface ApplyOnboardingSelectionInput {
2814
2883
  planId: string;
2815
2884
  cycle: string;
2885
+ /**
2886
+ * See `ImmediatePlanChangeInput.expectedCanceledAt`. The atomic path needs
2887
+ * it for the same reason the sequential one does: without it, the preferred
2888
+ * implementation is the one where the race stays open.
2889
+ */
2890
+ expectedCanceledAt: Date | null;
2816
2891
  /** For TRIAL → null, otherwise period start from `initialPeriodWindow`. */
2817
2892
  periodStart: Date | null;
2818
2893
  periodEnd: Date | null;
@@ -2829,6 +2904,12 @@ interface ApplyOnboardingSelectionResult {
2829
2904
  subscriptionId: string;
2830
2905
  /** null if no redeemPromo callback was provided or the callback returned null. */
2831
2906
  promoRedemption: PromoCodeRedemptionRecord | null;
2907
+ /**
2908
+ * False when the row's cancellation moved since the caller read it, in
2909
+ * which case nothing was written — including the promo redemption, which
2910
+ * shares the transaction.
2911
+ */
2912
+ claimed: boolean;
2832
2913
  }
2833
2914
  /**
2834
2915
  * Callback signature for promo-code redemption WITHIN the onboarding
@@ -2851,9 +2932,13 @@ interface TenantSubscriptionWritePort {
2851
2932
  changePlanImmediate(tenantId: string, input: ImmediatePlanChangeInput): Promise<{
2852
2933
  plan: string;
2853
2934
  billingCycle: string;
2935
+ /** False when the row's cancellation moved since the caller read it. */
2936
+ claimed: boolean;
2854
2937
  }>;
2855
2938
  /** Change at period end: set pending fields. */
2856
- schedulePlanChange(tenantId: string, input: ScheduledPlanChangeInput): Promise<void>;
2939
+ schedulePlanChange(tenantId: string, input: ScheduledPlanChangeInput): Promise<{
2940
+ claimed: boolean;
2941
+ }>;
2857
2942
  /**
2858
2943
  * Marks the pending PlanVersion as accepted. Idempotent — a duplicate
2859
2944
  * accept is a no-op. Returns `alreadyAccepted: true` if the status was
@@ -2866,12 +2951,52 @@ interface TenantSubscriptionWritePort {
2866
2951
  alreadyAccepted: boolean;
2867
2952
  }>;
2868
2953
  /**
2869
- * Cancel the subscription. `immediate=true` status CANCELED from now;
2870
- * `false` → canceledAt = currentPeriodEnd, status is preserved.
2954
+ * Record a cancellation. The dates are decided above this port.
2955
+ *
2956
+ * `canceledAt` is when the customer said it; `effectiveAt` is when it
2957
+ * lands. They differ for every ordinary cancellation, because a
2958
+ * subscription cancelled inside its term keeps running, keeps being billed
2959
+ * and keeps its entitlements until the term ends. An adapter that computed
2960
+ * the second from the first — which this one did, as
2961
+ * `immediate ? now : currentPeriodEnd` — was deciding a commercial
2962
+ * question in a persistence layer, and could not see the minimum term or
2963
+ * the notice period at all.
2964
+ *
2965
+ * `terminateNow` flips the status immediately, and is set when the
2966
+ * cancellation is already effective: an operator ending a contract, or the
2967
+ * rules finding nothing left to run — no period, no term, as on a trial.
2968
+ * It is never a client's request. A tenant may always declare a
2969
+ * cancellation and may never shorten the term they are in; what decides
2970
+ * this flag is the date the rules returned, not the date they asked for.
2971
+ *
2972
+ * `minimumTermUntil` extends the stored commitment, and is set only when
2973
+ * the cancellation itself extends it: a declaration made after the notice
2974
+ * deadline buys the following period. Left unset the stored term end is
2975
+ * unchanged, which is the ordinary case.
2871
2976
  */
2872
- cancelSubscription(tenantId: string, immediate: boolean, now: Date): Promise<{
2977
+ cancelSubscription(tenantId: string, input: {
2978
+ canceledAt: Date;
2979
+ effectiveAt: Date;
2980
+ terminateNow: boolean;
2981
+ minimumTermUntil?: Date;
2982
+ }): Promise<{
2873
2983
  canceledAt: Date | null;
2984
+ canceledEffectiveAt: Date | null;
2874
2985
  status: string;
2986
+ /**
2987
+ * True when a cancellation was already recorded and this call changed
2988
+ * nothing — the stored dates are returned instead.
2989
+ *
2990
+ * The caller checks first, but a check and a write are two moments, and
2991
+ * two requests can pass the check before either writes. Straddling a
2992
+ * notice deadline that costs a billing cycle: the first declaration
2993
+ * lands on time, the second recomputes against a later `now`, and an
2994
+ * unconditional write replaces the first answer with one a period
2995
+ * further out. An implementation therefore claims the row only while
2996
+ * both cancellation fields are still empty, and answers `true` here
2997
+ * when the claim finds nothing to claim.
2998
+ */
2999
+ alreadyCanceled: boolean;
2875
3000
  }>;
2876
3001
  /**
2877
3002
  * Atomic onboarding creation: sets plan + cycle + period window
package/dist/index.d.ts CHANGED
@@ -2505,7 +2505,20 @@ interface CreateSubscriptionContractData {
2505
2505
  }
2506
2506
  interface TerminateSubscriptionContractData {
2507
2507
  effectiveUntil: Date;
2508
- status: Extract<SubscriptionContractStatus, 'terminated' | 'superseded'>;
2508
+ /**
2509
+ * The terminal status, or `null` to end the contract by date alone.
2510
+ *
2511
+ * `findActiveByTenantId` already asks its question as a window —
2512
+ * `effectiveFrom <= asOf` and `effectiveUntil` null or after it — so a
2513
+ * contract given an end in the FUTURE is found until that moment and not
2514
+ * afterwards, with no scheduled job to flip anything.
2515
+ *
2516
+ * Writing a terminal status instead makes the contract disappear from that
2517
+ * lookup at once, which for a cancellation declared months ahead removes an
2518
+ * agreement the customer is still under. Null is how a caller says "it ends
2519
+ * then", and a status is how it says "it is over now".
2520
+ */
2521
+ status: Extract<SubscriptionContractStatus, 'terminated' | 'superseded'> | null;
2509
2522
  }
2510
2523
  interface SubscriptionContractFilter {
2511
2524
  projectKey?: string;
@@ -2564,6 +2577,24 @@ interface SubscriptionRecord {
2564
2577
  } | null;
2565
2578
  planVersionId: string;
2566
2579
  planVersion: PlanVersionRecord;
2580
+ /**
2581
+ * When a cancellation was declared, and when it takes effect.
2582
+ *
2583
+ * Required, and required together, because entitlement resolution ends a
2584
+ * subscription by reading them: without the second date it cannot tell a
2585
+ * subscription that ends next January from one that ended last January, and
2586
+ * it grants the latter everything. Nothing else in the platform would
2587
+ * notice — no repository filters a cancelled subscription out, and stopping
2588
+ * the billing period is a different decision from ending what a tenant may
2589
+ * do.
2590
+ *
2591
+ * `null` on both means no cancellation. On a row written before the two
2592
+ * fields separated, `canceledAt` carries the effective date and
2593
+ * `canceledEffectiveAt` is genuinely null; every reader in the platform
2594
+ * applies `canceledEffectiveAt ?? canceledAt` for that reason.
2595
+ */
2596
+ canceledAt: Date | null;
2597
+ canceledEffectiveAt: Date | null;
2567
2598
  }
2568
2599
  /** Snapshot of a `PlanVersion` row. */
2569
2600
  interface PlanVersionRecord {
@@ -2724,6 +2755,27 @@ interface SubscriptionUsageRecord {
2724
2755
  /** Current period window — for proration and change-effective date. */
2725
2756
  currentPeriodStart: Date | null;
2726
2757
  currentPeriodEnd: Date | null;
2758
+ /**
2759
+ * End of what was committed to, which the period end need not equal.
2760
+ *
2761
+ * The cancellation rules measure against this: a subscription cancelled
2762
+ * inside its term keeps running until the term ends, not until the period
2763
+ * does. Null on a trial, and on any subscription written before the field
2764
+ * existed — readers treat that as "the period end is the answer".
2765
+ */
2766
+ minimumTermUntil?: Date | null;
2767
+ /**
2768
+ * When a cancellation was declared, and when it lands.
2769
+ *
2770
+ * Required for the reason the same pair is required on
2771
+ * `SubscriptionRecord`: the tenant billing route reads them to refuse a
2772
+ * plan change on a subscription that has ended, and a record that omits
2773
+ * them answers "not cancelled" — so the change is applied and prorated
2774
+ * while entitlement resolution, which reads a record that does carry them,
2775
+ * grants nothing.
2776
+ */
2777
+ canceledAt: Date | null;
2778
+ canceledEffectiveAt: Date | null;
2727
2779
  pendingPlan: string | null;
2728
2780
  pendingBillingCycle: string | null;
2729
2781
  pendingEffectiveAt: Date | null;
@@ -2799,12 +2851,29 @@ interface ImmediatePlanChangeInput {
2799
2851
  * change, or target package without trial). A `Date` is persisted.
2800
2852
  */
2801
2853
  trialEndsAt?: Date | null;
2854
+ /**
2855
+ * `canceledAt` as the caller read it, so the write can claim the row only
2856
+ * while that is still true.
2857
+ *
2858
+ * Three of the plan route's decisions depend on the cancellation — whether
2859
+ * the change is refused at all, whether the billing cycle may move, and
2860
+ * whether a fresh period is opened — and a read and a write are two
2861
+ * moments. A cancellation declared in between made every one of them answer
2862
+ * about a state that no longer existed, and the write went ahead anyway: a
2863
+ * plan term recorded past the date the subscription ends.
2864
+ *
2865
+ * `null` is a value here rather than an absence. It claims a row that has
2866
+ * no cancellation, and loses against one that has acquired one.
2867
+ */
2868
+ expectedCanceledAt: Date | null;
2802
2869
  }
2803
2870
  /** Input for `schedulePlanChange` (change at period end). */
2804
2871
  interface ScheduledPlanChangeInput {
2805
2872
  pendingPlan: string;
2806
2873
  pendingBillingCycle: string;
2807
2874
  pendingEffectiveAt: Date;
2875
+ /** See `ImmediatePlanChangeInput.expectedCanceledAt`. */
2876
+ expectedCanceledAt: Date | null;
2808
2877
  }
2809
2878
  /**
2810
2879
  * Input for `applyOnboardingSelection`. Plan-change fields that the
@@ -2813,6 +2882,12 @@ interface ScheduledPlanChangeInput {
2813
2882
  interface ApplyOnboardingSelectionInput {
2814
2883
  planId: string;
2815
2884
  cycle: string;
2885
+ /**
2886
+ * See `ImmediatePlanChangeInput.expectedCanceledAt`. The atomic path needs
2887
+ * it for the same reason the sequential one does: without it, the preferred
2888
+ * implementation is the one where the race stays open.
2889
+ */
2890
+ expectedCanceledAt: Date | null;
2816
2891
  /** For TRIAL → null, otherwise period start from `initialPeriodWindow`. */
2817
2892
  periodStart: Date | null;
2818
2893
  periodEnd: Date | null;
@@ -2829,6 +2904,12 @@ interface ApplyOnboardingSelectionResult {
2829
2904
  subscriptionId: string;
2830
2905
  /** null if no redeemPromo callback was provided or the callback returned null. */
2831
2906
  promoRedemption: PromoCodeRedemptionRecord | null;
2907
+ /**
2908
+ * False when the row's cancellation moved since the caller read it, in
2909
+ * which case nothing was written — including the promo redemption, which
2910
+ * shares the transaction.
2911
+ */
2912
+ claimed: boolean;
2832
2913
  }
2833
2914
  /**
2834
2915
  * Callback signature for promo-code redemption WITHIN the onboarding
@@ -2851,9 +2932,13 @@ interface TenantSubscriptionWritePort {
2851
2932
  changePlanImmediate(tenantId: string, input: ImmediatePlanChangeInput): Promise<{
2852
2933
  plan: string;
2853
2934
  billingCycle: string;
2935
+ /** False when the row's cancellation moved since the caller read it. */
2936
+ claimed: boolean;
2854
2937
  }>;
2855
2938
  /** Change at period end: set pending fields. */
2856
- schedulePlanChange(tenantId: string, input: ScheduledPlanChangeInput): Promise<void>;
2939
+ schedulePlanChange(tenantId: string, input: ScheduledPlanChangeInput): Promise<{
2940
+ claimed: boolean;
2941
+ }>;
2857
2942
  /**
2858
2943
  * Marks the pending PlanVersion as accepted. Idempotent — a duplicate
2859
2944
  * accept is a no-op. Returns `alreadyAccepted: true` if the status was
@@ -2866,12 +2951,52 @@ interface TenantSubscriptionWritePort {
2866
2951
  alreadyAccepted: boolean;
2867
2952
  }>;
2868
2953
  /**
2869
- * Cancel the subscription. `immediate=true` status CANCELED from now;
2870
- * `false` → canceledAt = currentPeriodEnd, status is preserved.
2954
+ * Record a cancellation. The dates are decided above this port.
2955
+ *
2956
+ * `canceledAt` is when the customer said it; `effectiveAt` is when it
2957
+ * lands. They differ for every ordinary cancellation, because a
2958
+ * subscription cancelled inside its term keeps running, keeps being billed
2959
+ * and keeps its entitlements until the term ends. An adapter that computed
2960
+ * the second from the first — which this one did, as
2961
+ * `immediate ? now : currentPeriodEnd` — was deciding a commercial
2962
+ * question in a persistence layer, and could not see the minimum term or
2963
+ * the notice period at all.
2964
+ *
2965
+ * `terminateNow` flips the status immediately, and is set when the
2966
+ * cancellation is already effective: an operator ending a contract, or the
2967
+ * rules finding nothing left to run — no period, no term, as on a trial.
2968
+ * It is never a client's request. A tenant may always declare a
2969
+ * cancellation and may never shorten the term they are in; what decides
2970
+ * this flag is the date the rules returned, not the date they asked for.
2971
+ *
2972
+ * `minimumTermUntil` extends the stored commitment, and is set only when
2973
+ * the cancellation itself extends it: a declaration made after the notice
2974
+ * deadline buys the following period. Left unset the stored term end is
2975
+ * unchanged, which is the ordinary case.
2871
2976
  */
2872
- cancelSubscription(tenantId: string, immediate: boolean, now: Date): Promise<{
2977
+ cancelSubscription(tenantId: string, input: {
2978
+ canceledAt: Date;
2979
+ effectiveAt: Date;
2980
+ terminateNow: boolean;
2981
+ minimumTermUntil?: Date;
2982
+ }): Promise<{
2873
2983
  canceledAt: Date | null;
2984
+ canceledEffectiveAt: Date | null;
2874
2985
  status: string;
2986
+ /**
2987
+ * True when a cancellation was already recorded and this call changed
2988
+ * nothing — the stored dates are returned instead.
2989
+ *
2990
+ * The caller checks first, but a check and a write are two moments, and
2991
+ * two requests can pass the check before either writes. Straddling a
2992
+ * notice deadline that costs a billing cycle: the first declaration
2993
+ * lands on time, the second recomputes against a later `now`, and an
2994
+ * unconditional write replaces the first answer with one a period
2995
+ * further out. An implementation therefore claims the row only while
2996
+ * both cancellation fields are still empty, and answers `true` here
2997
+ * when the claim finds nothing to claim.
2998
+ */
2999
+ alreadyCanceled: boolean;
2875
3000
  }>;
2876
3001
  /**
2877
3002
  * Atomic onboarding creation: sets plan + cycle + period window
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/core",
3
- "version": "1.0.0-rc.4",
3
+ "version": "1.0.0-rc.6",
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",