christianselig-apollo-backend/internal/itunes/receipt.go

549 lines
22 KiB
Go

package itunes
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"time"
)
type numericString string
func (n *numericString) UnmarshalJSON(b []byte) error {
var number json.Number
if err := json.Unmarshal(b, &number); err != nil {
return err
}
*n = numericString(number.String())
return nil
}
type Environment string
const (
Sandbox Environment = "Sandbox"
Production Environment = "Production"
)
const (
SubscriptionMonthly = "MONTHLY"
SubscriptionTriMonthly = "TRIMONTHLY"
SubscriptionYearly = "YEARLY"
SubscriptionLifetime = "LIFETIME"
)
// Models are courtesy of awa/go-iap https://github.com/awa/go-iap/blob/master/appstore/model.go
type (
// The ReceiptCreationDate type indicates the date when the app receipt was created.
ReceiptCreationDate struct {
CreationDate string `json:"receipt_creation_date"`
CreationDateMS string `json:"receipt_creation_date_ms"`
CreationDatePST string `json:"receipt_creation_date_pst"`
}
// The RequestDate type indicates the date and time that the request was sent
RequestDate struct {
RequestDate string `json:"request_date"`
RequestDateMS string `json:"request_date_ms"`
RequestDatePST string `json:"request_date_pst"`
}
// The PurchaseDate type indicates the date and time that the item was purchased
PurchaseDate struct {
PurchaseDate string `json:"purchase_date"`
PurchaseDateMS string `json:"purchase_date_ms"`
PurchaseDatePST string `json:"purchase_date_pst"`
}
// The OriginalPurchaseDate type indicates the beginning of the subscription period
OriginalPurchaseDate struct {
OriginalPurchaseDate string `json:"original_purchase_date"`
OriginalPurchaseDateMS string `json:"original_purchase_date_ms"`
OriginalPurchaseDatePST string `json:"original_purchase_date_pst"`
}
// The ExpiresDate type indicates the expiration date for the subscription
ExpiresDate struct {
ExpiresDate string `json:"expires_date,omitempty"`
ExpiresDateMS int64 `json:"expires_date_ms,string,omitempty"`
ExpiresDatePST string `json:"expires_date_pst,omitempty"`
ExpiresDateFormatted string `json:"expires_date_formatted,omitempty"`
ExpiresDateFormattedPST string `json:"expires_date_formatted_pst,omitempty"`
}
// The CancellationDate type indicates the time and date of the cancellation by Apple customer support
CancellationDate struct {
CancellationDate string `json:"cancellation_date,omitempty"`
CancellationDateMS string `json:"cancellation_date_ms,omitempty"`
CancellationDatePST string `json:"cancellation_date_pst,omitempty"`
}
// The InApp type has the receipt attributes
InApp struct {
Quantity string `json:"quantity"`
ProductID string `json:"product_id"`
TransactionID string `json:"transaction_id"`
OriginalTransactionID string `json:"original_transaction_id"`
WebOrderLineItemID string `json:"web_order_line_item_id,omitempty"`
IsTrialPeriod string `json:"is_trial_period"`
ExpiresDate
PurchaseDate
OriginalPurchaseDate
CancellationDate
CancellationReason string `json:"cancellation_reason,omitempty"`
}
// The Receipt type has whole data of receipt
Receipt struct {
ReceiptType string `json:"receipt_type"`
AdamID int64 `json:"adam_id"`
AppItemID numericString `json:"app_item_id"`
BundleID string `json:"bundle_id"`
ApplicationVersion string `json:"application_version"`
DownloadID int64 `json:"download_id"`
VersionExternalIdentifier numericString `json:"version_external_identifier"`
OriginalApplicationVersion string `json:"original_application_version"`
InApp []InApp `json:"in_app"`
ReceiptCreationDate
RequestDate
OriginalPurchaseDate
}
// A pending renewal may refer to a renewal that is scheduled in the future or a renewal that failed in the past for some reason.
PendingRenewalInfo struct {
SubscriptionExpirationIntent string `json:"expiration_intent"`
SubscriptionAutoRenewProductID string `json:"auto_renew_product_id"`
SubscriptionRetryFlag string `json:"is_in_billing_retry_period"`
SubscriptionAutoRenewStatus string `json:"auto_renew_status"`
SubscriptionPriceConsentStatus string `json:"price_consent_status"`
ProductID string `json:"product_id"`
}
// The IAPResponse type has the response properties
// We defined each field by the current IAP response, but some fields are not mentioned
// in the following Apple's document;
// https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html
// If you get other types or fields from the IAP response, you should use the struct you defined.
IAPResponse struct {
Status int `json:"status"`
Environment Environment `json:"environment"`
Receipt Receipt `json:"receipt"`
LatestReceiptInfo []InApp `json:"latest_receipt_info,omitempty"`
LatestReceipt string `json:"latest_receipt,omitempty"`
PendingRenewalInfo []PendingRenewalInfo `json:"pending_renewal_info,omitempty"`
IsRetryable bool `json:"is-retryable,omitempty"`
VerificationInfo ClientVerificationInfo
DeleteDevice bool
}
VerificationInfo struct {
APNsToken string `json:"apns_token"`
Receipt string `json:"receipt"`
Sandbox bool `json:"sandbox"`
Accounts []Account `json:"accounts"`
}
Account struct {
AccountID string `json:"account_id"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
ClientVerificationInfo struct {
Products []VerificationProduct `json:"products"`
Issue string `json:"issue,omitempty"`
}
VerificationProduct struct {
Name string `json:"name"`
Status string `json:"status"`
SubscriptionType string `json:"subscription_type,omitempty"`
}
)
func NewIAPResponse(receipt string, production bool) (*IAPResponse, error) {
ctx := context.Background()
// Send the receipt data string off to Apple's servers to verify
appleVerificationURL := "https://buy.itunes.apple.com/verifyReceipt"
if !production {
appleVerificationURL = "https://sandbox.itunes.apple.com/verifyReceipt"
}
verificationPayload := map[string]string{
"receipt-data": receipt,
"password": "***REMOVED***",
}
bb, err := json.Marshal(verificationPayload)
if err != nil {
return nil, err
}
request, requestErr := http.NewRequestWithContext(ctx, "POST", appleVerificationURL, bytes.NewBuffer(bb))
if requestErr != nil {
fmt.Println(requestErr)
}
request.Header.Set("Content-Type", "application/json; charset=utf-8")
client := &http.Client{
Transport: &http.Transport{
Dial: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).Dial,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
IdleConnTimeout: 10 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
},
}
resp, err := client.Do(request)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
fmt.Printf("Weird HTTP status code from Apple: %d\n", resp.StatusCode)
}
decoder := json.NewDecoder(resp.Body)
iapr := &IAPResponse{}
err = decoder.Decode(iapr)
if err != nil {
return nil, err
}
if iapr.Status == 21007 {
// This is a sandbox receipt, reattempt with sandbox verification URL
return NewIAPResponse(receipt, false)
}
iapr.handleAppleResponse()
return iapr, nil
}
func (iapr *IAPResponse) handleAppleResponse() {
// In the case the receipt is invalid or something similar, we don't want to send down empty products, as the client always expects entries for each product, then will look at the "issue" key if the receipt itself is flawed
emptyUltraProduct := VerificationProduct{Name: "ultra", Status: "NO"}
emptyProProduct := VerificationProduct{Name: "pro", Status: "NO"}
emptyCommunityIconsProduct := VerificationProduct{Name: "community_icons", Status: "NO"}
emptySPCAProduct := VerificationProduct{Name: "spca", Status: "NO"}
emptyProducts := []VerificationProduct{emptyUltraProduct, emptyProProduct, emptyCommunityIconsProduct, emptySPCAProduct}
if iapr.Status != 0 {
if iapr.Status == 21000 || iapr.Status == 21002 || iapr.Status == 21003 || iapr.Status == 21004 || iapr.Status == 21005 || iapr.Status == 21009 {
iapr.VerificationInfo = ClientVerificationInfo{Products: emptyProducts, Issue: "APPLE_ERROR"}
} else if iapr.Status == 21008 || iapr.Status == 21009 {
// Oops, we sent it to the wrong endpoint (sandbox when we should have done production, or vice-versa), redo it
} else {
iapr.VerificationInfo = ClientVerificationInfo{Products: emptyProducts, Issue: "SERVER_ERROR"}
// Other weird error, should we do something?
}
return
}
// Check if bundle IDs are correct
if iapr.Receipt.BundleID != "com.christianselig.Apollo" {
// ❌ CAN REMOVE USER FROM SERVER
iapr.VerificationInfo = ClientVerificationInfo{Products: emptyProducts, Issue: "INVALID_RECEIPT"}
iapr.DeleteDevice = true
return
}
isLifetime := iapr.hasLifetimeSubscription()
currentTimedSubscription := iapr.currentlyActiveTimedSubscription()
proStatus := iapr.hasNormalInAppPurchase("apollo_pro")
communityIconsStatus := iapr.hasNormalInAppPurchase("community_icon_pack")
spcaStatus := iapr.hasNormalInAppPurchase("com.christianselig.spcaicon")
// For sandbox environment, be more lenient (just ensure bundle ID is accurate) because otherwise you'll break
// things for TestFlight users (see: https://twitter.com/ChristianSelig/status/1414990459861098496)
// TODO(andremedeiros): let this through for now
if iapr.Environment == Sandbox && false {
ultraProduct := VerificationProduct{Name: "ultra", Status: "SANDBOX", SubscriptionType: "SANDBOX"}
proProduct := VerificationProduct{Name: "pro", Status: "SANDBOX"}
communityIconsProduct := VerificationProduct{Name: "community_icons", Status: "SANDBOX"}
spcaProduct := VerificationProduct{Name: "spca", Status: "SANDBOX"}
products := []VerificationProduct{ultraProduct, proProduct, communityIconsProduct, spcaProduct}
iapr.VerificationInfo = ClientVerificationInfo{Products: products}
return
}
proProduct := VerificationProduct{Name: "pro", Status: inAppPurchaseStatusFromCode(proStatus)}
communityIconsProduct := VerificationProduct{Name: "community_icons", Status: inAppPurchaseStatusFromCode(communityIconsStatus)}
spcaProduct := VerificationProduct{Name: "spca", Status: inAppPurchaseStatusFromCode(spcaStatus)}
if isLifetime == 1 {
if currentTimedSubscription == SubscriptionMonthly || currentTimedSubscription == SubscriptionTriMonthly || currentTimedSubscription == SubscriptionYearly {
ultraProduct := VerificationProduct{Name: "ultra", Status: "LIFETIME_SUB_STILL_ACTIVE", SubscriptionType: "LIFETIME"}
products := []VerificationProduct{ultraProduct, proProduct, communityIconsProduct, spcaProduct}
iapr.VerificationInfo = ClientVerificationInfo{Products: products}
} else {
ultraProduct := VerificationProduct{Name: "ultra", Status: "LIFETIME", SubscriptionType: "LIFETIME"}
products := []VerificationProduct{ultraProduct, proProduct, communityIconsProduct, spcaProduct}
iapr.VerificationInfo = ClientVerificationInfo{Products: products}
}
return
} else if isLifetime == 2 && currentTimedSubscription == "" {
// ❌ CAN REMOVE USER FROM SERVER
ultraProduct := VerificationProduct{Name: "ultra", Status: "REFUND", SubscriptionType: "LIFETIME"}
products := []VerificationProduct{ultraProduct, proProduct, communityIconsProduct, spcaProduct}
iapr.VerificationInfo = ClientVerificationInfo{Products: products}
iapr.DeleteDevice = true
return
}
// The receipt IS valid, now do a bit more digging to ensure further
if len(iapr.LatestReceiptInfo) > 0 && currentTimedSubscription != "" {
mostRecentTransactionIndex := 0
mostRecentTransactionTime := int64(0)
choseOne := false
// We do things a little funny. If we just grab the furthest date in the future, we could (it happened)
// prevent someone who cancelled a yearly subscription, then renewed with a monthly subscription, from
// being labeled a valid user as we just grabbed the furthest date. So grab the furthest date that isn't cancelled.
for index, transaction := range iapr.LatestReceiptInfo {
if transaction.ExpiresDateMS > mostRecentTransactionTime && transaction.CancellationReason == "" {
mostRecentTransactionIndex = index
mostRecentTransactionTime = transaction.ExpiresDateMS
choseOne = true
}
}
// BUT, in case there is no such date (they cancelled) we want to be able to communicate that to them, so
// if we didn't select any, use the old method where it'll find a cancelled one to communicate back
if !choseOne {
for index, transaction := range iapr.LatestReceiptInfo {
if transaction.ExpiresDateMS > mostRecentTransactionTime {
mostRecentTransactionIndex = index
mostRecentTransactionTime = transaction.ExpiresDateMS
}
}
}
mostRecentTransaction := iapr.LatestReceiptInfo[mostRecentTransactionIndex]
// Check if product IDs are correct
if !strings.HasPrefix(mostRecentTransaction.ProductID, "com.christianselig.apollo.sub") {
// ❌ CAN REMOVE USER FROM SERVER
iapr.VerificationInfo = ClientVerificationInfo{Products: emptyProducts, Issue: "INVALID_RECEIPT"}
iapr.DeleteDevice = true
return
}
// Check if Apple Customer Service cancelled subscription for user (and why)
if mostRecentTransaction.CancellationReason == "0" || mostRecentTransaction.CancellationReason == "1" {
// ❌ CAN REMOVE USER FROM SERVER
ultraProduct := VerificationProduct{Name: "ultra", Status: "REFUND", SubscriptionType: currentTimedSubscription}
products := []VerificationProduct{ultraProduct, proProduct, communityIconsProduct, spcaProduct}
iapr.VerificationInfo = ClientVerificationInfo{Products: products}
iapr.DeleteDevice = true
return
}
// Comes in milliseconds, convert to normal seconds
mostRecentTransactionUnixTimestamp := mostRecentTransaction.ExpiresDateMS / 1000
// Check if it's not active
currentTimeUnixTimestamp := time.Now().Unix()
if mostRecentTransactionUnixTimestamp < currentTimeUnixTimestamp {
if len(iapr.PendingRenewalInfo) > 0 && iapr.PendingRenewalInfo[0].SubscriptionAutoRenewStatus == "0" {
// Expired and user disabled auto-renew
// ❌ CAN REMOVE USER FROM SERVER
ultraProduct := VerificationProduct{Name: "ultra", Status: "INACTIVE_DID_NOT_RENEW", SubscriptionType: currentTimedSubscription}
products := []VerificationProduct{ultraProduct, proProduct, communityIconsProduct, spcaProduct}
iapr.VerificationInfo = ClientVerificationInfo{Products: products}
iapr.DeleteDevice = true
return
}
if len(iapr.PendingRenewalInfo) > 0 && iapr.PendingRenewalInfo[0].SubscriptionRetryFlag == "1" {
// Apple is still trying to rebill, so consider this their grace period
// Note: this also encompasses the official Apple "grace period" feature that Apollo enabled, but as it's only 16 days and the billing retry period is 60, our leniency with the billing retry period fully encompasses the grace period as well
ultraProduct := VerificationProduct{Name: "ultra", Status: "ACTIVE_GRACE_PERIOD", SubscriptionType: currentTimedSubscription}
products := []VerificationProduct{ultraProduct, proProduct, communityIconsProduct, spcaProduct}
iapr.VerificationInfo = ClientVerificationInfo{Products: products}
} else {
// Billing retry period is over, so subscription is inactive due to a billing issue
// ❌ CAN REMOVE USER FROM SERVER
ultraProduct := VerificationProduct{Name: "ultra", Status: "INACTIVE_BILLING_ISSUE", SubscriptionType: currentTimedSubscription}
products := []VerificationProduct{ultraProduct, proProduct, communityIconsProduct, spcaProduct}
iapr.VerificationInfo = ClientVerificationInfo{Products: products}
iapr.DeleteDevice = true
}
return
}
// We've passed all the checks, return a thumbs up
if len(iapr.PendingRenewalInfo) > 0 && iapr.PendingRenewalInfo[0].SubscriptionAutoRenewStatus == "1" {
// They're auto-renewing! Indicate this
ultraProduct := VerificationProduct{Name: "ultra", Status: "ACTIVE_AUTORENEW_ON", SubscriptionType: currentTimedSubscription}
products := []VerificationProduct{ultraProduct, proProduct, communityIconsProduct, spcaProduct}
iapr.VerificationInfo = ClientVerificationInfo{Products: products}
return
} else {
// They're NOT auto renewing
// If they're within 8 days of it expiring because of this, indicate so
eightDaysInSeconds := int64(60 * 60 * 24 * 8)
if mostRecentTransactionUnixTimestamp-currentTimeUnixTimestamp < eightDaysInSeconds {
ultraProduct := VerificationProduct{Name: "ultra", Status: "ACTIVE_AUTORENEW_OFF_CLOSE_EXPIRY", SubscriptionType: currentTimedSubscription}
products := []VerificationProduct{ultraProduct, proProduct, communityIconsProduct, spcaProduct}
iapr.VerificationInfo = ClientVerificationInfo{Products: products}
} else {
ultraProduct := VerificationProduct{Name: "ultra", Status: "ACTIVE_AUTORENEW_OFF_DISTANT_EXPIRY", SubscriptionType: currentTimedSubscription}
products := []VerificationProduct{ultraProduct, proProduct, communityIconsProduct, spcaProduct}
iapr.VerificationInfo = ClientVerificationInfo{Products: products}
}
return
}
} else {
// We get here if they have no currently active timed subscription (ie: monthly, annually) AND no lifetime unlock
if len(iapr.PendingRenewalInfo) > 0 && iapr.PendingRenewalInfo[0].SubscriptionExpirationIntent != "" {
// If expiration intent has a value, it means the user let their subscription not renew and it's properly expired.
// Since the previous `currentlyActiveTimedSubscription` function also found no active other subscriptions, it
// stands to reason that there are also no other valid pending subscriptions, so we can safely say they expired
if iapr.PendingRenewalInfo[0].SubscriptionExpirationIntent == "2" {
// Billing issue
// ❌ CAN REMOVE USER FROM SERVER
ultraProduct := VerificationProduct{Name: "ultra", Status: "INACTIVE_BILLING_ISSUE"}
products := []VerificationProduct{ultraProduct, proProduct, communityIconsProduct, spcaProduct}
iapr.VerificationInfo = ClientVerificationInfo{Products: products}
iapr.DeleteDevice = true
} else {
// Cancelled for some other reason
// ❌ CAN REMOVE USER FROM SERVER
ultraProduct := VerificationProduct{Name: "ultra", Status: "INACTIVE_DID_NOT_RENEW"}
products := []VerificationProduct{ultraProduct, proProduct, communityIconsProduct, spcaProduct}
iapr.VerificationInfo = ClientVerificationInfo{Products: products}
iapr.DeleteDevice = true
}
return
} else {
// ❌ CAN REMOVE USER FROM SERVER
ultraProduct := VerificationProduct{Name: "ultra", Status: "NO"}
products := []VerificationProduct{ultraProduct, proProduct, communityIconsProduct, spcaProduct}
iapr.VerificationInfo = ClientVerificationInfo{Products: products}
iapr.DeleteDevice = true
return
}
}
}
func inAppPurchaseStatusFromCode(code int) string {
if code == 0 {
return "NO"
} else if code == 1 {
return "LIFETIME"
} else {
return "REFUND"
}
}
func (iapr *IAPResponse) hasNormalInAppPurchase(prefix string) int {
// Returns 0 if false, 1 if true, 2 if false because cancelled by customer service
// Check through all of them, in two stages, because they might have refunded Pro but bought it again later, so look for at least one
for _, transaction := range iapr.LatestReceiptInfo {
if strings.HasPrefix(transaction.ProductID, prefix) && transaction.CancellationReason == "" {
return 1
}
}
// If we got here, there's no non-cancelled Pro purchases on the receipt, so now check if there's any cancelled ones and return if so
for _, transaction := range iapr.LatestReceiptInfo {
if strings.HasPrefix(transaction.ProductID, prefix) {
if transaction.CancellationReason != "" {
return 2
}
}
}
return 0
}
func (iapr *IAPResponse) hasLifetimeSubscription() int {
// return 0 if true, 1 if false, 2 if false because it was cancelled by customer service
// return 0 if false, 1 if true, 2 if false because it was cancelled by customer service
// -1 is unknown (beginning value)
var tentativeValue = -1
for _, transaction := range iapr.LatestReceiptInfo {
if transaction.ProductID == "com.christianselig.apollo.ultra.lifetime" {
if transaction.CancellationReason == "0" || transaction.CancellationReason == "1" {
// Protect against the case that they have one Ultra purchase refunded, but another one that wasn't, we don't want the first refund to negate the fact they legitimately bought it the second time
if tentativeValue != 1 {
tentativeValue = 2
}
} else {
tentativeValue = 1
}
}
}
for _, transaction := range iapr.Receipt.InApp {
if transaction.ProductID == "com.christianselig.apollo.ultra.lifetime" {
if transaction.CancellationReason == "0" || transaction.CancellationReason == "1" {
if tentativeValue != 1 {
tentativeValue = 2
}
} else {
tentativeValue = 1
}
}
}
if tentativeValue == -1 {
return 0
} else {
return tentativeValue
}
}
func (iapr *IAPResponse) currentlyActiveTimedSubscription() string {
if len(iapr.PendingRenewalInfo) == 0 {
return ""
}
timedStatus := ""
for _, info := range iapr.PendingRenewalInfo {
if info.SubscriptionExpirationIntent != "" {
timedStatus = ""
} else if info.SubscriptionAutoRenewProductID == "com.christianselig.apollo.sub.monthly" {
timedStatus = SubscriptionMonthly
break
} else if info.SubscriptionAutoRenewProductID == "com.christianselig.apollo.sub.yearly" {
timedStatus = SubscriptionYearly
break
}
}
return timedStatus
}