Store Charge - Main Account Charge
🔗 Related Documentation​
- Pending Subaccount Invoices - Invoice processing
- Cancel Subscription - Subscription cancellation
- Common Billing Utilities - Shared billing functions
The Main Account Charge module processes charges for main accounts by executing single-item checkouts through the Internal API. It monitors the Queue collection for items with source: "subaccount-charge" and processes them using a two-phase checkout flow (preview + finalize) with JWT-authenticated API calls and exponential backoff retry logic.
Key Features:
- Two-Phase Checkout: Preview validation before finalization
- JWT Authentication: 10-day tokens with multi-scope access for API authorization
- In-Progress Locking: Prevents duplicate processing with database flags
- Exponential Backoff: 10 attempts with 60-second base delay for transient failures
- Cart API Integration: Uses single-item checkout endpoint for charges
- Queue-Based Processing: Leverages generic
Queuecollection for charge requests
Critical Business Impact:
- Revenue Collection: Ensures main accounts are charged for subaccount usage
- Automated Billing: Processes charges created by subaccount invoicing
- Retry Handling: Recovers from transient payment failures
- Business Data Tracking: Maintains business info, pricing, and action data
Architecture​
Execution Flow​
sequenceDiagram
participant Cron as Cron Scheduler
participant Service as Charge Service
participant DB as MongoDB
participant Queue as Bull Queue
participant Processor as Charge Processor
participant API as Internal API
participant Cart as Cart Service
participant Stripe as Stripe
Note over Cron,Stripe: Every 5 Seconds
Cron->>Service: Trigger charge check
Service->>DB: Find pending charges
Note over DB: source: "subaccount-charge"<br/>status: "pending"<br/>in_progress: false
DB-->>Service: Queue items array
Service->>DB: Mark in_progress = true
loop For each queue item
Service->>Service: Generate JWT token
Note over Service: 10-day expiration<br/>Scope: users.me sites store
Service->>Queue: Add to charge queue
Note over Queue: 10 attempts, 60s backoff
Queue->>Processor: Process charge job
Processor->>Processor: Extract charge data
Note over Processor: business, price<br/>external_action, charge
Processor->>API: POST /v1/store/cart/single-item-checkout?type=preview
Note over API: Validate charge<br/>Calculate totals
API-->>Processor: Preview validation OK
Processor->>API: POST /v1/store/cart/single-item-checkout?type=finalize
Note over API: Create invoice<br/>Charge payment method
API->>Cart: Process cart
Cart->>Stripe: Create invoice/charge
Stripe-->>Cart: Payment succeeded
Cart-->>API: Checkout complete
API-->>Processor: Success response
Processor->>DB: Delete queue item
Processor->>Processor: Log completion
end
Note over Processor: On Failure After 10 Attempts
Processor->>DB: Reset in_progress flag
Note over DB: in_progress: false<br/>status: "pending"
Component Structure​
queue-manager/
├── crons/
│ └── store/
│ └── charge/
│ └── charge_mainaccount.js # Cron scheduler
├── services/
│ └── store/
│ └── charge/
│ └── charge_mainaccount.js # Service logic
└── queues/
└── store/
└── charge/
└── charge_mainaccount.js # Queue processor
Cron Schedule​
File: queue-manager/crons/store/charge/charge_mainaccount.js
'*/5 * * * * *'; // Every 5 seconds
Pattern: High-frequency scheduler for rapid charge processing
- In-Progress Locking: Prevents concurrent executions with flag
- Purpose: Ensures charges are processed quickly after subaccount invoicing
Configuration​
Environment Variables​
| Variable | Type | Required | Description |
|---|---|---|---|
APP_SECRET | String | Yes | JWT secret for token signing |
API_BASE_URL | String | Yes | Internal API base URL for cart checkout endpoint |
JWT Token Configuration​
Generation: queue-manager/services/store/charge/charge_mainaccount.js
const jwt = require('jsonwebtoken');
const token = jwt.sign(
{
type: 'access_token',
uid: queue.user_id.toString(),
account_id: queue.account_id.toString(),
parent_account: queue.parent_account.toString(),
scope: 'users.me sites store',
},
process.env.APP_SECRET,
{ expiresIn: '10d' },
);
Token Claims:
type:access_token- Token type identifieruid: User MongoDB ObjectId from queue itemaccount_id: Main account ID (the account being charged)parent_account: Parent account ID (typically same as account_id for main accounts)scope:users.me sites store- Permission scopesexpiresIn:10d- 10-day expiration
Queue Retry Configuration​
Pattern: queue-manager/services/store/charge/charge_mainaccount.js
{
attempts: 10,
backoff: {
type: 'exponential',
delay: 60000 // 60 seconds base delay
}
}
Retry Schedule:
| Attempt | Delay | Total Wait |
|---|---|---|
| 1 | 0s | 0s |
| 2 | 60s | 60s |
| 3 | 120s | 180s |
| 4 | 240s | 420s |
| 5 | 480s | 900s |
| 6 | 960s | 1860s |
| 7 | 1920s | 3780s |
| 8 | 3840s | 7620s |
| 9 | 7680s | 15300s |
| 10 | 15360s | 30660s |
Total: ~8.5 hours of retry attempts
Service Implementation​
Queue Item Detection​
File: queue-manager/services/store/charge/charge_mainaccount.js
Query Pattern​
const queues = await Queue.find({
status: 'pending',
in_progress: false,
source: 'subaccount-charge',
});
Conditions:
status: 'pending'- Not yet processedin_progress: false- Not currently processingsource: 'subaccount-charge'- Specific charge type from subaccount invoicing
Purpose: Isolates charges created by subaccount billing system
In-Progress Locking​
const ids = queues.map(sub => sub._id);
await Queue.updateMany({ _id: { $in: ids } }, { in_progress: true });
Pattern: Bulk update sets in-progress flag before queue addition
- Race Condition Prevention: Multiple cron executions won't process same items
- Atomic Operation: Single database update for all queue items
Queue Addition​
await main_acc_queue.add(
{ queue: doc, token },
{
attempts: 10,
backoff: {
type: 'exponential',
delay: 60000,
},
},
);
Payload:
queue: Full queue document from MongoDBtoken: JWT token for API authentication
Error Handling:
catch (err) {
await Queue.updateOne(
{ _id: queue._id },
{ in_progress: false }
);
console.log('Error occurred while processing the data for main account charge.', err.message, err.stack);
}
Cleanup: Resets in-progress flag on queue addition failure
Queue Processor​
Two-Phase Checkout Flow​
File: queue-manager/queues/store/charge/charge_mainaccount.js
Phase 1: Preview​
const { queue, token } = job.data;
const { business, price, external_action, charge } = queue.additional_data;
const body = {
business,
price,
external_action,
charge,
};
await axios.post(`${API_URL}/v1/store/cart/single-item-checkout?type=preview`, body, {
headers: {
Authorization: `Bearer ${token}`,
},
});
Endpoint: POST /v1/store/cart/single-item-checkout?type=preview
Purpose: Validate charge data and calculate totals without committing
- Validation: Checks pricing, business data, account status
- Calculation: Computes taxes, fees, totals
- No Side Effects: Does not create invoices or charge payment methods
Request Body:
{
"business": {
"id": "60a1b2c3d4e5f6789abcdef0",
"name": "Example Business"
},
"price": {
"amount": 50.0,
"currency": "usd"
},
"external_action": "subaccount_charge",
"charge": {
"description": "Monthly service fee",
"metadata": { "subaccount_id": "..." }
}
}
Phase 2: Finalize​
await axios.post(`${API_URL}/v1/store/cart/single-item-checkout?type=finalize`, body, {
headers: {
Authorization: `Bearer ${token}`,
},
});
Endpoint: POST /v1/store/cart/single-item-checkout?type=finalize
Purpose: Commit the charge and create invoice
- Invoice Creation: Creates Stripe invoice
- Payment Processing: Charges default payment method
- Database Updates: Records transaction in internal database
- Side Effects: Money movement, invoice generation
Flow: Preview must succeed before finalize is called
Success Handling​
Callback: completedCb
const completedCb = async job => {
const { queue } = job.data;
try {
await Queue.deleteOne({ _id: queue._id });
} catch (err) {
console.error('Failed to update status on invoice', err.message, err.stack);
}
console.log(`Charged ${queue._id.toString()} for main account ${queue.account_id}`);
console.log('Job ID: ', job.id);
};
Action: Delete queue item after successful charge
- Cleanup: Removes completed charge from queue
- No Retry: Once deleted, will not be reprocessed
Logging: Console output with queue ID and account ID
Failure Handling​
Callback: failedCb
const failedCb = async (job, err) => {
const { queue } = job.data;
let message;
if (err.isAxiosError) {
message = err?.response?.data?.message;
}
console.error(
`FAILED TO Charge ${queue._id.toString()} for main account ${queue.account_id.toString()} |`,
message || err,
);
try {
if (job.attemptsMade >= job.opts.attempts) {
await Queue.updateOne({ _id: queue._id }, { in_progress: false });
}
} catch (err) {
console.error('Failed to update status on queue item', job.id, err.message, err.stack);
}
};
Retry Logic:
- During Retries: Flag remains
true, will retry - After Max Attempts: Resets
in_progress: false, status remains'pending' - Purpose: Allows future cron cycles to retry the charge
Error Extraction:
- Checks for Axios errors (API failures)
- Extracts API error message if available
- Falls back to generic error message
Data Models​
Queue Document Structure​
Collection: Queue
{
_id: ObjectId,
account_id: ObjectId, // Main account to charge
user_id: ObjectId, // User ID for JWT token
parent_account: ObjectId, // Parent account (usually same as account_id)
client_id: ObjectId, // Client identifier
status: 'pending' | 'completed' | 'failed',
in_progress: Boolean, // Processing lock flag
source: 'subaccount-charge', // Charge source identifier
type: String, // Queue type
sub_type: String, // Queue subtype
additional_data: {
business: {
id: ObjectId, // Business/subaccount identifier
name: String // Business name
},
price: {
amount: Number, // Charge amount
currency: String // Currency code
},
external_action: String, // Action type (e.g., 'subaccount_charge')
charge: {
description: String, // Charge description
metadata: Object // Additional metadata
}
},
created_at: Date,
updated_at: Date
}
Key Fields:
source: 'subaccount-charge': Identifies charges from subaccount billingadditional_data: Contains all checkout data (business, price, action, charge)in_progress: Prevents duplicate processingaccount_id: Main account that will be charged
Charge Data Structure​
additional_data Object:
{
business: {
id: ObjectId, // Subaccount or business ID
name: String // Business name for invoice
},
price: {
amount: Number, // Charge amount (in dollars, e.g., 50.00)
currency: String // Currency code (e.g., 'usd', 'eur')
},
external_action: String, // Action type identifier
charge: {
description: String, // Invoice line item description
metadata: { // Additional tracking data
subaccount_id: String,
invoice_id: String,
period_start: Date,
period_end: Date
}
}
}
Error Handling​
Common Error Scenarios​
1. Preview Validation Failure​
Causes:
- Invalid business ID
- Negative or zero price
- Missing required fields
- Account suspended or closed
Response: Axios error with API validation message
if (err.isAxiosError) {
message = err?.response?.data?.message;
}
Impact: Job fails, retries with exponential backoff
2. Finalize Payment Failure​
Causes:
- Invalid payment method
- Insufficient funds
- Card declined
- Network timeout between preview and finalize
Response: API error during finalize step
Critical: Preview succeeded but charge failed
- Partial State: Validation passed but payment didn't
- Retry: Will re-attempt full flow (preview + finalize)
3. Database Lock Contention​
Cause: Multiple cron executions attempt to process same queue item
Prevention: In-progress locking pattern
in_progress: false;
Impact: Only one execution processes each item
4. Queue Addition Failure​
Cause: Redis connection issues or Bull queue errors
Handling:
catch (err) {
await Queue.updateOne(
{ _id: queue._id },
{ in_progress: false }
);
}
Recovery: Reset in-progress flag, next cron cycle will retry
Retry Exhaustion​
After 10 Attempts:
if (job.attemptsMade >= job.opts.attempts) {
await Queue.updateOne({ _id: queue._id }, { in_progress: false });
}
State After Failure:
status: 'pending'- Still needs processingin_progress: false- Available for retry
Manual Intervention: May require investigation of persistent failures
API Integration​
Single-Item Checkout Endpoint​
Internal API Route: POST /v1/store/cart/single-item-checkout
Preview Request​
POST /v1/store/cart/single-item-checkout?type=preview HTTP/1.1
Host: api.dashclicks.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
{
"business": {
"id": "60a1b2c3d4e5f6789abcdef0",
"name": "Example Business"
},
"price": {
"amount": 50.00,
"currency": "usd"
},
"external_action": "subaccount_charge",
"charge": {
"description": "Monthly service fee",
"metadata": {}
}
}
Response (Success):
{
"success": true,
"data": {
"subtotal": 50.0,
"tax": 4.5,
"total": 54.5,
"currency": "usd"
}
}
Finalize Request​
Same request body with ?type=finalize query parameter
Response (Success):
{
"success": true,
"data": {
"invoice_id": "in_...",
"charge_id": "ch_...",
"amount_paid": 54.5,
"status": "paid"
}
}
Response (Error):
{
"success": false,
"message": "Payment method requires authentication"
}
Cart Service Flow​
Internal API → Cart Service → Stripe:
-
Preview: Validate data and calculate totals
- Check account status
- Calculate taxes based on location
- Compute total with fees
-
Finalize: Execute charge
- Create Stripe invoice
- Charge default payment method
- Handle 3D Secure if required
- Record transaction in database
Two-Phase Benefits:
- Validation First: Catch errors before attempting payment
- Idempotency: Preview can be called multiple times safely
- Better UX: Users see calculated totals before charge
Testing Scenarios​
1. Successful Charge​
Setup:
const queue = await Queue.create({
account_id: mainaccount._id,
user_id: user._id,
parent_account: mainaccount._id,
status: 'pending',
in_progress: false,
source: 'subaccount-charge',
additional_data: {
business: { id: subaccount._id, name: 'Test Business' },
price: { amount: 50.0, currency: 'usd' },
external_action: 'subaccount_charge',
charge: { description: 'Monthly fee' },
},
});
Expected Flow:
- Service detects queue item
- JWT token generated
- Preview validation succeeds
- Finalize payment succeeds
- Queue item deleted
2. Preview Failure​
Setup:
// Mock API to fail preview
nock(process.env.API_BASE_URL)
.post('/v1/store/cart/single-item-checkout?type=preview')
.reply(400, { message: 'Invalid business ID' });
Expected Flow:
- Preview request fails
- Finalize never called
- Job retries with exponential backoff
3. Finalize Failure After Preview Success​
Setup:
nock(process.env.API_BASE_URL)
.post('/v1/store/cart/single-item-checkout?type=preview')
.reply(200, { success: true });
nock(process.env.API_BASE_URL)
.post('/v1/store/cart/single-item-checkout?type=finalize')
.reply(402, { message: 'Card declined' });
Expected Flow:
- Preview succeeds
- Finalize fails with payment error
- Job retries full flow (preview + finalize)
4. Retry Exhaustion​
Setup:
// Mock API to always fail
nock(process.env.API_BASE_URL)
.post('/v1/store/cart/single-item-checkout?type=preview')
.times(10)
.reply(500, { message: 'Internal server error' });
Expected State After 10 Attempts:
const queue = await Queue.findById(queueId);
expect(queue.status).toBe('pending');
expect(queue.in_progress).toBe(false);
5. Concurrent Cron Execution​
Test:
await Promise.all([chargeMainAccount(), chargeMainAccount()]);
// Verify queue item only processed once
const jobs = await queue.getJobs(['completed']);
expect(jobs.length).toBe(1);
Performance Considerations​
Query Optimization​
Index Requirements:
// Queue collection
{
status: 1,
in_progress: 1,
source: 1
}
Query Pattern: Compound index improves query performance for pending charges
Batch Processing​
Current Pattern: Sequential queue additions
await Promise.all(
queues.map(async queue => {
// Generate token and add to queue
}),
);
Performance: Parallel processing for multiple items
- Concurrency: Limited by Bull queue concurrency (default: 1)
- Throughput: ~12 items/minute (5-second cron)
Two-Phase API Calls​
Network Overhead: Two HTTP requests per charge
- Preview: ~100-200ms
- Finalize: ~300-500ms
- Total: ~400-700ms per charge
Trade-off: Validation safety vs. speed
- Benefit: Catch errors before payment attempt
- Cost: Double network latency
Monitoring & Logging​
Log Patterns​
Service Logs:
console.log(`Invoice ${queue._id.toString()} added to queue for main account charge`);
Processor Logs:
// Success
console.log(`Charged ${queue._id.toString()} for main account ${queue.account_id}`);
// Failure
console.error(
`FAILED TO Charge ${queue._id.toString()} for main account ${queue.account_id.toString()} |`,
message,
);
Error Logs:
logger.error({
initiator: 'QM/store/charge-main-acc',
error: err,
});
Metrics to Monitor​
- Pending Queue Count:
Queue.countDocuments({ source: 'subaccount-charge', status: 'pending' }) - In-Progress Count:
Queue.countDocuments({ source: 'subaccount-charge', in_progress: true }) - Queue Depth: Bull queue waiting jobs
- Retry Rate: Jobs with
attemptsMade > 1 - Preview vs Finalize Failures: Track which phase fails more often
- Processing Time: Average job duration
Alerting Scenarios​
- High In-Progress Count: May indicate stale locks
- Growing Pending Count: Charges not completing
- High Retry Rate: Payment failures or API issues
- Preview Failure Spike: Data validation issues
- Finalize Failure Spike: Payment method problems
Related Documentation​
- Store Draft Invoices - Draft invoice synchronization
Summary​
The Main Account Charge module provides automated billing for main accounts through a two-phase checkout flow. Its preview-then-finalize pattern ensures data validation before payment attempts, while exponential backoff retry logic handles transient failures. JWT-authenticated API calls route charges through the Internal API's cart service for consistent Stripe integration.
Key Strengths:
- Two-Phase Safety: Validation before payment reduces failures
- Retry Logic: 10 attempts with 8.5 hours of retries
- Lock Prevention: In-progress flags prevent duplicate charges
- JWT Security: 10-day tokens with scoped permissions
- Generic Queue: Leverages common Queue collection for flexibility
Critical for:
- Main account revenue collection
- Subaccount billing rollup
- Failed payment recovery
- Cart service integration