@idealyst/payments 1.2.108 → 1.2.109

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,301 +1,358 @@
1
1
  // ============================================================================
2
- // Native Payment Implementation
3
- // Wraps @stripe/stripe-react-native Platform Pay API for Apple Pay & Google Pay
2
+ // Native IAP Implementation
3
+ // Wraps react-native-iap for StoreKit 2 (iOS) and Google Play Billing (Android)
4
4
  // ============================================================================
5
5
 
6
6
  import { Platform } from 'react-native';
7
7
  import type {
8
- PaymentConfig,
9
- PaymentProviderStatus,
10
- PaymentMethodAvailability,
11
- PaymentMethodType,
12
- PaymentSheetRequest,
13
- PaymentResult,
8
+ IAPConfig,
9
+ IAPProviderStatus,
10
+ IAPProduct,
11
+ IAPSubscription,
12
+ IAPPurchase,
13
+ ProductPlatform,
14
+ SubscriptionPeriod,
15
+ SubscriptionPeriodUnit,
16
+ SubscriptionDiscount,
17
+ DiscountPaymentMode,
14
18
  } from './types';
15
19
  import { INITIAL_PROVIDER_STATUS } from './constants';
16
- import { createPaymentError, normalizeError } from './errors';
20
+ import { createIAPError, normalizeError } from './errors';
17
21
 
18
- // Graceful optional import — @stripe/stripe-react-native may not be installed
22
+ // Graceful optional import — react-native-iap may not be installed
19
23
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
- let Stripe: any = null;
24
+ let RNIap: any = null;
21
25
  try {
22
- Stripe = require('@stripe/stripe-react-native');
26
+ RNIap = require('react-native-iap');
23
27
  } catch {
24
28
  // Will degrade gracefully when methods are called
25
29
  }
26
30
 
27
31
  // Module-level state
28
- let _status: PaymentProviderStatus = { ...INITIAL_PROVIDER_STATUS };
29
- let _config: PaymentConfig | null = null;
32
+ let _status: IAPProviderStatus = { ...INITIAL_PROVIDER_STATUS };
33
+ let _config: IAPConfig = {};
30
34
 
31
35
  /**
32
- * Get the current payment provider status.
36
+ * Get the current IAP provider status.
33
37
  */
