reveclicat 0.2.0 → 0.3.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@ All notable changes to this project are documented here. Format based on [Keep a
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.3.0] — 2026-08-29
8
+
9
+ ### Added
10
+ - Stripe in the generator: `--store stripe` / `subscriber.store: stripe` — constant `si_…` transaction id, `prod_…` product id default, `renewal_number` on every event, `BILLING_ISSUE` with the period pre-extended and `grace_period_expiration_at_ms: null`, recovery `RENEWAL` that does not re-extend, Stripe money fields (commission 0, takehome 1), `country_code: null`. `UNCANCELLATION` and `TEST` are rejected for Stripe with a store-specific message (official compatibility table). All rules derived from a real captured lifecycle (`test/fixtures/events/real/*.stripe.json`). New example `scenarios/stripe-billing-issue-recovers.yaml`. (T-080–T-083)
11
+ - State machine: `legalEvents(state, store)` / `transition(..., { store })` — legality now has a store dimension. (T-082)
12
+
13
+ ### Changed
14
+ - `RENEWAL` is now legal from `cancelled_pending_expiration` (recovery after a `BILLING_ERROR` cancellation, as observed in real Stripe traffic and described for App Store in the official flows). (T-082)
15
+
7
16
  ## [0.2.0] — 2026-08-29
8
17
 
9
18
  ### Added
@@ -49,7 +58,8 @@ First release. Unofficial project — not affiliated with RevenueCat, Inc.
49
58
  - Consistent error output: every error prints `✖ message` + `→ hint`; usage errors (unknown command/option, missing argument) exit with code 2, other failures with 1; `RCC_DEBUG=1` shows stack traces; `NO_COLOR` honoured. (T-052)
50
59
  - Programmatic API (`reveclicat` package): schemas, `Subscriber`, `runScenario`, `loadScenario`, `VirtualClock`, `createRng`.
51
60
 
52
- [Unreleased]: https://github.com/RadW2020/ReveCliCat/compare/v0.2.0...HEAD
61
+ [Unreleased]: https://github.com/RadW2020/ReveCliCat/compare/v0.3.0...HEAD
62
+ [0.3.0]: https://github.com/RadW2020/ReveCliCat/compare/v0.2.0...v0.3.0
53
63
  [0.2.0]: https://github.com/RadW2020/ReveCliCat/compare/v0.1.1...v0.2.0
54
64
  [0.1.1]: https://github.com/RadW2020/ReveCliCat/compare/v0.1.0...v0.1.1
55
65
  [0.1.0]: https://github.com/RadW2020/ReveCliCat/releases/tag/v0.1.0
package/README.md CHANGED
@@ -94,7 +94,7 @@ subscriber: # all optional
94
94
  period: P1M # ISO-8601 duration
95
95
  trial: P1W # omit → no trial
96
96
  grace_period: P16D # billing-retry window after BILLING_ISSUE
97
- store: app_store # app_store | play_store (Google-shaped ids and product_id format)
97
+ store: app_store # app_store | play_store | stripe (store-shaped ids, defaults and legal events)
98
98
  environment: SANDBOX # SANDBOX | PRODUCTION
99
99
  steps:
100
100
  - event: INITIAL_PURCHASE # starts the trial (period_type: TRIAL, price 0)
@@ -115,7 +115,7 @@ expect:
115
115
 
116
116
  Rules: a step is exactly one of `event` or `advance`; unknown keys are errors; validation errors point at `file:line:column`. Illegal transitions stop the run with the step number and the list of legal events. `EXPIRATION` is only allowed once the virtual clock has reached `expiration_at_ms` (or the end of the grace period) — the error tells you exactly how much to `advance`.
117
117
 
118
- Shipped examples (`rcc init` copies them): `trial-converts`, `trial-churns`, `billing-issue-recovers`, `billing-issue-churns`, `cancel-then-uncancel`, `happy-year` (12 renewals), `play-trial-converts` (Google Play ids).
118
+ Shipped examples (`rcc init` copies them): `trial-converts`, `trial-churns`, `billing-issue-recovers`, `billing-issue-churns`, `cancel-then-uncancel`, `happy-year` (12 renewals), `play-trial-converts` (Google Play ids), `stripe-billing-issue-recovers` (Stripe dunning flow).
119
119
 
120
120
  ## CI
121
121
 
@@ -174,7 +174,7 @@ none ──INITIAL_PURCHASE──▶ trial ──RENEWAL (conversion)──▶ a
174
174
  ## Fidelity & scope
