123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239 |
- package controller
- import (
- "context"
- "errors"
- "github.com/ente-io/museum/pkg/controller/commonbilling"
- "github.com/ente-io/museum/pkg/repo/storagebonus"
- "os"
- "github.com/ente-io/stacktrace"
- log "github.com/sirupsen/logrus"
- "github.com/awa/go-iap/playstore"
- "github.com/ente-io/museum/ente"
- "github.com/ente-io/museum/pkg/repo"
- "github.com/ente-io/museum/pkg/utils/config"
- "github.com/ente-io/museum/pkg/utils/email"
- "google.golang.org/api/androidpublisher/v3"
- )
- // PlayStoreController provides abstractions for handling billing on AppStore
- type PlayStoreController struct {
- PlayStoreClient *playstore.Client
- BillingRepo *repo.BillingRepository
- FileRepo *repo.FileRepository
- UserRepo *repo.UserRepository
- StorageBonusRepo *storagebonus.Repository
- BillingPlansPerCountry ente.BillingPlansPerCountry
- CommonBillCtrl *commonbilling.Controller
- }
- // PlayStorePackageName is the package name of the PlayStore item
- const PlayStorePackageName = "io.ente.photos"
- // Return a new instance of PlayStoreController
- func NewPlayStoreController(
- plans ente.BillingPlansPerCountry,
- billingRepo *repo.BillingRepository,
- fileRepo *repo.FileRepository,
- userRepo *repo.UserRepository,
- storageBonusRepo *storagebonus.Repository,
- commonBillCtrl *commonbilling.Controller,
- ) *PlayStoreController {
- playStoreClient, err := newPlayStoreClient()
- if err != nil {
- log.Fatal(err)
- }
- // We don't do nil checks for playStoreClient in the definitions of these
- // methods - if they're getting called, that means we're not in a test
- // environment and so playStoreClient really should've been there.
- return &PlayStoreController{
- PlayStoreClient: playStoreClient,
- BillingRepo: billingRepo,
- FileRepo: fileRepo,
- UserRepo: userRepo,
- BillingPlansPerCountry: plans,
- StorageBonusRepo: storageBonusRepo,
- CommonBillCtrl: commonBillCtrl,
- }
- }
- func newPlayStoreClient() (*playstore.Client, error) {
- playStoreCredentialsFile, err := config.CredentialFilePath("pst-service-account.json")
- if err != nil {
- return nil, stacktrace.Propagate(err, "")
- }
- if playStoreCredentialsFile == "" {
- // Can happen when running locally
- return nil, nil
- }
- jsonKey, err := os.ReadFile(playStoreCredentialsFile)
- if err != nil {
- return nil, stacktrace.Propagate(err, "")
- }
- playStoreClient, err := playstore.New(jsonKey)
- if err != nil {
- return nil, stacktrace.Propagate(err, "")
- }
- return playStoreClient, nil
- }
- // HandleNotification handles a PlayStore notification
- func (c *PlayStoreController) HandleNotification(notification playstore.DeveloperNotification) error {
- transactionID := notification.SubscriptionNotification.PurchaseToken
- productID := notification.SubscriptionNotification.SubscriptionID
- purchase, err := c.verifySubscription(productID, transactionID)
- if err != nil {
- return stacktrace.Propagate(err, "")
- }
- originalTransactionID := transactionID
- if purchase.LinkedPurchaseToken != "" {
- originalTransactionID = purchase.LinkedPurchaseToken
- }
- subscription, err := c.BillingRepo.GetSubscriptionForTransaction(originalTransactionID, ente.PlayStore)
- if err != nil {
- // First subscription, no user to link to
- log.Warn("Could not find transaction against " + originalTransactionID)
- log.Error(err)
- return nil
- }
- switch notification.SubscriptionNotification.NotificationType {
- case playstore.SubscriptionNotificationTypeExpired:
- user, err := c.UserRepo.Get(subscription.UserID)
- if err != nil {
- if errors.Is(err, ente.ErrUserDeleted) {
- // no-op user has already been deleted
- return nil
- }
- return stacktrace.Propagate(err, "")
- }
- // send deletion email for folks who are either on individual plan or admin of a family plan
- if user.FamilyAdminID == nil || *user.FamilyAdminID == subscription.UserID {
- storage, surpErr := c.StorageBonusRepo.GetPaidAddonSurplusStorage(context.Background(), subscription.UserID)
- if surpErr != nil {
- return stacktrace.Propagate(surpErr, "")
- }
- if storage == nil || *storage <= 0 {
- err = email.SendTemplatedEmail([]string{user.Email}, "ente", "support@ente.io",
- ente.SubscriptionEndedEmailSubject,
- ente.SubscriptionEndedEmailTemplate, map[string]interface{}{}, nil)
- if err != nil {
- return stacktrace.Propagate(err, "")
- }
- } else {
- log.WithField("storage", storage).Info("User has surplus storage, not sending email")
- }
- }
- // TODO: Add cron to delete files of users with expired subscriptions
- case playstore.SubscriptionNotificationTypeAccountHold:
- user, err := c.UserRepo.Get(subscription.UserID)
- if err != nil {
- return stacktrace.Propagate(err, "")
- }
- err = email.SendTemplatedEmail([]string{user.Email}, "ente", "support@ente.io",
- ente.AccountOnHoldEmailSubject,
- ente.OnHoldTemplate, map[string]interface{}{
- "PaymentProvider": "PlayStore",
- }, nil)
- if err != nil {
- return stacktrace.Propagate(err, "")
- }
- case playstore.SubscriptionNotificationTypeCanceled:
- err := c.BillingRepo.UpdateSubscriptionCancellationStatus(subscription.UserID, true)
- if err != nil {
- return stacktrace.Propagate(err, "")
- }
- }
- if transactionID != originalTransactionID { // Upgrade, Downgrade or Resubscription
- var newPlan ente.BillingPlan
- plans := c.BillingPlansPerCountry["EU"] // Country code is irrelevant since Storage will be the same for a given subscriptionID
- for _, plan := range plans {
- if plan.AndroidID == productID {
- newPlan = plan
- break
- }
- }
- if newPlan.Storage < subscription.Storage { // Downgrade
- canDowngrade, canDowngradeErr := c.CommonBillCtrl.CanDowngradeToGivenStorage(newPlan.Storage, subscription.UserID)
- if canDowngradeErr != nil {
- return stacktrace.Propagate(canDowngradeErr, "")
- }
- if !canDowngrade {
- return stacktrace.Propagate(ente.ErrCannotDowngrade, "")
- }
- log.Info("Usage is good")
- }
- newSubscription := ente.Subscription{
- Storage: newPlan.Storage,
- ExpiryTime: purchase.ExpiryTimeMillis * 1000,
- ProductID: productID,
- PaymentProvider: ente.AppStore,
- OriginalTransactionID: originalTransactionID,
- Attributes: ente.SubscriptionAttributes{LatestVerificationData: transactionID},
- }
- err = c.BillingRepo.ReplaceSubscription(
- subscription.ID,
- newSubscription,
- )
- if err != nil {
- return stacktrace.Propagate(err, "")
- }
- err = c.AcknowledgeSubscription(productID, transactionID)
- if err != nil {
- return stacktrace.Propagate(err, "")
- }
- } else {
- err = c.BillingRepo.UpdateSubscriptionExpiryTime(
- subscription.ID, purchase.ExpiryTimeMillis*1000)
- if err != nil {
- return stacktrace.Propagate(err, "")
- }
- }
- return c.BillingRepo.LogPlayStorePush(subscription.UserID, notification, *purchase)
- }
- // GetVerifiedSubscription verifies and returns the verified subscription
- func (c *PlayStoreController) GetVerifiedSubscription(userID int64, productID string, verificationData string) (ente.Subscription, error) {
- var s ente.Subscription
- s.UserID = userID
- s.ProductID = productID
- s.PaymentProvider = ente.PlayStore
- s.Attributes.LatestVerificationData = verificationData
- plans := c.BillingPlansPerCountry["EU"] // Country code is irrelevant since Storage will be the same for a given subscriptionID
- response, err := c.verifySubscription(productID, verificationData)
- if err != nil {
- return ente.Subscription{}, stacktrace.Propagate(err, "")
- }
- for _, plan := range plans {
- if plan.AndroidID == productID {
- s.Storage = plan.Storage
- break
- }
- }
- s.OriginalTransactionID = verificationData
- s.ExpiryTime = response.ExpiryTimeMillis * 1000
- return s, nil
- }
- // AcknowledgeSubscription acknowledges a subscription to PlayStore
- func (c *PlayStoreController) AcknowledgeSubscription(subscriptionID string, token string) error {
- req := &androidpublisher.SubscriptionPurchasesAcknowledgeRequest{}
- context := context.Background()
- return c.PlayStoreClient.AcknowledgeSubscription(context, PlayStorePackageName, subscriptionID, token, req)
- }
- // CancelSubscription cancels a PlayStore subscription
- func (c *PlayStoreController) CancelSubscription(subscriptionID string, verificationData string) error {
- context := context.Background()
- return c.PlayStoreClient.CancelSubscription(context, PlayStorePackageName, subscriptionID, verificationData)
- }
- func (c *PlayStoreController) verifySubscription(subscriptionID string, verificationData string) (*androidpublisher.SubscriptionPurchase, error) {
- context := context.Background()
- return c.PlayStoreClient.VerifySubscription(context, PlayStorePackageName, subscriptionID, verificationData)
- }
|