@idealyst/payments 1.2.108 → 1.2.110

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