From aa0827867068d1d916da1a80f6e5600c5a07e7d0 Mon Sep 17 00:00:00 2001 From: Andre Medeiros Date: Sat, 11 Sep 2021 10:53:19 -0400 Subject: [PATCH] receipts and device active until --- internal/api/devices.go | 4 +- internal/api/receipt.go | 77 ++-- internal/domain/device.go | 13 +- internal/itunes/receipt.go | 546 +++++++++++++++++++++++++ internal/repository/postgres_device.go | 22 +- 5 files changed, 623 insertions(+), 39 deletions(-) create mode 100644 internal/itunes/receipt.go diff --git a/internal/api/devices.go b/internal/api/devices.go index 2b90578..9afd111 100644 --- a/internal/api/devices.go +++ b/internal/api/devices.go @@ -28,7 +28,7 @@ func (a *api) upsertDeviceHandler(w http.ResponseWriter, r *http.Request) { return } - d.LastPingedAt = time.Now().Unix() + d.ActiveUntil = time.Now().Unix() + domain.DeviceGracePeriodDuration if err := a.deviceRepo.CreateOrUpdate(ctx, d); err != nil { a.errorResponse(w, r, 500, err.Error()) @@ -110,5 +110,7 @@ func (a *api) deleteDeviceHandler(w http.ResponseWriter, r *http.Request) { a.accountRepo.Disassociate(ctx, &acc, &dev) } + a.deviceRepo.Delete(ctx, vars["apns"]) + w.WriteHeader(http.StatusOK) } diff --git a/internal/api/receipt.go b/internal/api/receipt.go index b080789..5eefe76 100644 --- a/internal/api/receipt.go +++ b/internal/api/receipt.go @@ -1,32 +1,63 @@ package api import ( + "context" + "encoding/json" + "io/ioutil" "net/http" + "time" + + "github.com/gorilla/mux" + "github.com/sirupsen/logrus" + + "github.com/christianselig/apollo-backend/internal/domain" + "github.com/christianselig/apollo-backend/internal/itunes" ) -const receiptResponse = `{ - "products": [ - { - "name": "ultra", - "status": "SANDBOX", - "subscription_type": "SANDBOX" - }, - { - "name": "pro", - "status": "SANDBOX" - }, - { - "name": "community_icons", - "status": "SANDBOX" - }, - { - "name": "spca", - "status": "SANDBOX" - } - ] -}` - func (a *api) checkReceiptHandler(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + + vars := mux.Vars(r) + apns := vars["apns"] + + body, err := ioutil.ReadAll(r.Body) + iapr, err := itunes.NewIAPResponse(string(body), true) + + if err != nil { + a.logger.WithFields(logrus.Fields{ + "err": err, + }).Info("failed verifying receipt") + a.errorResponse(w, r, 500, err.Error()) + return + } + + if apns != "" { + dev, err := a.deviceRepo.GetByAPNSToken(ctx, apns) + if err != nil { + a.errorResponse(w, r, 500, err.Error()) + return + } + + if iapr.DeleteDevice { + accs, err := a.accountRepo.GetByAPNSToken(ctx, apns) + if err != nil { + a.errorResponse(w, r, 500, err.Error()) + return + } + + for _, acc := range accs { + a.accountRepo.Disassociate(ctx, &acc, &dev) + } + + a.deviceRepo.Delete(ctx, apns) + } else { + dev.ActiveUntil = time.Now().Unix() + domain.DeviceActiveAfterReceitCheckDuration + a.deviceRepo.Update(ctx, &dev) + } + } + w.WriteHeader(http.StatusOK) - w.Write([]byte(receiptResponse)) + + bb, _ := json.Marshal(iapr.VerificationInfo) + w.Write(bb) } diff --git a/internal/domain/device.go b/internal/domain/device.go index 51cc979..ccdbcfa 100644 --- a/internal/domain/device.go +++ b/internal/domain/device.go @@ -2,11 +2,16 @@ package domain import "context" +const ( + DeviceGracePeriodDuration = 3600 // 1 hour + DeviceActiveAfterReceitCheckDuration = 3600 * 24 * 7 // 1 week +) + type Device struct { - ID int64 - APNSToken string - Sandbox bool - LastPingedAt int64 + ID int64 + APNSToken string + Sandbox bool + ActiveUntil int64 } type DeviceRepository interface { diff --git a/internal/itunes/receipt.go b/internal/itunes/receipt.go new file mode 100644 index 0000000..6028848 --- /dev/null +++ b/internal/itunes/receipt.go @@ -0,0 +1,546 @@ +package itunes + +import ( + "bytes" + "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) { + // 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.NewRequest("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.Println(fmt.Sprintf("Weird HTTP status code from Apple: %d", 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 + choseOne = true + } + } + } + + 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 := int64(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 != "" || info.SubscriptionAutoRenewStatus == "0" { + 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 +} diff --git a/internal/repository/postgres_device.go b/internal/repository/postgres_device.go index 2adb0ce..ce9aa01 100644 --- a/internal/repository/postgres_device.go +++ b/internal/repository/postgres_device.go @@ -31,7 +31,7 @@ func (p *postgresDeviceRepository) fetch(ctx context.Context, query string, args &dev.ID, &dev.APNSToken, &dev.Sandbox, - &dev.LastPingedAt, + &dev.ActiveUntil, ); err != nil { return nil, err } @@ -42,7 +42,7 @@ func (p *postgresDeviceRepository) fetch(ctx context.Context, query string, args func (p *postgresDeviceRepository) GetByAPNSToken(ctx context.Context, token string) (domain.Device, error) { query := ` - SELECT id, apns_token, sandbox, last_pinged_at + SELECT id, apns_token, sandbox, active_until FROM devices WHERE apns_token = $1` @@ -59,7 +59,7 @@ func (p *postgresDeviceRepository) GetByAPNSToken(ctx context.Context, token str func (p *postgresDeviceRepository) GetByAccountID(ctx context.Context, id int64) ([]domain.Device, error) { query := ` - SELECT devices.id, apns_token, sandbox, last_pinged_at + SELECT devices.id, apns_token, sandbox, active_until FROM devices INNER JOIN devices_accounts ON devices.id = devices_accounts.device_id WHERE devices_accounts.account_id = $1` @@ -69,10 +69,10 @@ func (p *postgresDeviceRepository) GetByAccountID(ctx context.Context, id int64) func (p *postgresDeviceRepository) CreateOrUpdate(ctx context.Context, dev *domain.Device) error { query := ` - INSERT INTO devices (apns_token, sandbox, last_pinged_at) + INSERT INTO devices (apns_token, sandbox, active_until) VALUES ($1, $2, $3) ON CONFLICT(apns_token) DO - UPDATE SET last_pinged_at = $3 + UPDATE SET active_until = $3 RETURNING id` return p.pool.QueryRow( @@ -80,14 +80,14 @@ func (p *postgresDeviceRepository) CreateOrUpdate(ctx context.Context, dev *doma query, dev.APNSToken, dev.Sandbox, - dev.LastPingedAt, + dev.ActiveUntil, ).Scan(&dev.ID) } func (p *postgresDeviceRepository) Create(ctx context.Context, dev *domain.Device) error { query := ` INSERT INTO devices - (apns_token, sandbox, last_pinged_at) + (apns_token, sandbox, active_until) VALUES ($1, $2, $3) RETURNING id` @@ -96,17 +96,17 @@ func (p *postgresDeviceRepository) Create(ctx context.Context, dev *domain.Devic query, dev.APNSToken, dev.Sandbox, - dev.LastPingedAt, + dev.ActiveUntil, ).Scan(&dev.ID) } func (p *postgresDeviceRepository) Update(ctx context.Context, dev *domain.Device) error { query := ` UPDATE devices - SET last_pinged_at = $2 + SET active_until = $2 WHERE id = $1` - res, err := p.pool.Exec(ctx, query, dev.ID, dev.LastPingedAt) + res, err := p.pool.Exec(ctx, query, dev.ID, dev.ActiveUntil) if res.RowsAffected() != 1 { return fmt.Errorf("weird behaviour, total rows affected: %d", res.RowsAffected()) @@ -129,7 +129,7 @@ func (p *postgresDeviceRepository) PruneStale(ctx context.Context, before int64) query := ` WITH deleted_devices AS ( DELETE FROM devices - WHERE last_pinged_at < $1 + WHERE active_until < $1 RETURNING id ) DELETE FROM devices_accounts