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 +11 -1
- package/README.md +3 -3
- package/dist/cli.js +60 -19
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +17 -7
- package/dist/index.js +62 -19
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/scenarios/stripe-billing-issue-recovers.yaml +19 -0
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.
|
|
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 (
|
|
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)
|
|
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.
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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";
|