Skip to main content

Store Charge - Main Account Charge

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 Queue collection 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​

VariableTypeRequiredDescription
APP_SECRETStringYesJWT secret for token signing
API_BASE_URLStringYesInternal 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 identifier
  • uid: User MongoDB ObjectId from queue item
  • account_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 scopes
  • expiresIn: 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:

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

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:

  1. status: 'pending' - Not yet processed
  2. in_progress: false - Not currently processing
  3. source: '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 MongoDB
  • token: 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 billing
  • additional_data: Contains all checkout data (business, price, action, charge)
  • in_progress: Prevents duplicate processing
  • account_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 processing
  • in_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:

  1. Preview: Validate data and calculate totals

    • Check account status
    • Calculate taxes based on location
    • Compute total with fees
  2. 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:

  1. Service detects queue item
  2. JWT token generated
  3. Preview validation succeeds
  4. Finalize payment succeeds
  5. 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:

  1. Preview request fails
  2. Finalize never called
  3. 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:

  1. Preview succeeds
  2. Finalize fails with payment error
  3. 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​

  1. Pending Queue Count: Queue.countDocuments({ source: 'subaccount-charge', status: 'pending' })
  2. In-Progress Count: Queue.countDocuments({ source: 'subaccount-charge', in_progress: true })
  3. Queue Depth: Bull queue waiting jobs
  4. Retry Rate: Jobs with attemptsMade > 1
  5. Preview vs Finalize Failures: Track which phase fails more often
  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
  • Preview Failure Spike: Data validation issues
  • Finalize Failure Spike: Payment method problems


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
💬

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