Add NocoDB integration for order management with comprehensive Stripe webhook logging
All checks were successful
Build and Push Docker Images / docker (push) Successful in 2m28s

Features:
- Complete NocoDB schema with 42 fields supporting B2B and B2C customers
- Server-side NocoDB client (REST API integration)
- Stripe session data mapper with automatic field mapping
- Enhanced webhook handler with comprehensive logging
- Automatic order creation in NocoDB after payment
- Fulfillment data updates with Wren order IDs
- Support for business customers (VAT/EIN, business names)
- Complete billing address capture
- Non-blocking error handling (webhook succeeds even if NocoDB fails)

Files Added:
- server/utils/nocodbClient.js - NocoDB REST API client
- server/utils/nocodbMapper.js - Stripe to NocoDB data mapper
- docs/NOCODB_SCHEMA.md - Complete field reference (42 columns)
- docs/NOCODB_INTEGRATION_GUIDE.md - Testing and deployment guide
- docs/TESTING_STRIPE_WEBHOOK.md - Webhook testing instructions
- docs/STRIPE_INTEGRATION_SUMMARY.md - Project overview

Files Modified:
- server/routes/webhooks.js - Added NocoDB integration and enhanced logging
- src/types.ts - Updated OrderRecord interface with new fields
- src/api/nocodbClient.ts - Added createOrder() method
- .env.example - Added NocoDB configuration template

Schema includes:
- Payment tracking (Stripe session/intent/customer IDs, amounts, fees)
- Carbon offset details (tons, portfolio, Wren order ID)
- Customer information (name, email, phone, business name)
- Tax ID collection (VAT, EIN, etc.)
- Complete billing address
- Optional vessel/trip details for yacht calculations

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Matt 2025-11-03 16:35:15 +01:00
parent 94f422e540
commit dc4fc45c4f
10 changed files with 1537 additions and 39 deletions

View File

@ -31,6 +31,12 @@ WREN_DRY_RUN=true
# === Database Configuration ===
DATABASE_PATH=/app/data/orders.db
# === NocoDB Configuration ===
NOCODB_BASE_URL=https://your-nocodb-instance.com
NOCODB_BASE_ID=your_base_id_here
NOCODB_API_KEY=your_nocodb_api_key_here
NOCODB_ORDERS_TABLE_ID=your_orders_table_id_here
# === Email Configuration ===
SMTP_HOST=mail.puffinoffset.com
SMTP_PORT=587

View File

