Add Apple/Google IAP validation and subscription webhooks

- Add Apple App Store Server API integration for receipt/transaction validation
- Add Google Play Developer API integration for purchase token validation
- Add webhook endpoints for server-to-server subscription notifications
  - POST /api/subscription/webhook/apple/ (App Store Server Notifications v2)
  - POST /api/subscription/webhook/google/ (Real-time Developer Notifications)
- Support both StoreKit 1 (receipt_data) and StoreKit 2 (transaction_id)
- Add repository methods to find users by transaction ID or purchase token
- Add configuration for IAP credentials (APPLE_IAP_*, GOOGLE_IAP_*)
- Add setup documentation for configuring webhooks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-14 13:58:37 -06:00
parent 81885c4ea3
commit c58aaa5d5f
10 changed files with 1909 additions and 52 deletions

View File

@@ -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

39
go.mod
View File

@@ -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
)

85
go.sum
View File

@@ -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=

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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, &notification); err != nil {
return nil, fmt.Errorf("failed to parse notification: %w", err)
}
return &notification, 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, &notification); 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
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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"`
}