Skip to content

Commit 0369db4

Browse files
authored
feat: recurring payments + typed requests (#20)
* feat(applepay): recurring payments * docs(types): google pay request shapes * docs: recurring + google pay types * fix(applepay): normalize networks * docs: apple networks casing * fix: address review comments
1 parent 1309ec7 commit 0369db4

File tree

6 files changed

+721
-32
lines changed

6 files changed

+721
-32
lines changed

README.md

Lines changed: 232 additions & 22 deletions
Large diffs are not rendered by default.

docs/apple-pay-setup.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,46 @@ Your server must broker the merchant validation handshake before the device can
7272
- Provide a list of networks (`supportedNetworks`) that matches the networks approved by your processor.
7373
- Include realistic summary items and ensure the total is a final amount.
7474

75+
## Recurring payments (subscriptions)
76+
77+
This plugin supports Apple Pay recurring payments on **iOS 16+** via `recurringPaymentRequest`.
78+
79+
Key points:
80+
81+
- You still provide `paymentSummaryItems` (what’s shown in the sheet).
82+
- The recurring metadata is provided via `recurringPaymentRequest`.
83+
- If you pass `recurringPaymentRequest` on iOS 15 or earlier, the plugin rejects the call.
84+
85+
Example:
86+
87+
```ts
88+
import { Pay } from '@capgo/capacitor-pay';
89+
90+
await Pay.requestPayment({
91+
apple: {
92+
merchantIdentifier: 'merchant.com.example.app',
93+
countryCode: 'US',
94+
currencyCode: 'USD',
95+
supportedNetworks: ['visa', 'masterCard'],
96+
paymentSummaryItems: [
97+
{ label: 'Pro Plan', amount: '9.99' },
98+
{ label: 'Example Store', amount: '9.99' },
99+
],
100+
recurringPaymentRequest: {
101+
paymentDescription: 'Pro Plan Subscription',
102+
managementURL: 'https://example.com/account/subscription',
103+
regularBilling: {
104+
label: 'Pro Plan',
105+
amount: '9.99',
106+
intervalUnit: 'month',
107+
intervalCount: 1,
108+
startDate: Date.now(),
109+
},
110+
},
111+
},
112+
});
113+
```
114+
75115
## 9. Build and test on device
76116

77117
- Apple Pay is unavailable on the iOS simulator. Use a real device signed into an Apple ID with a supported card in Wallet.

docs/google-pay-setup.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,60 @@ Handle the encrypted payment data server-side before charging the customer:
6161
- `merchantInfo` for user-facing display
6262
3. Provide this JSON to `Pay.requestPayment({ google: { ... } })`.
6363

64+
## Subscriptions / recurring charges
65+
66+
Google Pay itself returns a **payment token** (or gateway payload). Subscriptions are typically implemented by:
67+
68+
1. Collecting a token once using `Pay.requestPayment`.
69+
2. Sending the token to your backend.
70+
3. Creating and managing recurring charges with your PSP/gateway (Stripe/Adyen/Braintree/etc).
71+
72+
Example `paymentDataRequest` (gateway tokenization):
73+
74+
```ts
75+
import { Pay, type GooglePayPaymentDataRequest } from '@capgo/capacitor-pay';
76+
77+
const paymentDataRequest: GooglePayPaymentDataRequest = {
78+
apiVersion: 2,
79+
apiVersionMinor: 0,
80+
allowedPaymentMethods: [
81+
{
82+
type: 'CARD',
83+
parameters: {
84+
allowedAuthMethods: ['PAN_ONLY', 'CRYPTOGRAM_3DS'],
85+
allowedCardNetworks: ['AMEX', 'DISCOVER', 'MASTERCARD', 'VISA'],
86+
},
87+
tokenizationSpecification: {
88+
type: 'PAYMENT_GATEWAY',
89+
parameters: {
90+
gateway: 'example',
91+
gatewayMerchantId: 'exampleGatewayMerchantId',
92+
},
93+
},
94+
},
95+
],
96+
merchantInfo: {
97+
merchantId: '01234567890123456789',
98+
merchantName: 'Example Merchant',
99+
},
100+
transactionInfo: {
101+
totalPriceStatus: 'FINAL',
102+
totalPrice: '9.99',
103+
currencyCode: 'USD',
104+
countryCode: 'US',
105+
},
106+
};
107+
108+
const result = await Pay.requestPayment({
109+
google: {
110+
environment: 'test',
111+
paymentDataRequest,
112+
},
113+
});
114+
115+
// Send `result.google?.paymentData` to your backend and use your PSP to start the subscription.
116+
```
117+
64118
## 8. Use the correct environment
65119

66120
- During development, set `environment: 'test'` and rely on test card numbers.

ios/Sources/PayPlugin/PayPlugin.swift

Lines changed: 218 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,20 @@
3535
"Native payment"
3636
],
3737
"scripts": {
38-
"verify": "npm run verify:ios && npm run verify:android && npm run verify:web",
38+
"verify": "bun run verify:ios && bun run verify:android && bun run verify:web",
3939
"verify:ios": "xcodebuild -scheme CapgoCapacitorPay -destination generic/platform=iOS",
4040
"verify:android": "cd android && ./gradlew clean build test && cd ..",
41-
"verify:web": "npm run build",
42-
"lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint",
43-
"fmt": "npm run eslint -- --fix && npm run prettier -- --write && npm run swiftlint -- --fix --format",
41+
"verify:web": "bun run build",
42+
"lint": "bun run eslint && bun run prettier -- --check && bun run swiftlint -- lint",
43+
"fmt": "bun run eslint -- --fix && bun run prettier -- --write && bun run swiftlint -- --fix --format",
4444
"eslint": "eslint . --ext ts",
4545
"prettier": "prettier-pretty-check \"**/*.{css,html,ts,js,java}\" --plugin=prettier-plugin-java",
4646
"swiftlint": "node-swiftlint",
4747
"docgen": "docgen --api PayPlugin --output-readme README.md --output-json dist/docs.json",
48-
"build": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.mjs",
48+
"build": "bun run clean && bun run docgen && tsc && rollup -c rollup.config.mjs",
4949
"clean": "rimraf ./dist",
5050
"watch": "tsc --watch",
51-
"prepublishOnly": "npm run build",
51+
"prepublishOnly": "bun run build",
5252
"check:wiring": "node scripts/check-capacitor-plugin-wiring.mjs"
5353
},
5454
"devDependencies": {

0 commit comments

Comments
 (0)