First Commit

This commit is contained in:
mark committed 2026-10-19 15:22:52 -07:00
1 parent 5eb9dd037d
commit 2e62c0e60b
11877 files changed
+2035312

No files matched your search

+239
View File
@@ -0,0 +1,239 @@
# PayPal Merchant Onboarding Flow
## BEFORE FIX (The Problem) ❌
```
┌─────────────────────────────────────────────────────────────────┐
│ 1. User clicks "Connect with PayPal" │
│ → onboard.php creates referral │
│ → Saves to pending_onboarding.json │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 2. Redirected to PayPal Onboarding │
│ → User sees business account signup │
│ → User CLOSES browser WITHOUT completing ❌ │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 3. Returns to return.php │
│ → Queries PayPal API for merchant_id │
│ → PayPal returns: "NEG5HMUKCTQNL" (PARTNER ID!) ❌ │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 4. OLD CODE: Accepts partner ID as merchant ID ❌ │
│ → Saves "NEG5HMUKCTQNL" to merchants.json │
│ → WRONG! This is YOUR ID, not the merchant's! │
└─────────────────────────────────────────────────────────────────┘
↓
STUCK FOREVER 😞
```
---
## AFTER FIX (The Solution) ✅
```
┌─────────────────────────────────────────────────────────────────┐
│ 1. User clicks "Connect with PayPal" │
│ → onboard.php creates referral │
│ → Saves to pending_onboarding.json │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 2. Redirected to PayPal Onboarding │
│ → User sees business account signup │
└─────────────────────────────────────────────────────────────────┘
↙ ↘
SCENARIO A SCENARIO B
(Incomplete) (Complete)
┌──────────────────────────┐ ┌──────────────────────────┐
│ User CLOSES browser │ │ User COMPLETES all │
│ WITHOUT finishing ❌ │ │ steps at PayPal ✅ │
└──────────────────────────┘ └──────────────────────────┘
↓ ↓
┌──────────────────────────┐ ┌──────────────────────────┐
│ Returns to return.php │ │ Returns to return.php │
│ PayPal returns: │ │ PayPal returns: │
│ "NEG5HMUKCTQNL" │ │ "ABC123XYZ456" ✅ │
│ (partner ID) │ │ (real merchant ID) │
└──────────────────────────┘ └──────────────────────────┘
↓ ↓
┌──────────────────────────┐ ┌──────────────────────────┐
│ NEW CODE: │ │ NEW CODE: │
│ ✅ REJECTS partner ID │ │ ✅ ACCEPTS merchant ID │
│ ✅ Marks as incomplete │ │ ✅ Saves to merchants │
│ ✅ Shows warning │ │ ✅ Shows success │
└──────────────────────────┘ └──────────────────────────┘
↓ ↓
┌──────────────────────────┐ ┌──────────────────────────┐
│ Dashboard shows: │ │ Dashboard shows: │
│ ⚠️ Incomplete │ │ ✅ Connected │
│ "Complete PayPal │ │ "Ready to accept │
│ signup to continue" │ │ payments" │
└──────────────────────────┘ └──────────────────────────┘
```
---
## Data Structure Comparison
### BEFORE FIX ❌
```json
// pending_onboarding.json
{
"school_ab32258ab0": {
"school": "m",
"created_at": 1760884706,
"partner_referral_id": "YzU2NGVl...",
"referral_token": "YzU2NGVl...",
"merchant_id": "NEG5HMUKCTQNL", ← WRONG! This is partner ID
"status": "connected" ← FALSE! Not really connected
}
}
```
### AFTER FIX - Incomplete ⚠️
```json
// pending_onboarding.json
{
"school_ab32258ab0": {
"school": "m",
"created_at": 1760884706,
"partner_referral_id": "YzU2NGVl...",
"referral_token": "YzU2NGVl...",
"status": "incomplete_onboarding", ← Honest status
"note": "Merchant did not complete PayPal onboarding",
"last_return_at": 1760885000
}
}
```
### AFTER FIX - Complete ✅
```json
// pending_onboarding.json
{
"school_3387003d82": {
"school": "b",
"created_at": 1760797314,
"partner_referral_id": "MTQ2Njlm...",
"referral_token": "MTQ2Njlm...",
"merchant_id": "796GQ4VM8SWSQ", ← CORRECT! Different ID
"status": "connected", ← TRUE! Really connected
"connected_at": 1760797400
}
}
// merchants.json
{
"b": {
"merchant_id": "796GQ4VM8SWSQ",
"tracking_id": "school_3387003d82",
"connected_at": 1760797400
}
}
```
---
## Key Changes in Code
### return.php
```php
// OLD CODE ❌
if (!empty($resp['body']['merchant_id'])) {
$merchantId = $resp['body']['merchant_id']; // Accepts ANY ID
}
// NEW CODE ✅
if (!empty($resp['body']['merchant_id'])) {
$foundId = $resp['body']['merchant_id'];
// CRITICAL: Don't accept partner's own ID
if (strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId; // Only accept if different
}
}
```
### sync_merchants.php
```php
// OLD CODE ❌
if (!empty($resp['body']['merchant_id'])) {
$merchantId = $resp['body']['merchant_id']; // Blindly accepts
}
// NEW CODE ✅
if (!empty($resp['body']['merchant_id'])) {
$foundId = $resp['body']['merchant_id'];
// Don't accept partner ID - means onboarding not completed
if (strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
}
}
```
---
## Testing Scenarios
### Test 1: Incomplete Onboarding ⚠️
1. Click "Connect with PayPal"
2. See PayPal signup page
3. **Immediately click "Return to School Lunch App"**
4. **Expected Result**:
- ⚠️ "Onboarding Incomplete" warning
- Status: `incomplete_onboarding`
- NO merchant_id saved
### Test 2: Complete Onboarding ✅
1. Click "Connect with PayPal"
2. See PayPal signup page
3. **Complete ALL steps** (business info, email verification, etc.)
4. Click "Return to Application"
5. **Expected Result**:
- ✅ "Onboarding completed" success message
- Status: `connected`
- Merchant ID saved (NOT `NEG5HMUKCTQNL`)
### Test 3: Sync Pending ⏳
1. Complete onboarding but merchant_id not immediately available
2. Click "🔄 Sync Now"
3. **Expected Result**:
- System queries PayPal API
- Retrieves merchant_id
- Updates status to `connected`
---
## Success Indicators ✅
You'll know it's working when:
1. **Different Merchant IDs**: Each merchant gets unique ID (not `NEG5HMUKCTQNL`)
2. **Clear Status**: See `connected` or `incomplete_onboarding` (not stuck)
3. **Warning Messages**: Incomplete onboardings show warnings
4. **Real Payments**: Can create orders with actual merchant IDs
---
## Your Real Example 🎯
From your `sync_debug.json`, school "b" worked correctly:
```json
{
"ts": 1760884845,
"track": "school_3387003d82",
"school": "b",
"attempt": "by-tracking",
"status": 200,
"body": {
"merchant_id": "796GQ4VM8SWSQ", ← SUCCESS! ✅
"tracking_id": "school_3387003d82"
}
}
```
This is what ALL your merchants should look like! 🎉
+286
View File
@@ -0,0 +1,286 @@
# 🚀 Production Deployment Checklist
## Before Going Live with PayPal Merchant Onboarding
---
## ✅ Configuration
- [ ] **Update `config.php` with LIVE credentials**
```php
'env' => 'live', // Change from 'sandbox'
'client_id' => 'YOUR_LIVE_CLIENT_ID',
'client_secret' => 'YOUR_LIVE_CLIENT_SECRET',
'partner_merchant_id' => 'YOUR_LIVE_PARTNER_ID',
```
- [ ] **Verify partner merchant ID**
- Go to: https://www.paypal.com/businessprofile/settings
- Account IDs section
- Copy your Merchant ID (Payer ID)
- Paste into `config.php`
- [ ] **Update base_url**
```php
'base_url' => 'https://yourdomain.com/payp', // Your actual domain
```
- [ ] **Enable HTTPS**
- PayPal requires HTTPS in production
- Install SSL certificate
- Force HTTPS in Apache/Nginx
---
## 🔒 Security
- [ ] **Secure storage directory**
```apache
# Add to .htaccess in /data folder
Deny from all
```
- [ ] **File permissions**
```bash
chmod 700 data/
chmod 600 data/*.json
```
- [ ] **Environment variables (recommended)**
- Move sensitive config to environment variables
- Don't commit credentials to Git
- [ ] **Add CSRF protection**
- Add tokens to forms
- Validate on submission
- [ ] **Rate limiting**
- Limit onboarding requests per IP
- Prevent abuse
---
## 📝 User Communication
- [ ] **Onboarding instructions page**
- Clear step-by-step guide
- Screenshots of PayPal flow
- What to expect at each step
- Troubleshooting tips
- [ ] **Email templates**
- Welcome email after successful connection
- Reminder email for incomplete onboarding
- Support contact information
- [ ] **Help documentation**
- FAQ about merchant onboarding
- Common issues and solutions
- How to reconnect if needed
---
## 🧪 Testing
- [ ] **Test with LIVE PayPal sandbox first**
- Use PayPal sandbox with production code
- Verify all flows work
- [ ] **Test complete onboarding**
- Full business account creation
- Email verification
- Bank account linking (if required)
- Return to your app
- [ ] **Test incomplete onboarding**
- Verify warning messages show
- Verify no bad data is saved
- Test retry flow
- [ ] **Test sync functionality**
- Manually trigger sync
- Verify it finds completed merchants
- Verify it marks incomplete ones
- [ ] **Test payment creation**
- Create orders with connected merchants
- Verify proper merchant_id routing
- Test capture flow
---
## 📊 Monitoring
- [ ] **Log monitoring**
- Set up alerts for errors
- Monitor `return_debug.json`
- Monitor `sync_debug.json`
- [ ] **Merchant status tracking**
- Dashboard for admin to view all merchants
- Filter by status (connected, incomplete, pending)
- Export functionality
- [ ] **Automated sync**
- Cron job to run sync_merchants.php
- Schedule: Every 5-10 minutes
- Alert on repeated failures
- [ ] **Metrics tracking**
- Onboarding completion rate
- Average time to complete
- Common drop-off points
- Failed onboarding reasons
---
## 🔄 Ongoing Maintenance
- [ ] **Weekly review**
- Check incomplete onboardings
- Follow up with merchants
- Resolve issues
- [ ] **Monthly cleanup**
- Archive old completed entries
- Remove abandoned onboardings (>30 days old)
- Backup data files
- [ ] **Quarterly updates**
- Check for PayPal API changes
- Update partner integration if needed
- Review and update documentation
---
## 🆘 Support Process
- [ ] **Support email/form**
- Dedicated support for onboarding issues
- Quick response time (<24 hours)
- [ ] **Troubleshooting guide for support team**
- How to check merchant status
- How to trigger manual sync
- How to view debug logs
- When to escalate to PayPal
- [ ] **Partner support contact**
- PayPal Partner Support phone/email
- Your Partner Manager contact
- Escalation process
---
## 📋 Pre-Launch Test Cases
### Test Case 1: Happy Path ✅
1. Merchant clicks "Connect with PayPal"
2. Completes entire onboarding at PayPal
3. Returns to your app
4. Sees success message
5. Merchant ID saved correctly
6. Can create and capture payments
### Test Case 2: Incomplete Onboarding ⚠️
1. Merchant clicks "Connect with PayPal"
2. Closes browser without completing
3. Returns to your app (manually)
4. Sees warning message
5. NO merchant ID saved
6. Can retry onboarding
### Test Case 3: Delayed Propagation ⏳
1. Merchant completes onboarding
2. Returns to your app
3. Merchant ID not immediately available
4. Clicks "Sync Now"
5. System retrieves merchant ID
6. Status updated to connected
### Test Case 4: Multiple Merchants 👥
1. Connect merchant A (complete)
2. Connect merchant B (incomplete)
3. Connect merchant C (complete)
4. Dashboard shows correct status for each
5. Can create orders ONLY for A and C
### Test Case 5: Reconnection 🔄
1. Merchant was incomplete
2. Clicks "Connect with PayPal" again
3. Completes full onboarding
4. Returns successfully
5. Status updated from incomplete to connected
---
## 🔍 Production Readiness Checklist
### Code Quality
- [ ] Error handling for all API calls
- [ ] Input validation on all forms
- [ ] SQL injection protection (if using DB)
- [ ] XSS protection
- [ ] Proper logging (not echo/print_r)
### Performance
- [ ] Response times < 2 seconds
- [ ] Optimize database queries (if using DB)
- [ ] Cache frequently accessed data
- [ ] CDN for static assets
### Scalability
- [ ] Can handle 100+ merchants
- [ ] Concurrent onboarding requests
- [ ] File storage vs. database decision
- [ ] Backup strategy
---
## 🎯 Launch Day
- [ ] Deploy to production
- [ ] Verify configuration
- [ ] Test one complete onboarding
- [ ] Monitor logs for 24 hours
- [ ] Have support team ready
- [ ] Gradual rollout (if possible)
---
## 📞 Emergency Contacts
- [ ] PayPal Partner Support: _________________
- [ ] PayPal Technical Issues: _________________
- [ ] Your DevOps Team: _________________
- [ ] Your Manager: _________________
---
## 🎉 Post-Launch (First Week)
- [ ] Day 1: Monitor every hour
- [ ] Day 2-3: Monitor every 4 hours
- [ ] Day 4-7: Monitor twice daily
- [ ] Review all incomplete onboardings
- [ ] Gather user feedback
- [ ] Document any issues found
- [ ] Plan improvements
---
## 📌 Remember
> The fix ensures that incomplete onboardings are properly detected and handled. Your main job now is to help merchants complete the full PayPal business account setup process. Communication is key! 🗝️
---
## Need Help?
Refer back to:
- `SOLUTION_SUMMARY.md` - Understanding the fix
- `FLOW_DIAGRAM.md` - Visual representation
- `README_ONBOARDING_FIX.md` - Detailed technical docs
- `QUICK_START.md` - Testing guide
Good luck with your launch! 🚀
+168
View File
@@ -0,0 +1,168 @@
# 🚀 QUICK START - Fix Your Merchant Onboarding
## What Just Happened?
I found and fixed the bug causing your merchants to get stuck in "onboarding" state. The problem: **your code was accepting your own partner ID as if it were the merchant's ID when they didn't complete PayPal signup.**
---
## 🔧 Step 1: Clean Up Old Data (2 minutes)
Open your browser and go to:
```
http://localhost/payp/cleanup.php
```
This will:
- Remove incorrect merchant IDs (your partner ID saved as merchant IDs)
- Mark incomplete onboardings properly
- Show you what was cleaned
**Do this NOW before testing!**
---
## ✅ Step 2: Test Complete Onboarding (5 minutes)
1. Go to: `http://localhost/payp/`
2. Fill in a school name and click **"Connect with PayPal"**
3. **IMPORTANT**: When redirected to PayPal:
- ✅ **DO**: Complete the ENTIRE business account setup
- ✅ **DO**: Fill in all required fields
- ✅ **DO**: Click through all steps until PayPal shows "Return to Application"
- ❌ **DON'T**: Close the browser early
- ❌ **DON'T**: Skip steps
4. Click **"Return to School Lunch App"** when PayPal prompts
5. **Verify Success**:
- You should see: ✅ "Onboarding completed and merchant saved"
- Dashboard shows the school in "Connected Schools"
- Merchant ID is **NOT** `NEG5HMUKCTQNL`
---
## ⚠️ Step 3: Test Incomplete Onboarding (2 minutes)
1. Go to: `http://localhost/payp/`
2. Fill in a different school name and click **"Connect with PayPal"**
3. **As soon as PayPal page loads**:
- Click "Return to School Lunch App" WITHOUT completing signup
- OR just close the browser tab
4. **Verify Failure Handling**:
- You should see: ⚠️ "Onboarding Incomplete" warning
- Dashboard shows the school in "Pending Onboarding" with warning icon
- NO merchant ID is saved
- Status shows: `incomplete_onboarding`
---
## 📊 Step 4: Check Your Data
Open these files and verify:
### `data/merchants.json`
Should contain only COMPLETED merchants with DIFFERENT IDs:
```json
{
"Test School": {
"merchant_id": "SOME_UNIQUE_ID", // NOT NEG5HMUKCTQNL
"tracking_id": "school_xxxxx",
"connected_at": 1234567890
}
}
```
### `data/pending_onboarding.json`
Should show clear status for each:
```json
{
"school_xxxxx": {
"school": "Complete School",
"merchant_id": "SOME_UNIQUE_ID",
"status": "connected" // ✅
},
"school_yyyyy": {
"school": "Incomplete School",
"status": "incomplete_onboarding", // ⚠️
"note": "Merchant did not complete PayPal onboarding"
}
}
```
---
## 🎯 Expected Results
### ✅ SUCCESS - Complete Onboarding
- Dashboard: ✅ Green success message
- Merchant ID: Different from `NEG5HMUKCTQNL`
- Status: `connected`
- Can create orders
### ⚠️ HANDLED - Incomplete Onboarding
- Dashboard: ⚠️ Yellow warning message
- No merchant ID saved
- Status: `incomplete_onboarding`
- Clear instructions to complete signup
### ❌ OLD BUG (Now Fixed)
- ~~Saves `NEG5HMUKCTQNL` as merchant ID~~
- ~~Status stuck at `connected` but not really connected~~
- ~~No warning messages~~
- ~~Can't create orders~~
---
## 🔍 Troubleshooting
### Problem: Still seeing `NEG5HMUKCTQNL` as merchant ID
- **Solution**: Run `cleanup.php` again, then test with a NEW school
### Problem: Dashboard doesn't show warnings
- **Solution**: Clear browser cache and refresh `index.php`
### Problem: Can't complete PayPal onboarding in sandbox
- **Solution**: Make sure you're using a PayPal sandbox business account for testing
- Check: https://developer.paypal.com/developer/accounts/
### Problem: Getting errors when creating orders
- **Solution**: Verify the merchant_id in `merchants.json` is NOT your partner ID
---
## 📋 Quick Checklist
- [ ] Ran `cleanup.php` to fix old data
- [ ] Tested complete onboarding (finish all PayPal steps)
- [ ] Saw ✅ success message
- [ ] Verified merchant ID is different from `NEG5HMUKCTQNL`
- [ ] Tested incomplete onboarding (close browser early)
- [ ] Saw ⚠️ warning message
- [ ] Verified NO merchant ID saved for incomplete
- [ ] Checked `data/merchants.json` has correct IDs
- [ ] Checked `data/pending_onboarding.json` has correct statuses
---
## 📚 More Information
- **Full explanation**: Read `SOLUTION_SUMMARY.md`
- **Visual flow**: Read `FLOW_DIAGRAM.md`
- **Detailed docs**: Read `README_ONBOARDING_FIX.md`
- **Diagnostics**: Open `partner_diag.php` in browser
---
## 🎉 You're Done!
Your PayPal merchant onboarding is now fixed! The key takeaway:
> **Merchants MUST complete the entire PayPal business account signup for you to receive their merchant ID. If they don't finish, the system now correctly detects this and shows a warning instead of saving incorrect data.**
Questions? Check the documentation files created in your project. 🚀
+145
View File
@@ -0,0 +1,145 @@
# PayPal Merchant Onboarding - Issue Fixed
## The Problem You Were Experiencing
Your application was getting **stuck in "onboarding" state** and never receiving the actual merchant ID. Instead, you were seeing your **partner merchant ID** (`NEG5HMUKCTQNL`) returned for all merchants.
### Root Cause
When merchants click the "Connect with PayPal" button, they are redirected to PayPal's onboarding flow. However:
1. **If they don't complete the entire signup process** (close browser, navigate away, etc.)
2. PayPal's API returns your **partner's own merchant ID** instead of the new merchant's ID
3. Your code was incorrectly saving this partner ID as if it were the merchant's ID
## What Was Fixed
### 1. **Partner ID Detection**
Added checks to reject the partner's own ID (`NEG5HMUKCTQNL`) when it's returned:
```php
// Don't accept the partner's own ID as merchant ID
if (strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
}
```
### 2. **Better Status Tracking**
Now tracks onboarding status:
- `incomplete_onboarding` - Merchant started but didn't complete PayPal signup
- `connected` - Merchant completed signup and ID is available
- `pending` - Waiting for PayPal to propagate the data
### 3. **User Feedback**
Enhanced the UI to show:
- ⚠️ Warning when onboarding is incomplete
- Clear instructions to complete the PayPal signup process
- Sync button to check for completed onboardings
## How To Use It Properly
### For Testing (Sandbox):
1. **Start Onboarding**: Click "Connect with PayPal" and enter a school name
2. **Complete the FULL PayPal Flow**:
- You'll be redirected to PayPal's sandbox signup
- **IMPORTANT**: You must complete the entire business account creation
- Don't just close the browser - finish all steps at PayPal
3. **Return**: After completing, you'll be redirected back
4. **Verify**: Check that the merchant ID is saved (not `NEG5HMUKCTQNL`)
### What Success Looks Like:
In `data/pending_onboarding.json`, you should see:
```json
{
"school_xxx": {
"school": "Test School",
"merchant_id": "SOME_OTHER_ID_NOT_YOUR_PARTNER_ID",
"status": "connected",
"connected_at": 1234567890
}
}
```
### What Failure Looks Like (Before Fix):
```json
{
"school_xxx": {
"school": "Test School",
"merchant_id": "NEG5HMUKCTQNL", // ❌ This is YOUR partner ID, not the merchant's!
"status": "connected"
}
}
```
### What Incomplete Looks Like (After Fix):
```json
{
"school_xxx": {
"school": "Test School",
"status": "incomplete_onboarding",
"note": "Merchant did not complete PayPal onboarding. Partner ID was returned instead."
}
}
```
## Files Modified
1. **return.php** - Return handler after PayPal onboarding
- Added partner ID detection and rejection
- Added incomplete status tracking
2. **sync_merchants.php** - Sync pending merchants
- Added partner ID filtering in all lookup methods
- Marks incomplete onboardings
3. **index.php** - Main dashboard
- Shows incomplete onboarding warnings
- Better status messages
## Testing Your Fix
1. **Clear old data**:
- Delete or backup `data/merchants.json` and `data/pending_onboarding.json`
2. **Test incomplete onboarding**:
- Click "Connect with PayPal"
- When redirected to PayPal, immediately click "Return to School Lunch App" WITHOUT completing signup
- You should see "⚠️ Onboarding Incomplete" warning
- No incorrect merchant ID should be saved
3. **Test complete onboarding**:
- Click "Connect with PayPal"
- **Complete the ENTIRE PayPal business account setup**
- You should see "✅ Onboarding completed"
- Verify the merchant ID is NOT `NEG5HMUKCTQNL`
## Expected Merchant IDs in Sandbox
- ✅ **Different from your partner ID**: Good! This is a real merchant
- ❌ **Same as partner ID** (`NEG5HMUKCTQNL`): Bad! Onboarding not completed
## One Successful Example Found
Looking at your debug logs, I found ONE successful onboarding:
- School: "b"
- Tracking ID: `school_3387003d82`
- Merchant ID: `796GQ4VM8SWSQ` ✅
This proves your integration works when merchants **complete the full signup process**.
## Next Steps
1. Test with a fresh onboarding
2. Make sure to complete ALL steps at PayPal during signup
3. For production, ensure your merchants understand they must complete the entire PayPal business account setup
4. Monitor `data/pending_onboarding.json` for `incomplete_onboarding` status entries
## Questions?
- Check `partner_diag.php` to see all merchant integrations
- Review `data/sync_debug.json` for detailed API responses
- Ensure your PayPal Partner credentials are correct in `config.php`
+178
View File
@@ -0,0 +1,178 @@
# 🎯 SOLUTION SUMMARY - PayPal Merchant Onboarding Issue
## The Problem
You were **always** getting your business account in "onboarding" state and never receiving actual merchant IDs. All entries in `pending_onboarding.json` showed only:
- `partner_referral_id`
- `referral_token`
- **NO `merchant_id`** field
When checking, you kept finding **your own partner merchant ID** (`NEG5HMUKCTQNL`) instead of unique merchant IDs.
---
## Root Cause Discovered ✅
After analyzing your debug logs, I found the issue:
**PayPal returns YOUR PARTNER ID when merchants DON'T complete the full signup process!**
Looking at `data/return_debug.json` and `data/sync_debug.json`:
- Most entries show: `"merchant_id": "NEG5HMUKCTQNL"` ❌ (your partner ID)
- ONE successful entry: `"merchant_id": "796GQ4VM8SWSQ"` ✅ (actual merchant - school "b")
This proves:
1. Your integration code is **working correctly**
2. Merchants are **not completing** the PayPal onboarding flow
3. When incomplete, PayPal returns the partner's ID as a placeholder
---
## What I Fixed 🔧
### 1. **Partner ID Detection**
Modified these files to reject your partner ID:
- `return.php` - Return handler after onboarding
- `sync_merchants.php` - Merchant sync utility
Now the code checks:
```php
if (strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
// Only accept if NOT the partner's own ID
$merchantId = $foundId;
}
```
### 2. **Status Tracking**
Added proper status field:
- `incomplete_onboarding` - User didn't finish PayPal signup
- `connected` - Successfully onboarded with real merchant ID
- `pending` - Waiting for PayPal to propagate
### 3. **User Interface**
Enhanced `index.php` to show:
- ⚠️ **Warning** for incomplete onboardings
- Clear instructions for users
- Visual status indicators
---
## Files Modified
| File | Changes |
|------|---------|
| `return.php` | Added partner ID filtering, status tracking |
| `sync_merchants.php` | Added partner ID checks in all lookup methods |
| `index.php` | Added incomplete onboarding warnings & better UI |
| `cleanup.php` | **NEW** - Utility to remove incorrect data |
| `README_ONBOARDING_FIX.md` | **NEW** - Full documentation |
---
## How To Fix Your Data 🧹
### Step 1: Run Cleanup
```
http://localhost/payp/cleanup.php
```
This will:
- Remove entries with incorrect partner IDs
- Mark incomplete onboardings
- Show you what was cleaned
### Step 2: Test New Onboarding
1. Go to `http://localhost/payp/`
2. Click "Connect with PayPal"
3. **IMPORTANT**: Complete the ENTIRE PayPal business account setup
- Don't just close the browser
- Finish all signup steps at PayPal
4. Click "Return to Application" when done
5. Verify you see: ✅ "Onboarding completed and merchant saved"
### Step 3: Verify Success
Check `data/pending_onboarding.json`:
```json
{
"school_xxx": {
"school": "Your School",
"merchant_id": "SOME_NEW_ID", // NOT NEG5HMUKCTQNL
"status": "connected"
}
}
```
---
## Why This Was Happening 🤔
### In PayPal Sandbox Testing:
1. You click "Connect with PayPal"
2. Redirected to PayPal's onboarding page
3. **If you don't complete all steps** and just click "Return"
4. PayPal returns your partner ID as a fallback
5. Old code accepted it and saved it incorrectly
### Your One Success:
School "b" (tracking: `school_3387003d82`) got ID: `796GQ4VM8SWSQ`
- This proves someone **completed the full onboarding**
- That's why it worked for this one!
---
## Testing Checklist ✅
- [ ] Run `cleanup.php` to fix old data
- [ ] Try new onboarding - **INCOMPLETE** (close browser early)
- Should see: ⚠️ "Onboarding Incomplete" warning
- Should NOT save merchant ID
- [ ] Try new onboarding - **COMPLETE** (finish all steps)
- Should see: ✅ "Onboarding completed"
- Should save DIFFERENT merchant ID (not `NEG5HMUKCTQNL`)
- [ ] Check `merchants.json` has correct IDs
- [ ] Test creating an order with the new merchant
---
## Quick Reference 📚
### Good Merchant ID:
```json
"merchant_id": "796GQ4VM8SWSQ" ✅ DIFFERENT from partner ID
```
### Bad Merchant ID (Before Fix):
```json
"merchant_id": "NEG5HMUKCTQNL" ❌ This is YOUR partner ID!
```
### Incomplete Status (After Fix):
```json
{
"status": "incomplete_onboarding",
"note": "Merchant did not complete PayPal onboarding"
}
```
---
## Need Help? 🆘
1. **View diagnostics**: `http://localhost/payp/partner_diag.php`
2. **Check debug logs**:
- `data/return_debug.json`
- `data/sync_debug.json`
3. **Read full docs**: `README_ONBOARDING_FIX.md`
---
## Production Considerations ⚠️
For production (when you go live):
1. **User Education**: Tell merchants they MUST complete the full PayPal signup
2. **Email Reminders**: Send follow-up if onboarding incomplete
3. **Support Flow**: Have a process to help merchants who get stuck
4. **Monitoring**: Watch for `incomplete_onboarding` status entries
---
**Bottom Line**: Your code is working! The issue was accepting the partner ID when merchants didn't finish signup. Now it's fixed and you'll see clear warnings when onboarding is incomplete. 🎉
+38
View File
@@ -0,0 +1,38 @@
<?php
$config = require __DIR__ . '/config.php';
require __DIR__ . '/lib/PayPalClient.php';
require __DIR__ . '/lib/Storage.php';
$orderId = $_GET['token'] ?? $_GET['orderId'] ?? null; // token param from PayPal return
if (!$orderId) {
http_response_code(400);
echo 'Missing orderId/token';
exit;
}
$client = new PayPalClient($config);
$storage = new Storage($config['storage_path']);
// Capture the order
$resp = $client->api('POST', '/v2/checkout/orders/' . urlencode($orderId) . '/capture');
if ($resp['status'] < 200 || $resp['status'] >= 300) {
http_response_code(500);
echo 'Capture failed: ' . htmlspecialchars(json_encode($resp));
exit;
}
$capture = $resp['body'];
// Update local order store
$orders = $storage->get('orders', []);
if (isset($orders[$orderId])) {
$orders[$orderId]['status'] = $capture['status'] ?? 'CAPTURED';
$storage->set('orders', $orders);
}
// Redirect back to home with a status indicator
$baseUrl = rtrim($config['base_url'], '/');
$status = isset($capture['status']) ? $capture['status'] : 'CAPTURED';
header('Location: ' . $baseUrl . '/index.php?captured=1&order=' . urlencode($orderId) . '&status=' . urlencode($status));
exit;
+121
View File
@@ -0,0 +1,121 @@
<?php
/**
* Cleanup utility to remove incorrect merchant IDs (partner IDs saved as merchant IDs)
* Run this once to clean up the bad data from before the fix
*/
$config = require __DIR__ . '/config.php';
require __DIR__ . '/lib/Storage.php';
header('Content-Type: text/html; charset=utf-8');
$storage = new Storage($config['storage_path']);
$partnerMerchantId = $config['partner_merchant_id'];
$cleaned = [
'merchants' => 0,
'pending' => 0,
];
// Clean merchants.json
$merchants = $storage->get('merchants', []);
$originalMerchantsCount = count($merchants);
foreach ($merchants as $school => $info) {
if (isset($info['merchant_id']) && strcasecmp($info['merchant_id'], $partnerMerchantId) === 0) {
unset($merchants[$school]);
$cleaned['merchants']++;
}
}
if ($cleaned['merchants'] > 0) {
$storage->set('merchants', $merchants);
}
// Clean pending_onboarding.json
$pending = $storage->get('pending_onboarding', []);
foreach ($pending as $trackId => &$entry) {
if (!is_array($entry)) continue;
if (isset($entry['merchant_id']) && strcasecmp($entry['merchant_id'], $partnerMerchantId) === 0) {
// Remove the incorrect merchant_id and mark as incomplete
unset($entry['merchant_id']);
$entry['status'] = 'incomplete_onboarding';
$entry['note'] = 'Removed incorrect partner ID. Merchant needs to complete onboarding.';
$entry['cleaned_at'] = time();
$cleaned['pending']++;
}
}
unset($entry);
if ($cleaned['pending'] > 0) {
$storage->set('pending_onboarding', $pending);
}
?><!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Cleanup Utility - PayPal Integration</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
.box { border: 1px solid #ddd; padding: 1.5rem; border-radius: .5rem; margin-bottom: 1rem; }
.success { background: #d4edda; border-color: #c3e6cb; color: #155724; }
.warning { background: #fff3cd; border-color: #ffeaa7; color: #856404; }
.info { background: #d1ecf1; border-color: #bee5eb; color: #0c5460; }
code { background: #f4f4f4; padding: .2rem .4rem; border-radius: .25rem; }
a { color: #0070ba; }
</style>
</head>
<body>
<h1>🧹 Data Cleanup Complete</h1>
<?php if ($cleaned['merchants'] > 0 || $cleaned['pending'] > 0): ?>
<div class="box success">
<h2>✅ Cleanup Results</h2>
<p><strong><?= $cleaned['merchants'] + $cleaned['pending'] ?></strong> incorrect entries fixed:</p>
<ul>
<li><strong><?= $cleaned['merchants'] ?></strong> incorrect merchant entries removed from <code>merchants.json</code></li>
<li><strong><?= $cleaned['pending'] ?></strong> pending entries corrected in <code>pending_onboarding.json</code></li>
</ul>
</div>
<?php else: ?>
<div class="box info">
<h2>ℹ️ No Cleanup Needed</h2>
<p>No incorrect merchant IDs found. Your data is clean!</p>
</div>
<?php endif; ?>
<div class="box warning">
<h3>⚠️ About the Cleanup</h3>
<p>This utility removed entries where your <strong>partner merchant ID</strong> (<code><?= htmlspecialchars($partnerMerchantId) ?></code>) was incorrectly saved as a merchant's ID.</p>
<p>These merchants will need to:</p>
<ol>
<li>Click "Connect with PayPal" again</li>
<li><strong>Complete the ENTIRE PayPal business account setup</strong></li>
<li>Return to your application to get properly connected</li>
</ol>
</div>
<div class="box info">
<h3>📊 Current Status</h3>
<p><strong>Total connected merchants:</strong> <?= count($merchants) ?></p>
<p><strong>Total pending entries:</strong> <?= count($pending) ?></p>
<?php if (count($merchants) > 0): ?>
<h4>Connected Merchants:</h4>
<ul>
<?php foreach ($merchants as $school => $info): ?>
<li><strong><?= htmlspecialchars($school) ?></strong> - <code><?= htmlspecialchars($info['merchant_id']) ?></code></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<div class="box">
<h3>🔗 Next Steps</h3>
<ul>
<li><a href="index.php">← Back to Dashboard</a></li>
<li><a href="partner_diag.php" target="_blank">View Partner Diagnostics</a></li>
</ul>
<p><small><strong>Note:</strong> You can safely run this cleanup script multiple times. It only removes entries that match your partner merchant ID.</small></p>
</div>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
<?php
// Basic configuration for PayPal integration.
// Fill in your Sandbox (or Live) credentials from developer.paypal.com
return [
// 'sandbox' or 'live'
'env' => 'sandbox',
// PayPal REST App credentials for your Partner (platform) app
'client_id' => 'AZ0RTbfvdUAECrswIOBVnSzLMBgvAzv8qI56YfXPGN46ukzx4qhmeflsOodvzNHE7wd5MRXupBxFmvr0',
'client_secret' => 'EN-EaI-l4s9_CQOWzvLhVa-N1JHMUWjR22b3kfn3u8q5IHa3-hGRe6X8Sh_0LRc3jZyGTKvsV3QjeW5U',
// Your Partner's PayPal Merchant ID (aka Payer ID). Required to look up merchant info by tracking_id
// Find this in Business Profile > Account IDs in your PayPal dashboard (sandbox or live as appropriate)
'partner_merchant_id' => 'NEG5HMUKCTQNL',
// Your base URL as accessed by the browser. Adjust port if using a different Apache port.
// For XAMPP default: http://localhost/payp
'base_url' => 'http://localhost/payp',
// Storage path (must be writable by Apache/PHP)
'storage_path' => __DIR__ . DIRECTORY_SEPARATOR . 'data',
];
+88
View File
@@ -0,0 +1,88 @@
<?php
$config = require __DIR__ . '/config.php';
require __DIR__ . '/lib/PayPalClient.php';
require __DIR__ . '/lib/Storage.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo 'Method Not Allowed';
exit;
}
$school = trim($_POST['school'] ?? '');
$amount = (string)($_POST['amount'] ?? '0');
if ($school === '' || !is_numeric($amount) || (float)$amount <= 0) {
http_response_code(400);
echo 'Invalid input';
exit;
}
$storage = new Storage($config['storage_path']);
$merchants = $storage->get('merchants', []);
$merchantId = $merchants[$school]['merchant_id'] ?? null;
if (!$merchantId) {
http_response_code(400);
echo 'School not onboarded';
exit;
}
$client = new PayPalClient($config);
$returnUrl = rtrim($config['base_url'], '/') . '/capture_order.php';
$cancelUrl = rtrim($config['base_url'], '/') . '/index.php';
// Orders v2 create, routing funds to payee.merchant_id
$payload = [
'intent' => 'CAPTURE',
'purchase_units' => [[
'amount' => [
'currency_code' => 'USD',
'value' => number_format((float)$amount, 2, '.', ''),
],
'payee' => [
'merchant_id' => $merchantId,
],
'description' => 'School Lunch Payment for ' . $school,
]],
'application_context' => [
'brand_name' => 'School Lunch Service',
'landing_page' => 'LOGIN',
'shipping_preference' => 'NO_SHIPPING',
'user_action' => 'PAY_NOW',
'return_url' => $returnUrl,
'cancel_url' => $cancelUrl,
],
];
$resp = $client->api('POST', '/v2/checkout/orders', $payload);
if ($resp['status'] < 200 || $resp['status'] >= 300) {
http_response_code(500);
echo 'Create order failed: ' . htmlspecialchars(json_encode($resp));
exit;
}
$order = $resp['body'];
$approve = null;
foreach (($order['links'] ?? []) as $lnk) {
if (($lnk['rel'] ?? '') === 'approve') { $approve = $lnk['href']; break; }
}
if (!$approve) {
http_response_code(500);
echo 'Missing approval link';
exit;
}
// Save a minimal order record for demo
$orders = $storage->get('orders', []);
$orders[$order['id']] = [
'school' => $school,
'amount' => $payload['purchase_units'][0]['amount']['value'],
'merchant_id' => $merchantId,
'status' => 'CREATED',
];
$storage->set('orders', $orders);
header('Location: ' . $approve);
exit;
+22
View File
@@ -0,0 +1,22 @@
{
"b": {
"merchant_id": "796GQ4VM8SWSQ",
"tracking_id": "school_3387003d82",
"connected_at": 1760885871
},
"lf": {
"merchant_id": "7A68CU68VGK8J",
"tracking_id": "school_b97918b706",
"connected_at": 1760889241
},
"lyc\u00e9e": {
"merchant_id": "7A68CU68VGK8J",
"tracking_id": "school_44045be337",
"connected_at": 1760889638
},
"testing": {
"merchant_id": "7A68CU68VGK8J",
"tracking_id": "school_08b8491e27",
"connected_at": 1760895157
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"1FC77406FJ583553M": {
"school": "lf",
"amount": "5.00",
"merchant_id": "7A68CU68VGK8J",
"status": "CREATED"
}
}
+56
View File
@@ -0,0 +1,56 @@
{
"school_a0e12d4318": {
"school": "aa",
"created_at": 1760886320,
"partner_referral_id": "ODRiNWQ0NDEtODE0NC00ZTE1LWIzOTEtNTU1MTg3Njk3MWQ2N1FUWkFmRENxc3lJQjNFUjloZEx6VHRjVGNMQkJpN2ZoVXVLNTFXc1p1RT12Mg==",
"referral_token": "ODRiNWQ0NDEtODE0NC00ZTE1LWIzOTEtNTU1MTg3Njk3MWQ2N1FUWkFmRENxc3lJQjNFUjloZEx6VHRjVGNMQkJpN2ZoVXVLNTFXc1p1RT12Mg==",
"status": "incomplete_onboarding",
"last_sync_at": 1760895145,
"note": "Merchant has not completed PayPal onboarding. Only partner ID is available."
},
"school_b97918b706": {
"school": "lf",
"created_at": 1760889168,
"partner_referral_id": "M2QzOTkwZmEtYmFhNS00NTc2LTk0OTMtYzlkZDExNTJmMDI5K1F1bEVCalQ2cG5xRTJCTVQ5RERPWnZvLzZQelVndGdDQkMvbytHMENMND12Mg==",
"referral_token": "M2QzOTkwZmEtYmFhNS00NTc2LTk0OTMtYzlkZDExNTJmMDI5K1F1bEVCalQ2cG5xRTJCTVQ5RERPWnZvLzZQelVndGdDQkMvbytHMENMND12Mg==",
"merchant_id": "7A68CU68VGK8J",
"status": "connected",
"connected_at": 1760889241
},
"school_6d371a110a": {
"school": "jjj",
"created_at": 1760889277,
"partner_referral_id": "NTYzYzEwZjAtMGM0NS00ZjU2LTg5ZGUtMTNiYWVlYWMxMjdmYmcyZ2M4dDBrRWJwYzJDcUdYQ3Y0SHFUU0NjUjZoa0dIdGo4N25EUnZDbz12Mg==",
"referral_token": "NTYzYzEwZjAtMGM0NS00ZjU2LTg5ZGUtMTNiYWVlYWMxMjdmYmcyZ2M4dDBrRWJwYzJDcUdYQ3Y0SHFUU0NjUjZoa0dIdGo4N25EUnZDbz12Mg==",
"status": "incomplete_onboarding",
"last_sync_at": 1760895149,
"note": "Merchant has not completed PayPal onboarding. Only partner ID is available."
},
"school_c7bc4cfab7": {
"school": "lyc\u00e9e france",
"created_at": 1760889357,
"partner_referral_id": "ODc5MTljZDUtY2Y4My00NGZjLWJjMDItM2EzZWIyZjFlOTkxdDcwTkRIY0E5UEgrR3ZHZ24wK1VCVjYvR3l6S2FHNmNPSDlTMUZOSWdwST12Mg==",
"referral_token": "ODc5MTljZDUtY2Y4My00NGZjLWJjMDItM2EzZWIyZjFlOTkxdDcwTkRIY0E5UEgrR3ZHZ24wK1VCVjYvR3l6S2FHNmNPSDlTMUZOSWdwST12Mg==",
"status": "incomplete_onboarding",
"last_sync_at": 1760895155,
"note": "Merchant has not completed PayPal onboarding. Only partner ID is available."
},
"school_44045be337": {
"school": "lyc\u00e9e",
"created_at": 1760889386,
"partner_referral_id": "ODZlZTU3ODctZWMwMy00MmY4LWIyYWItZDhiYjk3OTA0ZWMwZ21zRjZtdjVRb0JOR2g3L2JiY29MNnluNFRpL3BZcm1lTENHRDF3TTZnMD12Mg==",
"referral_token": "ODZlZTU3ODctZWMwMy00MmY4LWIyYWItZDhiYjk3OTA0ZWMwZ21zRjZtdjVRb0JOR2g3L2JiY29MNnluNFRpL3BZcm1lTENHRDF3TTZnMD12Mg==",
"merchant_id": "7A68CU68VGK8J",
"status": "connected",
"connected_at": 1760889638
},
"school_08b8491e27": {
"school": "testing",
"created_at": 1760895090,
"partner_referral_id": "MzIwZjhlZGYtMWEyZS00NjQzLTgzNDgtYmU2OTE4OTcxZGJjSE9IWlNlbjhoVVduc0tqUk53bTR0and5VjN6K2xUREZpekN3MUY0ckRGND12Mg==",
"referral_token": "MzIwZjhlZGYtMWEyZS00NjQzLTgzNDgtYmU2OTE4OTcxZGJjSE9IWlNlbjhoVVduc0tqUk53bTR0and5VjN6K2xUREZpekN3MUY0ckRGND12Mg==",
"merchant_id": "7A68CU68VGK8J",
"status": "connected",
"connected_at": 1760895157
}
}
+464
View File
@@ -0,0 +1,464 @@
[
{
"ts": 1760741267,
"school": "aq",
"tracking_id": "school_84f87ff983",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/MWMzYTk3OTItOWIwOC00YzQyLTlkZWMtYmE1YTAxMGI0ZTU1OVB4aFdySHdjVGRJQkJqbUNtZ3VLV2ZpN3lMYUpRREdBVzlyZG5rVndyST12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=MWMzYTk3OTItOWIwOC00YzQyLTlkZWMtYmE1YTAxMGI0ZTU1OVB4aFdySHdjVGRJQkJqbUNtZ3VLV2ZpN3lMYUpRREdBVzlyZG5rVndyST12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760743658,
"school": "g",
"tracking_id": "school_1d70dd81c8",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/ODQxM2MzYjItMDdhYi00ODY0LWIyMDYtYjRjMzIwNjBlZWFjWkRYTUZzWWdZcUdyYkZTMEhmSU1Pa21MMUZ3RUF6YmszZjNJMGJxTlZqWT12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=ODQxM2MzYjItMDdhYi00ODY0LWIyMDYtYjRjMzIwNjBlZWFjWkRYTUZzWWdZcUdyYkZTMEhmSU1Pa21MMUZ3RUF6YmszZjNJMGJxTlZqWT12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760744309,
"school": "v",
"tracking_id": "school_151a17f8e4",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/YmFkYjNhNDItNmUxYS00MmJkLWExMjktZThmNGZkNzJiNjY0VFk3cURnd2RkMmRxK29EaStpeFBxZG1sZXBWOS9xaVFZdnFoQnhMK0RSTT12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=YmFkYjNhNDItNmUxYS00MmJkLWExMjktZThmNGZkNzJiNjY0VFk3cURnd2RkMmRxK29EaStpeFBxZG1sZXBWOS9xaVFZdnFoQnhMK0RSTT12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760794408,
"school": "ggggggggg",
"tracking_id": "school_c6bf6ca93c",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/ZmQzZDk5ZDYtYTY1MC00ZjQyLWJhZmItOGYzOGM2NzM1ODkwSnFmUDBuOGsrd3Q1cVlENTR4Nmp6TWl1aWM2QXIrL0xFaDBheGpiY0JFZz12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=ZmQzZDk5ZDYtYTY1MC00ZjQyLWJhZmItOGYzOGM2NzM1ODkwSnFmUDBuOGsrd3Q1cVlENTR4Nmp6TWl1aWM2QXIrL0xFaDBheGpiY0JFZz12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760795720,
"school": "s",
"tracking_id": "school_dfd4578e02",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/YjJjNzQ0ZGEtMTg3Zi00NTBiLTkyYmUtNGM0YmE0MTlhNDE4K0hRQlllMTBTY2RRaWtjNGUrZllJOEp4Q0dpRXRoa3VoeEhlZVl5OFlZYz12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=YjJjNzQ0ZGEtMTg3Zi00NTBiLTkyYmUtNGM0YmE0MTlhNDE4K0hRQlllMTBTY2RRaWtjNGUrZllJOEp4Q0dpRXRoa3VoeEhlZVl5OFlZYz12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760796067,
"school": "m",
"tracking_id": "school_c626754298",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/MjkzOTU5ZTgtMjc5Yy00YzA4LThlMDktOTZlNjZkOTA5Mjk3M3hNRFpORHFtRVNEWVJJRDBDTmpETC84dm9VcGUzRGpSWEJWSDlHV3hZUT12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=MjkzOTU5ZTgtMjc5Yy00YzA4LThlMDktOTZlNjZkOTA5Mjk3M3hNRFpORHFtRVNEWVJJRDBDTmpETC84dm9VcGUzRGpSWEJWSDlHV3hZUT12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760796068,
"school": "m",
"tracking_id": "school_c626754298",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/OGI3MjZkZGMtNDJhNS00OTZjLThhYjItNGFiM2ZmMDA0NjcwdVBGTVF0UXkzeUtyRFA4dFI0Mzk5Z3NRaGpRN3ZScDkzZ1Q4OHgvY2VCST12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=OGI3MjZkZGMtNDJhNS00OTZjLThhYjItNGFiM2ZmMDA0NjcwdVBGTVF0UXkzeUtyRFA4dFI0Mzk5Z3NRaGpRN3ZScDkzZ1Q4OHgvY2VCST12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760796822,
"school": "ml",
"tracking_id": "school_c6fbbf852c",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/MTU4MzNhNjItNTkxMC00YTdlLWJjMjgtMWM4OTA5Zjg2MzgzYmE3elZRalRYNVpVYzFJWTBxdG5RbGF6UXNQclNUWFkzL2p4K3ZrVFEvND12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=MTU4MzNhNjItNTkxMC00YTdlLWJjMjgtMWM4OTA5Zjg2MzgzYmE3elZRalRYNVpVYzFJWTBxdG5RbGF6UXNQclNUWFkzL2p4K3ZrVFEvND12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760797314,
"school": "b",
"tracking_id": "school_3387003d82",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/MTQ2NjlmZTYtNjBlNy00YmU1LTg5MzQtMjRjNzU3NDAwMDgzN1dkZkl1NFM2bkZ4SmwrMGdUbWF1TUpYc2tjeHZ3Q241QmNCYkNQVktVTT12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=MTQ2NjlmZTYtNjBlNy00YmU1LTg5MzQtMjRjNzU3NDAwMDgzN1dkZkl1NFM2bkZ4SmwrMGdUbWF1TUpYc2tjeHZ3Q241QmNCYkNQVktVTT12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760884455,
"school": "pol",
"tracking_id": "school_a5a7e52f14",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/NjU5NGZlYTgtNzE5ZC00OGYyLWFmOGYtYTA2ZjU3M2VmMTA3ZW5JTkNwdVp6NXZQbW1xWC9Mak5CcFh2dG9TdGVqdHdRSWU0R3BtNUtYOD12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=NjU5NGZlYTgtNzE5ZC00OGYyLWFmOGYtYTA2ZjU3M2VmMTA3ZW5JTkNwdVp6NXZQbW1xWC9Mak5CcFh2dG9TdGVqdHdRSWU0R3BtNUtYOD12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760884706,
"school": "m",
"tracking_id": "school_ab32258ab0",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/YzU2NGVlMWEtYTIzYy00ZmRlLWE3ZmYtYjA2YjQ3NmE4Mzk3bVcyTFR4T2UrMHJvMkU3WmZOUklvUjVJSTZzU2JQMkF4VmVJT2FmV0JHND12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=YzU2NGVlMWEtYTIzYy00ZmRlLWE3ZmYtYjA2YjQ3NmE4Mzk3bVcyTFR4T2UrMHJvMkU3WmZOUklvUjVJSTZzU2JQMkF4VmVJT2FmV0JHND12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760885845,
"school": "sss",
"tracking_id": "school_486d3e9b54",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/YjU1MWExZmMtMjM2MS00NWU2LWE4ZDMtNTQ4NzA4NzNhOWVlOVpWc29ValdqT240d3kwcXpaQmFJRWpZcGs5UjJzcmhiOU9MbythdkFSVT12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=YjU1MWExZmMtMjM2MS00NWU2LWE4ZDMtNTQ4NzA4NzNhOWVlOVpWc29ValdqT240d3kwcXpaQmFJRWpZcGs5UjJzcmhiOU9MbythdkFSVT12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760885872,
"school": "sssssss",
"tracking_id": "school_26fa793cb8",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/MjNmNGY2ZjUtODE5My00ODA4LWFiOGMtMTA1YzcxNzk2MmJjeEJnQ3EwejFjd2doa3BYQkVDSEx1dmV4a1FlalNjV1ZLYW9IZHRScktKcz12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=MjNmNGY2ZjUtODE5My00ODA4LWFiOGMtMTA1YzcxNzk2MmJjeEJnQ3EwejFjd2doa3BYQkVDSEx1dmV4a1FlalNjV1ZLYW9IZHRScktKcz12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760885933,
"school": "mmmmmmmmmmmmmmmmmmmmmmm",
"tracking_id": "school_a963bb26d0",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/YmEwZGI3ZjgtYjFiNi00MjU1LTg3Y2UtYTBkYjY0ODE2YmFhZHh3UHpKVDg3clJ1cDA1eXNwcWJpSFFpZkFtQjRNMXpQRHJHMjRjSllYWT12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=YmEwZGI3ZjgtYjFiNi00MjU1LTg3Y2UtYTBkYjY0ODE2YmFhZHh3UHpKVDg3clJ1cDA1eXNwcWJpSFFpZkFtQjRNMXpQRHJHMjRjSllYWT12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760886144,
"school": "new",
"tracking_id": "school_5d71344bab",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/YjA1YzI4NDYtNjUyMS00NDI4LWIxZTYtODFjNzY1MzFlY2FjdWZQcHVJdng3UUxBUmQ4YWlBSEUzejhFZEZjQXdTUmVqaGhLNGFCOVJxYz12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=YjA1YzI4NDYtNjUyMS00NDI4LWIxZTYtODFjNzY1MzFlY2FjdWZQcHVJdng3UUxBUmQ4YWlBSEUzejhFZEZjQXdTUmVqaGhLNGFCOVJxYz12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760886320,
"school": "aa",
"tracking_id": "school_a0e12d4318",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/ODRiNWQ0NDEtODE0NC00ZTE1LWIzOTEtNTU1MTg3Njk3MWQ2N1FUWkFmRENxc3lJQjNFUjloZEx6VHRjVGNMQkJpN2ZoVXVLNTFXc1p1RT12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=ODRiNWQ0NDEtODE0NC00ZTE1LWIzOTEtNTU1MTg3Njk3MWQ2N1FUWkFmRENxc3lJQjNFUjloZEx6VHRjVGNMQkJpN2ZoVXVLNTFXc1p1RT12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760889168,
"school": "lf",
"tracking_id": "school_b97918b706",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/M2QzOTkwZmEtYmFhNS00NTc2LTk0OTMtYzlkZDExNTJmMDI5K1F1bEVCalQ2cG5xRTJCTVQ5RERPWnZvLzZQelVndGdDQkMvbytHMENMND12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=M2QzOTkwZmEtYmFhNS00NTc2LTk0OTMtYzlkZDExNTJmMDI5K1F1bEVCalQ2cG5xRTJCTVQ5RERPWnZvLzZQelVndGdDQkMvbytHMENMND12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760889277,
"school": "jjj",
"tracking_id": "school_6d371a110a",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/NTYzYzEwZjAtMGM0NS00ZjU2LTg5ZGUtMTNiYWVlYWMxMjdmYmcyZ2M4dDBrRWJwYzJDcUdYQ3Y0SHFUU0NjUjZoa0dIdGo4N25EUnZDbz12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=NTYzYzEwZjAtMGM0NS00ZjU2LTg5ZGUtMTNiYWVlYWMxMjdmYmcyZ2M4dDBrRWJwYzJDcUdYQ3Y0SHFUU0NjUjZoa0dIdGo4N25EUnZDbz12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760889357,
"school": "lyc\u00e9e france",
"tracking_id": "school_c7bc4cfab7",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/ODc5MTljZDUtY2Y4My00NGZjLWJjMDItM2EzZWIyZjFlOTkxdDcwTkRIY0E5UEgrR3ZHZ24wK1VCVjYvR3l6S2FHNmNPSDlTMUZOSWdwST12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=ODc5MTljZDUtY2Y4My00NGZjLWJjMDItM2EzZWIyZjFlOTkxdDcwTkRIY0E5UEgrR3ZHZ24wK1VCVjYvR3l6S2FHNmNPSDlTMUZOSWdwST12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760889386,
"school": "lyc\u00e9e",
"tracking_id": "school_44045be337",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/ODZlZTU3ODctZWMwMy00MmY4LWIyYWItZDhiYjk3OTA0ZWMwZ21zRjZtdjVRb0JOR2g3L2JiY29MNnluNFRpL3BZcm1lTENHRDF3TTZnMD12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=ODZlZTU3ODctZWMwMy00MmY4LWIyYWItZDhiYjk3OTA0ZWMwZ21zRjZtdjVRb0JOR2g3L2JiY29MNnluNFRpL3BZcm1lTENHRDF3TTZnMD12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
},
{
"ts": 1760895090,
"school": "testing",
"tracking_id": "school_08b8491e27",
"status": 201,
"body": {
"links": [
{
"href": "https:\/\/api.sandbox.paypal.com\/v2\/customer\/partner-referrals\/MzIwZjhlZGYtMWEyZS00NjQzLTgzNDgtYmU2OTE4OTcxZGJjSE9IWlNlbjhoVVduc0tqUk53bTR0and5VjN6K2xUREZpekN3MUY0ckRGND12Mg==",
"rel": "self",
"method": "GET",
"description": "Read Referral Data shared by the Caller."
},
{
"href": "https:\/\/www.sandbox.paypal.com\/bizsignup\/partner\/entry?referralToken=MzIwZjhlZGYtMWEyZS00NjQzLTgzNDgtYmU2OTE4OTcxZGJjSE9IWlNlbjhoVVduc0tqUk53bTR0and5VjN6K2xUREZpekN3MUY0ckRGND12Mg==",
"rel": "action_url",
"method": "GET",
"description": "Target WEB REDIRECT URL for the next action. Customer should be redirected to this URL in the browser."
}
]
}
}
]
+44
View File
@@ -0,0 +1,44 @@
[
{
"ts": 1760884801,
"query": {
"school": "pol",
"track": "school_a5a7e52f14"
},
"last_status": 200,
"last_body": {
"merchant_id": "NEG5HMUKCTQNL",
"tracking_id": "school_a5a7e52f14",
"links": [
{
"href": "\/v1\/customer\/partners\/NEG5HMUKCTQNL\/merchant-integrations\/NEG5HMUKCTQNL",
"rel": "get",
"method": "GET"
}
]
},
"track": "school_a5a7e52f14",
"school": "pol"
},
{
"ts": 1760884847,
"query": {
"school": "m",
"track": "school_ab32258ab0"
},
"last_status": 200,
"last_body": {
"merchant_id": "NEG5HMUKCTQNL",
"tracking_id": "school_ab32258ab0",
"links": [
{
"href": "\/v1\/customer\/partners\/NEG5HMUKCTQNL\/merchant-integrations\/NEG5HMUKCTQNL",
"rel": "get",
"method": "GET"
}
]
},
"track": "school_ab32258ab0",
"school": "m"
}
]
File diff suppressed because it is too large. Load diff
+182
View File
@@ -0,0 +1,182 @@
<?php
$config = require __DIR__ . '/config.php';
require __DIR__ . '/lib/Storage.php';
$storage = new Storage($config['storage_path']);
$merchants = $storage->get('merchants', []);
// Backfill: if pending_onboarding has connected entries with merchant_id, ensure they exist in merchants.json
$pending = $storage->get('pending_onboarding', []);
$changed = false;
foreach ($pending as $trackId => $entry) {
if (!is_array($entry)) continue;
$schoolName = $entry['school'] ?? null;
$mid = $entry['merchant_id'] ?? null;
$status = $entry['status'] ?? null;
if ($schoolName && $mid && strtolower((string)$status) === 'connected') {
if (!isset($merchants[$schoolName]) || empty($merchants[$schoolName]['merchant_id'])) {
$merchants[$schoolName] = [
'merchant_id' => $mid,
'tracking_id' => $trackId,
'connected_at' => $entry['connected_at'] ?? time(),
];
$changed = true;
}
}
}
if ($changed) {
$storage->set('merchants', $merchants);
}
?><!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>School Lunch PayPal Demo</title>
<style>
body{font-family:system-ui,Segoe UI,Arial,sans-serif; margin:2rem;}
.box{border:1px solid #ddd; padding:1rem; border-radius:.5rem; margin-bottom:1rem;}
label{display:block; margin:.5rem 0 .25rem;}
input,select,button{padding:.5rem; font-size:1rem;}
table{border-collapse:collapse; width:100%;}
th,td{border:1px solid #eee; padding:.5rem;}
</style>
</head>
<body>
<h1>School Lunch Payments (Demo)</h1>
<div class="box">
<h2>Connect a School's PayPal (Merchant Onboarding)</h2>
<form method="post" action="/payp/onboard.php">
<label>School Name</label>
<input name="school_name" placeholder="e.g., Green Valley Elementary" required />
<button type="submit">Connect with PayPal</button>
<div class="box">
<h2>Pending Onboarding</h2>
<?php
// Show entries that do not yet have a merchant_id saved
$pendingList = [];
$incompleteList = [];
foreach ($pending as $trackId => $entry) {
if (!is_array($entry)) continue;
$status = $entry['status'] ?? null;
if ($status === 'incomplete_onboarding') {
$incompleteList[$trackId] = $entry;
} elseif (empty($entry['merchant_id'])) {
$pendingList[$trackId] = $entry;
}
}
?>
<?php if ($incompleteList): ?>
<div style="background:#fff3cd; padding:1rem; margin-bottom:1rem; border-radius:.5rem;">
<h3 style="margin-top:0; color:#856404;">⚠️ Incomplete Onboarding</h3>
<p style="color:#856404;">The following merchants started but did NOT complete PayPal onboarding. They need to complete the signup process at PayPal.</p>
<table>
<thead>
<tr>
<th>School</th>
<th>Status</th>
<th>Note</th>
<th>Last Update</th>
</tr>
</thead>
<tbody>
<?php foreach ($incompleteList as $trackId => $entry): ?>
<tr style="background:#fff;">
<td><?= htmlspecialchars($entry['school'] ?? '') ?></td>
<td><span style="color:#856404;">❌ Incomplete</span></td>
<td><small><?= htmlspecialchars($entry['note'] ?? 'Onboarding not completed') ?></small></td>
<td><?= isset($entry['last_return_at']) ? date('Y-m-d H:i:s', (int)$entry['last_return_at']) : (isset($entry['created_at']) ? date('Y-m-d H:i:s', (int)$entry['created_at']) : '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if (!$pendingList): ?>
<p><?= $incompleteList ? 'No other pending onboardings.' : 'No pending onboardings.' ?></p>
<?php else: ?>
<p>Sometimes PayPal takes a short time to propagate onboarding. Click Sync to resolve merchant IDs.</p>
<p>
<a href="/payp/sync_merchants.php" style="padding:.5rem 1rem; background:#0070ba; color:white; text-decoration:none; border-radius:.25rem;">🔄 Sync Now</a>
</p>
<table>
<thead>
<tr>
<th>School</th>
<th>Tracking ID</th>
<th>Partner Referral</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<?php foreach ($pendingList as $trackId => $entry): ?>
<tr>
<td><?= htmlspecialchars($entry['school'] ?? '') ?></td>
<td><code><?= htmlspecialchars($trackId) ?></code></td>
<td><code><?= htmlspecialchars($entry['partner_referral_id'] ?? ($entry['referral_token'] ?? '')) ?></code></td>
<td><?= isset($entry['created_at']) ? date('Y-m-d H:i:s', (int)$entry['created_at']) : '' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</form>
<p>This uses PayPal Partner Referrals to onboard merchants and store their merchantId for routing payments.</p>
<p><small>After connecting, you'll be redirected back here.</small></p>
<p><a href="https://developer.paypal.com/docs/platforms/partner-referrals/" target="_blank">Docs</a></p>
<?php if (isset($_GET['sync'])): ?>
<p style="color:green">Sync completed. Updated <?= (int)$_GET['sync']; ?> record(s).</p>
<?php endif; ?>
<p><a href="/payp/partner_diag.php" target="_blank">Partner diagnostics</a></p>
<?php if (isset($_GET['onboard']) && $_GET['onboard']==='ok'): ?>
<p style="color:green;">✅ Onboarding completed and merchant saved.</p>
<?php endif; ?>
<?php if (isset($_GET['onboard']) && $_GET['onboard']==='incomplete'): ?>
<p style="color:#856404; background:#fff3cd; padding:1rem; border-radius:.5rem;">
⚠️ <strong>Onboarding Incomplete:</strong> The merchant started the PayPal signup process but did not complete it.
Please finish the entire PayPal business account setup to get your merchant ID. You can try connecting again.
</p>
<?php endif; ?>
<?php if (isset($_GET['onboard']) && $_GET['onboard']==='pending'): ?>
<p style="color:#0070ba; background:#e7f3ff; padding:1rem; border-radius:.5rem;">
ℹ️ <strong>Onboarding Pending:</strong> Your merchant ID is not available yet. This might take a few moments after completing PayPal signup.
Try clicking "Sync Now" in a minute or refresh this page.
</p>
<?php endif; ?>
<?php if (isset($_GET['captured']) && $_GET['captured'] === '1'): ?>
<p style="color:green">Payment captured successfully. Order <?= htmlspecialchars($_GET['order'] ?? '') ?> (status: <?= htmlspecialchars($_GET['status'] ?? 'CAPTURED') ?>)</p>
<?php endif; ?>
</div>
<div class="box">
<h2>Connected Schools</h2>
<?php if (!$merchants): ?>
<p>No schools connected yet.</p>
<?php else: ?>
<table>
<thead><tr><th>School</th><th>Merchant ID</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach ($merchants as $school => $info): ?>
<tr>
<td><?= htmlspecialchars($school) ?></td>
<td><code><?= htmlspecialchars($info['merchant_id'] ?? 'n/a') ?></code></td>
<td>
<form method="post" action="/payp/create_order.php" style="display:inline">
<input type="hidden" name="school" value="<?= htmlspecialchars($school) ?>" />
<input type="number" step="0.01" name="amount" value="5.00" style="width:7rem" />
<button type="submit">Create Order</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</body>
</html>
+92
View File
@@ -0,0 +1,92 @@
<?php
class PayPalClient
{
private string $clientId;
private string $clientSecret;
private string $env; // sandbox|live
private string $baseApi;
public function __construct(array $config)
{
$this->clientId = $config['client_id'] ?? '';
$this->clientSecret = $config['client_secret'] ?? '';
$this->env = $config['env'] ?? 'sandbox';
$this->baseApi = $this->env === 'live'
? 'https://api-m.paypal.com'
: 'https://api-m.sandbox.paypal.com';
if (!$this->clientId || !$this->clientSecret) {
throw new RuntimeException('Missing PayPal client_id/client_secret in config.php');
}
}
public function getAccessToken(): string
{
$url = $this->baseApi . '/v1/oauth2/token';
$headers = [
'Accept: application/json',
'Accept-Language: en_US',
];
$body = 'grant_type=client_credentials';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERPWD => $this->clientId . ':' . $this->clientSecret,
]);
$resp = curl_exec($ch);
if ($resp === false) {
throw new RuntimeException('OAuth token request failed: ' . curl_error($ch));
}
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($resp, true);
if ($code < 200 || $code >= 300 || !isset($data['access_token'])) {
throw new RuntimeException('OAuth token response error (' . $code . '): ' . $resp);
}
return $data['access_token'];
}
public function api(string $method, string $path, array $payload = null, array $headers = []): array
{
$url = $this->baseApi . $path;
$accessToken = $this->getAccessToken();
$defaultHeaders = [
'Content-Type: application/json',
'Authorization: Bearer ' . $accessToken,
];
$allHeaders = array_merge($defaultHeaders, $headers);
$ch = curl_init($url);
$opts = [
CURLOPT_HTTPHEADER => $allHeaders,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => strtoupper($method),
];
if ($payload !== null) {
$opts[CURLOPT_POSTFIELDS] = json_encode($payload);
}
curl_setopt_array($ch, $opts);
$resp = curl_exec($ch);
if ($resp === false) {
throw new RuntimeException('API request failed: ' . curl_error($ch));
}
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($resp, true);
if ($data === null && $resp !== '' && $resp !== 'null') {
// not JSON but body present
$data = ['raw' => $resp];
}
return ['status' => $code, 'body' => $data];
}
public function getBaseApi(): string
{
return $this->baseApi;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
class Storage
{
private string $dir;
public function __construct(string $dir)
{
$this->dir = rtrim($dir, DIRECTORY_SEPARATOR);
if (!is_dir($this->dir)) {
mkdir($this->dir, 0777, true);
}
if (!is_writable($this->dir)) {
throw new RuntimeException('Storage path not writable: ' . $this->dir);
}
}
private function path(string $name): string
{
return $this->dir . DIRECTORY_SEPARATOR . $name . '.json';
}
public function get(string $name, $default = [])
{
$p = $this->path($name);
if (!file_exists($p)) return $default;
$data = json_decode((string)file_get_contents($p), true);
return $data ?? $default;
}
public function set(string $name, $value): void
{
$p = $this->path($name);
file_put_contents($p, json_encode($value, JSON_PRETTY_PRINT));
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
$config = require __DIR__ . '/config.php';
require __DIR__ . '/lib/PayPalClient.php';
require __DIR__ . '/lib/Storage.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo 'Method Not Allowed';
exit;
}
$schoolName = trim($_POST['school_name'] ?? '');
if ($schoolName === '') {
http_response_code(400);
echo 'Missing school_name';
exit;
}
$client = new PayPalClient($config);
$storage = new Storage($config['storage_path']);
$trackingId = 'school_' . substr(md5($schoolName . '|' . time()), 0, 10);
// IMPORTANT: Change return_url to be more explicit
$returnUrl = rtrim($config['base_url'], '/') . '/return.php?school=' . urlencode($schoolName) . '&track=' . urlencode($trackingId);
$cancelUrl = rtrim($config['base_url'], '/') . '/index.php';
$payload = [
'tracking_id' => $trackingId,
'operations' => [[
'operation' => 'API_INTEGRATION',
'api_integration_preference' => [
'rest_api_integration' => [
'integration_method' => 'PAYPAL',
'integration_type' => 'THIRD_PARTY',
'third_party_details' => [
'features' => [
'PAYMENT', 'REFUND'
]
]
]
]
]],
'products' => ['PPCP'],
'legal_consents' => [[
'type' => 'SHARE_DATA_CONSENT',
'granted' => true
]],
'partner_config_override' => [
'return_url' => $returnUrl,
'return_url_description' => 'Return to School Lunch App',
// Add this to ensure PayPal shows the return button
'show_add_credit_card' => false,
],
'requested_capabilities' => [[
'capability' => 'PAYMENTS_RECEIVABLE'
]]
];
// ...existing code...
$resp = $client->api('POST', '/v2/customer/partner-referrals', $payload);
if ($resp['status'] < 200 || $resp['status'] >= 300) {
http_response_code(500);
echo 'Partner referral failed: ' . htmlspecialchars(json_encode($resp));
exit;
}
$body = $resp['body'];
$partnerReferralId = $body['partner_referral_id'] ?? ($body['referral_id'] ?? null);
$referralToken = null;
$links = $body['links'] ?? [];
$href = null;
$debugReferral = $storage->get('referral_debug', []);
$debugReferral[] = [
'ts' => time(),
'school' => $schoolName,
'tracking_id' => $trackingId,
'status' => $resp['status'],
'body' => $body,
];
$storage->set('referral_debug', $debugReferral);
foreach ($links as $lnk) {
if (($lnk['rel'] ?? '') === 'action_url') {
$href = $lnk['href'];
if (!$partnerReferralId) {
$u = parse_url($href);
if (!empty($u['query'])) {
parse_str($u['query'], $q);
if (!empty($q['partner_referral_id'])) {
$partnerReferralId = $q['partner_referral_id'];
} elseif (!empty($q['referral_id'])) {
$partnerReferralId = $q['referral_id'];
} elseif (!empty($q['referralToken'])) {
$referralToken = $q['referralToken'];
$partnerReferralId = $partnerReferralId ?? $referralToken;
}
}
}
break;
}
}
if (!$partnerReferralId) {
foreach ($links as $lnk) {
if (($lnk['rel'] ?? '') === 'self' && !empty($lnk['href'])) {
$parts = explode('/partner-referrals/', $lnk['href']);
if (count($parts) === 2 && $parts[1]) {
$partnerReferralId = $parts[1];
$referralToken = $referralToken ?? $partnerReferralId;
break;
}
}
}
}
if (!$href) {
http_response_code(500);
echo 'Did not receive onboarding URL';
exit;
}
$pending = $storage->get('pending_onboarding', []);
$pending[$trackingId] = [
'school' => $schoolName,
'created_at' => time(),
'partner_referral_id' => $partnerReferralId,
'referral_token' => $referralToken,
];
$storage->set('pending_onboarding', $pending);
if ($partnerReferralId && strpos($href, 'partner_referral_id') === false) {
$sep = (strpos($href, '?') === false) ? '?' : '&';
$href .= $sep . 'partner_referral_id=' . urlencode($partnerReferralId);
}
header('Location: ' . $href);
exit;
+51
View File
@@ -0,0 +1,51 @@
<?php
$config = require __DIR__ . '/config.php';
require __DIR__ . '/lib/PayPalClient.php';
require __DIR__ . '/lib/Storage.php';
header('Content-Type: text/plain');
try {
$client = new PayPalClient($config);
$storage = new Storage($config['storage_path']);
echo "Partner diagnostics\n\n";
echo "Env: " . $config['env'] . "\n";
echo "Base API: " . (new PayPalClient($config))->getBaseApi() . "\n";
echo "Partner Merchant ID (Payer ID): " . $config['partner_merchant_id'] . "\n\n";
// Try a simple pull of merchant-integrations
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id']) . '/merchant-integrations?page_size=20';
$resp = $client->api('GET', $path);
echo "GET $path => status: " . $resp['status'] . "\n";
if ($resp['status'] >= 200 && $resp['status'] < 300) {
$list = $resp['body']['merchant_integrations'] ?? [];
echo "Integrations returned: " . count($list) . "\n";
foreach ($list as $i => $mi) {
$mid = $mi['merchant_id'] ?? 'n/a';
$tid = $mi['tracking_id'] ?? 'n/a';
$rid = $mi['partner_referral_id'] ?? ($mi['referral_id'] ?? 'n/a');
$onb = $mi['onboarding_status'] ?? null;
echo sprintf("%02d. merchant_id=%s tracking_id=%s referral_id=%s status=%s\n", $i+1, $mid, $tid, $rid, is_array($onb)? json_encode($onb): (string)$onb);
}
} else {
echo json_encode($resp['body'], JSON_PRETTY_PRINT) . "\n";
}
echo "\nRecent sync_debug entries:\n";
$diag = $storage->get('sync_debug', []);
$tail = array_slice($diag, -5);
foreach ($tail as $row) {
echo json_encode($row, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE) . "\n";
}
echo "\nPending entries:\n";
$pending = $storage->get('pending_onboarding', []);
foreach ($pending as $track => $row) {
echo $track . ' => ' . json_encode($row, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE) . "\n";
}
} catch (Throwable $e) {
http_response_code(500);
echo 'Diagnostics error: ' . $e->getMessage();
}
+151
View File
@@ -0,0 +1,151 @@
<?php
$config = require __DIR__ . '/config.php';
require __DIR__ . '/lib/PayPalClient.php';
require __DIR__ . '/lib/Storage.php';
$school = $_GET['school'] ?? '';
$track = $_GET['track'] ?? '';
$pref = $_GET['partner_referral_id'] ?? null;
if (!$school || !$track) {
header('Location: ' . rtrim($config['base_url'], '/') . '/index.php');
exit;
}
$client = new PayPalClient($config);
$storage = new Storage($config['storage_path']);
// First, PayPal may append merchantId to the return URL on successful onboarding
$merchantId = $_GET['merchantId']
?? $_GET['merchant_id']
?? $_GET['merchantIdInPayPal']
?? null;
// If not provided, use Partner Lookup API to retrieve merchant info by tracking_id.
// Docs: GET /v1/customer/partners/<partner_merchant_id>/merchant-integrations?tracking_id=
if (!$merchantId) {
$merchantId = null;
$lastResp = null;
// poll up to ~15s total
for ($i = 0; $i < 15; $i++) {
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id']) . '/merchant-integrations?tracking_id=' . urlencode($track);
$resp = $client->api('GET', $path);
$lastResp = $resp;
if ($resp['status'] >= 200 && $resp['status'] < 300) {
if (!empty($resp['body']['merchant_id'])) {
$foundId = $resp['body']['merchant_id'];
// CRITICAL: Don't accept the partner's own ID as merchant ID
// This means the merchant hasn't completed onboarding yet
if (strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break;
}
}
}
usleep(1000000);
}
if (!$merchantId) {
$debug = $storage->get('return_debug', []);
$debug[] = [
'ts' => time(),
'query' => $_GET,
'last_status' => $lastResp['status'] ?? null,
'last_body' => $lastResp['body'] ?? null,
'track' => $track,
'school' => $school,
'reason' => 'Merchant onboarding not completed or partner ID returned'
];
$storage->set('return_debug', $debug);
}
}
// Fallback by partner_referral_id if available and still missing
if (!$merchantId) {
// Load pending entry for additional clues
$pending = $storage->get('pending_onboarding', []);
$entry = $pending[$track] ?? [];
$prefFromStore = $entry['partner_referral_id'] ?? $entry['referral_token'] ?? null;
$pref = $pref ?: $prefFromStore;
}
if (!$merchantId && $pref) {
// Try list integrations and filter by partner_referral_id
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id']) . '/merchant-integrations?onboarded=true';
$resp = $client->api('GET', $path);
if ($resp['status'] >= 200 && $resp['status'] < 300 && !empty($resp['body']['merchant_integrations'])) {
foreach ($resp['body']['merchant_integrations'] as $mi) {
$rid = $mi['partner_referral_id'] ?? ($mi['referral_id'] ?? null);
$foundId = $mi['merchant_id'] ?? null;
// Only accept if it's a different merchant ID (not the partner's own)
if ($rid && strcasecmp($rid, $pref) === 0 && $foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break;
}
}
// If still not found, but the list contains exactly one new integration with our tracking id, take it
if (!$merchantId) {
foreach ($resp['body']['merchant_integrations'] as $mi) {
$foundId = $mi['merchant_id'] ?? null;
if (!empty($mi['tracking_id']) && strcasecmp($mi['tracking_id'], $track) === 0 && $foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break;
}
}
}
}
}
// As a last resort, if merchants.json already contains the school mapping, use it
if (!$merchantId) {
$merchants = $storage->get('merchants', []);
if (!empty($merchants[$school]['merchant_id'])) {
$existingId = $merchants[$school]['merchant_id'];
// Only use if it's not the partner's own ID
if (strcasecmp($existingId, $config['partner_merchant_id']) !== 0) {
$merchantId = $existingId;
}
}
}
if (!$merchantId) {
// Mark as incomplete onboarding
$pending = $storage->get('pending_onboarding', []);
if (isset($pending[$track])) {
$pending[$track]['status'] = 'incomplete_onboarding';
$pending[$track]['last_return_at'] = time();
$pending[$track]['note'] = 'Merchant did not complete PayPal onboarding. Partner ID was returned instead.';
$storage->set('pending_onboarding', $pending);
}
// Not available yet; merchant needs to complete onboarding
header('Location: ' . rtrim($config['base_url'], '/') . '/index.php?onboard=incomplete');
exit;
}
// Persist school -> merchant mapping
$merchants = $storage->get('merchants', []);
$merchants[$school] = [
'merchant_id' => $merchantId,
'tracking_id' => $track,
'connected_at' => time(),
];
$storage->set('merchants', $merchants);
// Also annotate the matching pending record so it's visible where you looked
$pending = $storage->get('pending_onboarding', []);
if (!isset($pending[$track])) {
// if for some reason it's missing, recreate a minimal record
$pending[$track] = [
'school' => $school,
'created_at' => time(),
];
}
$pending[$track]['merchant_id'] = $merchantId;
$pending[$track]['status'] = 'connected';
$pending[$track]['connected_at'] = time();
// Store the raw return query for troubleshooting
$pending[$track]['last_return_query'] = $_GET;
$storage->set('pending_onboarding', $pending);
header('Location: ' . rtrim($config['base_url'], '/') . '/index.php?onboard=ok');
exit;
+124
View File
@@ -0,0 +1,124 @@
<?php
$config = require __DIR__ . '/config.php';
require __DIR__ . '/lib/PayPalClient.php';
require __DIR__ . '/lib/Storage.php';
try {
$client = new PayPalClient($config);
$storage = new Storage($config['storage_path']);
$pending = $storage->get('pending_onboarding', []);
$merchants = $storage->get('merchants', []);
$syncDebug = $storage->get('sync_debug', []);
$updated = 0;
foreach ($pending as $track => &$entry) {
if (!is_array($entry)) continue;
$school = $entry['school'] ?? null;
if (!$school) continue;
if (!empty($entry['merchant_id'])) continue; // already resolved
$merchantId = null;
// Try by tracking_id
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id']) . '/merchant-integrations?tracking_id=' . urlencode($track);
$resp = $client->api('GET', $path);
$syncDebug[] = ['ts' => time(), 'track' => $track, 'school' => $school, 'attempt' => 'by-tracking', 'status' => $resp['status'] ?? null, 'body' => $resp['body'] ?? null];
if ($resp['status'] >= 200 && $resp['status'] < 300) {
if (!empty($resp['body']['merchant_id'])) {
$foundId = $resp['body']['merchant_id'];
// Don't accept the partner's own ID - means onboarding not completed
if (strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
}
}
}
// Try by partner_referral_id if available
$ridCandidate = $entry['partner_referral_id'] ?? ($entry['referral_token'] ?? null);
if (!$merchantId && $ridCandidate) {
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id']) . '/merchant-integrations?onboarded=true';
$resp2 = $client->api('GET', $path);
$syncDebug[] = ['ts' => time(), 'track' => $track, 'school' => $school, 'attempt' => 'list-onboarded', 'status' => $resp2['status'] ?? null, 'body_count' => isset($resp2['body']['merchant_integrations']) ? count($resp2['body']['merchant_integrations']) : null];
if ($resp2['status'] >= 200 && $resp2['status'] < 300 && !empty($resp2['body']['merchant_integrations'])) {
foreach ($resp2['body']['merchant_integrations'] as $mi) {
$rid = $mi['partner_referral_id'] ?? ($mi['referral_id'] ?? null);
$foundId = $mi['merchant_id'] ?? null;
// Only accept if it's not the partner's own ID
if ($rid && strcasecmp($rid, $ridCandidate) === 0 && $foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break;
}
}
}
}
// Final fallback: list without onboarded filter and match by tracking_id/referral
if (!$merchantId) {
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id']) . '/merchant-integrations?page_size=200';
$resp3 = $client->api('GET', $path);
$syncDebug[] = ['ts' => time(), 'track' => $track, 'school' => $school, 'attempt' => 'list-all', 'status' => $resp3['status'] ?? null, 'body_count' => isset($resp3['body']['merchant_integrations']) ? count($resp3['body']['merchant_integrations']) : null];
if ($resp3['status'] >= 200 && $resp3['status'] < 300 && !empty($resp3['body']['merchant_integrations'])) {
foreach ($resp3['body']['merchant_integrations'] as $mi) {
$tid = $mi['tracking_id'] ?? null;
$rid = $mi['partner_referral_id'] ?? ($mi['referral_id'] ?? null);
$foundId = $mi['merchant_id'] ?? null;
// Only accept if it's not the partner's own ID
if (((!empty($tid) && strcasecmp($tid, $track) === 0) || ($ridCandidate && $rid && strcasecmp($rid, $ridCandidate) === 0)) && $foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break;
}
}
}
}
// Try reading the partner referral directly (may include status/links); capture merchant_id if present
if (!$merchantId && $ridCandidate) {
$path = '/v2/customer/partner-referrals/' . urlencode($ridCandidate);
$resp4 = $client->api('GET', $path);
$syncDebug[] = ['ts' => time(), 'track' => $track, 'school' => $school, 'attempt' => 'referral-get', 'status' => $resp4['status'] ?? null, 'body' => $resp4['body'] ?? null];
if ($resp4['status'] >= 200 && $resp4['status'] < 300 && !empty($resp4['body'])) {
// naive scan for a merchant_id field anywhere in response
$stack = [$resp4['body']];
while (!$merchantId && $stack) {
$node = array_pop($stack);
if (is_array($node)) {
if (isset($node['merchant_id']) && $node['merchant_id']) {
$merchantId = $node['merchant_id'];
break;
}
foreach ($node as $v) { if (is_array($v)) { $stack[] = $v; } }
}
}
}
}
if ($merchantId) {
$entry['merchant_id'] = $merchantId;
$entry['status'] = 'connected';
$entry['connected_at'] = time();
$merchants[$school] = [
'merchant_id' => $merchantId,
'tracking_id' => $track,
'connected_at' => $entry['connected_at'],
];
$updated++;
} else {
// Mark as incomplete if no merchant ID found
$entry['status'] = 'incomplete_onboarding';
$entry['last_sync_at'] = time();
$entry['note'] = 'Merchant has not completed PayPal onboarding. Only partner ID is available.';
}
}
unset($entry);
$storage->set('pending_onboarding', $pending);
$storage->set('merchants', $merchants);
$storage->set('sync_debug', $syncDebug);
header('Location: ' . rtrim($config['base_url'], '/') . '/index.php?sync=' . $updated);
exit;
} catch (Throwable $e) {
http_response_code(500);
echo 'Sync failed: ' . htmlspecialchars($e->getMessage());
}