Store Invoice - Pending Subaccount Charge
Overview
The Pending Subaccount Charge module automatically charges subaccounts for pending invoices by creating Stripe payment intents through the Internal API. It monitors the StoreInvoice collection for invoices flagged with pending_sub_account_charge: true and processes them using JWT-authenticated API calls with exponential backoff retry logic.
Key Features:
- Automated Charging: Processes pending subaccount invoices automatically
- 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
- Payment Intent API: Uses Stripe payment intents via Internal API endpoint
- Status Management: Tracks processing state with
pending_sub_account_charge_in_progressflag
Critical Business Impact:
- Revenue Collection: Ensures subaccounts are charged for services
- Automated Billing: Reduces manual intervention for failed charges
- Retry Handling: Recovers from transient payment failures
- Multi-Currency Support: Handles invoices in various currencies
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 Stripe as Stripe Payment Intent
Note over Cron,Stripe: Every 5 Seconds
Cron->>Service: Trigger charge check
Service->>DB: Find pending invoices
Note over DB: pending_sub_account_charge: true<br/>in_progress: false
DB-->>Service: Pending invoices array
Service->>DB: Mark in_progress = true
loop For each invoice
Service->>DB: Find account owner
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 amount & currency
Processor->>API: POST /v1/store/payment-intent
Note over API: Bearer token auth<br/>amount_due / 100
API->>Stripe: Create payment intent
Stripe-->>API: Payment intent created
API-->>Processor: Success response
Processor->>DB: Update invoice
Note over DB: pending_sub_account_charge: false<br/>in_progress: false
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/>pending_sub_account_charge: true
Component Structure
queue-manager/
├── crons/
│ └── store/
│ └── invoices/
│ └── pending_subaccount_charge.js # Cron scheduler
├── services/
│ └── store/
│ └── invoices/
│ └── pending_subaccount_charge.js # Service logic
└── queues/
└── store/
└── invoices/
└── pending_subaccount_charge.js # Queue processor
Cron Schedule
File: queue-manager/crons/store/invoices/pending_subaccount_charge.js
'*/5 * * * * *'; // Every 5 seconds
Pattern: High-frequency scheduler for rapid charge processing
- In-Progress Locking: Prevents concurrent executions with flag
- Purpose: Ensures pending charges are processed quickly
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 payment intent endpoint |
JWT Token Configuration
Generation: queue-manager/services/store/invoices/pending_subaccount_charge.js
const jwt = require('jsonwebtoken');
const token = jwt.sign(
{
type: 'access_token',
uid: user._id.toString(),
account_id: metadata.account_id.toString(),
parent_account: metadata.main_account_id.toString(),
scope: 'users.me sites store',
},
process.env.APP_SECRET,
{ expiresIn: '10d' },
);
Token Claims:
type:access_token- Token type identifieruid: User MongoDB ObjectId as stringaccount_id: Subaccount ID (invoice metadata)parent_account: Main account ID (invoice metadata)scope:users.me sites store- Permission scopesexpiresIn:10d- 10-day expiration
Queue Retry Configuration
Pattern: queue-manager/services/store/invoices/pending_subaccount_charge.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
Invoice Detection
File: queue-manager/services/store/invoices/pending_subaccount_charge.js
Query Pattern
const invoices = await StoreInvoice.find({
pending_sub_account_charge: true,
pending_sub_account_charge_in_progress: { $ne: true },
});
Conditions:
pending_sub_account_charge: true- Invoice flagged for chargingpending_sub_account_charge_in_progress: { $ne: true }- Not currently processing
Purpose: Prevents duplicate processing while allowing retry of failed charges
In-Progress Locking
const ids = invoices.map(sub => sub._id);
await StoreInvoice.updateMany(
{ _id: { $in: ids } },
{ pending_sub_account_charge_in_progress: true },
);
Pattern: Bulk update sets in-progress flag before queue addition
- Race Condition Prevention: Multiple cron executions won't process same invoices
- Atomic Operation: Single database update for all invoices
Owner Resolution
const metadata = doc.metadata;
const user = await User.findOne({
account_id: metadata.account_id,
is_owner: true,
});
Purpose: JWT token requires account owner's user ID
- Query: Finds owner of subaccount from invoice metadata
- Requirement: Owner must exist for token generation
Queue Addition
await queue.add(
{ invoice: doc, token },
{
attempts: 10,
backoff: {
type: 'exponential',
delay: 60000,
},
},
);
Payload:
invoice: Full invoice document from MongoDBtoken: JWT token for API authentication
Error Handling:
catch (err) {
await StoreInvoice.updateOne(
{ _id: invoice._id },
{ pending_sub_account_charge_in_progress: false }
);
console.log('Error occurred while processing the data for pending sub account charge.', err.message, err.stack);
}
Cleanup: Resets in-progress flag on queue addition failure
Queue Processor
Payment Intent Creation
File: queue-manager/queues/store/invoices/pending_subaccount_charge.js
Amount Conversion
const amount = invoice.amount_due / 100;
const currency = invoice.currency;
Stripe Amount Convention:
- Stripe stores amounts in cents (e.g., $10.00 = 1000)
- Division by 100 converts cents to dollars
- Currency code passed directly (e.g., 'usd', 'eur', 'gbp')
API Request
const body = {
amount,
currency,
};
await axios.post(`${API_URL}/v1/store/payment-intent`, body, {
headers: {
Authorization: `Bearer ${token}`,
},
});
Endpoint: POST /v1/store/payment-intent
Request Body:
{
"amount": 10.5,
"currency": "usd"
}
Headers:
Authorization:Bearer {jwt_token}
Response: Payment intent created via Internal API (which calls Stripe)
Success Handling
Callback: completedCb
const completedCb = async job => {
const { invoice } = job.data;
try {
await StoreInvoice.updateOne(
{ _id: invoice._id },
{
pending_sub_account_charge_in_progress: false,
pending_sub_account_charge: false,
},
);
} catch (err) {
console.error('Failed to update status on invoice', err.message, err.stack);
}
console.log(`Charged ${invoice._id.toString()} for SubAccount ${invoice.metadata.account_id}`);
console.log('Job ID: ', job.id);
};
Updates:
pending_sub_account_charge_in_progress: false- Release lockpending_sub_account_charge: false- Mark as charged
Logging: Console output with invoice ID and account ID
Failure Handling
Callback: failedCb
const failedCb = async (job, err) => {
const { invoice } = job.data;
let message;
if (err.isAxiosError) {
message = err?.response?.data?.message;
}
console.error(
`FAILED TO Charge ${invoice._id.toString()} for SubAccount ${invoice.metadata.account_id.toString()} |`,
message || err,
);
try {
if (job.attemptsMade >= job.opts.attempts) {
await StoreInvoice.updateOne(
{ _id: invoice._id },
{ pending_sub_account_charge_in_progress: false },
);
}
} catch (err) {
console.error('Failed to update status on invoice item', job.id, err.message, err.stack);
}
};
Retry Logic:
- During Retries: Flag remains
true, will retry - After Max Attempts: Resets
in_progress: falsebut keepspending_sub_account_charge: true - 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
StoreInvoice Document Structure
Collection: StoreInvoice
{
_id: ObjectId,
stripe_id: String, // Stripe invoice ID
amount_due: Number, // Amount in cents (e.g., 1050 = $10.50)
currency: String, // Currency code (e.g., 'usd', 'eur')
status: String, // Invoice status (e.g., 'draft', 'open', 'paid')
pending_sub_account_charge: Boolean, // Flag for pending charge
pending_sub_account_charge_in_progress: Boolean, // Processing lock flag
metadata: {
account_id: ObjectId, // Subaccount ID
main_account_id: ObjectId, // Main account ID (parent)
user_id: ObjectId, // User who created invoice
// ... other metadata fields
},
created_at: Date,
updated_at: Date
}
Key Fields:
pending_sub_account_charge: Set by webhook or manual process when charge neededpending_sub_account_charge_in_progress: Prevents duplicate processingamount_due: Stored in cents (Stripe convention)metadata.account_id: Subaccount to chargemetadata.main_account_id: Parent account (for JWT token)
User Document (Owner Lookup)
Collection: User
{
_id: ObjectId,
account_id: ObjectId,
is_owner: Boolean,
// ... other user fields
}
Query Pattern: Find owner of subaccount for JWT token generation
Error Handling
Common Error Scenarios
1. User Not Found
Cause: No owner found for subaccount
const user = await User.findOne({
account_id: metadata.account_id,
is_owner: true,
});
// user may be null
Impact: Cannot generate JWT token, job will fail
Resolution: Ensure every account has an owner with is_owner: true
2. Payment Intent Failure
Causes:
- Invalid payment method
- Insufficient funds
- Card declined
- Network timeout
Response: Axios error with API message
if (err.isAxiosError) {
message = err?.response?.data?.message;
}
Retry: Exponential backoff for transient issues
3. Database Lock Contention
Cause: Multiple cron executions attempt to process same invoice
Prevention: In-progress locking pattern
pending_sub_account_charge_in_progress: {
$ne: true;
}
Impact: Only one execution processes each invoice
4. Queue Addition Failure
Cause: Redis connection issues or Bull queue errors
Handling:
catch (err) {
await StoreInvoice.updateOne(
{ _id: invoice._id },
{ pending_sub_account_charge_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 StoreInvoice.updateOne(
{ _id: invoice._id },
{ pending_sub_account_charge_in_progress: false },
);
}
State After Failure:
pending_sub_account_charge: true- Still needs chargingpending_sub_account_charge_in_progress: false- Available for retry
Manual Intervention: May require investigation of persistent failures
API Integration
Payment Intent Endpoint
Internal API Route: POST /v1/store/payment-intent
Request:
POST /v1/store/payment-intent HTTP/1.1
Host: api.dashclicks.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
{
"amount": 10.50,
"currency": "usd"
}
Response (Success):
{
"success": true,
"data": {
"payment_intent_id": "pi_...",
"client_secret": "pi_..._secret_...",
"status": "succeeded"
}
}
Response (Error):
{
"success": false,
"message": "Payment method requires authentication"
}
Stripe Payment Intent Flow
Internal API → Stripe API:
- Create Payment Intent: Internal API calls
stripe.paymentIntents.create() - Charge Customer: Stripe attempts to charge default payment method
- Handle 3D Secure: If required, returns client secret for authentication
- Confirm Payment: Completes payment or fails with error
Handled by Internal API: Queue Manager only triggers the payment via HTTP request
Testing Scenarios
1. Successful Charge
Setup:
const invoice = await StoreInvoice.create({
stripe_id: 'in_test123',
amount_due: 5000, // $50.00
currency: 'usd',
pending_sub_account_charge: true,
pending_sub_account_charge_in_progress: false,
metadata: {
account_id: subaccount._id,
main_account_id: mainaccount._id,
},
});
const owner = await User.create({
account_id: subaccount._id,
is_owner: true,
});
Expected Flow:
- Service detects invoice
- Owner found, JWT generated
- Queue processes payment intent request
- Invoice updated:
pending_sub_account_charge: false
2. Payment Failure with Retry
Setup:
// Mock API to fail first 2 attempts, succeed on 3rd
nock(process.env.API_BASE_URL)
.post('/v1/store/payment-intent')
.times(2)
.reply(402, { message: 'Insufficient funds' });
nock(process.env.API_BASE_URL).post('/v1/store/payment-intent').reply(200, { success: true });
Expected Flow:
- First 2 attempts fail with 402
- Exponential backoff delays retry
- Third attempt succeeds
- Invoice marked as charged
3. Retry Exhaustion
Setup:
// Mock API to always fail
nock(process.env.API_BASE_URL)
.post('/v1/store/payment-intent')
.times(10)
.reply(500, { message: 'Internal server error' });
Expected State After 10 Attempts:
const invoice = await StoreInvoice.findById(invoiceId);
expect(invoice.pending_sub_account_charge).toBe(true);
expect(invoice.pending_sub_account_charge_in_progress).toBe(false);
4. Concurrent Cron Execution
Test:
await Promise.all([pendingSubaccountCharge(), pendingSubaccountCharge()]);
// Verify invoice only processed once
const jobs = await queue.getJobs(['completed']);
expect(jobs.length).toBe(1);
5. Multi-Currency Invoice
Setup:
const invoice = await StoreInvoice.create({
amount_due: 10000, // €100.00
currency: 'eur',
pending_sub_account_charge: true,
});
Expected:
- Amount converted:
10000 / 100 = 100.00 - Currency passed as-is:
'eur' - Payment intent created in EUR
Performance Considerations
Query Optimization
Index Requirements:
// StoreInvoice collection
{
pending_sub_account_charge: 1,
pending_sub_account_charge_in_progress: 1
}
// User collection
{
account_id: 1,
is_owner: 1
}
Query Pattern: Compound index improves query performance
Batch Processing
Current Pattern: Sequential queue additions
await Promise.all(
invoices.map(async invoice => {
// Generate token and add to queue
}),
);
Performance: Parallel processing for multiple invoices
- Concurrency: Limited by Bull queue concurrency (default: 1)
- Throughput: ~12 invoices/minute (5-second cron)
In-Progress Locking
Lock Granularity: Per-invoice locking with database flag
Advantages:
- Prevents duplicate charges
- Allows concurrent processing of different invoices
- Survives process crashes (persisted in DB)
Disadvantages:
- Requires cleanup if process dies mid-execution
- No automatic timeout (flag persists indefinitely)
Monitoring & Logging
Log Patterns
Service Logs:
console.log(`Invoice ${invoice._id.toString()} added to queue for pending subaccount charge`);
Processor Logs:
// Success
console.log(`Charged ${invoice._id.toString()} for SubAccount ${invoice.metadata.account_id}`);
// Failure
console.error(
`FAILED TO Charge ${invoice._id.toString()} for SubAccount ${invoice.metadata.account_id.toString()} |`,
message,
);
Error Logs:
logger.error({
initiator: 'QM/store/pending-sub-acc-charge',
error: err,
});
Metrics to Monitor
- Pending Invoice Count:
StoreInvoice.countDocuments({ pending_sub_account_charge: true }) - In-Progress Count:
StoreInvoice.countDocuments({ pending_sub_account_charge_in_progress: true }) - Queue Depth: Bull queue waiting jobs
- Retry Rate: Jobs with
attemptsMade > 1 - Failure Rate: Jobs reaching max attempts
- 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
- Queue Backlog: > 100 pending invoices
Related Documentation
- Store Draft Invoices - Draft invoice synchronization
- Charge Main Account - Main account charging
- Cancel Subscription - Subscription cancellation
- Common Billing Utilities - Shared billing functions
Summary
The Pending Subaccount Charge module provides automated revenue collection for subaccounts by processing flagged invoices through Stripe payment intents. Its in-progress locking mechanism prevents duplicate charges, while exponential backoff retry logic handles transient payment failures. JWT-authenticated API calls ensure secure payment processing with proper user context.
Key Strengths:
- Automated Processing: No manual intervention required
- Retry Logic: 10 attempts with 8.5 hours of retries
- Multi-Currency: Handles various currency codes
- Lock Prevention: In-progress flags prevent duplicate charges
- JWT Security: 10-day tokens with scoped permissions
Critical for:
- Revenue collection automation
- Subaccount billing management
- Failed payment recovery
- Stripe payment intent integration