@ -0,0 +1,335 @@
# NocoDB Integration Guide
## 🎯 Overview
This guide explains how to test and verify the NocoDB integration with Stripe webhooks.
## ✅ Implementation Summary
### Files Created/Modified
**New Files:**
- `server/utils/nocodbClient.js` - NocoDB REST API client
- `server/utils/nocodbMapper.js` - Maps Stripe data to NocoDB format
- `docs/NOCODB_INTEGRATION_GUIDE.md` - This file
**Modified Files:**
- `server/routes/webhooks.js` - Added NocoDB integration
- `src/api/nocodbClient.ts` - Added `createOrder()` method
- `.env.example` - Added NocoDB configuration template
### Integration Flow
```
Stripe Payment Complete
Webhook: checkout.session.completed
logStripeSessionData() - Comprehensive logging
Order.updateStatus() - Update local DB
saveOrderToNocoDB() - Save to NocoDB ✨ NEW
fulfillOrder() - Create Wren offset
updateNocoDBFulfillment() - Update with Wren data ✨ NEW
Send receipt email
```
## 🔧 Environment Variables
Ensure these are set in `server/.env`:
```bash
# NocoDB Configuration
NOCODB_BASE_URL=https://database.puffinoffset.com
NOCODB_BASE_ID=p11p8be6tzttkhy
NOCODB_API_KEY=Y1thvyr9N53n8WFn7rCgY3ZnLlzPFc4_BdwXCmx-
NOCODB_ORDERS_TABLE_ID=mxusborf4x91e1j
```
**How to find these values:**
1. **NOCODB_BASE_URL**: Your NocoDB instance URL
2. **NOCODB_BASE_ID**: Go to Base → Copy Base ID
3. **NOCODB_API_KEY**: User Settings → Tokens → Create Token
4. **NOCODB_ORDERS_TABLE_ID**: Open table → URL contains table ID
## 🧪 Testing the Integration
### Method 1: Stripe CLI Test (Recommended)
**Setup:**
```bash
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# In another terminal, start the server
cd server
npm run dev
```
**Trigger Test Payment:**
```bash
stripe trigger checkout.session.completed
```
**Expected Console Output:**
```
📬 Received webhook: checkout.session.completed
╔════════════════════════════════════════════════════════════════╗
║ STRIPE CHECKOUT SESSION - COMPREHENSIVE DATA ║
╚════════════════════════════════════════════════════════════════╝
... (all Stripe data logged)
✅ Checkout session completed: cs_test_...
💳 Payment confirmed for order: e0e976e5-...
Amount: $1649.79
💾 Saving order e0e976e5-... to NocoDB...
✅ Order saved to NocoDB successfully
NocoDB Record ID: 123
Order ID: e0e976e5-...
Customer: Matthew Ciaccio (matt@letsbe.solutions)
Business: LetsBe Solutions LLC
Tax ID: eu_vat - FRAB123456789
🌱 Fulfilling order via Wren API...
✅ Order fulfilled successfully
Wren Order ID: wren_...
Tons offset: 6.73
💾 Updating NocoDB with fulfillment data...
✅ NocoDB updated with fulfillment data
Wren Order ID: wren_...
Status: fulfilled
📧 Receipt email sent to matt@letsbe.solutions
```
### Method 2: Real Test Payment
1. **Start Server:**
```bash
cd server
npm run dev
```
2. **Make Test Payment:**
- Navigate to checkout
- Use test card: `4242 4242 4242 4242`
- Fill in test billing information
- Complete payment
3. **Check Logs:**
- Look for NocoDB save confirmation
- Verify fulfillment update logs
4. **Verify in NocoDB:**
- Open NocoDB Orders table
- Find the new order by `orderId`
- Check all fields are populated
## 🔍 Verification Checklist
After a test payment, verify:
### ✅ Server Logs
- [ ] Comprehensive Stripe data logged
- [ ] "Saving order to NocoDB" message
- [ ] "Order saved to NocoDB successfully" with Record ID
- [ ] "Updating NocoDB with fulfillment data" message
- [ ] "NocoDB updated with fulfillment data" message
### ✅ NocoDB Database
- [ ] New record exists in Orders table
- [ ] `orderId` matches Stripe order
- [ ] `status` = "fulfilled" (after Wren fulfillment)
- [ ] `stripeSessionId` populated
- [ ] `stripePaymentIntent` populated
- [ ] `baseAmount`, `processingFee`, `totalAmount` correct
- [ ] `co2Tons`, `portfolioId` from metadata
- [ ] `customerName`, `customerEmail` populated
- [ ] `customerPhone` populated (if collected)
- [ ] `businessName` populated (if B2B)
- [ ] `taxIdType` and `taxIdValue` populated (if collected)
- [ ] `billingLine1`, `billingCity`, `billingCountry` populated
- [ ] `wrenOrderId` populated after fulfillment
- [ ] `fulfilledAt` timestamp set
### ✅ Error Handling
Test error scenarios:
- [ ] NocoDB temporarily unavailable (should log error but not fail webhook)
- [ ] Invalid NocoDB credentials (should skip gracefully)
- [ ] Wren API failure (should still save to NocoDB with status='paid')
## 🐛 Troubleshooting
### Issue: "NocoDB not configured"
**Cause:** Environment variables missing or incorrect
**Fix:**
```bash
# Check environment variables
cd server
cat .env | grep NOCODB
# Ensure all 4 variables are set:
# NOCODB_BASE_URL
# NOCODB_BASE_ID
# NOCODB_API_KEY
# NOCODB_ORDERS_TABLE_ID
```
### Issue: "NocoDB request failed: 401"
**Cause:** Invalid API key
**Fix:**
1. Go to NocoDB → User Settings → Tokens
2. Create new token
3. Update `NOCODB_API_KEY` in `.env`
4. Restart server
### Issue: "NocoDB request failed: 404"
**Cause:** Incorrect table ID or base ID
**Fix:**
1. Open your Orders table in NocoDB
2. Check URL: `https://nocodb.com/nc/BASE_ID/TABLE_ID`
3. Update `NOCODB_BASE_ID` and `NOCODB_ORDERS_TABLE_ID`
4. Restart server
### Issue: Order saved but fields are null
**Cause:** Stripe metadata not passed correctly
**Fix:**
Check checkout session creation includes metadata:
```javascript
const session = await stripe.checkout.sessions.create({
metadata: {
baseAmount: '160174',
processingFee: '4805',
portfolioId: '37',
tons: '6.73',
// Add vessel/trip data if available
},
// ... other settings
});
```
### Issue: Business fields not populated
**Cause:** Stripe checkout not collecting business information
**Fix:**
Update checkout session to collect business details:
```javascript
const session = await stripe.checkout.sessions.create({
customer_creation: 'always',
tax_id_collection: {
enabled: true,
required: 'never' // or 'if_supported'
},
// ... other settings
});
```
## 📊 Database Record Example
**Expected NocoDB Record (Business Customer):**
```json
{
"Id": 123,
"CreatedAt": "2025-11-03T14:30:00.000Z",
"UpdatedAt": "2025-11-03T14:31:00.000Z",
"orderId": "e0e976e5-4272-4f5b-a379-c059df6cb5de",
"status": "fulfilled",
"source": "web",
"stripeSessionId": "cs_test_...",
"stripePaymentIntent": "pi_...",
"stripeCustomerId": "cus_TM7pU6vRGh0N5N",
"baseAmount": "160174",
"processingFee": "4805",
"totalAmount": "164979",
"currency": "USD",
"amountUSD": "164979",
"paymentMethod": "card",
"co2Tons": "6.73",
"portfolioId": "37",
"portfolioName": null,
"wrenOrderId": "wren_order_123",
"certificateUrl": null,
"fulfilledAt": "2025-11-03T14:31:00.000Z",
"customerName": "LetsBe Solutions LLC",
"customerEmail": "matt@letsbe.solutions",
"customerPhone": "+33633219796",
"businessName": "LetsBe Solutions LLC",
"taxIdType": "eu_vat",
"taxIdValue": "FRAB123456789",
"billingLine1": "108 Avenue du Trois Septembre",
"billingLine2": null,
"billingCity": "Cap-d'Ail",
"billingState": null,
"billingPostalCode": "06320",
"billingCountry": "FR",
"vesselName": null,
"imoNumber": null,
"vesselType": null,
"vesselLength": null,
"departurePort": null,
"arrivalPort": null,
"distance": null,
"avgSpeed": null,
"duration": null,
"enginePower": null,
"notes": null
}
```
## 🚀 Deployment Checklist
Before deploying to production:
- [ ] All NocoDB columns created (42 total)
- [ ] Environment variables configured in production `.env`
- [ ] NocoDB API key has proper permissions
- [ ] Stripe webhook endpoint configured in Stripe Dashboard
- [ ] Webhook signing secret set in production `.env`
- [ ] Test payment processed successfully
- [ ] Verify data appears correctly in NocoDB
- [ ] Error handling tested (NocoDB unavailable scenario)
- [ ] Logs reviewed for any warnings or errors
## 📝 Next Steps
1. **Test with Real Payment:**
- Make a test purchase
- Verify all fields in NocoDB
- Check fulfillment updates correctly
2. **Enable Additional Features (Optional):**
- Phone number collection in checkout
- Tax ID collection for B2B customers
- Vessel/trip metadata for yacht calculations
3. **Admin Portal Integration:**
- Connect admin dashboard to NocoDB
- Display orders table with filters
- Add order management functionality
4. **Monitoring:**
- Set up alerts for failed NocoDB saves
- Monitor webhook processing times
- Track fulfillment success rate
## 📚 Related Documentation
- **Schema Reference:** `docs/NOCODB_SCHEMA.md`
- **Stripe Webhook Testing:** `docs/TESTING_STRIPE_WEBHOOK.md`
- **Integration Summary:** `docs/STRIPE_INTEGRATION_SUMMARY.md`
---
**Status:** ✅ Integration complete and ready for testing
**Last Updated:** 2025-11-03

212
docs/NOCODB_SCHEMA.md Normal file
View File

