From a7a5dfd6a86ecfdf3c19f0c8cd3a2f9db4df6826 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Fri, 3 May 2024 18:21:33 -0700 Subject: [PATCH] Add support for App Store Server API v1.11 and App Store Server Notifications v2.11 https://developer.apple.com/documentation/appstoreservernotifications/app_store_server_notifications_changelog https://developer.apple.com/documentation/appstoreserverapi/app_store_server_api_changelog --- .../itunes/storekit/client/APIError.java | 8 ++++ .../storekit/model/ConsumptionRequest.java | 39 +++++++++++++++- .../model/ConsumptionRequestReason.java | 45 +++++++++++++++++++ .../com/apple/itunes/storekit/model/Data.java | 37 ++++++++++++++- .../storekit/model/RefundPreference.java | 44 ++++++++++++++++++ .../client/AppStoreServerAPIClientTest.java | 5 ++- .../ResponseBodyV2DecodedPayloadTest.java | 31 +++++++++++++ .../signedConsumptionRequestNotification.json | 16 +++++++ 8 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/apple/itunes/storekit/model/ConsumptionRequestReason.java create mode 100644 src/main/java/com/apple/itunes/storekit/model/RefundPreference.java create mode 100644 src/test/resources/models/signedConsumptionRequestNotification.json diff --git a/src/main/java/com/apple/itunes/storekit/client/APIError.java b/src/main/java/com/apple/itunes/storekit/client/APIError.java index e97ffd12..22c5fd5b 100644 --- a/src/main/java/com/apple/itunes/storekit/client/APIError.java +++ b/src/main/java/com/apple/itunes/storekit/client/APIError.java @@ -282,8 +282,16 @@ public enum APIError { * * @see InvalidTransactionNotConsumableError */ + @Deprecated INVALID_TRANSACTION_NOT_CONSUMABLE(4000043L), + /** + * An error that indicates the transaction identifier represents an unsupported in-app purchase type. + * + * @see InvalidTransactionTypeNotSupportedError + */ + INVALID_TRANSACTION_TYPE_NOT_SUPPORTED(4000047L), + /** * An error that indicates the subscription doesn't qualify for a renewal-date extension due to its subscription state. * diff --git a/src/main/java/com/apple/itunes/storekit/model/ConsumptionRequest.java b/src/main/java/com/apple/itunes/storekit/model/ConsumptionRequest.java index 3540ed6e..401ffb5a 100644 --- a/src/main/java/com/apple/itunes/storekit/model/ConsumptionRequest.java +++ b/src/main/java/com/apple/itunes/storekit/model/ConsumptionRequest.java @@ -24,6 +24,7 @@ public class ConsumptionRequest { private static final String SERIALIZED_NAME_LIFETIME_DOLLARS_REFUNDED = "lifetimeDollarsRefunded"; private static final String SERIALIZED_NAME_LIFETIME_DOLLARS_PURCHASED = "lifetimeDollarsPurchased"; private static final String SERIALIZED_NAME_USER_STATUS = "userStatus"; + private static final String SERIALIZED_NAME_REFUND_PREFERENCE = "refundPreference"; @JsonProperty(SERIALIZED_NAME_CUSTOMER_CONSENTED) private Boolean customerConsented; @JsonProperty(SERIALIZED_NAME_CONSUMPTION_STATUS) @@ -46,6 +47,8 @@ public class ConsumptionRequest { private Integer lifetimeDollarsPurchased; @JsonProperty(SERIALIZED_NAME_USER_STATUS) private Integer userStatus; + @JsonProperty(SERIALIZED_NAME_REFUND_PREFERENCE) + private Integer refundPreference; public ConsumptionRequest() { @@ -348,6 +351,36 @@ public void setRawUserStatus(Integer rawUserStatus) { this.userStatus = rawUserStatus; } + public ConsumptionRequest refundPreference(RefundPreference refundPreference) { + this.refundPreference = refundPreference != null ? refundPreference.getValue() : null; + return this; + } + + /** + * A value that indicates your preference, based on your operational logic, as to whether Apple should grant the refund. + * + * @return refundPreference + * @see refundPreference + **/ + public RefundPreference getRefundPreference() { + return refundPreference != null ? RefundPreference.fromValue(refundPreference) : null; + } + + /** + * @see #getRefundPreference() + */ + public Integer getRawRefundPreference() { + return refundPreference; + } + + public void setRefundPreference(RefundPreference refundPreference) { + this.refundPreference = refundPreference != null ? refundPreference.getValue() : null; + } + + public void setRawRefundPreference(Integer rawRefundPreference) { + this.refundPreference = rawRefundPreference; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -367,12 +400,13 @@ public boolean equals(Object o) { Objects.equals(this.playTime, consumptionRequest.playTime) && Objects.equals(this.lifetimeDollarsRefunded, consumptionRequest.lifetimeDollarsRefunded) && Objects.equals(this.lifetimeDollarsPurchased, consumptionRequest.lifetimeDollarsPurchased) && - Objects.equals(this.userStatus, consumptionRequest.userStatus); + Objects.equals(this.userStatus, consumptionRequest.userStatus) && + Objects.equals(this.refundPreference, consumptionRequest.refundPreference); } @Override public int hashCode() { - return Objects.hash(customerConsented, consumptionStatus, platform, sampleContentProvided, deliveryStatus, appAccountToken, accountTenure, playTime, lifetimeDollarsRefunded, lifetimeDollarsPurchased, userStatus); + return Objects.hash(customerConsented, consumptionStatus, platform, sampleContentProvided, deliveryStatus, appAccountToken, accountTenure, playTime, lifetimeDollarsRefunded, lifetimeDollarsPurchased, userStatus, refundPreference); } @Override @@ -389,6 +423,7 @@ public String toString() { ", lifetimeDollarsRefunded=" + lifetimeDollarsRefunded + ", lifetimeDollarsPurchased=" + lifetimeDollarsPurchased + ", userStatus=" + userStatus + + ", refundPreference=" + refundPreference + '}'; } } diff --git a/src/main/java/com/apple/itunes/storekit/model/ConsumptionRequestReason.java b/src/main/java/com/apple/itunes/storekit/model/ConsumptionRequestReason.java new file mode 100644 index 00000000..6c7e3d66 --- /dev/null +++ b/src/main/java/com/apple/itunes/storekit/model/ConsumptionRequestReason.java @@ -0,0 +1,45 @@ +// Copyright (c) 2024 Apple Inc. Licensed under MIT License. + +package com.apple.itunes.storekit.model; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * The customer-provided reason for a refund request. + * + * @see consumptionRequestReason + */ +public enum ConsumptionRequestReason { + + UNINTENDED_PURCHASE("UNINTENDED_PURCHASE"), + FULFILLMENT_ISSUE("FULFILLMENT_ISSUE"), + UNSATISFIED_WITH_PURCHASE("UNSATISFIED_WITH_PURCHASE"), + LEGAL("LEGAL"), + OTHER("OTHER"); + + private final String value; + + ConsumptionRequestReason(String value) { + this.value = value; + } + + public static ConsumptionRequestReason fromValue(String value) { + for (ConsumptionRequestReason b : ConsumptionRequestReason.values()) { + if (b.value.equals(value)) { + return b; + } + } + return null; + } + + @JsonValue + public String getValue() { + return value; + } + + @Override + public String toString() { + return String.valueOf(value); + } +} + diff --git a/src/main/java/com/apple/itunes/storekit/model/Data.java b/src/main/java/com/apple/itunes/storekit/model/Data.java index 919c9a30..4d3cd498 100644 --- a/src/main/java/com/apple/itunes/storekit/model/Data.java +++ b/src/main/java/com/apple/itunes/storekit/model/Data.java @@ -21,6 +21,7 @@ public class Data { private static final String SERIALIZED_NAME_SIGNED_TRANSACTION_INFO = "signedTransactionInfo"; private static final String SERIALIZED_NAME_SIGNED_RENEWAL_INFO = "signedRenewalInfo"; private static final String SERIALIZED_NAME_STATUS = "status"; + private static final String SERIALIZED_NAME_CONSUMPTION_REQUEST_REASON = "consumptionRequestReason"; @JsonProperty(SERIALIZED_NAME_ENVIRONMENT) private String environment; @JsonProperty(SERIALIZED_NAME_APP_APPLE_ID) @@ -35,6 +36,8 @@ public class Data { private String signedRenewalInfo; @JsonProperty(SERIALIZED_NAME_STATUS) private Integer status; + @JsonProperty(SERIALIZED_NAME_CONSUMPTION_REQUEST_REASON) + private String consumptionRequestReason; @JsonAnySetter private Map unknownFields; @@ -197,6 +200,36 @@ public void setRawStatus(Integer rawStatus) { this.status = rawStatus; } + public Data consumptionRequestReason(ConsumptionRequestReason consumptionRequestReason) { + this.consumptionRequestReason = consumptionRequestReason != null ? consumptionRequestReason.getValue() : null; + return this; + } + + /** + * The reason the customer requested the refund. + * + * @return consumptionRequestReason + * @see consumptionRequestReason + **/ + public ConsumptionRequestReason getConsumptionRequestReason() { + return consumptionRequestReason != null ? ConsumptionRequestReason.fromValue(consumptionRequestReason) : null; + } + + /** + * @see #getConsumptionRequestReason() + */ + public String getRawConsumptionRequestReason() { + return consumptionRequestReason; + } + + public void setConsumptionRequestReason(ConsumptionRequestReason consumptionRequestReason) { + this.consumptionRequestReason = consumptionRequestReason != null ? consumptionRequestReason.getValue() : null; + } + + public void setRawConsumptionRequestReason(String rawConsumptionRequestReason) { + this.consumptionRequestReason = rawConsumptionRequestReason; + } + public Data unknownFields(Map unknownFields) { this.unknownFields = unknownFields; return this; @@ -231,12 +264,13 @@ public boolean equals(Object o) { Objects.equals(this.signedTransactionInfo, data.signedTransactionInfo) && Objects.equals(this.signedRenewalInfo, data.signedRenewalInfo) && Objects.equals(this.status, data.status) && + Objects.equals(this.consumptionRequestReason, data.consumptionRequestReason) && Objects.equals(this.unknownFields, data.unknownFields); } @Override public int hashCode() { - return Objects.hash(environment, appAppleId, bundleId, bundleVersion, signedTransactionInfo, signedRenewalInfo, status, unknownFields); + return Objects.hash(environment, appAppleId, bundleId, bundleVersion, signedTransactionInfo, signedRenewalInfo, status, consumptionRequestReason, unknownFields); } @Override @@ -249,6 +283,7 @@ public String toString() { ", signedTransactionInfo='" + signedTransactionInfo + '\'' + ", signedRenewalInfo='" + signedRenewalInfo + '\'' + ", status=" + status + + ", consumptionRequestReason='" + consumptionRequestReason + '\'' + ", unknownFields=" + unknownFields + '}'; } diff --git a/src/main/java/com/apple/itunes/storekit/model/RefundPreference.java b/src/main/java/com/apple/itunes/storekit/model/RefundPreference.java new file mode 100644 index 00000000..1aaa5907 --- /dev/null +++ b/src/main/java/com/apple/itunes/storekit/model/RefundPreference.java @@ -0,0 +1,44 @@ +// Copyright (c) 2024 Apple Inc. Licensed under MIT License. + +package com.apple.itunes.storekit.model; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * A value that indicates your preferred outcome for the refund request. + * + * @see refundPreference + */ +public enum RefundPreference { + + UNDECLARED(0), + PREFER_GRANT(1), + PREFER_DECLINE(2), + NO_PREFERENCE(3); + + private final Integer value; + + RefundPreference(Integer value) { + this.value = value; + } + + public static RefundPreference fromValue(Integer value) { + for (RefundPreference b : RefundPreference.values()) { + if (b.value.equals(value)) { + return b; + } + } + return null; + } + + @JsonValue + public Integer getValue() { + return value; + } + + @Override + public String toString() { + return String.valueOf(value); + } +} + 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 c7b7d2f5..2ffe0e0e 100644 --- a/src/test/java/com/apple/itunes/storekit/client/AppStoreServerAPIClientTest.java +++ b/src/test/java/com/apple/itunes/storekit/client/AppStoreServerAPIClientTest.java @@ -28,6 +28,7 @@ import com.apple.itunes.storekit.model.Platform; import com.apple.itunes.storekit.model.PlayTime; import com.apple.itunes.storekit.model.RefundHistoryResponse; +import com.apple.itunes.storekit.model.RefundPreference; import com.apple.itunes.storekit.model.SendAttemptItem; import com.apple.itunes.storekit.model.SendAttemptResult; import com.apple.itunes.storekit.model.SendTestNotificationResponse; @@ -429,6 +430,7 @@ public void testSendConsumptionData() throws APIException, IOException { Assertions.assertEquals(6, ((Number) root.get("lifetimeDollarsRefunded")).intValue()); Assertions.assertEquals(7, ((Number) root.get("lifetimeDollarsPurchased")).intValue()); Assertions.assertEquals(4, ((Number) root.get("userStatus")).intValue()); + Assertions.assertEquals(3, ((Number) root.get("refundPreference")).intValue()); }); ConsumptionRequest consumptionRequest = new ConsumptionRequest() @@ -442,7 +444,8 @@ public void testSendConsumptionData() throws APIException, IOException { .playTime(PlayTime.ONE_DAY_TO_FOUR_DAYS) .lifetimeDollarsRefunded(LifetimeDollarsRefunded.ONE_THOUSAND_DOLLARS_TO_ONE_THOUSAND_NINE_HUNDRED_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS) .lifetimeDollarsPurchased(LifetimeDollarsPurchased.TWO_THOUSAND_DOLLARS_OR_GREATER) - .userStatus(UserStatus.LIMITED_ACCESS); + .userStatus(UserStatus.LIMITED_ACCESS) + .refundPreference(RefundPreference.NO_PREFERENCE); client.sendConsumptionData("49571273", consumptionRequest); } diff --git a/src/test/java/com/apple/itunes/storekit/model/ResponseBodyV2DecodedPayloadTest.java b/src/test/java/com/apple/itunes/storekit/model/ResponseBodyV2DecodedPayloadTest.java index b2be5c52..c14bb7f1 100644 --- a/src/test/java/com/apple/itunes/storekit/model/ResponseBodyV2DecodedPayloadTest.java +++ b/src/test/java/com/apple/itunes/storekit/model/ResponseBodyV2DecodedPayloadTest.java @@ -42,6 +42,37 @@ public void testNotificationDecoding() throws IOException, NoSuchAlgorithmExcept Assertions.assertEquals("signed_renewal_info_value", notification.getData().getSignedRenewalInfo()); Assertions.assertEquals(Status.ACTIVE, notification.getData().getStatus()); Assertions.assertEquals(1, notification.getData().getRawStatus()); + Assertions.assertNull(notification.getData().getConsumptionRequestReason()); + Assertions.assertNull(notification.getData().getRawConsumptionRequestReason()); + } + + @Test + public void testConsumptionRequestNotificationDecoding() throws IOException, NoSuchAlgorithmException, VerificationException { + String signedNotification = SignedDataCreator.createSignedDataFromJson("models/signedConsumptionRequestNotification.json"); + + ResponseBodyV2DecodedPayload notification = TestingUtility.getSignedPayloadVerifier().verifyAndDecodeNotification(signedNotification); + + Assertions.assertEquals(NotificationTypeV2.CONSUMPTION_REQUEST, notification.getNotificationType()); + Assertions.assertEquals("CONSUMPTION_REQUEST", notification.getRawNotificationType()); + Assertions.assertNull(notification.getSubtype()); + Assertions.assertNull(notification.getRawSubtype()); + Assertions.assertEquals("002e14d5-51f5-4503-b5a8-c3a1af68eb20", notification.getNotificationUUID()); + Assertions.assertEquals("2.0", notification.getVersion()); + Assertions.assertEquals(1698148900000L, notification.getSignedDate()); + Assertions.assertNotNull(notification.getData()); + Assertions.assertNull(notification.getSummary()); + Assertions.assertNull(notification.getExternalPurchaseToken()); + Assertions.assertEquals(Environment.LOCAL_TESTING, notification.getData().getEnvironment()); + Assertions.assertEquals("LocalTesting", notification.getData().getRawEnvironment()); + Assertions.assertEquals(41234L, notification.getData().getAppAppleId()); + Assertions.assertEquals("com.example", notification.getData().getBundleId()); + Assertions.assertEquals("1.2.3", notification.getData().getBundleVersion()); + Assertions.assertEquals("signed_transaction_info_value", notification.getData().getSignedTransactionInfo()); + Assertions.assertEquals("signed_renewal_info_value", notification.getData().getSignedRenewalInfo()); + Assertions.assertEquals(Status.ACTIVE, notification.getData().getStatus()); + Assertions.assertEquals(1, notification.getData().getRawStatus()); + Assertions.assertEquals(ConsumptionRequestReason.UNINTENDED_PURCHASE, notification.getData().getConsumptionRequestReason()); + Assertions.assertEquals("UNINTENDED_PURCHASE", notification.getData().getRawConsumptionRequestReason()); } @Test diff --git a/src/test/resources/models/signedConsumptionRequestNotification.json b/src/test/resources/models/signedConsumptionRequestNotification.json new file mode 100644 index 00000000..5cfa6ea1 --- /dev/null +++ b/src/test/resources/models/signedConsumptionRequestNotification.json @@ -0,0 +1,16 @@ +{ + "notificationType": "CONSUMPTION_REQUEST", + "notificationUUID": "002e14d5-51f5-4503-b5a8-c3a1af68eb20", + "data": { + "environment": "LocalTesting", + "appAppleId": 41234, + "bundleId": "com.example", + "bundleVersion": "1.2.3", + "signedTransactionInfo": "signed_transaction_info_value", + "signedRenewalInfo": "signed_renewal_info_value", + "status": 1, + "consumptionRequestReason": "UNINTENDED_PURCHASE" + }, + "version": "2.0", + "signedDate": 1698148900000 +} \ No newline at end of file