@@ -192,9 +192,196 @@ public class PayPlugin: CAPPlugin, CAPBridgedPlugin, PKPaymentAuthorizationContr
192192 }
193193 }
194194
195+ if let recurringOptions = options [ " recurringPaymentRequest " ] as? [ String : Any ] {
196+ if #available( iOS 16 . 0 , * ) {
197+ paymentRequest. recurringPaymentRequest = try buildRecurringPaymentRequest ( from: recurringOptions)
198+ } else {
199+ throw PayPluginError . invalidConfiguration ( " `recurringPaymentRequest` requires iOS 16 or later. " )
200+ }
201+ }
202+
195203 return paymentRequest
196204 }
197205
206+ @available ( iOS 16 . 0 , * )
207+ /// Builds a PassKit recurring payment request (`PKRecurringPaymentRequest`) from the JS options object.
208+ private func buildRecurringPaymentRequest( from options: [ String : Any ] ) throws -> PKRecurringPaymentRequest {
209+ guard let paymentDescription = options [ " paymentDescription " ] as? String , !paymentDescription. isEmpty else {
210+ throw PayPluginError . invalidConfiguration ( " `recurringPaymentRequest.paymentDescription` is required. " )
211+ }
212+
213+ let regularBilling = try recurringPaymentSummaryItem ( from: options [ " regularBilling " ] , fieldName: " recurringPaymentRequest.regularBilling " )
214+
215+ guard let managementURLString = options [ " managementURL " ] as? String ,
216+ let managementURL = URL ( string: managementURLString) else {
217+ throw PayPluginError . invalidConfiguration ( " `recurringPaymentRequest.managementURL` must be a valid URL string. " )
218+ }
219+
220+ let recurringRequest = PKRecurringPaymentRequest (
221+ paymentDescription: paymentDescription,
222+ regularBilling: regularBilling,
223+ managementURL: managementURL
224+ )
225+
226+ if let billingAgreement = options [ " billingAgreement " ] as? String , !billingAgreement. isEmpty {
227+ recurringRequest. billingAgreement = billingAgreement
228+ }
229+
230+ if let tokenNotificationURLValue = options [ " tokenNotificationURL " ] {
231+ guard let tokenNotificationURLString = tokenNotificationURLValue as? String ,
232+ let tokenNotificationURL = URL ( string: tokenNotificationURLString) else {
233+ throw PayPluginError . invalidConfiguration (
234+ " `recurringPaymentRequest.tokenNotificationURL` must be a valid URL string when provided. "
235+ )
236+ }
237+ recurringRequest. tokenNotificationURL = tokenNotificationURL
238+ }
239+
240+ if let trialBillingRaw = options [ " trialBilling " ] {
241+ recurringRequest. trialBilling = try recurringPaymentSummaryItem (
242+ from: trialBillingRaw,
243+ fieldName: " recurringPaymentRequest.trialBilling "
244+ )
245+ }
246+
247+ return recurringRequest
248+ }
249+
250+ @available ( iOS 16 . 0 , * )
251+ /// Parses and validates a recurring payment summary item (regular or trial billing).
252+ private func recurringPaymentSummaryItem( from value: Any ? , fieldName: String ) throws -> PKRecurringPaymentSummaryItem {
253+ guard let rawItem = value as? [ String : Any ] ,
254+ let label = rawItem [ " label " ] as? String ,
255+ let amountString = rawItem [ " amount " ] as? String else {
256+ throw PayPluginError . invalidConfiguration ( " ` \( fieldName) ` must include `label` and `amount`. " )
257+ }
258+
259+ let amount = NSDecimalNumber ( string: amountString)
260+ if amount == NSDecimalNumber . notANumber {
261+ throw PayPluginError . invalidConfiguration ( " ` \( fieldName) .amount` must be a valid decimal string. " )
262+ }
263+
264+ let item = PKRecurringPaymentSummaryItem ( label: label, amount: amount)
265+
266+ if let typeString = rawItem [ " type " ] as? String {
267+ switch typeString. lowercased ( ) {
268+ case " pending " :
269+ item. type = . pending
270+ default :
271+ item. type = . final
272+ }
273+ }
274+
275+ if let intervalUnitRaw = rawItem [ " intervalUnit " ] ?? rawItem [ " recurringPaymentIntervalUnit " ] ,
276+ let intervalUnit = parseRecurringIntervalUnit ( from: intervalUnitRaw) {
277+ item. intervalUnit = intervalUnit
278+ } else {
279+ throw PayPluginError . invalidConfiguration ( " ` \( fieldName) .intervalUnit` is required. " )
280+ }
281+
282+ if let intervalCountRaw = rawItem [ " intervalCount " ] ?? rawItem [ " recurringPaymentIntervalCount " ] {
283+ if let intervalCount = parseInt ( from: intervalCountRaw) , intervalCount > 0 {
284+ item. intervalCount = intervalCount
285+ } else {
286+ throw PayPluginError . invalidConfiguration ( " ` \( fieldName) .intervalCount` must be a positive integer. " )
287+ }
288+ } else {
289+ throw PayPluginError . invalidConfiguration ( " ` \( fieldName) .intervalCount` is required. " )
290+ }
291+
292+ if let startDateRaw = rawItem [ " startDate " ] ?? rawItem [ " recurringPaymentStartDate " ] {
293+ guard let startDate = parseDate ( from: startDateRaw) else {
294+ throw PayPluginError . invalidConfiguration ( " ` \( fieldName) .startDate` must be a valid date. " )
295+ }
296+ item. startDate = startDate
297+ }
298+
299+ if let endDateRaw = rawItem [ " endDate " ] ?? rawItem [ " recurringPaymentEndDate " ] {
300+ guard let endDate = parseDate ( from: endDateRaw) else {
301+ throw PayPluginError . invalidConfiguration ( " ` \( fieldName) .endDate` must be a valid date. " )
302+ }
303+ item. endDate = endDate
304+ }
305+
306+ return item
307+ }
308+
309+ @available ( iOS 16 . 0 , * )
310+ /// Maps string values used by Apple Pay on the Web to `NSCalendar.Unit` values required by PassKit.
311+ private func parseRecurringIntervalUnit( from value: Any ) -> NSCalendar . Unit ? {
312+ guard let stringValue = value as? String else {
313+ return nil
314+ }
315+
316+ switch stringValue. lowercased ( ) {
317+ case " day " :
318+ return . day
319+ case " week " :
320+ return . weekOfYear
321+ case " month " :
322+ return . month
323+ case " year " :
324+ return . year
325+ default :
326+ return nil
327+ }
328+ }
329+
330+ /// Parses a positive integer from common JS number representations.
331+ private func parseInt( from value: Any ) -> Int ? {
332+ if let intValue = value as? Int {
333+ return intValue
334+ }
335+ if let doubleValue = value as? Double {
336+ guard doubleValue. isFinite,
337+ doubleValue. rounded ( ) == doubleValue,
338+ doubleValue >= Double ( Int . min) ,
339+ doubleValue <= Double ( Int . max) else {
340+ return nil
341+ }
342+ return Int ( doubleValue)
343+ }
344+ if let stringValue = value as? String {
345+ return Int ( stringValue)
346+ }
347+ return nil
348+ }
349+
350+ /// Parses a date from a JS value.
351+ /// - For numbers, expects **milliseconds since Unix epoch**.
352+ /// - For strings, accepts ISO 8601 and `yyyy-MM-dd` (UTC) formats.
353+ private func parseDate( from value: Any ) -> Date ? {
354+ if let doubleValue = value as? Double {
355+ return parseDate ( fromUnixNumeric: doubleValue)
356+ }
357+ if let intValue = value as? Int {
358+ return parseDate ( fromUnixNumeric: Double ( intValue) )
359+ }
360+ if let stringValue = value as? String {
361+ let iso = ISO8601DateFormatter ( )
362+ if let parsed = iso. date ( from: stringValue) {
363+ return parsed
364+ }
365+
366+ // Common "YYYY-MM-DD" input used by Apple Pay examples.
367+ let df = DateFormatter ( )
368+ df. locale = Locale ( identifier: " en_US_POSIX " )
369+ df. timeZone = TimeZone ( secondsFromGMT: 0 )
370+ df. dateFormat = " yyyy-MM-dd "
371+ return df. date ( from: stringValue)
372+ }
373+
374+ return nil
375+ }
376+
377+ /// Parses a numeric date as **milliseconds since Unix epoch** (per the public TS contract).
378+ private func parseDate( fromUnixNumeric value: Double ) -> Date ? {
379+ guard value. isFinite else {
380+ return nil
381+ }
382+ return Date ( timeIntervalSince1970: value / 1000.0 )
383+ }
384+
198385 private func paymentSummaryItems( from value: Any ? ) -> [ PKPaymentSummaryItem ] {
199386 guard let items = value as? [ Any ] else {
200387 return [ ]
@@ -233,12 +420,42 @@ public class PayPlugin: CAPPlugin, CAPBridgedPlugin, PKPaymentAuthorizationContr
233420
234421 return networkStrings. compactMap { element in
235422 if let stringValue = element as? String {
236- return PKPaymentNetwork ( rawValue: stringValue)
423+ return PKPaymentNetwork ( rawValue: normalizePaymentNetwork ( stringValue) )
237424 }
238425 return nil
239426 }
240427 }
241428
429+ private func normalizePaymentNetwork( _ value: String ) -> String {
430+ let trimmed = value. trimmingCharacters ( in: . whitespacesAndNewlines)
431+ let key = trimmed. lowercased ( )
432+
433+ // Accept common Apple Pay on the Web identifiers too.
434+ // PassKit network raw values are case-sensitive (for example "Visa", "MasterCard", "AmEx").
435+ switch key {
436+ case " visa " :
437+ return PKPaymentNetwork . visa. rawValue
438+ case " mastercard " :
439+ return PKPaymentNetwork . masterCard. rawValue
440+ case " amex " :
441+ return PKPaymentNetwork . amex. rawValue
442+ case " discover " :
443+ return PKPaymentNetwork . discover. rawValue
444+ case " jcb " :
445+ return PKPaymentNetwork . JCB. rawValue
446+ case " vpay " :
447+ return PKPaymentNetwork . vPay. rawValue
448+ case " maestro " :
449+ return PKPaymentNetwork . maestro. rawValue
450+ case " girocard " :
451+ return PKPaymentNetwork . girocard. rawValue
452+ case " mada " :
453+ return PKPaymentNetwork . mada. rawValue
454+ default :
455+ return trimmed
456+ }
457+ }
458+
242459 private func parseMerchantCapabilities( from values: [ String ] ) -> PKMerchantCapability {
243460 var capabilities : PKMerchantCapability = [ ]
244461
0 commit comments