Skip to main content

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_progress flag

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

VariableTypeRequiredDescription
APP_SECRETStringYesJWT secret for token signing
API_BASE_URLStringYesInternal 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 identifier
  • uid: User MongoDB ObjectId as string
  • account_id: Subaccount ID (invoice metadata)
  • parent_account: Main account ID (invoice metadata)
  • scope: users.me sites store - Permission scopes
  • expiresIn: 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:

AttemptDelayTotal Wait
10s0s
260s60s
3120s180s
4240s420s
5480s900s
6960s1860s
71920s3780s
83840s7620s
97680s15300s
1015360s30660s

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:

  1. pending_sub_account_charge: true - Invoice flagged for charging
  2. pending_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 MongoDB
  • token: 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:

  1. pending_sub_account_charge_in_progress: false - Release lock
  2. pending_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: false but keeps pending_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 needed
  • pending_sub_account_charge_in_progress: Prevents duplicate processing
  • amount_due: Stored in cents (Stripe convention)
  • metadata.account_id: Subaccount to charge
  • metadata.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 charging
  • pending_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:

  1. Create Payment Intent: Internal API calls stripe.paymentIntents.create()
  2. Charge Customer: Stripe attempts to charge default payment method
  3. Handle 3D Secure: If required, returns client secret for authentication
  4. 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:

  1. Service detects invoice
  2. Owner found, JWT generated
  3. Queue processes payment intent request
  4. 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:

  1. First 2 attempts fail with 402
  2. Exponential backoff delays retry
  3. Third attempt succeeds
  4. 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

  1. Pending Invoice Count: StoreInvoice.countDocuments({ pending_sub_account_charge: true })
  2. In-Progress Count: StoreInvoice.countDocuments({ pending_sub_account_charge_in_progress: true })
  3. Queue Depth: Bull queue waiting jobs
  4. Retry Rate: Jobs with attemptsMade > 1
  5. Failure Rate: Jobs reaching max attempts
  6. 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


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
💬

Documentation Assistant

Ask me anything about the docs

Hi! I'm your documentation assistant. Ask me anything about the docs!

I can help you with:
- Code examples
- Configuration details
- Troubleshooting
- Best practices

Try asking: How do I configure the API?
09:31 AM