@ -0,0 +1,212 @@
# NocoDB Orders Table Schema
## Proposed Schema (Updated 2025-11-03)
### Core Identification
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `Id` | Number | Yes (auto) | NocoDB record ID |
| `CreatedAt` | DateTime | Yes (auto) | Record creation timestamp |
| `UpdatedAt` | DateTime | Yes (auto) | Record last update timestamp |
| `orderId` | String | Yes | Unique order identifier (UUID) |
| `status` | Enum | Yes | Order status: `pending`, `paid`, `fulfilled`, `cancelled` |
| `source` | String | No | Order source: `web`, `mobile-app`, `manual`, `api` |
### Payment Information
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `stripeSessionId` | String | No | Stripe Checkout Session ID |
| `stripePaymentIntent` | String | No | Stripe Payment Intent ID (for refunds) |
| `baseAmount` | String | Yes | Pre-fee amount in cents (e.g., "160174") |
| `processingFee` | String | Yes | Stripe processing fee in cents (e.g., "4805") |
| `totalAmount` | String | Yes | Total charged amount in cents (baseAmount + processingFee) |
| `currency` | String | Yes | Currency code: `USD`, `EUR`, `GBP`, `CHF` |
| `amountUSD` | String | No | Amount converted to USD for reporting |
| `paymentMethod` | String | No | Payment method type (e.g., "card", "bank_transfer") |
### Carbon Offset Details
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `co2Tons` | String | Yes | Tons of CO2 offset (e.g., "6.73") |
| `portfolioId` | String | Yes | Wren portfolio ID (e.g., "37") |
| `portfolioName` | String | No | Human-readable portfolio name |
| `wrenOrderId` | String | No | Wren API order ID (populated after fulfillment) |
| `certificateUrl` | String | No | URL to offset certificate |
| `fulfilledAt` | DateTime | No | Timestamp when order was fulfilled with Wren |
### Customer Information
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `customerName` | String | Yes | Customer display name (business or individual) |
| `customerEmail` | String | Yes | Customer email address |
| `customerPhone` | String | No | Customer phone (if phone collection enabled in Stripe) |
| `businessName` | String | No | Business name for B2B purchases |
| `stripeCustomerId` | String | No | Stripe Customer ID (for recurring customers) |
| `taxIdType` | String | No | Tax ID type (e.g., "eu_vat", "us_ein", "gb_vat") |
| `taxIdValue` | String | No | Tax identification number |
| `billingCity` | String | No | Billing address city |
| `billingCountry` | String | No | Billing address country code (e.g., "FR") |
| `billingLine1` | String | No | Billing address line 1 |
| `billingLine2` | String | No | Billing address line 2 |
| `billingPostalCode` | String | No | Billing address postal/zip code |
| `billingState` | String | No | Billing address state/region |
### Vessel Information (Optional - for yacht calculations)
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `vesselName` | String | No | Name of vessel |
| `imoNumber` | String | No | IMO vessel identification number |
| `vesselType` | String | No | Type of vessel (e.g., "Motor Yacht") |
| `vesselLength` | String | No | Vessel length in meters |
### Trip Details (Optional - for trip-based calculations)
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `departurePort` | String | No | Departure port name |
| `arrivalPort` | String | No | Arrival port name |
| `distance` | String | No | Distance in nautical miles |
| `avgSpeed` | String | No | Average speed in knots |
| `duration` | String | No | Trip duration in hours |
| `enginePower` | String | No | Engine power in horsepower |
### Administrative
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `notes` | LongText | No | Internal admin notes |
---
## Changes from Original Schema
### ✅ Added Fields
**Payment & Stripe Integration:**
- `stripeSessionId` - Link to Stripe Checkout Session
- `stripePaymentIntent` - Link to Stripe Payment Intent
- `stripeCustomerId` - Reusable Stripe Customer ID
- `baseAmount` - Pre-fee amount from Stripe metadata
- `processingFee` - Stripe fee from metadata
**Billing Address:**
- `billingCity` - From Stripe address
- `billingCountry` - From Stripe address
- `billingLine1` - From Stripe address
- `billingLine2` - From Stripe address
- `billingPostalCode` - From Stripe address
- `billingState` - From Stripe address
**B2B Customer Support:**
- `businessName` - Business name for B2B purchases
- `taxIdType` - Tax ID type (eu_vat, us_ein, gb_vat, etc.)
- `taxIdValue` - Tax identification number
### ⚠️ Modified Fields
- `customerPhone` - Now optional, populated from Stripe if phone collection is enabled
- `customerName` - Now serves as display name (either business_name or individual_name)
### ❌ Removed Fields
- `customerCompany` - Not provided by Stripe, can be added manually if needed
- `paymentReference` - Redundant with `stripePaymentIntent`
### ⚠️ Made Optional
All vessel and trip fields are now optional since they only apply to specific order types (not all orders are for vessel trips).
---
## Stripe Webhook Mapping
When receiving Stripe webhook `checkout.session.completed`:
```typescript
{
// Payment Information
stripeSessionId: session.id,
stripePaymentIntent: session.payment_intent,
baseAmount: session.metadata.baseAmount, // in cents
processingFee: session.metadata.processingFee, // in cents
totalAmount: session.amount_total.toString(), // in cents
currency: session.currency,
paymentMethod: session.payment_method_types[0], // 'card', 'us_bank_account', etc.
// Carbon Offset Details
co2Tons: session.metadata.tons,
portfolioId: session.metadata.portfolioId,
// Customer Information
customerName: session.customer_details.name, // Display name (business or individual)
customerEmail: session.customer_details.email,
customerPhone: session.customer_details.phone, // if phone collection enabled
// Business Customer Fields (B2B)
businessName: session.customer_details.business_name, // For B2B purchases
stripeCustomerId: session.customer, // Reusable customer ID
// Tax Collection (if enabled)
taxIdType: session.customer_details.tax_ids?.[0]?.type, // 'eu_vat', 'us_ein', etc.
taxIdValue: session.customer_details.tax_ids?.[0]?.value, // Tax number
// Billing Address
billingCity: session.customer_details.address?.city,
billingCountry: session.customer_details.address?.country,
billingLine1: session.customer_details.address?.line1,
billingLine2: session.customer_details.address?.line2,
billingPostalCode: session.customer_details.address?.postal_code,
billingState: session.customer_details.address?.state,
// Order Status
status: 'paid'
}
```
### Real-World Example (Business Purchase)
From actual Stripe payload `evt_1SPPa3Pdj1mnVT5kscrqB21t`:
```typescript
{
stripeSessionId: "cs_test_b1HSYDGs73Ail2Vumu0qC3yu96ce9X4qnozsDr5hDwRndpZOsq8H47flLc",
stripePaymentIntent: "pi_3SPPa2Pdj1mnVT5k2qsmDiV1",
stripeCustomerId: "cus_TM7pU6vRGh0N5N",
baseAmount: "16023588", // $160,235.88
processingFee: "480708", // $4,807.08
totalAmount: "16504296", // $165,042.96
currency: "usd",
co2Tons: "673.26",
portfolioId: "37",
customerName: "LetsBe Solutions LLC", // Business name used as display name
customerEmail: "matt@letsbe.solutions",
customerPhone: "+33633219796",
businessName: "LetsBe Solutions LLC",
taxIdType: "eu_vat",
taxIdValue: "FRAB123456789",
billingLine1: "108 Avenue du Trois Septembre",
billingLine2: null,
billingCity: "Cap-d'Ail",
billingState: null,
billingPostalCode: "06320",
billingCountry: "FR",
paymentMethod: "card",
status: "paid"
}
```
---
## Field Type Notes
**Why String for numeric fields?**
NocoDB stores all custom fields as strings by default. Numeric calculations should be done in application code by parsing these strings. This prevents precision issues with currency and decimal values.
**Date/DateTime fields:**
- `CreatedAt`, `UpdatedAt`, `fulfilledAt` use NocoDB's DateTime type
- ISO 8601 format: `2025-11-03T14:30:00.000Z`
**Enum constraints:**
- `status`: Must be one of `pending`, `paid`, `fulfilled`, `cancelled`
- `currency`: Must be one of `USD`, `EUR`, `GBP`, `CHF`
- `source`: Typically `web`, `mobile-app`, `manual`, or `api`

View File