175
175
 
176
176
  - Schemas, enums and inclusion rules come from the official docs (fetched 2026-08-29) and the official sample payloads are used as test fixtures. The `TEST` event has no published sample, so its schema is marked *provisional* — a captured real one is very welcome (see `docs/BACKLOG.md`, T-004).
177
- - The generator models **App Store** (`--store app_store`, 16-digit transaction ids, original kept across resubscriptions) and **Google Play** (`--store play_store`, `GPA.…` order ids with `..N` renewal suffixes, `<subscription_id>:<base_plan_id>` product ids, new order on resubscription). Stripe, Amazon and Roku, a built-in tunnel, a web UI and hosted mode are intentionally out of scope (see the Icebox in `docs/BACKLOG.md`). Receivers accept events from every store.
177
+ - The generator models **App Store** (`--store app_store`, 16-digit transaction ids, original kept across resubscriptions), **Google Play** (`--store play_store`, `GPA.…` order ids with `..N` renewal suffixes, `<subscription_id>:<base_plan_id>` product ids, new order on resubscription) and **Stripe** (`--store stripe`, one `si_…` id for the whole subscription, `prod_…` product ids, `renewal_number`, billing issues with the period pre-extended and no grace field, no `UNCANCELLATION`/`TEST` — all taken from real captured events). Amazon, Roku and RevenueCat Billing, a built-in tunnel, a web UI and hosted mode are intentionally out of scope (see the Icebox in `docs/BACKLOG.md`). Receivers accept events from every store.
178
178
  - Programmatic use: `import { runScenario, Subscriber, WebhookEnvelopeSchema } from "reveclicat"`.
179
179
 
180
180
  ## How this was built
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import { Command } from "commander";
9
9
  // package.json
