๐ซ Subscription Cancellation Processing
๐ Overviewโ
The subscription cancel module handles cancellations of third-party service subscriptions when customers cancel store products. It manages Yext listing services and Duda site unpublishing through external APIs, with intelligent retry scheduling, exponential backoff for failed attempts, and comprehensive logging for audit trails.
Source Files:
- Cron:
queue-manager/crons/store/subscriptions/cancel.js - Service:
queue-manager/services/store/subscriptions/cancel.js - Queue Processors:
queue-manager/queues/store/subscriptions/cancel/listings.js(Yext)queue-manager/queues/store/subscriptions/cancel/sites.js(Duda)queue-manager/queues/store/subscriptions/cancel/numbers.js(Commented out)
๐ฏ Purposeโ
- Third-Party Service Cancellation: Cancel Yext and Duda subscriptions via APIs
- Intelligent Retry: Reschedule if cancellation attempted too early
- Exponential Backoff: Retry failed attempts with increasing delays
- Audit Logging: Track all cancellation attempts and outcomes
- Site Unpublishing: Unpublish Duda sites (InstaSites and Agency Sites)
- Listing Deactivation: Cancel Yext listing services (SKU: LC-00000019)
โ๏ธ Configurationโ
Environment Variablesโ
# Yext API Configuration
YEXT_API_KEYS=your_yext_api_key_here
YEXT_API_VPARAM=20200525 # API version parameter
# Duda API Configuration
DUDA_TOKEN=config_id_for_duda_credentials # Reference to Config collection
# Retry Configuration
SUBSCRIPTION_CANCEL_QUEUES_ATTEMPTS=10 # Max retry attempts (default: 10)
# Module enable flag
QM_SUBSCRIPTION_CANCEL=true
Cron Scheduleโ
'*/30 * * * * *'; // Every 30 seconds
Frequency: Every 30 seconds Concurrency: Single execution (in-progress locking) Retry Strategy: Exponential backoff starting at 60 seconds
๐ Processing Flowโ
High-Level Architectureโ
sequenceDiagram
participant CRON as Cron (30s)
participant SERVICE as Cancel Service
participant QUEUE_MODEL as Queue Collection
participant LISTING_Q as Listings Queue
participant SITE_Q as Sites Queue
participant YEXT as Yext API
participant DUDA as Duda API
participant LOGS as ThirdParty Logs
CRON->>SERVICE: Trigger every 30s
SERVICE->>QUEUE_MODEL: Find pending cancellations
QUEUE_MODEL-->>SERVICE: listings & sites entries
SERVICE->>QUEUE_MODEL: Set in_progress=true
alt Listing Cancellation
SERVICE->>LISTING_Q: Add to listings queue
LISTING_Q->>YEXT: POST /cancelservices
alt Too early to cancel
YEXT-->>LISTING_Q: Error 1322 with retry date
LISTING_Q->>QUEUE_MODEL: Set retry_on=date, in_progress=false
LISTING_Q-->>LISTING_Q: Will retry after date
else Cancellation success
YEXT-->>LISTING_Q: Success
LISTING_Q->>QUEUE_MODEL: Delete queue entry
LISTING_Q->>LOGS: Log success
else Cancellation failed (max retries)
YEXT-->>LISTING_Q: Error
LISTING_Q->>QUEUE_MODEL: Delete queue entry
LISTING_Q->>LOGS: Log failure
end
end
alt Site Cancellation
SERVICE->>SITE_Q: Add to sites queue
SITE_Q->>DUDA: Update site (clear domain)
SITE_Q->>DUDA: Unpublish site
alt Success
DUDA-->>SITE_Q: Site unpublished
SITE_Q->>QUEUE_MODEL: Delete queue entry
SITE_Q->>QUEUE_MODEL: Update site status=UNPUBLISHED
SITE_Q->>LOGS: Log success
else Failed (max retries)
DUDA-->>SITE_Q: Error
SITE_Q->>QUEUE_MODEL: Delete queue entry
SITE_Q->>LOGS: Log failure
end
end
๐ง Component Detailsโ
1. Cron Schedulerโ
File: crons/store/subscriptions/cancel.js
const subscriptionCancel = require('../../../services/store/subscriptions/cancel');
const cron = require('node-cron');
const logger = require('../../../utilities/logger');
let inProgress = false;
exports.start = async () => {
try {
cron.schedule('*/30 * * * * *', async () => {
if (!inProgress) {
inProgress = true;
await subscriptionCancel();
inProgress = false;
}
});
} catch (err) {
logger.error({ initiator: 'QM/store/subscriptions/cancel', error: err });
}
};
In-Progress Locking: Prevents concurrent executions to avoid duplicate cancellations.
2. Service Layerโ
File: services/store/subscriptions/cancel.js
const Queue = require('../../../models/queues');
const listingCancelQueue = require('../../../queues/store/subscriptions/cancel/listings');
const siteCancelQueue = require('../../../queues/store/subscriptions/cancel/sites');
const logger = require('../../../utilities/logger');
module.exports = async () => {
// Find pending cancellations ready to process
const subscriptions = await Queue.find({
source: 'subscription-cancel',
status: 'pending',
in_progress: false,
'additional_data.type': { $in: ['listings', 'sites'] },
$or: [
{ retry_on: { $exists: 0 } }, // No retry scheduled
{ retry_on: { $lt: new Date() } }, // Retry time has passed
],
});
const ids = subscriptions.map(sub => sub._id);
await Queue.updateMany({ _id: { $in: ids } }, { in_progress: true });
if (subscriptions.length) {
await Promise.allSettled(
subscriptions.map(async subscription => {
const { _id, account_id, additional_data: data } = subscription;
try {
await addData({ id: _id, account_id, data });
} catch (err) {
await Queue.updateMany({ _id }, { in_progress: false });
logger.error({
initiator: 'QM/services/store/subscriptions/cancel',
error: err,
job_data: { id: _id, account_id, data },
});
throw err;
}
}),
);
}
};
const addData = async ({ id, account_id, data }) => {
let queue;
switch (data.type) {
case 'listings':
queue = await listingCancelQueue.start();
await queue.add(
{ id, account_id, data },
{
jobId: id.toString(),
removeOnComplete: true,
attempts: parseInt(process.env.SUBSCRIPTION_CANCEL_QUEUES_ATTEMPTS || '10'),
backoff: {
type: 'exponential',
delay: 60000, // Start at 60 seconds
},
},
);
break;
case 'sites':
queue = await siteCancelQueue.start();
await queue.add(
{ id: data.id, type: data.sub_type },
{
jobId: id.toString(),
removeOnComplete: true,
attempts: parseInt(process.env.SUBSCRIPTION_CANCEL_QUEUES_ATTEMPTS || '10'),
backoff: {
type: 'exponential',
delay: 60000,
},
},
);
break;
}
};
Query Logic:
// Find cancellations ready to process
{
source: 'subscription-cancel',
status: 'pending',
in_progress: false,
'additional_data.type': { $in: ['listings', 'sites'] },
$or: [
{ retry_on: { $exists: 0 } }, // First attempt
{ retry_on: { $lt: new Date() } } // Retry date reached
]
}
Retry Scheduling:
- If
retry_ondoesn't exist, process immediately - If
retry_onis in the past, process now - If
retry_onis in the future, skip (will process later)
3. Listings Queue Processor (Yext)โ
File: queues/store/subscriptions/cancel/listings.js
Processing Logicโ
const processCb = async (job, done) => {
try {
const { account_id } = job.data;
const account = await Account.findById(account_id);
const entity = account._doc.yext?.entity;
if (!entity) {
logger.error({
initiator: 'QM/queues/store/subscriptions/cancel/listing',
message: 'No yext entity id associated to account',
});
return done();
}
try {
await deactivateEntity(entity);
account.yext_status = false;
await account.save();
} catch (err) {
// Check for "too early to cancel" error
if (err.response?.data?.meta?.errors?.[0]?.code == 1322) {
// Extract retry date from error message
let retryOn = err.response.data.meta.errors[0].message
.match(/\(([^)]+)\)/g)[1]
.replace(/[\(\)]/g, '');
// Reschedule for later
await QueueModel.findByIdAndUpdate(job.id, {
in_progress: false,
retry_on: new Date(retryOn),
});
logger.warn({
initiator: 'QM/queues/store/subscriptions/cancel/listing',
error: err,
job: job.id,
job_data: job.data,
});
return done(null, 'rescheduled');
}
return done(err);
}
return done();
} catch (err) {
done(err);
}
};
Yext API Call:
const deactivateEntity = async id => {
const url = 'https://api.yext.com/v2/accounts/me/cancelservices';
const options = {
method: 'POST',
headers: { 'api-key': `${process.env.YEXT_API_KEYS}` },
data: {
locationId: id,
skus: [sku], // SKU: 'LC-00000019'
},
params: {
v: process.env.YEXT_API_VPARAM || '20200525',
},
url,
};
return await axios(options);
};
Yext SKU: LC-00000019 (Listing Service)
Intelligent Retry Schedulingโ
Yext Error 1322:
{
"meta": {
"errors": [
{
"code": 1322,
"message": "Cannot cancel service yet. Try again after (2025-11-15T10:00:00Z)"
}
]
}
}
Retry Logic:
- Detect error code 1322
- Extract retry date from error message using regex:
/\(([^)]+)\)/g - Update queue entry:
retry_on: new Date(retryDate), in_progress: false - Service will pick it up after the retry date
Why This Matters:
- Yext has minimum subscription periods
- Cannot cancel immediately after activation
- Prevents wasted API calls and failed attempts
Completion Callbackโ
const completedCb = async (job, results) => {
if (results == 'rescheduled') {
logger.log({
initiator: 'QM/queues/store/subscriptions/cancel/listing',
message: 'Listings cancelation rescheduled',
job: job.id,
job_data: job.data,
});
return;
}
try {
const item = await QueueModel.findByIdAndDelete(job.id);
await new StoreThirdPartyLogs({
account_id: item.account_id,
user_id: item.user_id,
client_id: item.client_id,
parent_account: item.parent_account,
status: 'success',
type: 'listings-subscription-cancel',
additional_data: item.additional_data,
}).save();
} catch (err) {
logger.warn({
initiator: 'QM/queues/store/subscriptions/cancel/listing',
message: 'Failed to remove queue item and/or add log entry.',
error: err,
job: job.id,
job_data: job.data,
});
}
logger.log({
initiator: 'QM/queues/store/subscriptions/cancel/listing',
message: 'Listings canceled',
job: job.id,
job_data: job.data,
});
};
Failure Callbackโ
const failedCb = async (job, err) => {
let message = err.message;
if (err.isAxiosError) {
message = err?.response?.data?.message;
}
try {
// After max retries, log failure and remove from queue
if (job.attemptsMade >= parseInt(process.env.SUBSCRIPTION_CANCEL_QUEUES_ATTEMPTS || '10')) {
const item = await QueueModel.findByIdAndDelete(job.id);
await new StoreThirdPartyLogs({
account_id: item.account_id,
user_id: item.user_id,
client_id: item.client_id,
parent_account: item.parent_account,
status: 'failed',
type: 'listings-subscription-cancel',
additional_data: item.additional_data,
}).save();
}
} catch (err) {
logger.warn({
initiator: 'QM/queues/store/subscriptions/cancel/listing',
message: 'Failed to remove queue item and/or add log entry.',
error: err,
job: job.id,
job_data: job.data,
});
}
logger.error({
initiator: 'QM/queues/store/subscriptions/cancel/listing',
message,
error: err,
job: job.id,
job_data: job.data,
});
};
4. Sites Queue Processor (Duda)โ
File: queues/store/subscriptions/cancel/sites.js
Processing Logicโ
const processCb = async (job, done) => {
try {
const { id, type } = job.data;
let site;
if (type === 'instasite') {
site = await Instasite.findById(id);
} else {
site = await AgencySite.findById(id);
}
await unpublishSite(site._doc.builder_id);
done();
} catch (err) {
done(err);
}
};
Site Types:
instasite: InstaSites (automated site generation)agency-site: Agency Sites (custom client sites)
Duda API Integrationโ
const DudaClient = async () => {
let creds = await Config.findOne({
_id: process.env.DUDA_TOKEN,
});
const duda = new Duda({
user: creds._doc.username,
pass: creds._doc.password,
});
return duda;
};
const unpublishSite = async id => {
const client = await DudaClient();
// Clear custom domain
await client.sites.update({
site_name: id,
site_domain: '',
});
// Unpublish the site
return client.sites.unpublish({
site_name: id,
});
};
Unpublish Process:
- Fetch Duda credentials from Config collection
- Clear site domain (removes custom domain)
- Unpublish site (takes site offline)
Completion Callbackโ
const completedCb = async job => {
try {
const item = await QueueModel.findByIdAndDelete(job.id);
// Log success
await new StoreThirdPartyLogs({
account_id: item.account_id,
user_id: item.user_id,
client_id: item.client_id,
parent_account: item.parent_account,
status: 'success',
type: 'sites-subscription-cancel',
additional_data: item.additional_data,
}).save();
// Update site status in database
const { sub_type, id } = item.additional_data;
const Model = sub_type === 'instasite' ? Instasite : AgencySite;
try {
await Model.updateOne({ _id: id }, { $set: { status: 'UNPUBLISHED' } });
} catch (err) {
logger.warn({
initiator: 'QM/queues/store/subscriptions/cancel/sites',
message: 'Failed to update status of the site',
error: err,
job: job.id,
job_data: job.data,
});
}
} catch (err) {
logger.warn({
initiator: 'QM/queues/store/subscriptions/cancel/sites',
message: 'Failed to remove queue item and/or add log entry.',
error: err,
job: job.id,
job_data: job.data,
});
}
logger.log({
initiator: 'QM/queues/store/subscriptions/cancel/sites',
message: 'Site unpublish complete',
job: job.id,
job_data: job.data,
});
};
Status Update: Sets site status: 'UNPUBLISHED' for tracking.
๐ Data Modelsโ
Queue Entry (Listings)โ
{
_id: ObjectId,
source: 'subscription-cancel',
status: 'pending',
in_progress: Boolean,
retry_on: Date, // Set by Yext error 1322
account_id: ObjectId,
user_id: ObjectId,
client_id: ObjectId,
parent_account: ObjectId,
additional_data: {
type: 'listings',
// Listing-specific data
},
createdAt: Date,
updatedAt: Date
}
Queue Entry (Sites)โ
{
_id: ObjectId,
source: 'subscription-cancel',
status: 'pending',
in_progress: Boolean,
account_id: ObjectId,
user_id: ObjectId,
client_id: ObjectId,
parent_account: ObjectId,
additional_data: {
type: 'sites',
sub_type: 'instasite' | 'agency-site',
id: ObjectId // Site ID
},
createdAt: Date,
updatedAt: Date
}
Third Party Logsโ
{
_id: ObjectId,
account_id: ObjectId,
user_id: ObjectId,
client_id: ObjectId,
parent_account: ObjectId,
status: 'success' | 'failed',
type: 'listings-subscription-cancel' | 'sites-subscription-cancel',
additional_data: Object,
createdAt: Date
}
Account Yext Statusโ
{
_id: ObjectId,
yext: {
entity: String // Yext entity/location ID
},
yext_status: Boolean // Set to false after cancellation
}
Site Statusโ
// Instasite or AgencySite model
{
_id: ObjectId,
builder_id: String, // Duda site name
status: 'PUBLISHED' | 'UNPUBLISHED',
// ... other fields
}
๐จ Error Handlingโ
Yext-Specific Errorsโ
| Error Code | Message | Action | Resolution |
|---|---|---|---|
| 1322 | Too early to cancel | Reschedule using date from error | Wait until specified date |
| 401 | Unauthorized | Fail job, log error | Verify Yext API key |
| 404 | Location not found | Complete job (already canceled?) | Log and mark success |
| 500 | Yext server error | Retry with exponential backoff | Check Yext status page |
Duda-Specific Errorsโ
| Error Type | Cause | Action |
|---|---|---|
| Authentication failed | Invalid credentials | Verify DUDA_TOKEN config |
| Site not found | Invalid builder_id | Complete job (already deleted?) |
| API rate limit | Too many requests | Retry with backoff |
| Network timeout | Duda API slow | Retry with backoff |
Retry Strategyโ
Exponential Backoff:
{
attempts: 10,
backoff: {
type: 'exponential',
delay: 60000 // 60 seconds base
}
}
Retry Schedule:
- Attempt 1: Immediate
- Attempt 2: 60 seconds later
- Attempt 3: 120 seconds later (2 min)
- Attempt 4: 240 seconds later (4 min)
- Attempt 5: 480 seconds later (8 min)
- Attempt 6: 960 seconds later (16 min)
- Attempt 7: 1920 seconds later (32 min)
- Attempt 8: 3840 seconds later (64 min)
- Attempt 9: 7680 seconds later (128 min)
- Attempt 10: 15360 seconds later (256 min)
After 10 failures: Job removed, logged as failed.
๐ Performance Considerationsโ
API Rate Limitsโ
Yext API:
- Rate limit: ~100 requests per minute
- Timeout: 30 seconds
- Retry-After header: Respected by exponential backoff
Duda API:
- Rate limit: ~500 requests per 10 minutes
- Timeout: 60 seconds
- Multiple operations per cancellation (update + unpublish)
Optimization Strategiesโ
- Batch Processing: Process multiple cancellations in parallel
- Promise.allSettled: Continues processing even if one fails
- Remove on Complete: Automatic cleanup of successful jobs
- In-Progress Locking: Prevents duplicate API calls
Scaling Considerationsโ
Current:
- Processes every 30 seconds
- No concurrency limit on individual cancellations
- Single queue for all listings, single for all sites
Improvements:
// Add concurrency limits
{
processCb,
failedCb,
completedCb,
concurrency: 5 // Process 5 cancellations at once
}
๐งช Testing Considerationsโ
Test Scenariosโ
-
Listings - Success:
- Mock Yext API success response
- Verify yext_status set to false
- Verify log entry created
-
Listings - Too Early (Error 1322):
- Mock Yext error with retry date
- Verify retry_on field set
- Verify in_progress set to false
- Verify job picked up after retry date
-
Listings - Max Retries:
- Mock 10 consecutive failures
- Verify job deleted
- Verify failed log entry created
-
Sites - Success:
- Mock Duda client responses
- Verify site unpublished
- Verify status set to UNPUBLISHED
- Verify log entry created
-
Sites - Duda Error:
- Mock Duda API error
- Verify exponential backoff
- Verify max retries behavior
Mock Setupโ
jest.mock('axios');
jest.mock('@dudadev/partner-api');
jest.mock('../../../models/queues');
jest.mock('../../../models/account');
describe('Subscription Cancel - Listings', () => {
test('Reschedules on Yext error 1322', async () => {
axios.mockRejectedValue({
response: {
data: {
meta: {
errors: [
{
code: 1322,
message: 'Cannot cancel yet. Try again after (2025-11-15T10:00:00Z)',
},
],
},
},
},
});
// Test rescheduling logic
// Verify retry_on set to 2025-11-15
});
});
describe('Subscription Cancel - Sites', () => {
test('Unpublishes site via Duda', async () => {
const mockDuda = {
sites: {
update: jest.fn().mockResolvedValue({}),
unpublish: jest.fn().mockResolvedValue({}),
},
};
Duda.mockImplementation(() => mockDuda);
// Test site unpublish
// Verify both update and unpublish called
});
});
๐ Related Documentationโ
- Store Module Overview
- Subscription Downgrade
- Subscription Activate
- Third Party Logs Model (link removed - file does not exist)
- Queue Wrapper
๐ Notesโ
Why Two-Step Duda Unpublish?โ
- Clear domain first: Prevents DNS issues
- Unpublish second: Takes site offline
If domain not cleared, unpublish may fail with domain conflict.
Numbers Queue (Commented Out)โ
The numbers cancellation queue exists but is currently disabled:
// case "numbers":
// queue = await numberCancelQueue.start();
// ...
// break;
Likely Reason: Number cancellation handled differently (Twilio integration).
Retry vs Rescheduleโ
Retry (Exponential Backoff):
- For transient errors (network, rate limits)
- Automatic Bull queue retry
- Increases delay between attempts
Reschedule (Specific Date):
- For Yext error 1322 (too early)
- Explicit future date from Yext
- Job skipped until date reached
StoreThirdPartyLogs Purposeโ
Provides audit trail for:
- Billing disputes ("When was my subscription canceled?")
- Support debugging ("Why did cancellation fail?")
- Compliance tracking (cancellation timestamps)
- Analytics (success/failure rates)
removeOnComplete: trueโ
Jobs automatically deleted from Redis after success:
- Reduces Redis memory usage
- Successful jobs don't need retention
- Failed jobs kept for debugging (up to max retries)
Complexity: High (External API integration, intelligent retry scheduling)
Business Impact: HIGH - Revenue operations, customer experience
Dependencies: Yext API, Duda API, Config model for credentials
Retry Strategy: Exponential backoff (10 attempts) + Intelligent rescheduling
Last Updated: 2025-10-10