@chipmobilesdk/rn-payment 0.4.0 → 0.5.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.
@@ -1,157 +1,146 @@
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 { ProductKey, PurchaseOptions, PurchaseResult } from '../config/types';
17
+ import type { ResolvedConfig } from '../config/validate';
18
+ import type { ProviderPort } from '../adapter/port';
19
+ import { storeIdForProductKey } from '../entitlements/mapping';
20
+ import type { PersistedSnapshot } from '../persistence/snapshot';
21
+
22
+ /**
23
+ * How already-paid time is handled when a plan changes.
24
+ *
25
+ * Named for the user-visible effect rather than mirroring a store's enum, so the
26
+ * same vocabulary will map onto StoreKit later (FR-004).
27
+ */
28
+ export type ProrationMode =
29
+ /** Switch now; credit unused time against the new plan. The usual upgrade. */
30
+ | 'immediate_with_time_proration'
31
+ /** Switch now; charge the full new price immediately and restart the period. */
32
+ | 'immediate_and_charge_full_price'
33
+ /** Switch now; charge the prorated difference for the current period. */
34
+ | 'immediate_and_charge_prorated_price'
35
+ /** Keep the current plan until it expires, then start the new one. Usual downgrade. */
36
+ | 'deferred';
37
+
38
+ /** Store-specific replacement codes stay here, behind the neutral vocabulary above. */
39
+ const PRORATION_CODES: Record<ProrationMode, string> = {
40
+ immediate_with_time_proration: 'WITH_TIME_PRORATION',
41
+ immediate_and_charge_full_price: 'CHARGE_FULL_PRICE',
42
+ immediate_and_charge_prorated_price: 'CHARGE_PRORATED_PRICE',
43
+ deferred: 'DEFERRED',
44
+ };
45
+
46
+ export type PriceChangeResult =
47
+ | { kind: 'confirmed' }
48
+ | { kind: 'dismissed' }
49
+ /** The platform has no client-side flow; it notifies the user itself (FR-067). */
50
+ | { kind: 'unsupported' }
51
+ | { kind: 'failed'; code: string };
52
+
53
+ export interface SubscriptionsDeps {
54
+ resolved: ResolvedConfig;
55
+ provider: ProviderPort;
56
+ snapshot: () => PersistedSnapshot;
57
+ purchase: (
58
+ productKey: ProductKey,
59
+ options?: PurchaseOptions & { replaceProductKey?: ProductKey; prorationMode?: string },
60
+ ) => Promise<PurchaseResult>;
61
+ }
62
+
63
+ export interface Subscriptions {
64
+ changePlan(
65
+ fromProductKey: ProductKey,
66
+ toProductKey: ProductKey,
67
+ mode: ProrationMode,
68
+ options?: PurchaseOptions,
69
+ ): Promise<PurchaseResult>;
70
+ openManagement(productKey?: ProductKey): Promise<void>;
71
+ confirmPriceChange(productKey: ProductKey): Promise<PriceChangeResult>;
72
+ }
73
+
74
+ export function createSubscriptions(deps: SubscriptionsDeps): Subscriptions {
75
+ const { resolved } = deps;
76
+
77
+ return {
78
+ async changePlan(fromProductKey, toProductKey, mode, options): Promise<PurchaseResult> {
79
+ if (!deps.provider.capabilities().supportsPlanChange) {
80
+ return { kind: 'unsupported' };
81
+ }
82
+
83
+ const target = resolved.entries.get(toProductKey);
84
+ if (!target) {
85
+ return { kind: 'unknown_product', productKey: toProductKey };
86
+ }
87
+ if (!resolved.entries.has(fromProductKey)) {
88
+ return { kind: 'unknown_product', productKey: fromProductKey };
89
+ }
90
+
91
+ // The store needs the *token* of the subscription being replaced, not its
92
+ // product id — that is how it knows which active purchase to migrate, and
93
+ // it is why a plan change needs a live local record rather than just two
94
+ // product keys.
95
+ const active = deps
96
+ .snapshot()
97
+ .purchases.find(
98
+ p =>
99
+ p.productKey === fromProductKey &&
100
+ p.productType === 'subscription' &&
101
+ p.state === 'purchased',
102
+ );
103
+
104
+ if (!active) {
105
+ // No subscription to replace. Treated as a plain purchase would be
106
+ // surprising — the caller asked to *change* something that is not there,
107
+ // which almost always means its own state is stale.
108
+ return { kind: 'already_owned', productKey: fromProductKey };
109
+ }
110
+
111
+ // Routed through the normal purchase flow on purpose: a plan change is a
112
+ // purchase, so it must inherit the single-flight lock, the environment
113
+ // guard, validation, and the finalization queue rather than reimplementing
114
+ // any of them.
115
+ return deps.purchase(toProductKey, {
116
+ ...options,
117
+ replaceProductKey: fromProductKey,
118
+ prorationMode: PRORATION_CODES[mode],
119
+ });
120
+ },
121
+
122
+ async openManagement(productKey?: ProductKey): Promise<void> {
123
+ const storeProductId = productKey ? storeIdForProductKey(resolved, productKey) : undefined;
124
+ // No-ops rather than throwing where the platform has no such screen: this
125
+ // is wired to a button, and a button that crashes is worse than one that
126
+ // does nothing on a platform that cannot honour it.
127
+ await deps.provider.openSubscriptionManagement(storeProductId);
128
+ },
129
+
130
+ async confirmPriceChange(productKey): Promise<PriceChangeResult> {
131
+ if (!deps.provider.capabilities().supportsPriceChangeConfirmation) {
132
+ return { kind: 'unsupported' };
133
+ }
134
+ const storeProductId = storeIdForProductKey(resolved, productKey);
135
+ if (!storeProductId) {
136
+ return { kind: 'failed', code: 'PAYMENT_UNKNOWN_PRODUCT' };
137
+ }
138
+
139
+ const outcome = await deps.provider.confirmPriceChange(storeProductId);
140
+ if (!outcome.ok) {
141
+ return { kind: 'failed', code: 'PAYMENT_STORE_ERROR' };
142
+ }
143
+ return outcome.confirmed ? { kind: 'confirmed' } : { kind: 'dismissed' };
144
+ },
145
+ };
146
+ }