@ -0,0 +1,232 @@
# Stripe Integration & Database Schema - Summary
## 📋 Overview
This document summarizes the database schema updates and Stripe webhook logging enhancements completed on 2025-11-03.
## ✅ Completed Tasks
### 1. Enhanced Webhook Logging
**File:** `server/routes/webhooks.js`
Added comprehensive logging function `logStripeSessionData()` that displays:
- Session information (ID, payment intent, status, timestamps)
- Payment amounts (total, subtotal, currency)
- Customer details (name, business name, individual name, email, phone)
- Billing address (all fields)
- Business customer fields (business name, tax IDs)
- Payment method types
- Metadata (custom fields)
- Additional fields (locale, customer ID, etc.)
**Output Format:**
```
╔════════════════════════════════════════════════════════════════╗
║ STRIPE CHECKOUT SESSION - COMPREHENSIVE DATA ║
╚════════════════════════════════════════════════════════════════╝
📋 SESSION INFORMATION:
💰 PAYMENT AMOUNT:
👤 CUSTOMER DETAILS:
📬 BILLING ADDRESS:
💳 PAYMENT METHOD:
🏷️ METADATA:
... (and more)
```
### 2. Updated Database Schema
**Files:**
- `docs/NOCODB_SCHEMA.md` (comprehensive documentation)
- `src/types.ts` (TypeScript interfaces)
#### Added Fields (19 new fields)
**Payment & Stripe Integration (5 fields):**
- `stripeSessionId` - Link to Stripe Checkout Session
- `stripePaymentIntent` - For refunds/payment verification
- `stripeCustomerId` - Reusable customer ID
- `baseAmount` - Pre-fee amount (from metadata)
- `processingFee` - Stripe processing fee (from metadata)
**Billing Address (6 fields):**
- `billingCity`
- `billingCountry`
- `billingLine1`
- `billingLine2`
- `billingPostalCode`
- `billingState`
**B2B Customer Support (3 fields):**
- `businessName` - Business name for B2B purchases
- `taxIdType` - Tax ID type (eu_vat, us_ein, gb_vat, etc.)
- `taxIdValue` - Tax identification number
**Customer Details (1 field):**
- `customerPhone` - Customer phone number (if collected)
#### Removed Fields (2 fields)
- `customerCompany` - Redundant with `businessName`
- `paymentReference` - Redundant with `stripePaymentIntent`
#### Made Optional
All vessel and trip fields (since not all orders are yacht trips):
- `vesselName`, `imoNumber`, `vesselType`, `vesselLength`
- `departurePort`, `arrivalPort`, `distance`, `avgSpeed`, `duration`, `enginePower`
### 3. Real-World Data Verification
Analyzed actual business payment from Stripe (event: `evt_1SPPa3Pdj1mnVT5kscrqB21t`):
```typescript
{
amount: $165,042.96,
baseAmount: $160,235.88,
processingFee: $4,807.08,
businessName: "LetsBe Solutions LLC",
taxIdType: "eu_vat",
taxIdValue: "FRAB123456789",
customerPhone: "+33633219796",
billingCountry: "FR"
// ... all other fields
}
```
### 4. Documentation Created
- **`docs/NOCODB_SCHEMA.md`** - Complete field reference with:
- Field definitions table
- Required vs optional indicators
- Stripe webhook mapping guide
- Real-world example data
- Field type notes
- Change log
- **`docs/TESTING_STRIPE_WEBHOOK.md`** - Testing guide with:
- Three testing methods (Stripe CLI, real payments, dashboard)
- Expected log output examples
- Verification checklist
- Common issues & fixes
- Test data to use
- **`docs/STRIPE_INTEGRATION_SUMMARY.md`** - This file
## 📊 Final Schema Statistics
**Total Fields:** 42 fields
- **Required:** 10 fields
- **Optional:** 32 fields
**Categories:**
- Core Identification: 6 fields
- Payment Information: 8 fields
- Carbon Offset Details: 6 fields
- Customer Information: 13 fields
- Vessel Information: 4 fields (optional)
- Trip Details: 6 fields (optional)
- Administrative: 1 field
## 🎯 Key Features
### B2B Support
- Full business customer information capture
- Tax ID collection (VAT, EIN, etc.)
- Business name separate from individual name
- Reusable customer profiles via Stripe Customer ID
### Payment Transparency
- Separate base amount and processing fee tracking
- Full Stripe payment references for refunds
- Multiple payment method support
- Currency conversion tracking (amountUSD)
### Comprehensive Billing
- Complete billing address capture
- Phone number collection (optional)
- Country/region support for international customers
### Flexible Use Cases
- Yacht trip calculations (vessel/trip fields optional)
- Direct offset purchases (no vessel required)
- Multiple order sources (web, mobile-app, manual, api)
## 🔄 Data Flow
```
Checkout Form
Stripe Checkout Session
↓ (metadata)
Stripe Payment Success
↓ (webhook)
Server Webhook Handler
↓ (logStripeSessionData)
Enhanced Logging
↓ (session data)
NocoDB Database
Admin Portal Display
```
## 🧪 Testing Status
**Webhook Logging Enhanced** - Comprehensive data display implemented
**Pending:** Live webhook testing with real payment
**Pending:** NocoDB table creation
**Pending:** Webhook-to-database mapping implementation
## 📝 Next Steps
1. **Test Enhanced Logging**
- Run test payment through Stripe
- Verify all fields appear in logs
- Document any missing fields
2. **Create NocoDB Table**
- Use schema from `docs/NOCODB_SCHEMA.md`
- Configure field types (String, DateTime, etc.)
- Set required field validation
3. **Implement Database Integration**
- Create `nocodbClient.createOrder()` method
- Map Stripe session data to NocoDB fields
- Call from webhook handler after payment confirmation
4. **Add Webhook Handler**
- Extract all fields from `session` object
- Handle B2B vs individual customer logic
- Store complete record in NocoDB
- Add error handling and retry logic
5. **Enable Phone & Tax Collection**
- Update checkout session creation in `server/routes/checkout.js`
- Add `phone_number_collection: { enabled: true }`
- Add `tax_id_collection: { enabled: true }`
## 📚 Reference Files
- **Schema:** `docs/NOCODB_SCHEMA.md`
- **Testing:** `docs/TESTING_STRIPE_WEBHOOK.md`
- **Types:** `src/types.ts:147-202` (OrderRecord interface)
- **Webhook:** `server/routes/webhooks.js:15-124` (logStripeSessionData)
- **NocoDB Client:** `src/api/nocodbClient.ts`
## 💡 Key Insights from Real Data
1. **Business Customers:** Stripe clearly distinguishes business vs individual:
- `business_name` populated for B2B
- `individual_name` populated for B2C
- `name` serves as display name (either business or individual)
2. **Tax Collection:** When enabled, provides structured tax IDs:
- Type (eu_vat, us_ein, gb_vat, etc.)
- Value (actual tax number)
- Multiple tax IDs possible (array)
3. **Phone Numbers:** Available when `phone_number_collection.enabled: true`
4. **Processing Fees:** Must be calculated and passed in metadata (not automatic)
5. **Customer IDs:** Stripe creates reusable customer objects for future purchases
---
**Status:** ✅ Schema finalized and documented
**Last Updated:** 2025-11-03
**Real Data Verified:** Yes (business purchase with VAT)

