diff --git a/docs/SUBSCRIPTION_WEBHOOKS.md b/docs/SUBSCRIPTION_WEBHOOKS.md new file mode 100644 index 0000000..5297962 --- /dev/null +++ b/docs/SUBSCRIPTION_WEBHOOKS.md @@ -0,0 +1,247 @@ +# Subscription Webhook Setup + +This document explains how to configure Apple App Store Server Notifications and Google Real-time Developer Notifications for server-to-server subscription status updates. + +## Overview + +The webhook endpoints allow Apple and Google to notify your server when subscription events occur: +- New subscriptions +- Renewals +- Cancellations +- Refunds +- Expirations +- Grace period events + +**Webhook Endpoints:** +- Apple: `POST /api/subscription/webhook/apple/` +- Google: `POST /api/subscription/webhook/google/` + +## Apple App Store Server Notifications v2 + +### 1. Configure in App Store Connect + +1. Log in to [App Store Connect](https://appstoreconnect.apple.com) +2. Navigate to **My Apps** > Select your app +3. Go to **App Information** (under General) +4. Scroll to **App Store Server Notifications** +5. Enter your webhook URLs: + - **Production Server URL**: `https://your-domain.com/api/subscription/webhook/apple/` + - **Sandbox Server URL**: `https://your-domain.com/api/subscription/webhook/apple/` (or separate staging URL) +6. Select **Version 2** for the notification version + +### 2. Environment Variables + +Set these environment variables on your server: + +```bash +# Apple IAP Configuration (optional but recommended for validation) +APPLE_IAP_KEY_PATH=/path/to/AuthKey_XXXXX.p8 # Your App Store Connect API key +APPLE_IAP_KEY_ID=XXXXXXXXXX # Key ID from App Store Connect +APPLE_IAP_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Issuer ID from App Store Connect +APPLE_IAP_BUNDLE_ID=com.your.app.bundleid # Your app's bundle ID +APPLE_IAP_SANDBOX=true # Set to false for production +``` + +### 3. Generate App Store Connect API Key + +To enable server-side receipt validation: + +1. Go to [App Store Connect > Users and Access > Keys](https://appstoreconnect.apple.com/access/api) +2. Click **Generate API Key** +3. Give it a name (e.g., "Casera IAP Server") +4. Select **App Manager** role (minimum required for IAP) +5. Download the `.p8` file - **you can only download it once!** +6. Note the **Key ID** and **Issuer ID** + +### 4. Apple Notification Types + +Your server will receive these notification types: + +| Type | Description | Action | +|------|-------------|--------| +| `SUBSCRIBED` | New subscription | Upgrade user to Pro | +| `DID_RENEW` | Subscription renewed | Extend expiry date | +| `DID_CHANGE_RENEWAL_STATUS` | Auto-renew toggled | Update auto_renew flag | +| `DID_FAIL_TO_RENEW` | Billing failed | User may be in grace period | +| `EXPIRED` | Subscription expired | Downgrade to Free | +| `REFUND` | User got refund | Downgrade to Free | +| `REVOKE` | Access revoked | Downgrade to Free | +| `GRACE_PERIOD_EXPIRED` | Grace period ended | Downgrade to Free | + +--- + +## Google Real-time Developer Notifications + +Google uses Cloud Pub/Sub to deliver subscription notifications. Setup requires creating a Pub/Sub topic and subscription. + +### 1. Create a Pub/Sub Topic + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Select your project (or create one) +3. Navigate to **Pub/Sub** > **Topics** +4. Click **Create Topic** +5. Name it (e.g., `casera-subscriptions`) +6. Note the full topic name: `projects/your-project-id/topics/casera-subscriptions` + +### 2. Create a Push Subscription + +1. In the Pub/Sub Topics list, click on your topic +2. Click **Create Subscription** +3. Configure: + - **Subscription ID**: `casera-subscription-webhook` + - **Delivery type**: Push + - **Endpoint URL**: `https://your-domain.com/api/subscription/webhook/google/` + - **Acknowledgment deadline**: 60 seconds +4. Click **Create** + +### 3. Link to Google Play Console + +1. Go to [Google Play Console](https://play.google.com/console) +2. Select your app +3. Navigate to **Monetization** > **Monetization setup** +4. Under **Real-time developer notifications**: + - Enter your topic name: `projects/your-project-id/topics/casera-subscriptions` +5. Click **Save** +6. Click **Send test notification** to verify the connection + +### 4. Create a Service Account for Validation + +1. Go to [Google Cloud Console > IAM & Admin > Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts) +2. Click **Create Service Account** +3. Name it (e.g., `casera-iap-validator`) +4. Grant roles: + - `Pub/Sub Subscriber` (for webhook handling) +5. Click **Done** +6. Click on the service account > **Keys** > **Add Key** > **Create new key** +7. Select **JSON** and download the key file + +### 5. Link Play Console to Cloud Project + +1. Go to **Google Play Console** > **Setup** > **API access** +2. Link your Google Cloud project +3. Under **Service accounts**, grant your service account access: + - `View financial data` (for subscription validation) + +### 6. Environment Variables + +```bash +# Google IAP Configuration +GOOGLE_IAP_SERVICE_ACCOUNT_PATH=/path/to/service-account.json +GOOGLE_IAP_PACKAGE_NAME=com.your.app.packagename +``` + +### 7. Google Notification Types + +Your server will receive these notification types: + +| Type Code | Type | Description | Action | +|-----------|------|-------------|--------| +| 1 | `SUBSCRIPTION_RECOVERED` | Recovered from hold | Re-upgrade to Pro | +| 2 | `SUBSCRIPTION_RENEWED` | Subscription renewed | Extend expiry date | +| 3 | `SUBSCRIPTION_CANCELED` | User canceled | Mark as canceled, will expire | +| 4 | `SUBSCRIPTION_PURCHASED` | New purchase | Upgrade to Pro | +| 5 | `SUBSCRIPTION_ON_HOLD` | Account hold | User may lose access soon | +| 6 | `SUBSCRIPTION_IN_GRACE_PERIOD` | In grace period | User still has access | +| 7 | `SUBSCRIPTION_RESTARTED` | User resubscribed | Re-upgrade to Pro | +| 12 | `SUBSCRIPTION_REVOKED` | Subscription revoked | Downgrade to Free | +| 13 | `SUBSCRIPTION_EXPIRED` | Subscription expired | Downgrade to Free | + +--- + +## Testing + +### Test Apple Webhooks + +1. Use the **Sandbox** environment in App Store Connect +2. Create a Sandbox tester account +3. Make test purchases on a test device +4. Monitor your server logs for webhook events + +### Test Google Webhooks + +1. In Google Play Console > **Monetization setup** +2. Click **Send test notification** +3. Your server should receive a test notification +4. Check server logs for: `"Google Webhook: Received test notification"` + +### Local Development Testing + +For local testing, use a tunnel service like ngrok: + +```bash +# Start ngrok +ngrok http 8000 + +# Use the ngrok URL for webhook configuration +# e.g., https://abc123.ngrok.io/api/subscription/webhook/apple/ +``` + +--- + +## Security Considerations + +### Apple Webhook Verification + +The webhook handler includes optional JWS signature verification. Apple signs all notifications using their certificate chain. While the current implementation decodes the payload without full verification, you can enable verification by: + +1. Downloading Apple's root certificates from https://www.apple.com/certificateauthority/ +2. Calling `VerifyAppleSignature()` before processing + +### Google Webhook Security + +Options for securing Google webhooks: + +1. **IP Whitelisting**: Google publishes [Pub/Sub IP ranges](https://cloud.google.com/pubsub/docs/reference/service_apis_overview#ip-ranges) +2. **Push Authentication**: Configure your Pub/Sub subscription to include an authentication header +3. **OIDC Token**: Have Pub/Sub include an OIDC token that your server verifies + +### General Security + +- Always use HTTPS for webhook endpoints +- Validate the bundle ID / package name in each notification +- Log all webhook events for debugging and audit +- Return 200 OK even if processing fails (to prevent retries flooding your server) + +--- + +## Troubleshooting + +### Apple Webhooks Not Arriving + +1. Verify the URL is correct in App Store Connect +2. Check your server is reachable from the internet +3. Ensure you're testing with a Sandbox account +4. Check server logs for any processing errors + +### Google Webhooks Not Arriving + +1. Verify the topic name format: `projects/PROJECT_ID/topics/TOPIC_NAME` +2. Check the Pub/Sub subscription is active +3. Test with "Send test notification" in Play Console +4. Check Cloud Logging for Pub/Sub delivery errors + +### User Not Found for Webhook + +This typically means: +- The user made a purchase but the receipt/token wasn't stored +- The transaction ID or purchase token doesn't match any stored data + +The handler logs these cases but returns success to prevent Apple/Google from retrying. + +--- + +## Database Schema + +Relevant fields in `user_subscriptions` table: + +```sql +apple_receipt_data TEXT -- Stores receipt/transaction ID for Apple +google_purchase_token TEXT -- Stores purchase token for Google +cancelled_at TIMESTAMP -- When user canceled (will expire at end of period) +auto_renew BOOLEAN -- Whether subscription will auto-renew +``` + +These fields are used by webhook handlers to: +1. Find the user associated with a transaction +2. Track cancellation state +3. Update renewal preferences diff --git a/go.mod b/go.mod index 9222ea8..97cfd72 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/treytartt/casera-api -go 1.23.0 - -toolchain go1.23.12 +go 1.24.0 require ( github.com/gin-contrib/cors v1.7.3 @@ -19,9 +17,11 @@ require ( github.com/shopspring/decimal v1.4.0 github.com/sideshow/apns2 v0.25.0 github.com/spf13/viper v1.20.1 - github.com/stretchr/testify v1.10.0 - golang.org/x/crypto v0.40.0 - golang.org/x/text v0.27.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.45.0 + golang.org/x/oauth2 v0.34.0 + golang.org/x/text v0.31.0 + google.golang.org/api v0.257.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 @@ -29,15 +29,21 @@ require ( ) require ( + cloud.google.com/go/auth v0.17.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect @@ -45,6 +51,9 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect + github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect @@ -64,7 +73,6 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/robfig/cron/v3 v3.0.1 // indirect - github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/sagikazarmark/locafero v0.9.0 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect @@ -77,12 +85,19 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect golang.org/x/arch v0.12.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/time v0.11.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect + google.golang.org/grpc v1.77.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 00ae0ee..6cd6aca 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= +cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -21,6 +27,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -33,6 +41,11 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -53,12 +66,20 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw= @@ -118,8 +139,8 @@ github.com/redis/go-redis/v9 v9.17.1 h1:7tl732FjYPRT9H9aNfyTwKg9iTETjWjGKEJ2t/5i github.com/redis/go-redis/v9 v9.17.1/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= @@ -156,8 +177,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= @@ -170,19 +191,35 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -192,19 +229,31 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.257.0 h1:8Y0lzvHlZps53PEaw+G29SsQIkuKrumGWs9puiexNAA= +google.golang.org/api v0.257.0/go.mod h1:4eJrr+vbVaZSqs7vovFd1Jb/A6ml6iw2e6FBYf3GAO4= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= diff --git a/internal/config/config.go b/internal/config/config.go index f21c1e1..4df7824 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,6 +23,8 @@ type Config struct { Storage StorageConfig AppleAuth AppleAuthConfig GoogleAuth GoogleAuthConfig + AppleIAP AppleIAPConfig + GoogleIAP GoogleIAPConfig } type ServerConfig struct { @@ -85,6 +87,21 @@ type GoogleAuthConfig struct { IOSClientID string // iOS client ID (optional, for audience verification) } +// AppleIAPConfig holds Apple App Store Server API configuration +type AppleIAPConfig struct { + KeyPath string // Path to .p8 private key file + KeyID string // Key ID from App Store Connect + IssuerID string // Issuer ID from App Store Connect + BundleID string // App bundle ID (e.g., com.tt.casera) + Sandbox bool // Use sandbox environment for testing +} + +// GoogleIAPConfig holds Google Play Developer API configuration +type GoogleIAPConfig struct { + ServiceAccountPath string // Path to service account JSON file + PackageName string // Android package name (e.g., com.tt.casera) +} + type WorkerConfig struct { // Scheduled job times (UTC) TaskReminderHour int @@ -206,6 +223,17 @@ func Load() (*Config, error) { AndroidClientID: viper.GetString("GOOGLE_ANDROID_CLIENT_ID"), IOSClientID: viper.GetString("GOOGLE_IOS_CLIENT_ID"), }, + AppleIAP: AppleIAPConfig{ + KeyPath: viper.GetString("APPLE_IAP_KEY_PATH"), + KeyID: viper.GetString("APPLE_IAP_KEY_ID"), + IssuerID: viper.GetString("APPLE_IAP_ISSUER_ID"), + BundleID: viper.GetString("APPLE_IAP_BUNDLE_ID"), + Sandbox: viper.GetBool("APPLE_IAP_SANDBOX"), + }, + GoogleIAP: GoogleIAPConfig{ + ServiceAccountPath: viper.GetString("GOOGLE_IAP_SERVICE_ACCOUNT_PATH"), + PackageName: viper.GetString("GOOGLE_IAP_PACKAGE_NAME"), + }, } // Validate required fields @@ -267,6 +295,11 @@ func setDefaults() { viper.SetDefault("STORAGE_BASE_URL", "/uploads") viper.SetDefault("STORAGE_MAX_FILE_SIZE", 10*1024*1024) // 10MB viper.SetDefault("STORAGE_ALLOWED_TYPES", "image/jpeg,image/png,image/gif,image/webp,application/pdf") + + // Apple IAP defaults + viper.SetDefault("APPLE_IAP_SANDBOX", true) // Default to sandbox for safety + + // Google IAP defaults - no defaults needed, will fail gracefully if not configured } func validate(cfg *Config) error { diff --git a/internal/handlers/subscription_handler.go b/internal/handlers/subscription_handler.go index f1ab514..1fc28e3 100644 --- a/internal/handlers/subscription_handler.go +++ b/internal/handlers/subscription_handler.go @@ -115,17 +115,18 @@ func (h *SubscriptionHandler) ProcessPurchase(c *gin.Context) { switch req.Platform { case "ios": - if req.ReceiptData == "" { + // StoreKit 2 uses transaction_id, StoreKit 1 uses receipt_data + if req.TransactionID == "" && req.ReceiptData == "" { c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.receipt_data_required")}) return } - subscription, err = h.subscriptionService.ProcessApplePurchase(user.ID, req.ReceiptData) + subscription, err = h.subscriptionService.ProcessApplePurchase(user.ID, req.ReceiptData, req.TransactionID) case "android": if req.PurchaseToken == "" { c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.purchase_token_required")}) return } - subscription, err = h.subscriptionService.ProcessGooglePurchase(user.ID, req.PurchaseToken) + subscription, err = h.subscriptionService.ProcessGooglePurchase(user.ID, req.PurchaseToken, req.ProductID) } if err != nil { @@ -171,9 +172,9 @@ func (h *SubscriptionHandler) RestoreSubscription(c *gin.Context) { switch req.Platform { case "ios": - subscription, err = h.subscriptionService.ProcessApplePurchase(user.ID, req.ReceiptData) + subscription, err = h.subscriptionService.ProcessApplePurchase(user.ID, req.ReceiptData, req.TransactionID) case "android": - subscription, err = h.subscriptionService.ProcessGooglePurchase(user.ID, req.PurchaseToken) + subscription, err = h.subscriptionService.ProcessGooglePurchase(user.ID, req.PurchaseToken, req.ProductID) } if err != nil { diff --git a/internal/handlers/subscription_webhook_handler.go b/internal/handlers/subscription_webhook_handler.go new file mode 100644 index 0000000..b230938 --- /dev/null +++ b/internal/handlers/subscription_webhook_handler.go @@ -0,0 +1,772 @@ +package handlers + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + + "github.com/treytartt/casera-api/internal/config" + "github.com/treytartt/casera-api/internal/models" + "github.com/treytartt/casera-api/internal/repositories" +) + +// SubscriptionWebhookHandler handles subscription webhook callbacks +type SubscriptionWebhookHandler struct { + subscriptionRepo *repositories.SubscriptionRepository + userRepo *repositories.UserRepository + appleRootCerts []*x509.Certificate +} + +// NewSubscriptionWebhookHandler creates a new webhook handler +func NewSubscriptionWebhookHandler( + subscriptionRepo *repositories.SubscriptionRepository, + userRepo *repositories.UserRepository, +) *SubscriptionWebhookHandler { + return &SubscriptionWebhookHandler{ + subscriptionRepo: subscriptionRepo, + userRepo: userRepo, + } +} + +// ==================== +// Apple App Store Server Notifications v2 +// ==================== + +// AppleNotificationPayload represents the outer signed payload from Apple +type AppleNotificationPayload struct { + SignedPayload string `json:"signedPayload"` +} + +// AppleNotificationData represents the decoded notification data +type AppleNotificationData struct { + NotificationType string `json:"notificationType"` + Subtype string `json:"subtype"` + NotificationUUID string `json:"notificationUUID"` + Data AppleNotificationDataInner `json:"data"` + Version string `json:"version"` + SignedDate int64 `json:"signedDate"` +} + +// AppleNotificationDataInner contains the transaction details +type AppleNotificationDataInner struct { + AppAppleID int64 `json:"appAppleId"` + BundleID string `json:"bundleId"` + BundleVersion string `json:"bundleVersion"` + Environment string `json:"environment"` + SignedTransactionInfo string `json:"signedTransactionInfo"` + SignedRenewalInfo string `json:"signedRenewalInfo"` +} + +// AppleTransactionInfo represents decoded transaction info +type AppleTransactionInfo struct { + TransactionID string `json:"transactionId"` + OriginalTransactionID string `json:"originalTransactionId"` + ProductID string `json:"productId"` + PurchaseDate int64 `json:"purchaseDate"` + ExpiresDate int64 `json:"expiresDate"` + Type string `json:"type"` + AppAccountToken string `json:"appAccountToken"` // Your user ID if set during purchase + BundleID string `json:"bundleId"` + Environment string `json:"environment"` + RevocationDate *int64 `json:"revocationDate,omitempty"` + RevocationReason *int `json:"revocationReason,omitempty"` +} + +// AppleRenewalInfo represents subscription renewal info +type AppleRenewalInfo struct { + AutoRenewProductID string `json:"autoRenewProductId"` + AutoRenewStatus int `json:"autoRenewStatus"` // 1 = will renew, 0 = turned off + ExpirationIntent int `json:"expirationIntent"` + IsInBillingRetry bool `json:"isInBillingRetryPeriod"` +} + +// HandleAppleWebhook handles POST /api/subscription/webhook/apple/ +func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + log.Printf("Apple Webhook: Failed to read body: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"}) + return + } + + var payload AppleNotificationPayload + if err := json.Unmarshal(body, &payload); err != nil { + log.Printf("Apple Webhook: Failed to parse payload: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + + // Decode and verify the signed payload (JWS) + notification, err := h.decodeAppleSignedPayload(payload.SignedPayload) + if err != nil { + log.Printf("Apple Webhook: Failed to decode signed payload: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid signed payload"}) + return + } + + log.Printf("Apple Webhook: Received %s (subtype: %s) for bundle %s", + notification.NotificationType, notification.Subtype, notification.Data.BundleID) + + // Verify bundle ID matches our app + cfg := config.Get() + if cfg != nil && cfg.AppleIAP.BundleID != "" { + if notification.Data.BundleID != cfg.AppleIAP.BundleID { + log.Printf("Apple Webhook: Bundle ID mismatch: got %s, expected %s", + notification.Data.BundleID, cfg.AppleIAP.BundleID) + c.JSON(http.StatusBadRequest, gin.H{"error": "bundle ID mismatch"}) + return + } + } + + // Decode transaction info + transactionInfo, err := h.decodeAppleTransaction(notification.Data.SignedTransactionInfo) + if err != nil { + log.Printf("Apple Webhook: Failed to decode transaction: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid transaction info"}) + return + } + + // Decode renewal info if present + var renewalInfo *AppleRenewalInfo + if notification.Data.SignedRenewalInfo != "" { + renewalInfo, _ = h.decodeAppleRenewalInfo(notification.Data.SignedRenewalInfo) + } + + // Process the notification + if err := h.processAppleNotification(notification, transactionInfo, renewalInfo); err != nil { + log.Printf("Apple Webhook: Failed to process notification: %v", err) + // Still return 200 to prevent Apple from retrying + } + + // Always return 200 OK to acknowledge receipt + c.JSON(http.StatusOK, gin.H{"status": "received"}) +} + +// decodeAppleSignedPayload decodes and verifies an Apple JWS payload +func (h *SubscriptionWebhookHandler) decodeAppleSignedPayload(signedPayload string) (*AppleNotificationData, error) { + // JWS format: header.payload.signature + parts := strings.Split(signedPayload, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid JWS format") + } + + // Decode payload (we're trusting Apple's signature for now) + // In production, you should verify the signature using Apple's root certificate + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to decode payload: %w", err) + } + + var notification AppleNotificationData + if err := json.Unmarshal(payload, ¬ification); err != nil { + return nil, fmt.Errorf("failed to parse notification: %w", err) + } + + return ¬ification, nil +} + +// decodeAppleTransaction decodes a signed transaction info JWS +func (h *SubscriptionWebhookHandler) decodeAppleTransaction(signedTransaction string) (*AppleTransactionInfo, error) { + parts := strings.Split(signedTransaction, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid JWS format") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to decode payload: %w", err) + } + + var info AppleTransactionInfo + if err := json.Unmarshal(payload, &info); err != nil { + return nil, fmt.Errorf("failed to parse transaction info: %w", err) + } + + return &info, nil +} + +// decodeAppleRenewalInfo decodes signed renewal info JWS +func (h *SubscriptionWebhookHandler) decodeAppleRenewalInfo(signedRenewal string) (*AppleRenewalInfo, error) { + parts := strings.Split(signedRenewal, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid JWS format") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to decode payload: %w", err) + } + + var info AppleRenewalInfo + if err := json.Unmarshal(payload, &info); err != nil { + return nil, fmt.Errorf("failed to parse renewal info: %w", err) + } + + return &info, nil +} + +// processAppleNotification handles the business logic for Apple notifications +func (h *SubscriptionWebhookHandler) processAppleNotification( + notification *AppleNotificationData, + transaction *AppleTransactionInfo, + renewal *AppleRenewalInfo, +) error { + // Find user by stored receipt data (original transaction ID) + user, err := h.findUserByAppleTransaction(transaction.OriginalTransactionID) + if err != nil { + log.Printf("Apple Webhook: Could not find user for transaction %s: %v", + transaction.OriginalTransactionID, err) + // Not an error - might be a transaction we don't track + return nil + } + + log.Printf("Apple Webhook: Processing %s for user %d (product: %s)", + notification.NotificationType, user.ID, transaction.ProductID) + + switch notification.NotificationType { + case "SUBSCRIBED": + // New subscription or resubscription + return h.handleAppleSubscribed(user.ID, transaction, renewal) + + case "DID_RENEW": + // Subscription successfully renewed + return h.handleAppleRenewed(user.ID, transaction, renewal) + + case "DID_CHANGE_RENEWAL_STATUS": + // User turned auto-renew on/off + return h.handleAppleRenewalStatusChange(user.ID, transaction, renewal) + + case "DID_FAIL_TO_RENEW": + // Billing issue - subscription may still be in grace period + return h.handleAppleFailedToRenew(user.ID, transaction, renewal) + + case "EXPIRED": + // Subscription expired + return h.handleAppleExpired(user.ID, transaction) + + case "REFUND": + // User got a refund + return h.handleAppleRefund(user.ID, transaction) + + case "REVOKE": + // Family sharing revoked or refund + return h.handleAppleRevoke(user.ID, transaction) + + case "GRACE_PERIOD_EXPIRED": + // Grace period ended without successful billing + return h.handleAppleGracePeriodExpired(user.ID, transaction) + + default: + log.Printf("Apple Webhook: Unhandled notification type: %s", notification.NotificationType) + } + + return nil +} + +func (h *SubscriptionWebhookHandler) findUserByAppleTransaction(originalTransactionID string) (*models.User, error) { + // Look up user subscription by stored receipt data + subscription, err := h.subscriptionRepo.FindByAppleReceiptContains(originalTransactionID) + if err != nil { + return nil, err + } + + user, err := h.userRepo.FindByID(subscription.UserID) + if err != nil { + return nil, err + } + + return user, nil +} + +func (h *SubscriptionWebhookHandler) handleAppleSubscribed(userID uint, tx *AppleTransactionInfo, renewal *AppleRenewalInfo) error { + expiresAt := time.Unix(tx.ExpiresDate/1000, 0) + autoRenew := renewal != nil && renewal.AutoRenewStatus == 1 + + if err := h.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil { + return err + } + if err := h.subscriptionRepo.SetAutoRenew(userID, autoRenew); err != nil { + return err + } + + log.Printf("Apple Webhook: User %d subscribed, expires %v, autoRenew=%v", userID, expiresAt, autoRenew) + return nil +} + +func (h *SubscriptionWebhookHandler) handleAppleRenewed(userID uint, tx *AppleTransactionInfo, renewal *AppleRenewalInfo) error { + expiresAt := time.Unix(tx.ExpiresDate/1000, 0) + + if err := h.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil { + return err + } + + log.Printf("Apple Webhook: User %d renewed, new expiry %v", userID, expiresAt) + return nil +} + +func (h *SubscriptionWebhookHandler) handleAppleRenewalStatusChange(userID uint, tx *AppleTransactionInfo, renewal *AppleRenewalInfo) error { + if renewal == nil { + return nil + } + + autoRenew := renewal.AutoRenewStatus == 1 + if err := h.subscriptionRepo.SetAutoRenew(userID, autoRenew); err != nil { + return err + } + + if !autoRenew { + // User turned off auto-renew (will cancel at end of period) + now := time.Now().UTC() + if err := h.subscriptionRepo.SetCancelledAt(userID, now); err != nil { + return err + } + log.Printf("Apple Webhook: User %d turned off auto-renew, will expire at end of period", userID) + } else { + // User turned auto-renew back on + if err := h.subscriptionRepo.ClearCancelledAt(userID); err != nil { + return err + } + log.Printf("Apple Webhook: User %d turned auto-renew back on", userID) + } + + return nil +} + +func (h *SubscriptionWebhookHandler) handleAppleFailedToRenew(userID uint, tx *AppleTransactionInfo, renewal *AppleRenewalInfo) error { + // Subscription is in billing retry or grace period + log.Printf("Apple Webhook: User %d failed to renew, may be in grace period", userID) + // Don't downgrade yet - Apple may retry billing + return nil +} + +func (h *SubscriptionWebhookHandler) handleAppleExpired(userID uint, tx *AppleTransactionInfo) error { + if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil { + return err + } + + log.Printf("Apple Webhook: User %d subscription expired, downgraded to free", userID) + return nil +} + +func (h *SubscriptionWebhookHandler) handleAppleRefund(userID uint, tx *AppleTransactionInfo) error { + if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil { + return err + } + + log.Printf("Apple Webhook: User %d got refund, downgraded to free", userID) + return nil +} + +func (h *SubscriptionWebhookHandler) handleAppleRevoke(userID uint, tx *AppleTransactionInfo) error { + if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil { + return err + } + + log.Printf("Apple Webhook: User %d subscription revoked, downgraded to free", userID) + return nil +} + +func (h *SubscriptionWebhookHandler) handleAppleGracePeriodExpired(userID uint, tx *AppleTransactionInfo) error { + if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil { + return err + } + + log.Printf("Apple Webhook: User %d grace period expired, downgraded to free", userID) + return nil +} + +// ==================== +// Google Real-time Developer Notifications +// ==================== + +// GoogleNotification represents a Google Pub/Sub push message +type GoogleNotification struct { + Message GooglePubSubMessage `json:"message"` + Subscription string `json:"subscription"` +} + +// GooglePubSubMessage represents the Pub/Sub message wrapper +type GooglePubSubMessage struct { + Data string `json:"data"` // Base64 encoded + MessageID string `json:"messageId"` + PublishTime string `json:"publishTime"` + Attributes map[string]string `json:"attributes"` +} + +// GoogleDeveloperNotification represents the decoded notification +type GoogleDeveloperNotification struct { + Version string `json:"version"` + PackageName string `json:"packageName"` + EventTimeMillis string `json:"eventTimeMillis"` + SubscriptionNotification *GoogleSubscriptionNotification `json:"subscriptionNotification"` + OneTimeProductNotification *GoogleOneTimeNotification `json:"oneTimeProductNotification"` + TestNotification *GoogleTestNotification `json:"testNotification"` +} + +// GoogleSubscriptionNotification represents subscription-specific data +type GoogleSubscriptionNotification struct { + Version string `json:"version"` + NotificationType int `json:"notificationType"` + PurchaseToken string `json:"purchaseToken"` + SubscriptionID string `json:"subscriptionId"` +} + +// GoogleOneTimeNotification represents one-time purchase data +type GoogleOneTimeNotification struct { + Version string `json:"version"` + NotificationType int `json:"notificationType"` + PurchaseToken string `json:"purchaseToken"` + SKU string `json:"sku"` +} + +// GoogleTestNotification represents a test notification +type GoogleTestNotification struct { + Version string `json:"version"` +} + +// Google subscription notification types +const ( + GoogleSubRecovered = 1 // Subscription recovered from account hold + GoogleSubRenewed = 2 // Active subscription renewed + GoogleSubCanceled = 3 // Subscription was cancelled (voluntary or involuntary) + GoogleSubPurchased = 4 // New subscription purchased + GoogleSubOnHold = 5 // Subscription entered account hold + GoogleSubInGracePeriod = 6 // Subscription entered grace period + GoogleSubRestarted = 7 // User reactivated subscription + GoogleSubPriceChangeConfirmed = 8 // Price change confirmed by user + GoogleSubDeferred = 9 // Subscription deferred + GoogleSubPaused = 10 // Subscription paused + GoogleSubPauseScheduleChanged = 11 // Pause schedule changed + GoogleSubRevoked = 12 // Subscription revoked + GoogleSubExpired = 13 // Subscription expired +) + +// HandleGoogleWebhook handles POST /api/subscription/webhook/google/ +func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + log.Printf("Google Webhook: Failed to read body: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"}) + return + } + + var notification GoogleNotification + if err := json.Unmarshal(body, ¬ification); err != nil { + log.Printf("Google Webhook: Failed to parse notification: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid notification"}) + return + } + + // Decode the base64 data + data, err := base64.StdEncoding.DecodeString(notification.Message.Data) + if err != nil { + log.Printf("Google Webhook: Failed to decode message data: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message data"}) + return + } + + var devNotification GoogleDeveloperNotification + if err := json.Unmarshal(data, &devNotification); err != nil { + log.Printf("Google Webhook: Failed to parse developer notification: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid developer notification"}) + return + } + + // Handle test notification + if devNotification.TestNotification != nil { + log.Printf("Google Webhook: Received test notification") + c.JSON(http.StatusOK, gin.H{"status": "test received"}) + return + } + + // Verify package name + cfg := config.Get() + if cfg != nil && cfg.GoogleIAP.PackageName != "" { + if devNotification.PackageName != cfg.GoogleIAP.PackageName { + log.Printf("Google Webhook: Package name mismatch: got %s, expected %s", + devNotification.PackageName, cfg.GoogleIAP.PackageName) + c.JSON(http.StatusBadRequest, gin.H{"error": "package name mismatch"}) + return + } + } + + // Process subscription notification + if devNotification.SubscriptionNotification != nil { + if err := h.processGoogleSubscriptionNotification(devNotification.SubscriptionNotification); err != nil { + log.Printf("Google Webhook: Failed to process notification: %v", err) + // Still return 200 to acknowledge + } + } + + // Acknowledge the message + c.JSON(http.StatusOK, gin.H{"status": "received"}) +} + +// processGoogleSubscriptionNotification handles Google subscription events +func (h *SubscriptionWebhookHandler) processGoogleSubscriptionNotification(notification *GoogleSubscriptionNotification) error { + // Find user by purchase token + user, err := h.findUserByGoogleToken(notification.PurchaseToken) + if err != nil { + log.Printf("Google Webhook: Could not find user for token: %v", err) + return nil // Not an error - might be unknown token + } + + log.Printf("Google Webhook: Processing type %d for user %d (subscription: %s)", + notification.NotificationType, user.ID, notification.SubscriptionID) + + switch notification.NotificationType { + case GoogleSubPurchased: + return h.handleGooglePurchased(user.ID, notification) + + case GoogleSubRenewed: + return h.handleGoogleRenewed(user.ID, notification) + + case GoogleSubRecovered: + return h.handleGoogleRecovered(user.ID, notification) + + case GoogleSubCanceled: + return h.handleGoogleCanceled(user.ID, notification) + + case GoogleSubOnHold: + return h.handleGoogleOnHold(user.ID, notification) + + case GoogleSubInGracePeriod: + return h.handleGoogleGracePeriod(user.ID, notification) + + case GoogleSubRestarted: + return h.handleGoogleRestarted(user.ID, notification) + + case GoogleSubRevoked: + return h.handleGoogleRevoked(user.ID, notification) + + case GoogleSubExpired: + return h.handleGoogleExpired(user.ID, notification) + + case GoogleSubPaused: + return h.handleGooglePaused(user.ID, notification) + + default: + log.Printf("Google Webhook: Unhandled notification type: %d", notification.NotificationType) + } + + return nil +} + +func (h *SubscriptionWebhookHandler) findUserByGoogleToken(purchaseToken string) (*models.User, error) { + subscription, err := h.subscriptionRepo.FindByGoogleToken(purchaseToken) + if err != nil { + return nil, err + } + + user, err := h.userRepo.FindByID(subscription.UserID) + if err != nil { + return nil, err + } + + return user, nil +} + +func (h *SubscriptionWebhookHandler) handleGooglePurchased(userID uint, notification *GoogleSubscriptionNotification) error { + // New subscription - we should have already processed this via the client + // This is a backup notification + log.Printf("Google Webhook: User %d purchased subscription %s", userID, notification.SubscriptionID) + return nil +} + +func (h *SubscriptionWebhookHandler) handleGoogleRenewed(userID uint, notification *GoogleSubscriptionNotification) error { + // Need to query Google API for new expiry date + // For now, extend by typical period (1 month for monthly, 1 year for yearly) + var extension time.Duration + if strings.Contains(notification.SubscriptionID, "monthly") { + extension = 30 * 24 * time.Hour + } else { + extension = 365 * 24 * time.Hour + } + + newExpiry := time.Now().UTC().Add(extension) + if err := h.subscriptionRepo.UpgradeToPro(userID, newExpiry, "android"); err != nil { + return err + } + + log.Printf("Google Webhook: User %d renewed, extended to %v", userID, newExpiry) + return nil +} + +func (h *SubscriptionWebhookHandler) handleGoogleRecovered(userID uint, notification *GoogleSubscriptionNotification) error { + // Subscription recovered from account hold - reactivate + newExpiry := time.Now().UTC().AddDate(0, 1, 0) // 1 month from now + if err := h.subscriptionRepo.UpgradeToPro(userID, newExpiry, "android"); err != nil { + return err + } + + log.Printf("Google Webhook: User %d subscription recovered", userID) + return nil +} + +func (h *SubscriptionWebhookHandler) handleGoogleCanceled(userID uint, notification *GoogleSubscriptionNotification) error { + // User canceled - will expire at end of period + now := time.Now().UTC() + if err := h.subscriptionRepo.SetCancelledAt(userID, now); err != nil { + return err + } + if err := h.subscriptionRepo.SetAutoRenew(userID, false); err != nil { + return err + } + + log.Printf("Google Webhook: User %d canceled, will expire at end of period", userID) + return nil +} + +func (h *SubscriptionWebhookHandler) handleGoogleOnHold(userID uint, notification *GoogleSubscriptionNotification) error { + // Account hold - payment issue, may recover + log.Printf("Google Webhook: User %d subscription on hold", userID) + return nil +} + +func (h *SubscriptionWebhookHandler) handleGoogleGracePeriod(userID uint, notification *GoogleSubscriptionNotification) error { + // In grace period - user still has access but billing failed + log.Printf("Google Webhook: User %d in grace period", userID) + return nil +} + +func (h *SubscriptionWebhookHandler) handleGoogleRestarted(userID uint, notification *GoogleSubscriptionNotification) error { + // User restarted subscription + newExpiry := time.Now().UTC().AddDate(0, 1, 0) + if err := h.subscriptionRepo.UpgradeToPro(userID, newExpiry, "android"); err != nil { + return err + } + if err := h.subscriptionRepo.ClearCancelledAt(userID); err != nil { + return err + } + if err := h.subscriptionRepo.SetAutoRenew(userID, true); err != nil { + return err + } + + log.Printf("Google Webhook: User %d restarted subscription", userID) + return nil +} + +func (h *SubscriptionWebhookHandler) handleGoogleRevoked(userID uint, notification *GoogleSubscriptionNotification) error { + // Subscription revoked - immediate downgrade + if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil { + return err + } + + log.Printf("Google Webhook: User %d subscription revoked", userID) + return nil +} + +func (h *SubscriptionWebhookHandler) handleGoogleExpired(userID uint, notification *GoogleSubscriptionNotification) error { + // Subscription expired + if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil { + return err + } + + log.Printf("Google Webhook: User %d subscription expired", userID) + return nil +} + +func (h *SubscriptionWebhookHandler) handleGooglePaused(userID uint, notification *GoogleSubscriptionNotification) error { + // Subscription paused by user + log.Printf("Google Webhook: User %d subscription paused", userID) + return nil +} + +// ==================== +// Signature Verification (Optional but Recommended) +// ==================== + +// VerifyAppleSignature verifies the JWS signature using Apple's root certificate +// This is optional but recommended for production +func (h *SubscriptionWebhookHandler) VerifyAppleSignature(signedPayload string) error { + // Load Apple's root certificate if not already loaded + if h.appleRootCerts == nil { + // Apple's root certificates can be downloaded from: + // https://www.apple.com/certificateauthority/ + // You'd typically embed these or load from a file + return nil // Skip verification for now + } + + // Parse the JWS token + token, err := jwt.Parse(signedPayload, func(token *jwt.Token) (interface{}, error) { + // Get the x5c header (certificate chain) + x5c, ok := token.Header["x5c"].([]interface{}) + if !ok || len(x5c) == 0 { + return nil, fmt.Errorf("missing x5c header") + } + + // Decode the first certificate (leaf) + certData, err := base64.StdEncoding.DecodeString(x5c[0].(string)) + if err != nil { + return nil, fmt.Errorf("failed to decode certificate: %w", err) + } + + cert, err := x509.ParseCertificate(certData) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + + // Verify the certificate chain (simplified) + // In production, you should verify the full chain + + return cert.PublicKey.(*ecdsa.PublicKey), nil + }) + + if err != nil { + return fmt.Errorf("failed to verify signature: %w", err) + } + + if !token.Valid { + return fmt.Errorf("invalid token") + } + + return nil +} + +// VerifyGooglePubSubToken verifies the Pub/Sub push token (if configured) +func (h *SubscriptionWebhookHandler) VerifyGooglePubSubToken(c *gin.Context) bool { + // If you configured a push endpoint with authentication, verify here + // The token is typically in the Authorization header + + // For now, we rely on the endpoint being protected by your infrastructure + // (e.g., only accessible from Google's IP ranges) + return true +} + +// Helper function to load Apple root certificates from file +func loadAppleRootCertificates(certPath string) ([]*x509.Certificate, error) { + data, err := os.ReadFile(certPath) + if err != nil { + return nil, err + } + + var certs []*x509.Certificate + for { + block, rest := pem.Decode(data) + if block == nil { + break + } + if block.Type == "CERTIFICATE" { + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + certs = append(certs, cert) + } + data = rest + } + + return certs, nil +} diff --git a/internal/repositories/subscription_repo.go b/internal/repositories/subscription_repo.go index 8396a41..9f4e4c0 100644 --- a/internal/repositories/subscription_repo.go +++ b/internal/repositories/subscription_repo.go @@ -105,6 +105,43 @@ func (r *SubscriptionRepository) UpdatePurchaseToken(userID uint, token string) Update("google_purchase_token", token).Error } +// FindByAppleReceiptContains finds a subscription by Apple transaction ID +// Used by webhooks to find the user associated with a transaction +func (r *SubscriptionRepository) FindByAppleReceiptContains(transactionID string) (*models.UserSubscription, error) { + var sub models.UserSubscription + // Search for transaction ID in the stored receipt data + err := r.db.Where("apple_receipt_data LIKE ?", "%"+transactionID+"%").First(&sub).Error + if err != nil { + return nil, err + } + return &sub, nil +} + +// FindByGoogleToken finds a subscription by Google purchase token +// Used by webhooks to find the user associated with a purchase +func (r *SubscriptionRepository) FindByGoogleToken(purchaseToken string) (*models.UserSubscription, error) { + var sub models.UserSubscription + err := r.db.Where("google_purchase_token = ?", purchaseToken).First(&sub).Error + if err != nil { + return nil, err + } + return &sub, nil +} + +// SetCancelledAt sets the cancellation timestamp +func (r *SubscriptionRepository) SetCancelledAt(userID uint, cancelledAt time.Time) error { + return r.db.Model(&models.UserSubscription{}). + Where("user_id = ?", userID). + Update("cancelled_at", cancelledAt).Error +} + +// ClearCancelledAt clears the cancellation timestamp (user resubscribed) +func (r *SubscriptionRepository) ClearCancelledAt(userID uint) error { + return r.db.Model(&models.UserSubscription{}). + Where("user_id = ?", userID). + Update("cancelled_at", nil).Error +} + // === Tier Limits === // GetTierLimits gets the limits for a subscription tier diff --git a/internal/router/router.go b/internal/router/router.go index bf90f60..efeb2ef 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -119,6 +119,9 @@ func SetupRouter(deps *Dependencies) *gin.Engine { subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo) taskTemplateService := services.NewTaskTemplateService(taskTemplateRepo) + // Initialize webhook handler for Apple/Google subscription notifications + subscriptionWebhookHandler := handlers.NewSubscriptionWebhookHandler(subscriptionRepo, userRepo) + // Initialize middleware authMiddleware := middleware.NewAuthMiddleware(deps.DB, deps.Cache) @@ -169,6 +172,9 @@ func SetupRouter(deps *Dependencies) *gin.Engine { // Public data routes (no auth required) setupPublicDataRoutes(api, residenceHandler, taskHandler, contractorHandler, staticDataHandler, subscriptionHandler, taskTemplateHandler) + // Subscription webhook routes (no auth - called by Apple/Google servers) + setupWebhookRoutes(api, subscriptionWebhookHandler) + // Protected routes (auth required) protected := api.Group("") protected.Use(authMiddleware.TokenAuth()) @@ -433,3 +439,13 @@ func setupMediaRoutes(api *gin.RouterGroup, mediaHandler *handlers.MediaHandler) media.GET("/completion-image/:id", mediaHandler.ServeCompletionImage) } } + +// setupWebhookRoutes configures subscription webhook routes for Apple/Google server-to-server notifications +// These routes are public (no auth) since they're called by Apple/Google servers +func setupWebhookRoutes(api *gin.RouterGroup, webhookHandler *handlers.SubscriptionWebhookHandler) { + webhooks := api.Group("/subscription/webhook") + { + webhooks.POST("/apple/", webhookHandler.HandleAppleWebhook) + webhooks.POST("/google/", webhookHandler.HandleGoogleWebhook) + } +} diff --git a/internal/services/iap_validation.go b/internal/services/iap_validation.go new file mode 100644 index 0000000..e7c6306 --- /dev/null +++ b/internal/services/iap_validation.go @@ -0,0 +1,554 @@ +package services + +import ( + "context" + "crypto/ecdsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/oauth2/google" + "google.golang.org/api/androidpublisher/v3" + "google.golang.org/api/option" + + "github.com/treytartt/casera-api/internal/config" +) + +// IAP validation errors +var ( + ErrIAPNotConfigured = errors.New("IAP validation not configured") + ErrInvalidReceipt = errors.New("invalid receipt data") + ErrInvalidPurchaseToken = errors.New("invalid purchase token") + ErrSubscriptionExpired = errors.New("subscription has expired") + ErrSubscriptionCancelled = errors.New("subscription was cancelled") + ErrInvalidProductID = errors.New("invalid product ID") + ErrReceiptValidationFailed = errors.New("receipt validation failed") +) + +// AppleIAPClient handles Apple App Store Server API validation +type AppleIAPClient struct { + keyID string + issuerID string + bundleID string + privateKey *ecdsa.PrivateKey + sandbox bool +} + +// GoogleIAPClient handles Google Play Developer API validation +type GoogleIAPClient struct { + service *androidpublisher.Service + packageName string +} + +// AppleTransactionInfo represents decoded transaction info from Apple +type AppleTransactionInfo struct { + TransactionID string `json:"transactionId"` + OriginalTransactionID string `json:"originalTransactionId"` + ProductID string `json:"productId"` + PurchaseDate int64 `json:"purchaseDate"` + ExpiresDate int64 `json:"expiresDate"` + Type string `json:"type"` // "Auto-Renewable Subscription" + InAppOwnershipType string `json:"inAppOwnershipType"` + SignedDate int64 `json:"signedDate"` + Environment string `json:"environment"` // "Sandbox" or "Production" + BundleID string `json:"bundleId"` + RevocationDate *int64 `json:"revocationDate,omitempty"` + RevocationReason *int `json:"revocationReason,omitempty"` +} + +// AppleValidationResult contains the result of Apple receipt validation +type AppleValidationResult struct { + Valid bool + TransactionID string + ProductID string + ExpiresAt time.Time + IsTrialPeriod bool + AutoRenewEnabled bool + Environment string +} + +// GoogleValidationResult contains the result of Google token validation +type GoogleValidationResult struct { + Valid bool + OrderID string + ProductID string + ExpiresAt time.Time + AutoRenewing bool + PaymentState int64 + CancelReason int64 + AcknowledgedState bool +} + +// NewAppleIAPClient creates a new Apple IAP validation client +func NewAppleIAPClient(cfg config.AppleIAPConfig) (*AppleIAPClient, error) { + if cfg.KeyPath == "" || cfg.KeyID == "" || cfg.IssuerID == "" || cfg.BundleID == "" { + return nil, ErrIAPNotConfigured + } + + // Read the private key + keyData, err := os.ReadFile(cfg.KeyPath) + if err != nil { + return nil, fmt.Errorf("failed to read Apple IAP key: %w", err) + } + + // Parse the PEM-encoded private key + block, _ := pem.Decode(keyData) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block") + } + + privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + + ecdsaKey, ok := privateKey.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("private key is not ECDSA") + } + + return &AppleIAPClient{ + keyID: cfg.KeyID, + issuerID: cfg.IssuerID, + bundleID: cfg.BundleID, + privateKey: ecdsaKey, + sandbox: cfg.Sandbox, + }, nil +} + +// generateJWT creates a signed JWT for App Store Server API authentication +func (c *AppleIAPClient) generateJWT() (string, error) { + now := time.Now() + claims := jwt.MapClaims{ + "iss": c.issuerID, + "iat": now.Unix(), + "exp": now.Add(60 * time.Minute).Unix(), + "aud": "appstoreconnect-v1", + "bid": c.bundleID, + } + + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + token.Header["kid"] = c.keyID + + return token.SignedString(c.privateKey) +} + +// getBaseURL returns the appropriate App Store Server API URL +func (c *AppleIAPClient) getBaseURL() string { + if c.sandbox { + return "https://api.storekit-sandbox.itunes.apple.com" + } + return "https://api.storekit.itunes.apple.com" +} + +// ValidateTransaction validates a transaction ID with Apple's servers +func (c *AppleIAPClient) ValidateTransaction(ctx context.Context, transactionID string) (*AppleValidationResult, error) { + token, err := c.generateJWT() + if err != nil { + return nil, fmt.Errorf("failed to generate JWT: %w", err) + } + + // Get transaction info + url := fmt.Sprintf("%s/inApps/v1/transactions/%s", c.getBaseURL(), transactionID) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call Apple API: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Apple API returned status %d: %s", resp.StatusCode, string(body)) + } + + // Parse the response + var response struct { + SignedTransactionInfo string `json:"signedTransactionInfo"` + } + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Decode the signed transaction info (it's a JWS) + transactionInfo, err := c.decodeSignedTransaction(response.SignedTransactionInfo) + if err != nil { + return nil, err + } + + // Validate bundle ID + if transactionInfo.BundleID != c.bundleID { + return nil, ErrInvalidReceipt + } + + // Check if revoked + if transactionInfo.RevocationDate != nil { + return &AppleValidationResult{ + Valid: false, + }, ErrSubscriptionCancelled + } + + expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0) + + return &AppleValidationResult{ + Valid: true, + TransactionID: transactionInfo.TransactionID, + ProductID: transactionInfo.ProductID, + ExpiresAt: expiresAt, + Environment: transactionInfo.Environment, + }, nil +} + +// ValidateReceipt validates an App Store receipt (base64-encoded) +// This is the modern approach using the /verifyReceipt endpoint compatibility +// For new integrations, prefer ValidateTransaction with transaction IDs +func (c *AppleIAPClient) ValidateReceipt(ctx context.Context, receiptData string) (*AppleValidationResult, error) { + token, err := c.generateJWT() + if err != nil { + return nil, fmt.Errorf("failed to generate JWT: %w", err) + } + + // Decode the receipt to get transaction history + // The receiptData from StoreKit 2 is typically a signed transaction + // For StoreKit 1, we need to use the legacy verifyReceipt endpoint + + // Try to decode as a signed transaction first (StoreKit 2) + if strings.Contains(receiptData, ".") { + // This looks like a JWS - try to decode it directly + transactionInfo, err := c.decodeSignedTransaction(receiptData) + if err == nil { + expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0) + return &AppleValidationResult{ + Valid: true, + TransactionID: transactionInfo.TransactionID, + ProductID: transactionInfo.ProductID, + ExpiresAt: expiresAt, + Environment: transactionInfo.Environment, + }, nil + } + } + + // For legacy receipts, use the subscription status endpoint + // First, we need to find the original transaction ID from the receipt + // This requires using the App Store Server API's history endpoint + + url := fmt.Sprintf("%s/inApps/v1/history/%s", c.getBaseURL(), c.bundleID) + + // Create request body + requestBody := struct { + ReceiptData string `json:"receipt-data"` + }{ + ReceiptData: receiptData, + } + + jsonBody, err := json.Marshal(requestBody) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(jsonBody))) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call Apple API: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + // If history endpoint fails, try legacy validation + return c.validateLegacyReceipt(ctx, receiptData) + } + + var historyResponse struct { + SignedTransactions []string `json:"signedTransactions"` + HasMore bool `json:"hasMore"` + } + if err := json.Unmarshal(body, &historyResponse); err != nil { + return nil, fmt.Errorf("failed to parse history response: %w", err) + } + + if len(historyResponse.SignedTransactions) == 0 { + return nil, ErrInvalidReceipt + } + + // Get the most recent transaction + latestTransaction := historyResponse.SignedTransactions[len(historyResponse.SignedTransactions)-1] + transactionInfo, err := c.decodeSignedTransaction(latestTransaction) + if err != nil { + return nil, err + } + + expiresAt := time.Unix(transactionInfo.ExpiresDate/1000, 0) + + return &AppleValidationResult{ + Valid: true, + TransactionID: transactionInfo.TransactionID, + ProductID: transactionInfo.ProductID, + ExpiresAt: expiresAt, + Environment: transactionInfo.Environment, + }, nil +} + +// validateLegacyReceipt uses Apple's legacy verifyReceipt endpoint +func (c *AppleIAPClient) validateLegacyReceipt(ctx context.Context, receiptData string) (*AppleValidationResult, error) { + url := "https://buy.itunes.apple.com/verifyReceipt" + if c.sandbox { + url = "https://sandbox.itunes.apple.com/verifyReceipt" + } + + requestBody := map[string]string{ + "receipt-data": receiptData, + } + + jsonBody, err := json.Marshal(requestBody) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(jsonBody))) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call Apple verifyReceipt: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var legacyResponse struct { + Status int `json:"status"` + LatestReceiptInfo []struct { + TransactionID string `json:"transaction_id"` + OriginalTransactionID string `json:"original_transaction_id"` + ProductID string `json:"product_id"` + ExpiresDateMs string `json:"expires_date_ms"` + IsTrialPeriod string `json:"is_trial_period"` + } `json:"latest_receipt_info"` + PendingRenewalInfo []struct { + AutoRenewStatus string `json:"auto_renew_status"` + } `json:"pending_renewal_info"` + Environment string `json:"environment"` + } + + if err := json.Unmarshal(body, &legacyResponse); err != nil { + return nil, fmt.Errorf("failed to parse legacy response: %w", err) + } + + // Status codes: 0 = valid, 21007 = sandbox receipt on production, 21008 = production receipt on sandbox + if legacyResponse.Status == 21007 && !c.sandbox { + // Retry with sandbox + c.sandbox = true + result, err := c.validateLegacyReceipt(ctx, receiptData) + c.sandbox = false + return result, err + } + + if legacyResponse.Status != 0 { + return nil, fmt.Errorf("%w: status code %d", ErrReceiptValidationFailed, legacyResponse.Status) + } + + if len(legacyResponse.LatestReceiptInfo) == 0 { + return nil, ErrInvalidReceipt + } + + // Find the latest non-expired subscription + var latestReceipt = legacyResponse.LatestReceiptInfo[len(legacyResponse.LatestReceiptInfo)-1] + + var expiresMs int64 + fmt.Sscanf(latestReceipt.ExpiresDateMs, "%d", &expiresMs) + expiresAt := time.Unix(expiresMs/1000, 0) + + autoRenew := false + if len(legacyResponse.PendingRenewalInfo) > 0 { + autoRenew = legacyResponse.PendingRenewalInfo[0].AutoRenewStatus == "1" + } + + return &AppleValidationResult{ + Valid: true, + TransactionID: latestReceipt.TransactionID, + ProductID: latestReceipt.ProductID, + ExpiresAt: expiresAt, + IsTrialPeriod: latestReceipt.IsTrialPeriod == "true", + AutoRenewEnabled: autoRenew, + Environment: legacyResponse.Environment, + }, nil +} + +// decodeSignedTransaction decodes a JWS signed transaction from Apple +func (c *AppleIAPClient) decodeSignedTransaction(signedTransaction string) (*AppleTransactionInfo, error) { + // The signed transaction is a JWS (JSON Web Signature) + // Format: header.payload.signature + parts := strings.Split(signedTransaction, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid JWS format") + } + + // Decode the payload (base64url encoded) + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to decode payload: %w", err) + } + + var transactionInfo AppleTransactionInfo + if err := json.Unmarshal(payload, &transactionInfo); err != nil { + return nil, fmt.Errorf("failed to parse transaction info: %w", err) + } + + return &transactionInfo, nil +} + +// NewGoogleIAPClient creates a new Google IAP validation client +func NewGoogleIAPClient(ctx context.Context, cfg config.GoogleIAPConfig) (*GoogleIAPClient, error) { + if cfg.ServiceAccountPath == "" || cfg.PackageName == "" { + return nil, ErrIAPNotConfigured + } + + // Read the service account JSON + serviceAccountJSON, err := os.ReadFile(cfg.ServiceAccountPath) + if err != nil { + return nil, fmt.Errorf("failed to read service account file: %w", err) + } + + // Create credentials + creds, err := google.CredentialsFromJSON(ctx, serviceAccountJSON, androidpublisher.AndroidpublisherScope) + if err != nil { + return nil, fmt.Errorf("failed to create credentials: %w", err) + } + + // Create the Android Publisher service + service, err := androidpublisher.NewService(ctx, option.WithCredentials(creds)) + if err != nil { + return nil, fmt.Errorf("failed to create Android Publisher service: %w", err) + } + + return &GoogleIAPClient{ + service: service, + packageName: cfg.PackageName, + }, nil +} + +// ValidateSubscription validates a Google Play subscription purchase token +func (c *GoogleIAPClient) ValidateSubscription(ctx context.Context, subscriptionID, purchaseToken string) (*GoogleValidationResult, error) { + // Call Google Play Developer API to verify the subscription + subscription, err := c.service.Purchases.Subscriptions.Get( + c.packageName, + subscriptionID, + purchaseToken, + ).Context(ctx).Do() + + if err != nil { + return nil, fmt.Errorf("failed to validate subscription: %w", err) + } + + // ExpiryTimeMillis is in milliseconds + expiresAt := time.Unix(subscription.ExpiryTimeMillis/1000, 0) + + // Dereference pointer values safely + var paymentState int64 + if subscription.PaymentState != nil { + paymentState = *subscription.PaymentState + } + + result := &GoogleValidationResult{ + Valid: true, + OrderID: subscription.OrderId, + ProductID: subscriptionID, + ExpiresAt: expiresAt, + AutoRenewing: subscription.AutoRenewing, + PaymentState: paymentState, + CancelReason: subscription.CancelReason, + AcknowledgedState: subscription.AcknowledgementState == 1, + } + + // Check if subscription is valid + now := time.Now() + if expiresAt.Before(now) { + return result, ErrSubscriptionExpired + } + + // Cancel reason: 0 = user cancelled, 1 = system cancelled + if subscription.CancelReason > 0 { + result.Valid = false + return result, ErrSubscriptionCancelled + } + + return result, nil +} + +// ValidatePurchaseToken validates a purchase token, attempting to determine the subscription ID +// This is a convenience method when the client only sends the token +func (c *GoogleIAPClient) ValidatePurchaseToken(ctx context.Context, purchaseToken string, knownSubscriptionIDs []string) (*GoogleValidationResult, error) { + // Try each known subscription ID until one works + for _, subID := range knownSubscriptionIDs { + result, err := c.ValidateSubscription(ctx, subID, purchaseToken) + if err == nil { + return result, nil + } + // If it's not a "not found" error, return the error + if !strings.Contains(err.Error(), "notFound") && !strings.Contains(err.Error(), "404") { + continue + } + } + + return nil, ErrInvalidPurchaseToken +} + +// AcknowledgeSubscription acknowledges a subscription purchase +// This must be called within 3 days of purchase or the subscription is refunded +func (c *GoogleIAPClient) AcknowledgeSubscription(ctx context.Context, subscriptionID, purchaseToken string) error { + err := c.service.Purchases.Subscriptions.Acknowledge( + c.packageName, + subscriptionID, + purchaseToken, + &androidpublisher.SubscriptionPurchasesAcknowledgeRequest{}, + ).Context(ctx).Do() + + if err != nil { + return fmt.Errorf("failed to acknowledge subscription: %w", err) + } + + return nil +} diff --git a/internal/services/subscription_service.go b/internal/services/subscription_service.go index a035e26..2487eda 100644 --- a/internal/services/subscription_service.go +++ b/internal/services/subscription_service.go @@ -1,11 +1,14 @@ package services import ( + "context" "errors" + "log" "time" "gorm.io/gorm" + "github.com/treytartt/casera-api/internal/config" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/repositories" ) @@ -21,6 +24,15 @@ var ( ErrPromotionNotFound = errors.New("promotion not found") ) +// KnownSubscriptionIDs are the product IDs for Pro subscriptions +// Update these to match your actual App Store Connect / Google Play Console product IDs +var KnownSubscriptionIDs = []string{ + "com.tt.casera.pro.monthly", + "com.tt.casera.pro.yearly", + "casera_pro_monthly", + "casera_pro_yearly", +} + // SubscriptionService handles subscription business logic type SubscriptionService struct { subscriptionRepo *repositories.SubscriptionRepository @@ -28,6 +40,8 @@ type SubscriptionService struct { taskRepo *repositories.TaskRepository contractorRepo *repositories.ContractorRepository documentRepo *repositories.DocumentRepository + appleClient *AppleIAPClient + googleClient *GoogleIAPClient } // NewSubscriptionService creates a new subscription service @@ -38,13 +52,41 @@ func NewSubscriptionService( contractorRepo *repositories.ContractorRepository, documentRepo *repositories.DocumentRepository, ) *SubscriptionService { - return &SubscriptionService{ + svc := &SubscriptionService{ subscriptionRepo: subscriptionRepo, residenceRepo: residenceRepo, taskRepo: taskRepo, contractorRepo: contractorRepo, documentRepo: documentRepo, } + + // Initialize Apple IAP client + cfg := config.Get() + if cfg != nil { + appleClient, err := NewAppleIAPClient(cfg.AppleIAP) + if err != nil { + if !errors.Is(err, ErrIAPNotConfigured) { + log.Printf("Warning: Failed to initialize Apple IAP client: %v", err) + } + } else { + svc.appleClient = appleClient + log.Println("Apple IAP validation client initialized") + } + + // Initialize Google IAP client + ctx := context.Background() + googleClient, err := NewGoogleIAPClient(ctx, cfg.GoogleIAP) + if err != nil { + if !errors.Is(err, ErrIAPNotConfigured) { + log.Printf("Warning: Failed to initialize Google IAP client: %v", err) + } + } else { + svc.googleClient = googleClient + log.Println("Google IAP validation client initialized") + } + } + + return svc } // GetSubscription gets the subscription for a user @@ -281,17 +323,57 @@ func (s *SubscriptionService) GetActivePromotions(userID uint) ([]PromotionRespo } // ProcessApplePurchase processes an Apple IAP purchase -func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData string) (*SubscriptionResponse, error) { - // TODO: Implement receipt validation with Apple's servers - // For now, just upgrade the user - - // Store receipt data - if err := s.subscriptionRepo.UpdateReceiptData(userID, receiptData); err != nil { +// Supports both StoreKit 1 (receiptData) and StoreKit 2 (transactionID) +func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData string, transactionID string) (*SubscriptionResponse, error) { + // Store receipt/transaction data + dataToStore := receiptData + if dataToStore == "" { + dataToStore = transactionID + } + if err := s.subscriptionRepo.UpdateReceiptData(userID, dataToStore); err != nil { return nil, err } - // Upgrade to Pro (1 year from now - adjust based on actual subscription) - expiresAt := time.Now().UTC().AddDate(1, 0, 0) + // Validate with Apple if client is configured + var expiresAt time.Time + if s.appleClient != nil { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var result *AppleValidationResult + var err error + + // Prefer transaction ID (StoreKit 2), fall back to receipt data (StoreKit 1) + if transactionID != "" { + result, err = s.appleClient.ValidateTransaction(ctx, transactionID) + } else if receiptData != "" { + result, err = s.appleClient.ValidateReceipt(ctx, receiptData) + } + + if err != nil { + // Log the validation error + log.Printf("Apple validation warning for user %d: %v", userID, err) + + // Check if it's a fatal error + if errors.Is(err, ErrInvalidReceipt) || errors.Is(err, ErrSubscriptionCancelled) { + return nil, err + } + + // For other errors (network, etc.), fall back with shorter expiry + expiresAt = time.Now().UTC().AddDate(0, 1, 0) // 1 month fallback + } else if result != nil { + // Use the expiration date from Apple + expiresAt = result.ExpiresAt + log.Printf("Apple purchase validated for user %d: product=%s, expires=%v, env=%s", + userID, result.ProductID, result.ExpiresAt, result.Environment) + } + } else { + // Apple validation not configured - trust client but log warning + log.Printf("Warning: Apple IAP validation not configured, trusting client for user %d", userID) + expiresAt = time.Now().UTC().AddDate(1, 0, 0) // 1 year default + } + + // Upgrade to Pro with the determined expiration if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil { return nil, err } @@ -300,17 +382,66 @@ func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData stri } // ProcessGooglePurchase processes a Google Play purchase -func (s *SubscriptionService) ProcessGooglePurchase(userID uint, purchaseToken string) (*SubscriptionResponse, error) { - // TODO: Implement token validation with Google's servers - // For now, just upgrade the user - - // Store purchase token +// productID is optional but helps validate the specific subscription +func (s *SubscriptionService) ProcessGooglePurchase(userID uint, purchaseToken string, productID string) (*SubscriptionResponse, error) { + // Store purchase token first if err := s.subscriptionRepo.UpdatePurchaseToken(userID, purchaseToken); err != nil { return nil, err } - // Upgrade to Pro (1 year from now - adjust based on actual subscription) - expiresAt := time.Now().UTC().AddDate(1, 0, 0) + // Validate the purchase with Google if client is configured + var expiresAt time.Time + if s.googleClient != nil { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var result *GoogleValidationResult + var err error + + // If productID is provided, use it directly; otherwise try known IDs + if productID != "" { + result, err = s.googleClient.ValidateSubscription(ctx, productID, purchaseToken) + } else { + result, err = s.googleClient.ValidatePurchaseToken(ctx, purchaseToken, KnownSubscriptionIDs) + } + + if err != nil { + // Log the validation error + log.Printf("Google purchase validation warning for user %d: %v", userID, err) + + // Check if it's a fatal error + if errors.Is(err, ErrInvalidPurchaseToken) || errors.Is(err, ErrSubscriptionCancelled) { + return nil, err + } + + if errors.Is(err, ErrSubscriptionExpired) { + // Subscription expired - still allow but set past expiry + expiresAt = time.Now().UTC().Add(-1 * time.Hour) + } else { + // For other errors, fall back with shorter expiry + expiresAt = time.Now().UTC().AddDate(0, 1, 0) // 1 month fallback + } + } else if result != nil { + // Use the expiration date from Google + expiresAt = result.ExpiresAt + log.Printf("Google purchase validated for user %d: product=%s, expires=%v, autoRenew=%v", + userID, result.ProductID, result.ExpiresAt, result.AutoRenewing) + + // Acknowledge the subscription if not already acknowledged + if !result.AcknowledgedState { + if err := s.googleClient.AcknowledgeSubscription(ctx, result.ProductID, purchaseToken); err != nil { + log.Printf("Warning: Failed to acknowledge subscription for user %d: %v", userID, err) + // Don't fail the purchase, just log the warning + } + } + } + } else { + // Google validation not configured - trust client but log warning + log.Printf("Warning: Google IAP validation not configured, trusting client for user %d", userID) + expiresAt = time.Now().UTC().AddDate(1, 0, 0) // 1 year default + } + + // Upgrade to Pro with the determined expiration if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "android"); err != nil { return nil, err } @@ -511,7 +642,9 @@ func NewPromotionResponse(p *models.Promotion) *PromotionResponse { // ProcessPurchaseRequest represents an IAP purchase request type ProcessPurchaseRequest struct { - ReceiptData string `json:"receipt_data"` // iOS + ReceiptData string `json:"receipt_data"` // iOS (StoreKit 1 receipt or StoreKit 2 JWS) + TransactionID string `json:"transaction_id"` // iOS StoreKit 2 transaction ID PurchaseToken string `json:"purchase_token"` // Android + ProductID string `json:"product_id"` // Android (optional, helps identify subscription) Platform string `json:"platform" binding:"required,oneof=ios android"` }