@chipmobilesdk/rn-payment 0.1.0 → 0.2.1

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.
@@ -1,157 +1,157 @@
1
- /**
2
- * Subscription plan changes and the route to the store's management screen (US12).
3
- *
4
- * Two platform rules drive this module:
5
- *
6
- * - **Proration is the app's decision, inside what the store permits** (FR-065).
7
- * Whether a user who upgrades gets charged immediately, keeps their remaining
8
- * time, or starts a fresh period is a product and pricing choice, so the
9
- * package forwards it rather than picking.
10
- * - **Cancellation belongs to the store.** Platforms forbid an app presenting its
11
- * own cancellation screen, so this module offers only a route to the store's
12
- * (FR-066) — there is deliberately no self-built alternative anywhere in the
13
- * package.
14
- */
15
-
16
- import type {
17
- ProductKey,
18
- PurchaseOptions,
19
- PurchaseResult,
20
- } from '../config/types';
21
- import type { ResolvedConfig } from '../config/validate';
22
- import type { ProviderPort } from '../adapter/port';
23
- import { storeIdForProductKey } from '../entitlements/mapping';
24
- import type { PersistedSnapshot } from '../persistence/snapshot';
25
-
26
- /**
27
- * How already-paid time is handled when a plan changes.
28
- *
29
- * Named for the user-visible effect rather than mirroring a store's enum, so the
30
- * same vocabulary will map onto StoreKit later (FR-004).
31
- */
32
- export type ProrationMode =
33
- /** Switch now; credit unused time against the new plan. The usual upgrade. */
34
- | 'immediate_with_time_proration'
35
- /** Switch now; charge the full new price immediately and restart the period. */
36
- | 'immediate_and_charge_full_price'
37
- /** Switch now; charge the prorated difference for the current period. */
38
- | 'immediate_and_charge_prorated_price'
39
- /** Keep the current plan until it expires, then start the new one. Usual downgrade. */
40
- | 'deferred';
41
-
42
- /** Store-specific replacement codes stay here, behind the neutral vocabulary above. */
43
- const PRORATION_CODES: Record<ProrationMode, string> = {
44
- immediate_with_time_proration: 'WITH_TIME_PRORATION',
45
- immediate_and_charge_full_price: 'CHARGE_FULL_PRICE',
46
- immediate_and_charge_prorated_price: 'CHARGE_PRORATED_PRICE',
47
- deferred: 'DEFERRED',
48
- };
49
-
50
- export type PriceChangeResult =
51
- | { kind: 'confirmed' }
52
- | { kind: 'dismissed' }
53
- /** The platform has no client-side flow; it notifies the user itself (FR-067). */
54
- | { kind: 'unsupported' }
55
- | { kind: 'failed'; code: string };
56
-
57
- export interface SubscriptionsDeps {
58
- resolved: ResolvedConfig;
59
- provider: ProviderPort;
60
- snapshot: () => PersistedSnapshot;
61
- purchase: (
62
- productKey: ProductKey,
63
- options?: PurchaseOptions & { replaceProductKey?: ProductKey; prorationMode?: string },
64
- ) => Promise<PurchaseResult>;
65
- }
66
-
67
- export interface Subscriptions {
68
- changePlan(
69
- fromProductKey: ProductKey,
70
- toProductKey: ProductKey,
71
- mode: ProrationMode,
72
- options?: PurchaseOptions,
73
- ): Promise<PurchaseResult>;
74
- openManagement(productKey?: ProductKey): Promise<void>;
75
- confirmPriceChange(productKey: ProductKey): Promise<PriceChangeResult>;
76
- }
77
-
78
- export function createSubscriptions(deps: SubscriptionsDeps): Subscriptions {
79
- const { resolved } = deps;
80
-
81
- return {
82
- async changePlan(
83
- fromProductKey,
84
- toProductKey,
85
- mode,
86
- options,
87
- ): Promise<PurchaseResult> {
88
- if (!deps.provider.capabilities().supportsPlanChange) {
89
- return { kind: 'unsupported' };
90
- }
91
-
92
- const target = resolved.entries.get(toProductKey);
93
- if (!target) {
94
- return { kind: 'unknown_product', productKey: toProductKey };
95
- }
96
- if (!resolved.entries.has(fromProductKey)) {
97
- return { kind: 'unknown_product', productKey: fromProductKey };
98
- }
99
-
100
- // The store needs the *token* of the subscription being replaced, not its
101
- // product id — that is how it knows which active purchase to migrate, and
102
- // it is why a plan change needs a live local record rather than just two
103
- // product keys.
104
- const active = deps
105
- .snapshot()
106
- .purchases.find(
107
- p =>
108
- p.productKey === fromProductKey &&
109
- p.productType === 'subscription' &&
110
- p.state === 'purchased',
111
- );
112
-
113
- if (!active) {
114
- // No subscription to replace. Treated as a plain purchase would be
115
- // surprising — the caller asked to *change* something that is not there,
116
- // which almost always means its own state is stale.
117
- return { kind: 'already_owned', productKey: fromProductKey };
118
- }
119
-
120
- // Routed through the normal purchase flow on purpose: a plan change is a
121
- // purchase, so it must inherit the single-flight lock, the environment
122
- // guard, validation, and the finalization queue rather than reimplementing
123
- // any of them.
124
- return deps.purchase(toProductKey, {
125
- ...options,
126
- replaceProductKey: fromProductKey,
127
- prorationMode: PRORATION_CODES[mode],
128
- });
129
- },
130
-
131
- async openManagement(productKey?: ProductKey): Promise<void> {
132
- const storeProductId = productKey
133
- ? storeIdForProductKey(resolved, productKey)
134
- : undefined;
135
- // No-ops rather than throwing where the platform has no such screen: this
136
- // is wired to a button, and a button that crashes is worse than one that
137
- // does nothing on a platform that cannot honour it.
138
- await deps.provider.openSubscriptionManagement(storeProductId);
139
- },
140
-
141
- async confirmPriceChange(productKey): Promise<PriceChangeResult> {
142
- if (!deps.provider.capabilities().supportsPriceChangeConfirmation) {
143
- return { kind: 'unsupported' };
144
- }
145
- const storeProductId = storeIdForProductKey(resolved, productKey);
146
- if (!storeProductId) {
147
- return { kind: 'failed', code: 'PAYMENT_UNKNOWN_PRODUCT' };
148
- }
149
-
150
- const outcome = await deps.provider.confirmPriceChange(storeProductId);
151
- if (!outcome.ok) {
152
- return { kind: 'failed', code: 'PAYMENT_STORE_ERROR' };
153
- }
154
- return outcome.confirmed ? { kind: 'confirmed' } : { kind: 'dismissed' };
155
- },
156
- };
157
- }
1
+ /**
2
+ * Subscription plan changes and the route to the store's management screen (US12).
3
+ *
4
+ * Two platform rules drive this module:
5
+ *
6
+ * - **Proration is the app's decision, inside what the store permits** (FR-065).
7
+ * Whether a user who upgrades gets charged immediately, keeps their remaining
8
+ * time, or starts a fresh period is a product and pricing choice, so the
9
+ * package forwards it rather than picking.
10
+ * - **Cancellation belongs to the store.** Platforms forbid an app presenting its
11
+ * own cancellation screen, so this module offers only a route to the store's
12
+ * (FR-066) — there is deliberately no self-built alternative anywhere in the
13
+ * package.
14
+ */
15
+
16
+ import type {
17
+ ProductKey,
18
+ PurchaseOptions,
19
+ PurchaseResult,
20
+ } from '../config/types';
21
+ import type { ResolvedConfig } from '../config/validate';
22
+ import type { ProviderPort } from '../adapter/port';
23
+ import { storeIdForProductKey } from '../entitlements/mapping';
24
+ import type { PersistedSnapshot } from '../persistence/snapshot';
25
+
26
+ /**
27
+ * How already-paid time is handled when a plan changes.
28
+ *
29
+ * Named for the user-visible effect rather than mirroring a store's enum, so the
30
+ * same vocabulary will map onto StoreKit later (FR-004).
31
+ */
32
+ export type ProrationMode =
33
+ /** Switch now; credit unused time against the new plan. The usual upgrade. */
34
+ | 'immediate_with_time_proration'
35
+ /** Switch now; charge the full new price immediately and restart the period. */
36
+ | 'immediate_and_charge_full_price'
37
+ /** Switch now; charge the prorated difference for the current period. */
38
+ | 'immediate_and_charge_prorated_price'
39
+ /** Keep the current plan until it expires, then start the new one. Usual downgrade. */
40
+ | 'deferred';
41
+
42
+ /** Store-specific replacement codes stay here, behind the neutral vocabulary above. */
43
+ const PRORATION_CODES: Record<ProrationMode, string> = {
44
+ immediate_with_time_proration: 'WITH_TIME_PRORATION',
45
+ immediate_and_charge_full_price: 'CHARGE_FULL_PRICE',
46
+ immediate_and_charge_prorated_price: 'CHARGE_PRORATED_PRICE',
47
+ deferred: 'DEFERRED',
48
+ };
49
+
50
+ export type PriceChangeResult =
51
+ | { kind: 'confirmed' }
52
+ | { kind: 'dismissed' }
53
+ /** The platform has no client-side flow; it notifies the user itself (FR-067). */
54
+ | { kind: 'unsupported' }
55
+ | { kind: 'failed'; code: string };
56
+
57
+ export interface SubscriptionsDeps {
58
+ resolved: ResolvedConfig;
59
+ provider: ProviderPort;
60
+ snapshot: () => PersistedSnapshot;
61
+ purchase: (
62
+ productKey: ProductKey,
63
+ options?: PurchaseOptions & { replaceProductKey?: ProductKey; prorationMode?: string },
64
+ ) => Promise<PurchaseResult>;
65
+ }
66
+
67
+ export interface Subscriptions {
68
+ changePlan(
69
+ fromProductKey: ProductKey,
70
+ toProductKey: ProductKey,
71
+ mode: ProrationMode,
72
+ options?: PurchaseOptions,
73
+ ): Promise<PurchaseResult>;
74
+ openManagement(productKey?: ProductKey): Promise<void>;
75
+ confirmPriceChange(productKey: ProductKey): Promise<PriceChangeResult>;
76
+ }
77
+
78
+ export function createSubscriptions(deps: SubscriptionsDeps): Subscriptions {
79
+ const { resolved } = deps;
80
+
81
+ return {
82
+ async changePlan(
83
+ fromProductKey,
84
+ toProductKey,
85
+ mode,
86
+ options,
87
+ ): Promise<PurchaseResult> {
88
+ if (!deps.provider.capabilities().supportsPlanChange) {
89
+ return { kind: 'unsupported' };
90
+ }
91
+
92
+ const target = resolved.entries.get(toProductKey);
93
+ if (!target) {
94
+ return { kind: 'unknown_product', productKey: toProductKey };
95
+ }
96
+ if (!resolved.entries.has(fromProductKey)) {
97
+ return { kind: 'unknown_product', productKey: fromProductKey };
98
+ }
99
+
100
+ // The store needs the *token* of the subscription being replaced, not its
101
+ // product id — that is how it knows which active purchase to migrate, and
102
+ // it is why a plan change needs a live local record rather than just two
103
+ // product keys.
104
+ const active = deps
105
+ .snapshot()
106
+ .purchases.find(
107
+ p =>
108
+ p.productKey === fromProductKey &&
109
+ p.productType === 'subscription' &&
110
+ p.state === 'purchased',
111
+ );
112
+
113
+ if (!active) {
114
+ // No subscription to replace. Treated as a plain purchase would be
115
+ // surprising — the caller asked to *change* something that is not there,
116
+ // which almost always means its own state is stale.
117
+ return { kind: 'already_owned', productKey: fromProductKey };
118
+ }
119
+
120
+ // Routed through the normal purchase flow on purpose: a plan change is a
121
+ // purchase, so it must inherit the single-flight lock, the environment
122
+ // guard, validation, and the finalization queue rather than reimplementing
123
+ // any of them.
124
+ return deps.purchase(toProductKey, {
125
+ ...options,
126
+ replaceProductKey: fromProductKey,
127
+ prorationMode: PRORATION_CODES[mode],
128
+ });
129
+ },
130
+
131
+ async openManagement(productKey?: ProductKey): Promise<void> {
132
+ const storeProductId = productKey
133
+ ? storeIdForProductKey(resolved, productKey)
134
+ : undefined;
135
+ // No-ops rather than throwing where the platform has no such screen: this
136
+ // is wired to a button, and a button that crashes is worse than one that
137
+ // does nothing on a platform that cannot honour it.
138
+ await deps.provider.openSubscriptionManagement(storeProductId);
139
+ },
140
+
141
+ async confirmPriceChange(productKey): Promise<PriceChangeResult> {
142
+ if (!deps.provider.capabilities().supportsPriceChangeConfirmation) {
143
+ return { kind: 'unsupported' };
144
+ }
145
+ const storeProductId = storeIdForProductKey(resolved, productKey);
146
+ if (!storeProductId) {
147
+ return { kind: 'failed', code: 'PAYMENT_UNKNOWN_PRODUCT' };
148
+ }
149
+
150
+ const outcome = await deps.provider.confirmPriceChange(storeProductId);
151
+ if (!outcome.ok) {
152
+ return { kind: 'failed', code: 'PAYMENT_STORE_ERROR' };
153
+ }
154
+ return outcome.confirmed ? { kind: 'confirmed' } : { kind: 'dismissed' };
155
+ },
156
+ };
157
+ }
@@ -134,17 +134,34 @@ function subscriptionDetailFor(
134
134
  * touch I/O (PERF-001). The engine calls it after any state change and caches the
135
135
  * result.
136
136
  */
137
- export function aggregate(input: AggregateInput): EntitlementMap {
137
+ /**
138
+ * How far the snapshot can be trusted right now.
139
+ *
140
+ * Shared by entitlements and the pending set (FR-103) so the two can never
141
+ * disagree about whether the package knows anything — a paywall that showed
142
+ * "not entitled" next to "nothing pending" from one stale snapshot would offer
143
+ * Buy to a user whose payment is in flight.
144
+ */
145
+ export function snapshotCertainty(input: {
146
+ config: Pick<ResolvedConfig, 'staleAfterMs'>;
147
+ snapshot: Pick<PersistedSnapshot, 'lastSyncedAt'>;
148
+ hasSynced: boolean;
149
+ now: number;
150
+ }): { isStale: boolean; undetermined: boolean } {
138
151
  const { config, snapshot, hasSynced, now } = input;
139
-
140
152
  const isStale =
141
153
  snapshot.lastSyncedAt !== undefined &&
142
154
  now - snapshot.lastSyncedAt > config.staleAfterMs;
143
155
 
144
- // Both roads lead to `unknown`, never to `not_entitled`. Never having synced
156
+ // Both roads lead to `unknown`, never to a confident "no". Never having synced
145
157
  // and having synced too long ago are the same epistemic state: we cannot
146
158
  // currently support a claim either way.
147
- const undetermined = !hasSynced || isStale;
159
+ return { isStale, undetermined: !hasSynced || isStale };
160
+ }
161
+
162
+ export function aggregate(input: AggregateInput): EntitlementMap {
163
+ const { config, snapshot } = input;
164
+ const { isStale, undetermined } = snapshotCertainty(input);
148
165
 
149
166
  const valid = snapshot.purchases.filter(p => isValidSource(p, input));
150
167
 
package/src/index.ts CHANGED
@@ -35,6 +35,9 @@ export type {
35
35
  PaymentConfig,
36
36
  PaymentStatus,
37
37
  PaymentStatusKind,
38
+ PendingPurchase,
39
+ PendingPurchasesState,
40
+ PendingResolution,
38
41
  ProductKey,
39
42
  ProductType,
40
43
  PurchaseOptions,
@@ -127,6 +130,7 @@ export {
127
130
  useEntitlement,
128
131
  useEntitlements,
129
132
  usePaymentStatus,
133
+ usePendingPurchases,
130
134
  useProducts,
131
135
  usePurchase,
132
136
  useSync,
@@ -23,10 +23,11 @@ import {
23
23
  const INSTANCE_ID = 'chipmobilesdk.payment';
24
24
  const KEY_PREFIX = 'snapshot';
25
25
 
26
+ /** The slice of a react-native-mmkv v4 instance this store uses (v4 names). */
26
27
  interface MmkvLike {
27
28
  getString(key: string): string | undefined;
28
29
  set(key: string, value: string): void;
29
- delete(key: string): void;
30
+ remove(key: string): unknown;
30
31
  clearAll(): void;
31
32
  getAllKeys(): string[];
32
33
  }
@@ -54,21 +55,28 @@ function createMemoryBacking(): MmkvLike {
54
55
  set(key, value) {
55
56
  map.set(key, value);
56
57
  },
57
- delete(key) {
58
- map.delete(key);
59
- },
58
+ remove: key => map.delete(key),
60
59
  clearAll: () => map.clear(),
61
60
  getAllKeys: () => [...map.keys()],
62
61
  };
63
62
  }
64
63
 
64
+ /**
65
+ * Opens the MMKV instance through v4's `createMMKV`.
66
+ *
67
+ * Up to 0.2.0 this called `new MMKV()`, the v3 constructor. In v4 — the only
68
+ * major the peer range allows — `MMKV` is a type, not a value, so the call threw
69
+ * and the store fell back to memory on every device: entitlements read `unknown`
70
+ * after each cold start, and a pending purchase was forgotten after an app kill.
71
+ * Tests could not see it, because the test double still exported a v3 class.
72
+ */
65
73
  function resolveBacking(): { backing: MmkvLike; durable: boolean } {
66
74
  try {
67
75
  // Required lazily so the package loads where the native module is absent.
68
- const mod = require('react-native-mmkv') as {
69
- MMKV: new (config?: { id?: string }) => MmkvLike;
76
+ const { createMMKV } = require('react-native-mmkv') as {
77
+ createMMKV: (config?: { id?: string }) => MmkvLike;
70
78
  };
71
- return { backing: new mod.MMKV({ id: INSTANCE_ID }), durable: true };
79
+ return { backing: createMMKV({ id: INSTANCE_ID }), durable: true };
72
80
  } catch {
73
81
  return { backing: createMemoryBacking(), durable: false };
74
82
  }
@@ -132,7 +140,7 @@ export function createSnapshotStore(
132
140
  try {
133
141
  for (const key of backing.getAllKeys()) {
134
142
  if (key.startsWith(`${KEY_PREFIX}:`)) {
135
- backing.delete(key);
143
+ backing.remove(key);
136
144
  }
137
145
  }
138
146
  } catch {