View File

@ -0,0 +1,263 @@
# Testing Stripe Webhook Data Collection
## Purpose
This guide explains how to test the Stripe webhook to verify all available data fields before finalizing the NocoDB schema.
## Enhanced Logging
The webhook handler now includes comprehensive logging via `logStripeSessionData()` function that displays:
- ✅ Session information (ID, payment intent, status)
- ✅ Payment amounts (total, subtotal, currency)
- ✅ Customer details (name, email, phone)
- ✅ Billing address (all fields)
- ✅ Payment method types
- ✅ Metadata (our custom fields)
- ✅ Additional fields (locale, reference IDs)
- ✅ Shipping address (if collected)
- ✅ Tax IDs (if collected)
## Testing Methods
### Method 1: Stripe CLI (Recommended for Development)
**Setup:**
```bash
# Install Stripe CLI if not already installed
# Windows: scoop install stripe
# macOS: brew install stripe/stripe-cli/stripe
# Linux: See https://stripe.com/docs/stripe-cli
# Login to Stripe
stripe login
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
```
**Trigger Test Payment:**
```bash
# Trigger a checkout.session.completed event
stripe trigger checkout.session.completed
```
**Expected Output:**
The server console will show:
1. The structured data log from `logStripeSessionData()` (formatted with boxes)
2. The full JSON payload
3. Payment confirmation message
4. Order fulfillment logs
### Method 2: Real Stripe Test Payment
**Setup:**
1. Ensure server is running: `npm run dev` (from `/server` directory)
2. Ensure webhook endpoint is publicly accessible (use ngrok or similar)
3. Configure webhook in Stripe Dashboard → Developers → Webhooks
4. Add webhook endpoint: `https://your-domain.com/api/webhooks/stripe`
5. Select event: `checkout.session.completed`
6. Copy webhook signing secret to `.env`: `STRIPE_WEBHOOK_SECRET=whsec_...`
**Execute Test:**
1. Navigate to your app's checkout flow
2. Use Stripe test card: `4242 4242 4242 4242`
3. Fill in test billing information
4. Complete checkout
5. Check server logs
**Test Data to Use:**
```
Card Number: 4242 4242 4242 4242
Expiry: Any future date (e.g., 12/34)
CVC: Any 3 digits (e.g., 123)
ZIP: Any 5 digits (e.g., 12345)
Name: Test User
Email: test@example.com
Phone: +1234567890 (if phone collection enabled)
Address:
Line 1: 123 Test Street
Line 2: Apt 4B
City: Test City
State: CA
ZIP: 12345
Country: US
```
### Method 3: Stripe Dashboard Test Events
**Execute:**
1. Go to Stripe Dashboard → Developers → Webhooks
2. Click on your webhook endpoint
3. Click "Send test webhook"
4. Select `checkout.session.completed`
5. Click "Send test webhook"
**Note:** This may have limited data compared to real checkout sessions.
## Reading the Logs
### Expected Log Structure
When a payment completes, you'll see logs in this order:
```
📬 Received webhook: checkout.session.completed
╔════════════════════════════════════════════════════════════════╗
║ STRIPE CHECKOUT SESSION - COMPREHENSIVE DATA ║
╚════════════════════════════════════════════════════════════════╝
📋 SESSION INFORMATION:
Session ID: cs_test_...
Payment Intent: pi_...
Payment Status: paid
Status: complete
Mode: payment
Created: 2025-11-03T...
Expires At: 2025-11-04T...
💰 PAYMENT AMOUNT:
Amount Total: 164979 cents ($1649.79)
Amount Subtotal: 164979 cents ($1649.79)
Currency: USD
👤 CUSTOMER DETAILS:
Name: Matthew Ciaccio
Email: matt@letsbe.solutions
Phone: +33612345678 (or N/A if not collected)
Tax Exempt: none
📬 BILLING ADDRESS:
Line 1: 108 Avenue du Trois Septembre
Line 2: N/A
City: Cap-d'Ail
State: N/A
Postal Code: 06320
Country: FR
🔗 CUSTOMER OBJECT:
Customer ID: cus_... (or N/A)
💳 PAYMENT METHOD:
Types: card
🏷️ METADATA (Our Custom Fields):
baseAmount: 160174
processingFee: 4805
portfolioId: 37
tons: 6.73
... (additional sections)
╔════════════════════════════════════════════════════════════════╗
║ END OF STRIPE DATA LOG ║
╚════════════════════════════════════════════════════════════════╝
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📦 Full Stripe Webhook Payload:
{
"id": "evt_...",
"object": "event",
"type": "checkout.session.completed",
"data": { ... }
}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ Checkout session completed: cs_test_...
💳 Payment confirmed for order: e0e976e5-...
Amount: $1649.79
🌱 Fulfilling order via Wren API...
```
## Verification Checklist
After running a test payment, verify the logs contain:
### ✅ Required Fields (Must Have Values)
- [ ] Session ID (`session.id`)
- [ ] Payment Intent (`session.payment_intent`)
- [ ] Amount Total (`session.amount_total`)
- [ ] Currency (`session.currency`)
- [ ] Customer Name (`session.customer_details.name`)
- [ ] Customer Email (`session.customer_details.email`)
- [ ] Metadata - baseAmount (`session.metadata.baseAmount`)
- [ ] Metadata - processingFee (`session.metadata.processingFee`)
- [ ] Metadata - portfolioId (`session.metadata.portfolioId`)
- [ ] Metadata - tons (`session.metadata.tons`)
### ⚠️ Optional Fields (Check if Available)
- [ ] Customer Phone (`session.customer_details.phone`)
- [ ] Billing Address Line 1 (`session.customer_details.address.line1`)
- [ ] Billing Address Line 2 (`session.customer_details.address.line2`)
- [ ] Billing City (`session.customer_details.address.city`)
- [ ] Billing State (`session.customer_details.address.state`)
- [ ] Billing Postal Code (`session.customer_details.address.postal_code`)
- [ ] Billing Country (`session.customer_details.address.country`)
### 📋 Schema Verification
Compare the logged fields against `docs/NOCODB_SCHEMA.md`:
1. Verify all required fields have values
2. Check which optional fields are actually populated
3. Identify any Stripe fields we're missing in our schema
4. Verify data types and formats
## Common Issues
### Phone Number Not Showing
**Cause:** Phone collection not enabled in checkout session creation.
**Fix:** In `server/routes/checkout.js`, ensure phone collection is enabled:
```javascript
const session = await stripe.checkout.sessions.create({
phone_number_collection: {
enabled: true
},
// ... other settings
});
```
### Address Fields Empty
**Cause:** Billing address collection not set to "required".
**Fix:** In checkout session creation:
```javascript
const session = await stripe.checkout.sessions.create({
billing_address_collection: 'required',
// ... other settings
});
```
### Metadata Missing
**Cause:** Metadata not passed when creating checkout session.
**Fix:** Ensure metadata is included in session creation:
```javascript
const session = await stripe.checkout.sessions.create({
metadata: {
baseAmount: '160174',
processingFee: '4805',
portfolioId: '37',
tons: '6.73',
// Add vessel/trip data here if available
},
// ... other settings
});
```
## Next Steps
After verifying the logged data:
1. **Document findings** - Note which fields are actually populated
2. **Update schema** - Add any missing fields to `docs/NOCODB_SCHEMA.md`
3. **Update types** - Update `OrderRecord` interface in `src/types.ts`
4. **Configure NocoDB** - Create table with verified fields
5. **Implement webhook handler** - Map Stripe data to NocoDB columns
## Reference
- [Stripe Checkout Session Object](https://stripe.com/docs/api/checkout/sessions/object)
- [Stripe Webhooks Documentation](https://stripe.com/docs/webhooks)
- [Stripe CLI Documentation](https://stripe.com/docs/stripe-cli)

View File

@ -5,9 +5,128 @@ import { createWrenOffsetOrder, getWrenPortfolios } from '../utils/wrenClient.js
import { sendReceiptEmail, sendAdminNotification } from '../utils/emailService.js';
import { selectComparisons } from '../utils/carbonComparisons.js';
import { formatPortfolioProjects } from '../utils/portfolioColors.js';
import { nocodbClient } from '../utils/nocodbClient.js';
import { mapStripeSessionToNocoDBOrder, mapWrenFulfillmentData } from '../utils/nocodbMapper.js';
const router = express.Router();
/**
* Log all available Stripe session data for schema verification
* This helps us verify what fields are actually available from Stripe
*/
function logStripeSessionData(session) {
console.log('\n╔════════════════════════════════════════════════════════════════╗');
console.log('║ STRIPE CHECKOUT SESSION - COMPREHENSIVE DATA ║');
console.log('╚════════════════════════════════════════════════════════════════╝\n');
// Core Session Info
console.log('📋 SESSION INFORMATION:');
console.log(' Session ID:', session.id || 'N/A');
console.log(' Payment Intent:', session.payment_intent || 'N/A');
console.log(' Payment Status:', session.payment_status || 'N/A');
console.log(' Status:', session.status || 'N/A');
console.log(' Mode:', session.mode || 'N/A');
console.log(' Created:', session.created ? new Date(session.created * 1000).toISOString() : 'N/A');
console.log(' Expires At:', session.expires_at ? new Date(session.expires_at * 1000).toISOString() : 'N/A');
// Amount Details
console.log('\n💰 PAYMENT AMOUNT:');
console.log(' Amount Total:', session.amount_total ? `${session.amount_total} cents ($${(session.amount_total / 100).toFixed(2)})` : 'N/A');
console.log(' Amount Subtotal:', session.amount_subtotal ? `${session.amount_subtotal} cents ($${(session.amount_subtotal / 100).toFixed(2)})` : 'N/A');
console.log(' Currency:', session.currency ? session.currency.toUpperCase() : 'N/A');
// Customer Details
console.log('\n👤 CUSTOMER DETAILS:');
if (session.customer_details) {
console.log(' Name (Display):', session.customer_details.name || 'N/A');
console.log(' Business Name:', session.customer_details.business_name || 'N/A');
console.log(' Individual Name:', session.customer_details.individual_name || 'N/A');
console.log(' Email:', session.customer_details.email || 'N/A');
console.log(' Phone:', session.customer_details.phone || 'N/A');
console.log(' Tax Exempt:', session.customer_details.tax_exempt || 'N/A');
// Billing Address
console.log('\n📬 BILLING ADDRESS:');
if (session.customer_details.address) {
const addr = session.customer_details.address;
console.log(' Line 1:', addr.line1 || 'N/A');
console.log(' Line 2:', addr.line2 || 'N/A');
console.log(' City:', addr.city || 'N/A');
console.log(' State:', addr.state || 'N/A');
console.log(' Postal Code:', addr.postal_code || 'N/A');
console.log(' Country:', addr.country || 'N/A');
} else {
console.log(' No address provided');
}
} else {
console.log(' No customer details provided');
}
// Customer Object Reference
console.log('\n🔗 CUSTOMER OBJECT:');
console.log(' Customer ID:', session.customer || 'N/A');
// Payment Method Details
console.log('\n💳 PAYMENT METHOD:');
if (session.payment_method_types && session.payment_method_types.length > 0) {
console.log(' Types:', session.payment_method_types.join(', '));
} else {
console.log(' Types: N/A');
}
// Metadata (our custom data)
console.log('\n🏷 METADATA (Our Custom Fields):');
if (session.metadata && Object.keys(session.metadata).length > 0) {
Object.entries(session.metadata).forEach(([key, value]) => {
console.log(` ${key}:`, value);
});
} else {
console.log(' No metadata');
}
// Line Items (what they purchased)
console.log('\n🛒 LINE ITEMS:');
if (session.line_items) {
console.log(' Available in session object');
} else {
console.log(' Not expanded (need to fetch separately)');
}
// Additional Fields
console.log('\n🔧 ADDITIONAL FIELDS:');
console.log(' Client Reference ID:', session.client_reference_id || 'N/A');
console.log(' Locale:', session.locale || 'N/A');
console.log(' Success URL:', session.success_url || 'N/A');
console.log(' Cancel URL:', session.cancel_url || 'N/A');
// Shipping (if collected)
if (session.shipping) {
console.log('\n📦 SHIPPING (if collected):');
console.log(' Name:', session.shipping.name || 'N/A');
if (session.shipping.address) {
const addr = session.shipping.address;
console.log(' Address Line 1:', addr.line1 || 'N/A');
console.log(' Address Line 2:', addr.line2 || 'N/A');
console.log(' City:', addr.city || 'N/A');
console.log(' State:', addr.state || 'N/A');
console.log(' Postal Code:', addr.postal_code || 'N/A');
console.log(' Country:', addr.country || 'N/A');
}
}
// Tax IDs (if collected)
if (session.customer_details?.tax_ids && session.customer_details.tax_ids.length > 0) {
console.log('\n🆔 TAX IDS (if collected):');
session.customer_details.tax_ids.forEach((taxId, index) => {
console.log(` Tax ID ${index + 1}:`, taxId.type, '-', taxId.value);
});
}
console.log('\n╔════════════════════════════════════════════════════════════════╗');
console.log('║ END OF STRIPE DATA LOG ║');
console.log('╚════════════════════════════════════════════════════════════════╝\n');
}
/**
* POST /api/webhooks/stripe
* Handle Stripe webhook events
@ -29,6 +148,11 @@ router.post('/stripe', express.raw({ type: 'application/json' }), async (req, re
console.log(`📬 Received webhook: ${event.type}`);
// Log comprehensive Stripe data for schema verification
if (event.type === 'checkout.session.completed') {
logStripeSessionData(event.data.object);
}
// Log full webhook payload for debugging and data extraction
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('📦 Full Stripe Webhook Payload:');
@ -98,15 +222,11 @@ async function handleCheckoutSessionCompleted(session) {
}
console.log(`💳 Payment confirmed for order: ${order.id}`);
console.log(` Customer Email: ${session.customer_details?.email}`);
console.log(` Customer Name: ${session.customer_details?.name || 'Not provided'}`);
console.log(` Amount: $${(order.total_amount / 100).toFixed(2)}`);
if (session.customer_details?.address) {
console.log(` Address: ${JSON.stringify(session.customer_details.address)}`);
}
if (session.metadata && Object.keys(session.metadata).length > 0) {
console.log(` Metadata: ${JSON.stringify(session.metadata)}`);
}
// (Detailed session data already logged above via logStripeSessionData())
// Save order to NocoDB
await saveOrderToNocoDB(order, session);
// Fulfill order via Wren API
await fulfillOrder(order, session);
@ -257,6 +377,9 @@ async function fulfillOrder(order, session) {
console.log(` Wren Order ID: ${wrenOrder.id}`);
console.log(` Tons offset: ${order.tons}`);
// Update NocoDB with fulfillment data
await updateNocoDBFulfillment(order.id, wrenOrder);
// Send receipt email to customer
const customerEmail = session.customer_details?.email || order.customer_email;
if (customerEmail) {
@ -330,4 +453,75 @@ async function fulfillOrder(order, session) {
}
}
/**
* Save order to NocoDB
* @param {Object} order - Local database order object
* @param {Object} session - Stripe session object
*/
async function saveOrderToNocoDB(order, session) {
// Skip if NocoDB is not configured
if (!nocodbClient.isConfigured()) {
console.log(' NocoDB not configured, skipping database save');
return;
}
try {
console.log(`💾 Saving order ${order.id} to NocoDB...`);
// Map Stripe session data to NocoDB format
const nocodbOrderData = mapStripeSessionToNocoDBOrder(session, order);
// Create record in NocoDB
const response = await nocodbClient.createOrder(nocodbOrderData);
console.log(`✅ Order saved to NocoDB successfully`);
console.log(` NocoDB Record ID: ${response.Id}`);
console.log(` Order ID: ${nocodbOrderData.orderId}`);
console.log(` Customer: ${nocodbOrderData.customerName} (${nocodbOrderData.customerEmail})`);
if (nocodbOrderData.businessName) {
console.log(` Business: ${nocodbOrderData.businessName}`);
}
if (nocodbOrderData.taxIdType && nocodbOrderData.taxIdValue) {
console.log(` Tax ID: ${nocodbOrderData.taxIdType} - ${nocodbOrderData.taxIdValue}`);
}
} catch (error) {
console.error('❌ Failed to save order to NocoDB:', error.message);
console.error(' This is non-fatal - order is still saved locally');
// Don't throw - we don't want to fail the webhook if NocoDB is down
}
}
/**
* Update NocoDB with fulfillment data
* @param {string} orderId - Order ID
* @param {Object} wrenOrder - Wren order response
*/
async function updateNocoDBFulfillment(orderId, wrenOrder) {
// Skip if NocoDB is not configured
if (!nocodbClient.isConfigured()) {
return;
}
try {
console.log(`💾 Updating NocoDB with fulfillment data for order ${orderId}...`);
// Map Wren fulfillment data
const fulfillmentData = mapWrenFulfillmentData(orderId, wrenOrder);
// Update NocoDB record
await nocodbClient.updateOrderFulfillment(orderId, fulfillmentData);
console.log(`✅ NocoDB updated with fulfillment data`);
console.log(` Wren Order ID: ${fulfillmentData.wrenOrderId}`);
console.log(` Status: ${fulfillmentData.status}`);
} catch (error) {
console.error('❌ Failed to update NocoDB with fulfillment:', error.message);
// Non-fatal - don't throw
}
}
export default router;

View File

@ -0,0 +1,101 @@
/**
* NocoDB Client for Server
* Simple REST API client for NocoDB operations
*/
class NocoDBClient {
constructor() {
this.config = {
baseUrl: process.env.NOCODB_BASE_URL || '',
baseId: process.env.NOCODB_BASE_ID || '',
apiKey: process.env.NOCODB_API_KEY || '',
ordersTableId: process.env.NOCODB_ORDERS_TABLE_ID || '',
};
if (!this.config.baseUrl || !this.config.baseId || !this.config.apiKey || !this.config.ordersTableId) {
console.warn('⚠️ NocoDB configuration incomplete. Database integration disabled.');
}
this.baseUrl = `${this.config.baseUrl}/api/v2/tables/${this.config.ordersTableId}/records`;
}
/**
* Make authenticated request to NocoDB
*/
async request(endpoint, options = {}) {
const url = endpoint.startsWith('http') ? endpoint : `${this.baseUrl}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'xc-token': this.config.apiKey,
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`NocoDB request failed: ${response.status} - ${errorText}`);
}
return response.json();
}
/**
* Create new order record
*/
async createOrder(orderData) {
return this.request('', {
method: 'POST',
body: JSON.stringify(orderData),
});
}
/**
* Update order fields
*/
async updateOrder(recordId, data) {
return this.request(`/${recordId}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
/**
* Find order by orderId field
*/
async findOrderByOrderId(orderId) {
const params = new URLSearchParams();
params.append('where', `(orderId,eq,${orderId})`);
params.append('limit', '1');
const response = await this.request(`?${params.toString()}`);
return response.list?.[0] || null;
}
/**
* Update order with Wren fulfillment data
*/
async updateOrderFulfillment(orderId, fulfillmentData) {
// First find the NocoDB record ID
const order = await this.findOrderByOrderId(orderId);
if (!order) {
throw new Error(`Order not found in NocoDB: ${orderId}`);
}
// Update the record
return this.updateOrder(order.Id, fulfillmentData);
}
/**
* Check if NocoDB is configured
*/
isConfigured() {
return !!(this.config.baseUrl && this.config.baseId && this.config.apiKey && this.config.ordersTableId);
}
}
// Export singleton instance
export const nocodbClient = new NocoDBClient();

View File

@ -0,0 +1,133 @@
/**
* NocoDB Order Mapper
* Maps Stripe Checkout Session data to NocoDB OrderRecord format
*/
/**
* Map Stripe session data to NocoDB OrderRecord format
* @param {Object} session - Stripe checkout session object
* @param {Object} order - Local database order object
* @returns {Object} NocoDB-compatible order record
*/
export function mapStripeSessionToNocoDBOrder(session, order) {
const metadata = session.metadata || {};
const customerDetails = session.customer_details || {};
const address = customerDetails.address || {};
const taxIds = customerDetails.tax_ids || [];
return {
// Order identification
orderId: order.id,
status: 'paid', // Will be updated to 'fulfilled' after Wren order
source: determineOrderSource(session),
// Payment information
stripeSessionId: session.id,
stripePaymentIntent: session.payment_intent || null,
stripeCustomerId: session.customer || null,
baseAmount: metadata.baseAmount || order.base_amount?.toString() || '0',
processingFee: metadata.processingFee || order.processing_fee?.toString() || '0',
totalAmount: session.amount_total?.toString() || order.total_amount?.toString() || '0',
currency: session.currency?.toUpperCase() || order.currency || 'USD',
amountUSD: calculateUSDAmount(session.amount_total, session.currency),
paymentMethod: session.payment_method_types?.[0] || null,
// Carbon offset details
co2Tons: metadata.tons || order.tons?.toString() || '0',
portfolioId: metadata.portfolioId || order.portfolio_id?.toString() || '',
portfolioName: null, // Will be populated later from Wren API
wrenOrderId: null, // Will be populated after fulfillment
certificateUrl: null, // Will be populated after fulfillment
fulfilledAt: null, // Will be set when order is fulfilled
// Customer information
customerName: customerDetails.name || 'Unknown',
customerEmail: customerDetails.email || order.customer_email || '',
customerPhone: customerDetails.phone || null,
businessName: customerDetails.business_name || null,
taxIdType: taxIds[0]?.type || null,
taxIdValue: taxIds[0]?.value || null,
// Billing address
billingLine1: address.line1 || null,
billingLine2: address.line2 || null,
billingCity: address.city || null,
billingState: address.state || null,
billingPostalCode: address.postal_code || null,
billingCountry: address.country || null,
// Vessel information (from metadata or order, if available)
vesselName: metadata.vesselName || order.vessel_name || null,
imoNumber: metadata.imoNumber || order.imo_number || null,
vesselType: metadata.vesselType || null,
vesselLength: metadata.vesselLength || null,
// Trip details (from metadata, if available)
departurePort: metadata.departurePort || null,
arrivalPort: metadata.arrivalPort || null,
distance: metadata.distance || null,
avgSpeed: metadata.avgSpeed || null,
duration: metadata.duration || null,
enginePower: metadata.enginePower || null,
// Administrative
notes: null,
};
}
/**
* Determine order source from session data
* @param {Object} session - Stripe checkout session object
* @returns {string} Order source
*/
function determineOrderSource(session) {
const metadata = session.metadata || {};
// Check if source is specified in metadata
if (metadata.source) {
return metadata.source;
}
// Check success URL for mobile app indicator
if (session.success_url?.includes('mobile-app')) {
return 'mobile-app';
}
// Default to web
return 'web';
}
/**
* Calculate USD amount for reporting
* @param {number} amount - Amount in cents
* @param {string} currency - Currency code
* @returns {string} USD amount in cents
*/
function calculateUSDAmount(amount, currency) {
if (!amount) return '0';
// If already USD, return as-is
if (currency?.toLowerCase() === 'usd') {
return amount.toString();
}
// TODO: Implement currency conversion using real-time rates
// For now, return the original amount
// In production, fetch rates from an API or use a conversion service
return amount.toString();
}
/**
* Update NocoDB order with Wren fulfillment data
* @param {string} orderId - Order ID
* @param {Object} wrenOrder - Wren API order response
* @returns {Object} Update data for NocoDB
*/
export function mapWrenFulfillmentData(orderId, wrenOrder) {
return {
wrenOrderId: wrenOrder.id,
certificateUrl: wrenOrder.certificate_url || null,
fulfilledAt: new Date().toISOString(),
status: 'fulfilled',
};
}

View File

@ -265,6 +265,16 @@ export class NocoDBClient {
});
}
/**
* Create new order record
*/
async createOrder(orderData: Record<string, any>): Promise<any> {
return this.request<any>('', {
method: 'POST',
body: JSON.stringify(orderData),
});
}
/**
* Get orders grouped by time period (for charts)
*/

View File

@ -153,43 +153,55 @@ export interface OrderRecord {
// Order identification
orderId: string;
status: 'pending' | 'paid' | 'fulfilled' | 'cancelled';
source?: string; // 'web', 'mobile-app', 'manual'
source?: string; // 'web', 'mobile-app', 'manual', 'api'
// Vessel information
vesselName: string;
imoNumber?: string;
vesselType?: string;
vesselLength?: string;
// Trip details
departurePort?: string;
arrivalPort?: string;
distance: string;
avgSpeed: string;
duration?: string;
enginePower?: string;
// Payment information
stripeSessionId?: string; // Stripe Checkout Session ID
stripePaymentIntent?: string; // Stripe Payment Intent ID (for refunds)
baseAmount: string; // Pre-fee amount in cents (from Stripe metadata)
processingFee: string; // Stripe processing fee in cents (from Stripe metadata)
totalAmount: string; // Total charged amount in cents (baseAmount + processingFee)
currency: string; // 'USD', 'EUR', 'GBP', 'CHF'
amountUSD?: string; // Amount converted to USD for reporting
paymentMethod?: string; // Payment method type (e.g., "card", "bank_transfer")
// Carbon offset details
co2Tons: string;
portfolioId?: string;
portfolioName?: string;
totalAmount: string;
currency: string;
amountUSD: string;
co2Tons: string; // Tons of CO2 offset (from Stripe metadata.tons)
portfolioId: string; // Wren portfolio ID (from Stripe metadata)
portfolioName?: string; // Human-readable portfolio name
wrenOrderId?: string; // Wren API order ID (populated after fulfillment)
certificateUrl?: string; // URL to offset certificate
fulfilledAt?: string; // Timestamp when order was fulfilled with Wren
// Customer information
customerName: string;
customerEmail: string;
customerCompany?: string;
customerPhone?: string;
customerName: string; // From Stripe customer_details.name (business_name or individual_name)
customerEmail: string; // From Stripe customer_details.email
customerPhone?: string; // From Stripe customer_details.phone (if phone collection enabled)
businessName?: string; // From Stripe customer_details.business_name (B2B purchases)
stripeCustomerId?: string; // From Stripe customer (reusable customer ID)
taxIdType?: string; // From Stripe customer_details.tax_ids[0].type (e.g., "eu_vat", "us_ein")
taxIdValue?: string; // From Stripe customer_details.tax_ids[0].value
billingCity?: string; // From Stripe address.city
billingCountry?: string; // From Stripe address.country
billingLine1?: string; // From Stripe address.line1
billingLine2?: string; // From Stripe address.line2
billingPostalCode?: string; // From Stripe address.postal_code
billingState?: string; // From Stripe address.state
// Payment & fulfillment
paymentMethod?: string;
paymentReference?: string;
wrenOrderId?: string;
certificateUrl?: string;
fulfilledAt?: string;
// Vessel information (optional - for yacht calculations)
vesselName?: string; // Name of vessel
imoNumber?: string; // IMO vessel identification number
vesselType?: string; // Type of vessel (e.g., "Motor Yacht")
vesselLength?: string; // Vessel length in meters
// Trip details (optional - for trip-based calculations)
departurePort?: string; // Departure port name
arrivalPort?: string; // Arrival port name
distance?: string; // Distance in nautical miles
avgSpeed?: string; // Average speed in knots
duration?: string; // Trip duration in hours
enginePower?: string; // Engine power in horsepower
// Admin notes
notes?: string;
notes?: string; // Internal admin notes
}