10
10
  var package_default = {
11
11
  name: "reveclicat",
12
- version: "0.2.0",
12
+ version: "0.3.0",
13
13
  description: "Unofficial CLI to simulate RevenueCat subscription lifecycles and test webhooks locally and in CI. Not affiliated with RevenueCat, Inc.",
14
14
  type: "module",
15
15
  license: "MIT",
@@ -165,9 +165,17 @@ var CANCEL_REASONS = [
165
165
  "UNKNOWN"
166
166
  ];
167
167
  var EXPIRATION_REASONS = [...CANCEL_REASONS, "SUBSCRIPTION_PAUSED"];
168
- var CLI_STORES = ["app_store", "play_store"];
169
- var CLI_STORE_TO_STORE = { app_store: "APP_STORE", play_store: "PLAY_STORE" };
170
- var DEFAULT_PRODUCT_ID = { app_store: "com.example.premium.monthly", play_store: "com.example.premium:monthly" };
168
+ var CLI_STORES = ["app_store", "play_store", "stripe"];
169
+ var CLI_STORE_TO_STORE = { app_store: "APP_STORE", play_store: "PLAY_STORE", stripe: "STRIPE" };
170
+ var DEFAULT_PRODUCT_ID = {
171
+ app_store: "com.example.premium.monthly",
172
+ play_store: "com.example.premium:monthly",
173
+ stripe: "prod_RccPremiumMonthly"
174
+ };
175
+ var UNSUPPORTED_EVENTS_BY_STORE = {
176
+ stripe: ["UNCANCELLATION", "TEST"]
177
+ };
178
+ var STORE_LABEL = { app_store: "App Store", play_store: "Google Play", stripe: "Stripe" };
171
179
 
172
180
  // src/core/config.ts
173
181
  var CONFIG_FILE = "reveclicat.config.json";
@@ -422,10 +430,11 @@ var IllegalTransitionError = class extends RccError {
422
430
  state;
423
431
  event;
424
432
  legal;
425
- constructor(state, event) {
426
- const legal = legalEvents(state);
433
+ constructor(state, event, store) {
434
+ const legal = legalEvents(state, store);
435
+ const unsupported = store !== void 0 && (UNSUPPORTED_EVENTS_BY_STORE[store] ?? []).includes(event);
427
436
  super(
428
- `Illegal transition: cannot apply ${event} while the subscription is "${state}". Legal events from "${state}": ${legal.join(", ")}.`,
437
+ unsupported ? `${STORE_LABEL[store]} does not emit ${event} (RevenueCat store compatibility table). Legal events from "${state}" for ${STORE_LABEL[store]}: ${legal.join(", ")}.` : `Illegal transition: cannot apply ${event} while the subscription is "${state}". Legal events from "${state}": ${legal.join(", ")}.`,
429
438
  { hint: "Check the order of the steps in your scenario (e.g. a RENEWAL needs an INITIAL_PURCHASE first)." }
430
439
  );
431
440
  this.name = "IllegalTransitionError";
@@ -450,6 +459,9 @@ var TABLE = {
450
459
  },
451
460
  cancelled_pending_expiration: {
452
461
  UNCANCELLATION: (ctx) => ctx.resumeState,
462
+ // Real flows: recovery after a BILLING_ERROR cancellation (Stripe capture 2026-08-29); App Store "CANCELLATION
463
+ // followed by a RENEWAL" when cancelling <24 h before a trial ends (docs S4).
464
+ RENEWAL: () => "active",
453
465
  EXPIRATION: () => "expired"
454
466
  },
455
467
  billing_issue: {
@@ -459,13 +471,17 @@ var TABLE = {
459
471
  },
460
472
  expired: { INITIAL_PURCHASE: () => "active" }
461
473
  };
462
- function legalEvents(state) {
463
- return [...Object.keys(TABLE[state]), "TEST"];
474
+ function legalEvents(state, store) {
475
+ const excluded = store === void 0 ? [] : UNSUPPORTED_EVENTS_BY_STORE[store] ?? [];
476
+ return [...Object.keys(TABLE[state]), "TEST"].filter((e) => !excluded.includes(e));
464
477
  }
465
478
  function transition(state, event, ctx) {
479
+ if (ctx.store !== void 0 && (UNSUPPORTED_EVENTS_BY_STORE[ctx.store] ?? []).includes(event)) {
480
+ throw new IllegalTransitionError(state, event, ctx.store);
481
+ }
466
482
  if (event === "TEST") return state;
467
483
  const rule = TABLE[state][event];
468
- if (!rule) throw new IllegalTransitionError(state, event);
484
+ if (!rule) throw new IllegalTransitionError(state, event, ctx.store);
469
485
  return rule(ctx);
470
486
  }
471
487
 
@@ -632,6 +648,8 @@ var Subscriber = class {
632
648
  cliStore;
633
649
  /** Play: number of renewals on the current order (drives the `..N` suffix). */
634
650
  renewalIndex = 0;
651
+ /** A BILLING_ISSUE is open (until a RENEWAL recovers it or the subscription expires). */
652
+ billingRetry = false;
635
653
  environment;
636
654
  price;
637
655
  currency;
@@ -656,7 +674,7 @@ var Subscriber = class {
656
674
  /** Emit an event: check legality, time guards, build + validate payload, commit state. */
657
675
  emit(type, overrides = {}) {
658
676
  const from = this._state;
659
- const next = transition(from, type, { hasTrial: this.trial !== void 0, resumeState: this.resumeState });
677
+ const next = transition(from, type, { hasTrial: this.trial !== void 0, resumeState: this.resumeState, store: this.cliStore });
660
678
  const now = this.deps.clock.now();
661
679
  if (type === "EXPIRATION") {
662
680
  const due = Math.max(this.expirationAtMs ?? 0, from === "billing_issue" ? this.gracePeriodExpirationAtMs ?? 0 : 0);
@@ -679,6 +697,8 @@ var Subscriber = class {
679
697
  this.periodType = draft.periodType;
680
698
  this.gracePeriodExpirationAtMs = draft.gracePeriodExpirationAtMs;
681
699
  this.renewalIndex = draft.renewalIndex;
700
+ if (type === "BILLING_ISSUE") this.billingRetry = true;
701
+ if (type === "RENEWAL" || type === "EXPIRATION" || type === "INITIAL_PURCHASE") this.billingRetry = false;
682
702
  if (type === "CANCELLATION") this.resumeState = from === "trial" ? "trial" : "active";
683
703
  this._state = next;
684
704
  }
@@ -692,15 +712,23 @@ var Subscriber = class {
692
712
  for (let i = 0; i < n; i++) s += String(this.deps.rng.int(10));
693
713
  return s;
694
714
  }
695
- /** A brand-new order/transaction id in the store's format (see specs/F7-google-play.md). */
715
+ alnum(n) {
716
+ const A = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
717
+ let s = "";
718
+ for (let i = 0; i < n; i++) s += A[this.deps.rng.int(A.length)];
719
+ return s;
720
+ }
721
+ /** A brand-new order/transaction id in the store's format (specs/F7-google-play.md, specs/F8-stripe.md). */
696
722
  newTransactionId() {
723
+ if (this.cliStore === "stripe") return `si_${this.alnum(14)}`;
697
724
  if (this.cliStore === "play_store") {
698
725
  return `GPA.${this.digits(4)}-${this.digits(4)}-${this.digits(4)}-${this.digits(5)}`;
699
726
  }
700
727
  return String(1 + this.deps.rng.int(9)) + this.digits(15);
701
728
  }
702
- /** Renewal id: Play appends `..N` to the original order id; App Store issues a fresh transaction id. */
729
+ /** Renewal id: Play appends `..N`; Stripe keeps the subscription item id; App Store issues a fresh transaction id. */
703
730
  renewalTransactionId(originalId, index) {
731
+ if (this.cliStore === "stripe" && originalId !== void 0) return originalId;
704
732
  if (this.cliStore === "play_store" && originalId !== void 0) return `${originalId}..${index}`;
705
733
  return this.newTransactionId();
706
734
  }
@@ -718,7 +746,7 @@ var Subscriber = class {
718
746
  case "INITIAL_PURCHASE": {
719
747
  const startsTrial = from === "none" && this.trial !== void 0;
720
748
  d.transactionId = this.newTransactionId();
721
- if (this.cliStore === "play_store") d.originalTransactionId = d.transactionId;
749
+ if (this.cliStore !== "app_store") d.originalTransactionId = d.transactionId;
722
750
  else d.originalTransactionId ??= d.transactionId;
723
751
  d.renewalIndex = 0;
724
752
  d.purchasedAtMs = now;
@@ -728,8 +756,13 @@ var Subscriber = class {
728
756
  break;
729
757
  }
730
758
  case "RENEWAL": {
731
- const start = d.expirationAtMs ?? now;
732
759
  d.transactionId = this.renewalTransactionId(d.originalTransactionId, d.renewalIndex);
760
+ if (this.cliStore === "stripe" && this.billingRetry) {
761
+ d.periodType = "NORMAL";
762
+ d.gracePeriodExpirationAtMs = null;
763
+ break;
764
+ }
765
+ const start = d.expirationAtMs ?? now;
733
766
  d.renewalIndex += 1;
734
767
  d.purchasedAtMs = start;
735
768
  d.expirationAtMs = addDuration(start, this.period);
@@ -738,7 +771,13 @@ var Subscriber = class {
738
771
  break;
739
772
  }
740
773
  case "BILLING_ISSUE":
741
- d.gracePeriodExpirationAtMs = addDuration(now, this.grace);
774
+ if (this.cliStore === "stripe") {
775
+ d.expirationAtMs = addDuration(d.expirationAtMs ?? now, this.period);
776
+ d.renewalIndex += 1;
777
+ d.gracePeriodExpirationAtMs = null;
778
+ } else {
779
+ d.gracePeriodExpirationAtMs = addDuration(now, this.grace);
780
+ }
742
781
  break;
743
782
  case "TEST":
744
783
  if (from === "none") {
@@ -756,6 +795,7 @@ var Subscriber = class {
756
795
  buildPayload(type, from, now, d) {
757
796
  const isPurchase = type === "INITIAL_PURCHASE" || type === "RENEWAL" || type === "TEST";
758
797
  const price = isPurchase && d.periodType !== "TRIAL" ? this.price : 0;
798
+ const stripe = this.cliStore === "stripe";
759
799
  const payload = {
760
800
  type,
761
801
  id: this.deps.rng.uuid(),
@@ -776,16 +816,17 @@ var Subscriber = class {
776
816
  transaction_id: d.transactionId,
777
817
  original_transaction_id: d.originalTransactionId,
778
818
  is_family_share: false,
779
- country_code: this.countryCode,
819
+ country_code: stripe ? null : this.countryCode,
780
820
  store: this.store,
781
821
  currency: this.currency,
782
822
  price,
783
823
  price_in_purchased_currency: price,
784
824
  tax_percentage: 0,
785
- commission_percentage: 0.3,
786
- takehome_percentage: 0.7,
825
+ commission_percentage: stripe ? 0 : 0.3,
826
+ takehome_percentage: stripe ? 1 : 0.7,
787
827
  offer_code: null
788
828
  };
829
+ if (stripe) payload["renewal_number"] = d.renewalIndex + 1;
789
830
  switch (type) {
790
831
  case "RENEWAL":
791
832
  payload["is_trial_conversion"] = from === "trial";