34
- export function getPaymentStatus(): PaymentProviderStatus {
38
+ export function getIAPStatus(): IAPProviderStatus {
35
39
  return { ..._status };
36
40
  }
37
41
 
38
42
  /**
39
- * Initialize the Stripe SDK for platform payments.
43
+ * Initialize the IAP connection to the native store.
40
44
  */
41
- export async function initializePayments(
42
- config: PaymentConfig,
43
- ): Promise<void> {
44
- if (!Stripe) {
45
+ export async function initializeIAP(config?: IAPConfig): Promise<void> {
46
+ if (!RNIap) {
45
47
  _status = {
46
- ..._status,
47
48
  state: 'error',
48
- error: createPaymentError(
49
+ isStoreAvailable: false,
50
+ error: createIAPError(
49
51
  'not_available',
50
- '@stripe/stripe-react-native is not installed. Run: yarn add @stripe/stripe-react-native',
52
+ 'react-native-iap is not installed. Run: yarn add react-native-iap',
51
53
  ),
52
54
  };
53
55
  return;
54
56
  }
55
57
 
56
58
  _status = { ..._status, state: 'initializing' };
57
- _config = config;
59
+ _config = config ?? {};
58
60
 
59
61
  try {
60
- await Stripe.initStripe({
61
- publishableKey: config.publishableKey,
62
- merchantIdentifier: config.merchantIdentifier,
63
- urlScheme: config.urlScheme,
64
- });
65
-
66
- const availability = await checkPaymentAvailability();
62
+ await RNIap.initConnection();
63
+
64
+ // Flush failed purchases cached as pending on Android
65
+ if (Platform.OS === 'android') {
66
+ try {
67
+ await RNIap.flushFailedPurchasesCachedAsPendingAndroid();
68
+ } catch {
69
+ // Non-critical — ignore flush errors
70
+ }
71
+ }
67
72
 
68
73
  _status = {
69
74
  state: 'ready',
70
- availablePaymentMethods: availability,
71
- isPaymentAvailable: availability.some((m) => m.isAvailable),
75
+ isStoreAvailable: true,
72
76
  };
73
77
  } catch (error) {
74
78
  _status = {
75
- ..._status,
76
79
  state: 'error',
80
+ isStoreAvailable: false,
77
81
  error: normalizeError(error),
78
82
  };
79
83
  }
80
84
  }
81
85
 
82
86
  /**
83
- * Check which payment methods (Apple Pay, Google Pay, card) are available.
87
+ * Fetch products (one-time purchases) from the store by SKU.
84
88
  */
85
- export async function checkPaymentAvailability(): Promise<
86
- PaymentMethodAvailability[]
87
- > {
88
- if (!Stripe) {
89
- return [
90
- {
91
- type: 'apple_pay',
92
- isAvailable: false,
93
- unavailableReason: 'Stripe SDK not installed',
94
- },
95
- {
96
- type: 'google_pay',
97
- isAvailable: false,
98
- unavailableReason: 'Stripe SDK not installed',
99
- },
100
- { type: 'card', isAvailable: false, unavailableReason: 'Stripe SDK not installed' },
101
- ];
102
- }
103
-
104
- const results: PaymentMethodAvailability[] = [];
105
- const isIOS = Platform.OS === 'ios';
89
+ export async function getProducts(skus: string[]): Promise<IAPProduct[]> {
90
+ assertReady();
106
91
 
107
92
  try {
108
- const isSupported = await Stripe.isPlatformPaySupported({
109
- googlePay: {
110
- testEnv: _config?.testEnvironment ?? false,
111
- },
112
- });
93
+ const products = await RNIap!.getProducts({ skus });
94
+ return products.map(mapProduct);
95
+ } catch (error) {
96
+ throw normalizeError(error);
97
+ }
98
+ }
113
99
 
114
- results.push({
115
- type: 'apple_pay',
116
- isAvailable: isIOS && isSupported,
117
- unavailableReason: !isIOS
118
- ? 'Apple Pay is only available on iOS'
119
- : !isSupported
120
- ? 'Apple Pay is not configured on this device'
121
- : undefined,
122
- });
100
+ /**
101
+ * Fetch subscriptions from the store by SKU.
102
+ */
103
+ export async function getSubscriptions(
104
+ skus: string[],
105
+ ): Promise<IAPSubscription[]> {
106
+ assertReady();
123
107
 
124
- results.push({
125
- type: 'google_pay',
126
- isAvailable: !isIOS && isSupported,
127
- unavailableReason: isIOS
128
- ? 'Google Pay is only available on Android'
129
- : !isSupported
130
- ? 'Google Pay is not configured on this device'
131
- : undefined,
132
- });
108
+ try {
109
+ const subscriptions = await RNIap!.getSubscriptions({ skus });
110
+ return subscriptions.map(mapSubscription);
133
111
  } catch (error) {
134
- results.push(
135
- {
136
- type: 'apple_pay',
137
- isAvailable: false,
138
- unavailableReason: String(error),
139
- },
140
- {
141
- type: 'google_pay',
142
- isAvailable: false,
143
- unavailableReason: String(error),
144
- },
145
- );
112
+ throw normalizeError(error);
146
113
  }
114
+ }
147
115
 
148
- // Card payments are always conceptually available via Stripe
149
- results.push({ type: 'card', isAvailable: true });
116
+ /**
117
+ * Purchase a one-time product.
118
+ */
119
+ export async function purchaseProduct(sku: string): Promise<IAPPurchase> {
120
+ assertReady();
150
121
 
151
- _status = {
152
- ..._status,
153
- availablePaymentMethods: results,
154
- isPaymentAvailable: results.some((m) => m.isAvailable),
155
- };
122
+ try {
123
+ const purchase = await RNIap!.requestPurchase({ sku });
156
124
 
157
- return results;
125
+ if (_config.autoFinishTransactions) {
126
+ await RNIap!.finishTransaction({ purchase, isConsumable: false });
127
+ }
128
+
129
+ return mapPurchase(purchase);
130
+ } catch (error) {
131
+ throw normalizeError(error);
132
+ }
158
133
  }
159
134
 
160
135
  /**
161
- * Present the platform payment sheet and confirm a PaymentIntent.
162
- * Requires `request.clientSecret` from a server-created PaymentIntent.
136
+ * Purchase a subscription.
137
+ * On Android, an `offerToken` from the subscription's offer details may be required.
163
138
  */
164
- export async function confirmPayment(
165
- request: PaymentSheetRequest,
166
- ): Promise<PaymentResult> {
139
+ export async function purchaseSubscription(
140
+ sku: string,
141
+ offerToken?: string,
142
+ ): Promise<IAPPurchase> {
167
143
  assertReady();
168
144
 
169
- if (!request.clientSecret) {
170
- throw createPaymentError(
171
- 'invalid_request',
172
- 'clientSecret is required for confirmPayment. Create a PaymentIntent on your server first.',
173
- );
174
- }
175
-
176
145
  try {
177
- const { error, paymentIntent } =
178
- await Stripe!.confirmPlatformPayPayment(request.clientSecret, {
179
- googlePay: buildGooglePayConfig(request),
180
- applePay: buildApplePayConfig(request),
181
- });
182
-
183
- if (error) {
184
- throw error;
146
+ const request: Record<string, unknown> = { sku };
147
+
148
+ if (Platform.OS === 'android' && offerToken) {
149
+ request.subscriptionOffers = [{ sku, offerToken }];
185
150
  }
186
151
 
187
- return {
188
- paymentMethodType: detectPlatformPayType(),
189
- paymentIntentId: paymentIntent?.id,
190
- status: paymentIntent?.status,
191
- };
152
+ const purchase = await RNIap!.requestSubscription(request);
153
+
154
+ if (_config.autoFinishTransactions) {
155
+ await RNIap!.finishTransaction({ purchase, isConsumable: false });
156
+ }
157
+
158
+ return mapPurchase(purchase);
192
159
  } catch (error) {
193
160
  throw normalizeError(error);
194
161
  }
195
162
  }
196
163
 
197
164
  /**
198
- * Present the platform payment sheet and create a payment method
199
- * without confirming. Returns a payment method ID for server-side use.
165
+ * Finish a transaction. Call this after server-side receipt validation.
166
+ *
167
+ * @param purchase The purchase to finish.
168
+ * @param isConsumable Whether the product is consumable (can be purchased again).
200
169
  */
201
- export async function createPaymentMethod(
202
- request: PaymentSheetRequest,
203
- ): Promise<PaymentResult> {
170
+ export async function finishTransaction(
171
+ purchase: IAPPurchase,
172
+ isConsumable?: boolean,
173
+ ): Promise<void> {
204
174
  assertReady();
205
175
 
206
176
  try {
207
- const { error, paymentMethod } =
208
- await Stripe!.createPlatformPayPaymentMethod({
209
- googlePay: {
210
- ...buildGooglePayConfig(request),
211
- amount: request.amount.amount,
212
- },
213
- applePay: buildApplePayConfig(request),
214
- });
215
-
216
- if (error) {
217
- throw error;
218
- }
177
+ await RNIap!.finishTransaction({
178
+ purchase: { transactionId: purchase.transactionId },
179
+ isConsumable: isConsumable ?? false,
180
+ });
181
+ } catch (error) {
182
+ throw normalizeError(error);
183
+ }
184
+ }
219
185
 
220
- return {
221
- paymentMethodType: detectPlatformPayType(),
222
- paymentMethodId: paymentMethod?.id,
223
- };
186
+ /**
187
+ * Restore previous purchases (e.g., after reinstall or on a new device).
188
+ */
189
+ export async function restorePurchases(): Promise<IAPPurchase[]> {
190
+ assertReady();
191
+
192
+ try {
193
+ const purchases = await RNIap!.getAvailablePurchases();
194
+ return purchases.map((p: unknown) => mapPurchase(p, 'restored'));
224
195
  } catch (error) {
225
196
  throw normalizeError(error);
226
197
  }
227
198
  }
228
199
 
200
+ /**
201
+ * End the IAP connection. Call on cleanup (e.g., app unmount).
202
+ */
203
+ export async function endConnection(): Promise<void> {
204
+ if (!RNIap) return;
205
+
206
+ try {
207
+ await RNIap.endConnection();
208
+ _status = { ...INITIAL_PROVIDER_STATUS };
209
+ } catch {
210
+ // Ignore errors during cleanup
211
+ }
212
+ }
213
+
229
214
  // ============================================================================
230
215
  // Internal Helpers
231
216
  // ============================================================================
232
217
 
233
218
  function assertReady(): void {
234
- if (!Stripe) {
235
- throw createPaymentError(
219
+ if (!RNIap) {
220
+ throw createIAPError(
236
221
  'not_available',
237
- '@stripe/stripe-react-native is not installed',
222
+ 'react-native-iap is not installed',
238
223
  );
239
224
  }
240
225
  if (_status.state !== 'ready') {
241
- throw createPaymentError(
226
+ throw createIAPError(
242
227
  'not_initialized',
243
- 'Payment provider not initialized. Call initializePayments() first.',
228
+ 'IAP connection not initialized. Call initializeIAP() first.',
244
229
  );
245
230
  }
246
231
  }
247
232
 
248
- function detectPlatformPayType(): PaymentMethodType {
249
- return Platform.OS === 'ios' ? 'apple_pay' : 'google_pay';
233
+ function getPlatform(): ProductPlatform {
234
+ return Platform.OS === 'ios' ? 'ios' : 'android';
250
235
  }
251
236
 
252
- function buildGooglePayConfig(request: PaymentSheetRequest) {
237
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
238
+ function mapProduct(raw: any): IAPProduct {
239
+ return {
240
+ sku: raw.productId ?? raw.sku ?? '',
241
+ title: raw.title ?? '',
242
+ description: raw.description ?? '',
243
+ price: parseFloat(raw.price ?? '0'),
244
+ priceFormatted: raw.localizedPrice ?? raw.price ?? '',
245
+ currency: raw.currency ?? '',
246
+ type: 'iap',
247
+ platform: getPlatform(),
248
+ };
249
+ }
250
+
251
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
252
+ function mapSubscription(raw: any): IAPSubscription {
253
+ const base = mapProduct(raw);
254
+
253
255
  return {
254
- testEnv: _config?.testEnvironment ?? false,
255
- merchantName: _config!.merchantName,
256
- merchantCountryCode: _config!.merchantCountryCode,
257
- currencyCode: request.amount.currencyCode,
258
- billingAddressConfig: request.billingAddress
259
- ? {
260
- isRequired: request.billingAddress.isRequired ?? false,
261
- isPhoneNumberRequired:
262
- request.billingAddress.isPhoneNumberRequired ?? false,
263
- format:
264
- request.billingAddress.format === 'FULL'
265
- ? Stripe!.PlatformPay.BillingAddressFormat.Full
266
- : Stripe!.PlatformPay.BillingAddressFormat.Min,
267
- }
256
+ ...base,
257
+ type: 'sub',
258
+ subscriptionPeriod: parseSubscriptionPeriod(raw),
259
+ introductoryPrice: raw.introductoryPrice
260
+ ? mapDiscount(raw.introductoryPrice, 'introductory')
268
261
  : undefined,
262
+ discounts: raw.discounts?.map((d: unknown) =>
263
+ mapDiscount(d, 'promotional'),
264
+ ),
269
265
  };
270
266
  }
271
267
 
272
- function buildApplePayConfig(request: PaymentSheetRequest) {
273
- const cartItems: Array<{
274
- label: string;
275
- amount: string;
276
- paymentType: number;
277
- }> = [];
278
-
279
- if (request.lineItems) {
280
- for (const item of request.lineItems) {
281
- cartItems.push({
282
- label: item.label,
283
- amount: (item.amount / 100).toFixed(2),
284
- paymentType: Stripe!.PlatformPay.PaymentType.Immediate,
285
- });
286
- }
268
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
269
+ function parseSubscriptionPeriod(raw: any): SubscriptionPeriod {
270
+ // iOS: subscriptionPeriodUnitIOS / subscriptionPeriodNumberIOS
271
+ if (raw.subscriptionPeriodUnitIOS) {
272
+ return {
273
+ unit: mapPeriodUnit(raw.subscriptionPeriodUnitIOS),
274
+ numberOfUnits: parseInt(raw.subscriptionPeriodNumberIOS ?? '1', 10),
275
+ };
276
+ }
277
+
278
+ // Android: subscriptionPeriodAndroid (ISO 8601 duration, e.g., "P1M", "P1Y")
279
+ if (raw.subscriptionPeriodAndroid) {
280
+ return parseISO8601Period(raw.subscriptionPeriodAndroid);
281
+ }
282
+
283
+ return { unit: 'month', numberOfUnits: 1 };
284
+ }
285
+
286
+ function mapPeriodUnit(unit: string): SubscriptionPeriodUnit {
287
+ switch (unit.toUpperCase()) {
288
+ case 'DAY':
289
+ return 'day';
290
+ case 'WEEK':
291
+ return 'week';
292
+ case 'MONTH':
293
+ return 'month';
294
+ case 'YEAR':
295
+ return 'year';
296
+ default:
297
+ return 'month';
287
298
  }
299
+ }
300
+
301
+ function parseISO8601Period(period: string): SubscriptionPeriod {
302
+ // Simple ISO 8601 duration parser for P{n}{unit} (e.g., P1M, P3M, P1Y, P7D)
303
+ const match = period.match(/P(\d+)([DWMY])/i);
304
+ if (!match) return { unit: 'month', numberOfUnits: 1 };
305
+
306
+ const num = parseInt(match[1], 10);
307
+ switch (match[2].toUpperCase()) {
308
+ case 'D':
309
+ return { unit: 'day', numberOfUnits: num };
310
+ case 'W':
311
+ return { unit: 'week', numberOfUnits: num };
312
+ case 'M':
313
+ return { unit: 'month', numberOfUnits: num };
314
+ case 'Y':
315
+ return { unit: 'year', numberOfUnits: num };
316
+ default:
317
+ return { unit: 'month', numberOfUnits: num };
318
+ }
319
+ }
288
320
 
289
- // Total line item (required by Apple Pay)
290
- cartItems.push({
291
- label: _config?.merchantName ?? 'Total',
292
- amount: (request.amount.amount / 100).toFixed(2),
293
- paymentType: Stripe!.PlatformPay.PaymentType.Immediate,
294
- });
321
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
322
+ function mapDiscount(raw: any, type: 'introductory' | 'promotional'): SubscriptionDiscount {
323
+ const paymentModeMap: Record<string, DiscountPaymentMode> = {
324
+ FREETRIAL: 'freeTrial',
325
+ FREE_TRIAL: 'freeTrial',
326
+ PAYASYOUGO: 'payAsYouGo',
327
+ PAY_AS_YOU_GO: 'payAsYouGo',
328
+ PAYUPFRONT: 'payUpFront',
329
+ PAY_UP_FRONT: 'payUpFront',
330
+ };
295
331
 
296
332
  return {
297
- cartItems,
298
- merchantCountryCode: _config!.merchantCountryCode,
299
- currencyCode: request.amount.currencyCode,
333
+ identifier: raw.identifier,
334
+ price: parseFloat(raw.price ?? '0'),
335
+ priceFormatted: raw.localizedPrice ?? raw.price ?? '',
336
+ period: raw.subscriptionPeriod
337
+ ? parseISO8601Period(raw.subscriptionPeriod)
338
+ : { unit: 'month', numberOfUnits: 1 },
339
+ paymentMode:
340
+ paymentModeMap[(raw.paymentMode ?? '').toUpperCase()] ?? 'freeTrial',
341
+ type,
342
+ };
343
+ }
344
+
345
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
346
+ function mapPurchase(raw: any, stateOverride?: IAPPurchase['purchaseState']): IAPPurchase {
347
+ return {
348
+ sku: raw.productId ?? raw.sku ?? '',
349
+ transactionId: raw.transactionId ?? '',
350
+ transactionDate: raw.transactionDate
351
+ ? parseInt(raw.transactionDate, 10)
352
+ : Date.now(),
353
+ transactionReceipt: raw.transactionReceipt ?? '',
354
+ purchaseToken: raw.purchaseToken,
355
+ isAcknowledged: raw.isAcknowledgedAndroid,
356
+ purchaseState: stateOverride ?? 'purchased',
300
357
  };
301
358
  }