diff --git a/src/main/java/com/apple/itunes/storekit/client/BaseAppStoreServerAPIClient.java b/src/main/java/com/apple/itunes/storekit/client/BaseAppStoreServerAPIClient.java index 8e9a8151..d6ebc09d 100644 --- a/src/main/java/com/apple/itunes/storekit/client/BaseAppStoreServerAPIClient.java +++ b/src/main/java/com/apple/itunes/storekit/client/BaseAppStoreServerAPIClient.java @@ -2,6 +2,18 @@ package com.apple.itunes.storekit.client; +import com.apple.itunes.storekit.model.AdvancedCommerceRequestRefundRequest; +import com.apple.itunes.storekit.model.AdvancedCommerceRequestRefundResponse; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionCancelRequest; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionCancelResponse; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionChangeMetadataRequest; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionChangeMetadataResponse; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionMigrateRequest; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionMigrateResponse; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionPriceChangeRequest; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionPriceChangeResponse; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionRevokeRequest; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionRevokeResponse; import com.apple.itunes.storekit.model.AppTransactionInfoResponse; import com.apple.itunes.storekit.model.CheckTestNotificationResponse; import com.apple.itunes.storekit.model.ConsumptionRequest; @@ -623,6 +635,90 @@ public void finishTransaction(String transactionId) throws APIException, IOExcep makeHttpCall("/inApps/v1/transactions/" + transactionId + "/finish", "POST", Map.of(), null, Void.class, null); } + /** + * Increase or decrease the price of an auto-renewable subscription, a bundle, or individual items within a subscription at the next renewal. + * + * @param transactionId A transaction identifier of the auto-renewable subscription that is subject to the price change. + * @param subscriptionPriceChangeRequest The request body that contains the details of the price change. + * @return A response that contains signed JWS renewal and JWS transaction information after a subscription price change request. + * @throws APIException If a response was returned indicating the request could not be processed + * @throws IOException If an exception was thrown while making the request + * @see Change Subscription Price + */ + public AdvancedCommerceSubscriptionPriceChangeResponse changeSubscriptionPrice(String transactionId, AdvancedCommerceSubscriptionPriceChangeRequest subscriptionPriceChangeRequest) throws APIException, IOException { + return makeHttpCall("/advancedCommerce/v1/subscription/changePrice/" + transactionId, "POST", Map.of(), subscriptionPriceChangeRequest, AdvancedCommerceSubscriptionPriceChangeResponse.class, JSON); + } + + /** + * Turn off automatic renewal to cancel a customer’s auto-renewable subscription. + * + * @param transactionId The transaction identifier of the auto-renewable subscription to cancel. + * @param subscriptionCancelRequest The request body that includes information about the subscription to cancel. + * @return The response body for a successful subscription cancellation. + * @throws APIException If a response was returned indicating the request could not be processed + * @throws IOException If an exception was thrown while making the request + * @see Cancel a Subscription + */ + public AdvancedCommerceSubscriptionCancelResponse cancelSubscription(String transactionId, AdvancedCommerceSubscriptionCancelRequest subscriptionCancelRequest) throws APIException, IOException { + return makeHttpCall("/advancedCommerce/v1/subscription/cancel/" + transactionId, "POST", Map.of(), subscriptionCancelRequest, AdvancedCommerceSubscriptionCancelResponse.class, JSON); + } + + /** + * Immediately cancel a customer’s subscription and all the items that are included in the subscription, and request a full or prorated refund. + * + * @param transactionId The transaction identifier of the auto-renewable subscription to revoke. + * @param subscriptionRevokeRequest The request body you provide to terminate a subscription and all its items immediately. + * @return The response body for a successful revoke-subscription request. + * @throws APIException If a response was returned indicating the request could not be processed + * @throws IOException If an exception was thrown while making the request + * @see Revoke Subscription + */ + public AdvancedCommerceSubscriptionRevokeResponse revokeSubscription(String transactionId, AdvancedCommerceSubscriptionRevokeRequest subscriptionRevokeRequest) throws APIException, IOException { + return makeHttpCall("/advancedCommerce/v1/subscription/revoke/" + transactionId, "POST", Map.of(), subscriptionRevokeRequest, AdvancedCommerceSubscriptionRevokeResponse.class, JSON); + } + + /** + * Request a refund for a one-time charge or subscription transaction. + * + * @param transactionId The transaction identifier for which you request a refund. + * @param requestRefundRequest The request body for requesting a refund for a transaction. + * @return The response body for a transaction refund request. + * @throws APIException If a response was returned indicating the request could not be processed + * @throws IOException If an exception was thrown while making the request + * @see Request Transaction Refund + */ + public AdvancedCommerceRequestRefundResponse requestTransactionRefund(String transactionId, AdvancedCommerceRequestRefundRequest requestRefundRequest) throws APIException, IOException { + return makeHttpCall("/advancedCommerce/v1/transaction/requestRefund/" + transactionId, "POST", Map.of(), requestRefundRequest, AdvancedCommerceRequestRefundResponse.class, JSON); + } + + /** + * Update the SKU, display name, and description associated with a subscription, without affecting the subscription’s billing or its service. + * + * @param transactionId The transaction identifier of the auto-renewable subscription to get changes to its metadata. + * @param subscriptionChangeMetadataRequest The request body that contains the metadata changes. + * @return The response body for a successful subscription metadata change. + * @throws APIException If a response was returned indicating the request could not be processed + * @throws IOException If an exception was thrown while making the request + * @see Change Subscription Metadata + */ + public AdvancedCommerceSubscriptionChangeMetadataResponse changeSubscriptionMetadata(String transactionId, AdvancedCommerceSubscriptionChangeMetadataRequest subscriptionChangeMetadataRequest) throws APIException, IOException { + return makeHttpCall("/advancedCommerce/v1/subscription/changeMetadata/" + transactionId, "POST", Map.of(), subscriptionChangeMetadataRequest, AdvancedCommerceSubscriptionChangeMetadataResponse.class, JSON); + } + + /** + * Migrate a subscription that a customer purchased through Apple In-App Purchase to a subscription you manage using the Advanced Commerce API. + * + * @param transactionId The transaction identifier of the auto-renewable subscription to migrate. + * @param subscriptionMigrateRequest The request body that contains the details for the migration. + * @return A response that contains signed renewal and transaction information after a subscription successfully migrates to the Advanced Commerce API. + * @throws APIException If a response was returned indicating the request could not be processed + * @throws IOException If an exception was thrown while making the request + * @see Migrate a Subscription to Advanced Commerce API + */ + public AdvancedCommerceSubscriptionMigrateResponse migrateSubscriptionToAdvancedCommerceAPI(String transactionId, AdvancedCommerceSubscriptionMigrateRequest subscriptionMigrateRequest) throws APIException, IOException { + return makeHttpCall("/advancedCommerce/v1/subscription/migrate/" + transactionId, "POST", Map.of(), subscriptionMigrateRequest, AdvancedCommerceSubscriptionMigrateResponse.class, JSON); + } + protected interface HttpResponseInterface extends Closeable { /** * @return The HTTP status code of the response diff --git a/src/test/java/com/apple/itunes/storekit/client/AppStoreServerAPIClientTest.java b/src/test/java/com/apple/itunes/storekit/client/AppStoreServerAPIClientTest.java index f6b1a5d3..ceda9c57 100644 --- a/src/test/java/com/apple/itunes/storekit/client/AppStoreServerAPIClientTest.java +++ b/src/test/java/com/apple/itunes/storekit/client/AppStoreServerAPIClientTest.java @@ -3,6 +3,28 @@ package com.apple.itunes.storekit.client; import com.apple.itunes.storekit.model.AccountTenure; +import com.apple.itunes.storekit.model.AdvancedCommerceEffective; +import com.apple.itunes.storekit.model.AdvancedCommerceRefundReason; +import com.apple.itunes.storekit.model.AdvancedCommerceRefundType; +import com.apple.itunes.storekit.model.AdvancedCommerceRequestInfo; +import com.apple.itunes.storekit.model.AdvancedCommerceRequestRefundItem; +import com.apple.itunes.storekit.model.AdvancedCommerceRequestRefundRequest; +import com.apple.itunes.storekit.model.AdvancedCommerceRequestRefundResponse; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionCancelRequest; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionCancelResponse; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionChangeMetadataDescriptors; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionChangeMetadataItem; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionChangeMetadataRequest; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionChangeMetadataResponse; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionMigrateDescriptors; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionMigrateItem; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionMigrateRequest; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionMigrateResponse; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionPriceChangeItem; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionPriceChangeRequest; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionPriceChangeResponse; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionRevokeRequest; +import com.apple.itunes.storekit.model.AdvancedCommerceSubscriptionRevokeResponse; import com.apple.itunes.storekit.model.AppTransactionInfoResponse; import com.apple.itunes.storekit.model.BulletPoint; import com.apple.itunes.storekit.model.CheckTestNotificationResponse; @@ -1313,6 +1335,267 @@ public void testGetPerformanceTestResults() throws IOException, APIException { Assertions.assertEquals(Map.of("TIMED_OUT", 1, "NO_RESPONSE", 1), response.getRawFailures()); } + @Test + public void testChangeSubscriptionPrice() throws IOException, APIException { + AppStoreServerAPIClient client = getClientWithBody("models/advancedCommerceSubscriptionPriceChangeResponse.json", request -> { + Assertions.assertEquals("POST", request.method()); + Assertions.assertEquals("/advancedCommerce/v1/subscription/changePrice/4124214", request.url().encodedPath()); + RequestBody body = request.body(); + Assertions.assertNotNull(body); + Assertions.assertEquals(expectedMediaType, body.contentType()); + Buffer buffer = new Buffer(); + try { + body.writeTo(buffer); + } catch (IOException e) { + throw new RuntimeException(e); + } + Map root; + try { + root = new ObjectMapper().readValue(buffer.readUtf8(), Map.class); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + Assertions.assertEquals("7c80bb86-f892-4b21-a919-1357811d6c4f", ((Map) root.get("requestInfo")).get("requestReferenceId")); + Assertions.assertEquals("USD", root.get("currency")); + Assertions.assertEquals("USA", root.get("storefront")); + Map item = (Map) ((List) root.get("items")).get(0); + Assertions.assertEquals("AD_FREE_1M", item.get("SKU")); + Assertions.assertEquals(12990, ((Number) item.get("price")).intValue()); + Assertions.assertEquals(List.of("ADVANCED_FEATURES_1M"), item.get("dependentSKUs")); + }); + + AdvancedCommerceSubscriptionPriceChangeRequest priceChangeRequest = new AdvancedCommerceSubscriptionPriceChangeRequest( + new AdvancedCommerceRequestInfo(UUID.fromString("7c80bb86-f892-4b21-a919-1357811d6c4f")), + List.of(new AdvancedCommerceSubscriptionPriceChangeItem("AD_FREE_1M", 12990L) + .dependentSKUs(List.of("ADVANCED_FEATURES_1M")))) + .currency("USD") + .storefront("USA"); + + AdvancedCommerceSubscriptionPriceChangeResponse response = client.changeSubscriptionPrice("4124214", priceChangeRequest); + + Assertions.assertNotNull(response); + Assertions.assertEquals("signed_renewal_info", response.getSignedRenewalInfo()); + Assertions.assertEquals("signed_transaction_info", response.getSignedTransactionInfo()); + } + + @Test + public void testCancelSubscription() throws IOException, APIException { + AppStoreServerAPIClient client = getClientWithBody("models/advancedCommerceSubscriptionCancelResponse.json", request -> { + Assertions.assertEquals("POST", request.method()); + Assertions.assertEquals("/advancedCommerce/v1/subscription/cancel/4124214", request.url().encodedPath()); + RequestBody body = request.body(); + Assertions.assertNotNull(body); + Assertions.assertEquals(expectedMediaType, body.contentType()); + Buffer buffer = new Buffer(); + try { + body.writeTo(buffer); + } catch (IOException e) { + throw new RuntimeException(e); + } + Map root; + try { + root = new ObjectMapper().readValue(buffer.readUtf8(), Map.class); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + Assertions.assertEquals("932c6903-0ab8-4469-9f21-015f6fab013c", ((Map) root.get("requestInfo")).get("requestReferenceId")); + Assertions.assertEquals("USA", root.get("storefront")); + }); + + AdvancedCommerceSubscriptionCancelRequest cancelRequest = new AdvancedCommerceSubscriptionCancelRequest( + new AdvancedCommerceRequestInfo(UUID.fromString("932c6903-0ab8-4469-9f21-015f6fab013c"))) + .storefront("USA"); + + AdvancedCommerceSubscriptionCancelResponse response = client.cancelSubscription("4124214", cancelRequest); + + Assertions.assertNotNull(response); + Assertions.assertEquals("signed_renewal_info", response.getSignedRenewalInfo()); + Assertions.assertEquals("signed_transaction_info", response.getSignedTransactionInfo()); + } + + @Test + public void testRevokeSubscription() throws IOException, APIException { + AppStoreServerAPIClient client = getClientWithBody("models/advancedCommerceSubscriptionRevokeResponse.json", request -> { + Assertions.assertEquals("POST", request.method()); + Assertions.assertEquals("/advancedCommerce/v1/subscription/revoke/4124214", request.url().encodedPath()); + RequestBody body = request.body(); + Assertions.assertNotNull(body); + Assertions.assertEquals(expectedMediaType, body.contentType()); + Buffer buffer = new Buffer(); + try { + body.writeTo(buffer); + } catch (IOException e) { + throw new RuntimeException(e); + } + Map root; + try { + root = new ObjectMapper().readValue(buffer.readUtf8(), Map.class); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + Assertions.assertEquals("932c6903-0ab8-4469-9f21-015f6fab013c", ((Map) root.get("requestInfo")).get("requestReferenceId")); + Assertions.assertEquals("UNINTENDED_PURCHASE", root.get("refundReason")); + Assertions.assertEquals("PRORATED", root.get("refundType")); + Assertions.assertTrue((Boolean) root.get("refundRiskingPreference")); + Assertions.assertEquals("USA", root.get("storefront")); + }); + + AdvancedCommerceSubscriptionRevokeRequest revokeRequest = new AdvancedCommerceSubscriptionRevokeRequest( + new AdvancedCommerceRequestInfo(UUID.fromString("932c6903-0ab8-4469-9f21-015f6fab013c")), + true, + AdvancedCommerceRefundReason.UNINTENDED_PURCHASE, + AdvancedCommerceRefundType.PRORATED.getValue()) + .storefront("USA"); + + AdvancedCommerceSubscriptionRevokeResponse response = client.revokeSubscription("4124214", revokeRequest); + + Assertions.assertNotNull(response); + Assertions.assertEquals("signed_renewal_info", response.getSignedRenewalInfo()); + Assertions.assertEquals("signed_transaction_info", response.getSignedTransactionInfo()); + } + + @Test + public void testRequestTransactionRefund() throws IOException, APIException { + AppStoreServerAPIClient client = getClientWithBody("models/advancedCommerceRequestRefundResponse.json", request -> { + Assertions.assertEquals("POST", request.method()); + Assertions.assertEquals("/advancedCommerce/v1/transaction/requestRefund/4124214", request.url().encodedPath()); + RequestBody body = request.body(); + Assertions.assertNotNull(body); + Assertions.assertEquals(expectedMediaType, body.contentType()); + Buffer buffer = new Buffer(); + try { + body.writeTo(buffer); + } catch (IOException e) { + throw new RuntimeException(e); + } + Map root; + try { + root = new ObjectMapper().readValue(buffer.readUtf8(), Map.class); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + Assertions.assertEquals("932c6903-0ab8-4469-9f21-015f6fab013c", ((Map) root.get("requestInfo")).get("requestReferenceId")); + Assertions.assertEquals("USD", root.get("currency")); + Assertions.assertEquals("USA", root.get("storefront")); + Assertions.assertTrue((Boolean) root.get("refundRiskingPreference")); + Map item = (Map) ((List) root.get("items")).get(0); + Assertions.assertEquals("AD_FREE_1M", item.get("SKU")); + Assertions.assertEquals("UNSATISFIED_WITH_PURCHASE", item.get("refundReason")); + Assertions.assertEquals("FULL", item.get("refundType")); + Assertions.assertTrue((Boolean) item.get("revoke")); + }); + + AdvancedCommerceRequestRefundRequest requestRefundRequest = new AdvancedCommerceRequestRefundRequest( + List.of(new AdvancedCommerceRequestRefundItem("AD_FREE_1M", AdvancedCommerceRefundReason.UNSATISFIED_WITH_PURCHASE, AdvancedCommerceRefundType.FULL, true)), + true, + new AdvancedCommerceRequestInfo(UUID.fromString("932c6903-0ab8-4469-9f21-015f6fab013c"))) + .currency("USD") + .storefront("USA"); + + AdvancedCommerceRequestRefundResponse response = client.requestTransactionRefund("4124214", requestRefundRequest); + + Assertions.assertNotNull(response); + Assertions.assertEquals("signed_transaction_info_value", response.getSignedTransactionInfo()); + } + + @Test + public void testChangeSubscriptionMetadata() throws IOException, APIException { + AppStoreServerAPIClient client = getClientWithBody("models/advancedCommerceSubscriptionChangeMetadataResponse.json", request -> { + Assertions.assertEquals("POST", request.method()); + Assertions.assertEquals("/advancedCommerce/v1/subscription/changeMetadata/4124214", request.url().encodedPath()); + RequestBody body = request.body(); + Assertions.assertNotNull(body); + Assertions.assertEquals(expectedMediaType, body.contentType()); + Buffer buffer = new Buffer(); + try { + body.writeTo(buffer); + } catch (IOException e) { + throw new RuntimeException(e); + } + Map root; + try { + root = new ObjectMapper().readValue(buffer.readUtf8(), Map.class); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + Assertions.assertEquals("932c6903-0ab8-4469-9f21-015f6fab013c", ((Map) root.get("requestInfo")).get("requestReferenceId")); + Assertions.assertEquals("USA", root.get("storefront")); + Assertions.assertEquals("C003-00-1", root.get("taxCode")); + Map descriptors = (Map) root.get("descriptors"); + Assertions.assertEquals("NEXT_BILL_CYCLE", descriptors.get("effective")); + Assertions.assertEquals("Remove ads and unlock advanced features.", descriptors.get("description")); + Assertions.assertEquals("Ad-free package", descriptors.get("displayName")); + Map item = (Map) ((List) root.get("items")).get(0); + Assertions.assertEquals("AD_FREE_1M", item.get("currentSKU")); + Assertions.assertEquals("NEXT_BILL_CYCLE", item.get("effective")); + Assertions.assertEquals("AD_FREE_1M_V2", item.get("SKU")); + }); + + AdvancedCommerceSubscriptionChangeMetadataRequest changeMetadataRequest = new AdvancedCommerceSubscriptionChangeMetadataRequest( + new AdvancedCommerceRequestInfo(UUID.fromString("932c6903-0ab8-4469-9f21-015f6fab013c"))) + .descriptors(new AdvancedCommerceSubscriptionChangeMetadataDescriptors(AdvancedCommerceEffective.NEXT_BILL_CYCLE) + .description("Remove ads and unlock advanced features.") + .displayName("Ad-free package")) + .items(List.of(new AdvancedCommerceSubscriptionChangeMetadataItem("AD_FREE_1M", AdvancedCommerceEffective.NEXT_BILL_CYCLE) + .sku("AD_FREE_1M_V2"))) + .storefront("USA") + .taxCode("C003-00-1"); + + AdvancedCommerceSubscriptionChangeMetadataResponse response = client.changeSubscriptionMetadata("4124214", changeMetadataRequest); + + Assertions.assertNotNull(response); + Assertions.assertEquals("signed_renewal_info", response.getSignedRenewalInfo()); + Assertions.assertEquals("signed_transaction_info", response.getSignedTransactionInfo()); + } + + @Test + public void testMigrateSubscriptionToAdvancedCommerceAPI() throws IOException, APIException { + AppStoreServerAPIClient client = getClientWithBody("models/advancedCommerceSubscriptionMigrateResponse.json", request -> { + Assertions.assertEquals("POST", request.method()); + Assertions.assertEquals("/advancedCommerce/v1/subscription/migrate/4124214", request.url().encodedPath()); + RequestBody body = request.body(); + Assertions.assertNotNull(body); + Assertions.assertEquals(expectedMediaType, body.contentType()); + Buffer buffer = new Buffer(); + try { + body.writeTo(buffer); + } catch (IOException e) { + throw new RuntimeException(e); + } + Map root; + try { + root = new ObjectMapper().readValue(buffer.readUtf8(), Map.class); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + Assertions.assertEquals("932c6903-0ab8-4469-9f21-015f6fab013c", ((Map) root.get("requestInfo")).get("requestReferenceId")); + Assertions.assertEquals("com.example.base", root.get("targetProductId")); + Assertions.assertEquals("C003-00-1", root.get("taxCode")); + Assertions.assertEquals("USA", root.get("storefront")); + Map descriptors = (Map) root.get("descriptors"); + Assertions.assertEquals("Remove ads and unlock advanced features.", descriptors.get("description")); + Assertions.assertEquals("Ad-free package", descriptors.get("displayName")); + Map item = (Map) ((List) root.get("items")).get(0); + Assertions.assertEquals("AD_FREE_1M", item.get("SKU")); + Assertions.assertEquals("Remove ads for the service.", item.get("description")); + Assertions.assertEquals("Ad-free monthly plan", item.get("displayName")); + }); + + AdvancedCommerceSubscriptionMigrateRequest migrateRequest = new AdvancedCommerceSubscriptionMigrateRequest( + new AdvancedCommerceRequestInfo(UUID.fromString("932c6903-0ab8-4469-9f21-015f6fab013c")), + new AdvancedCommerceSubscriptionMigrateDescriptors("Remove ads and unlock advanced features.", "Ad-free package"), + List.of(new AdvancedCommerceSubscriptionMigrateItem("AD_FREE_1M", "Remove ads for the service.", "Ad-free monthly plan")), + "com.example.base", + "C003-00-1") + .storefront("USA"); + + AdvancedCommerceSubscriptionMigrateResponse response = client.migrateSubscriptionToAdvancedCommerceAPI("4124214", migrateRequest); + + Assertions.assertNotNull(response); + Assertions.assertEquals("signed_renewal_info_value", response.getSignedRenewalInfo()); + Assertions.assertEquals("signed_transaction_info_value", response.getSignedTransactionInfo()); + } + public AppStoreServerAPIClient getClientWithBody(String path, Consumer requestVerifier) throws IOException { String body = TestingUtility.readFile(path); return getAppStoreServerAPIClient(body, requestVerifier);