Skip to main content

๐Ÿšซ 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_on doesn't exist, process immediately
  • If retry_on is in the past, process now
  • If retry_on is 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:

  1. Detect error code 1322
  2. Extract retry date from error message using regex: /\(([^)]+)\)/g
  3. Update queue entry: retry_on: new Date(retryDate), in_progress: false
  4. 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:

  1. Fetch Duda credentials from Config collection
  2. Clear site domain (removes custom domain)
  3. 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 CodeMessageActionResolution
1322Too early to cancelReschedule using date from errorWait until specified date
401UnauthorizedFail job, log errorVerify Yext API key
404Location not foundComplete job (already canceled?)Log and mark success
500Yext server errorRetry with exponential backoffCheck Yext status page

Duda-Specific Errorsโ€‹

Error TypeCauseAction
Authentication failedInvalid credentialsVerify DUDA_TOKEN config
Site not foundInvalid builder_idComplete job (already deleted?)
API rate limitToo many requestsRetry with backoff
Network timeoutDuda API slowRetry 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โ€‹

  1. Batch Processing: Process multiple cancellations in parallel
  2. Promise.allSettled: Continues processing even if one fails
  3. Remove on Complete: Automatic cleanup of successful jobs
  4. 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โ€‹

  1. Listings - Success:

    • Mock Yext API success response
    • Verify yext_status set to false
    • Verify log entry created
  2. 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
  3. Listings - Max Retries:

    • Mock 10 consecutive failures
    • Verify job deleted
    • Verify failed log entry created
  4. Sites - Success:

    • Mock Duda client responses
    • Verify site unpublished
    • Verify status set to UNPUBLISHED
    • Verify log entry created
  5. 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
});
});

๐Ÿ“ Notesโ€‹

Why Two-Step Duda Unpublish?โ€‹

  1. Clear domain first: Prevents DNS issues
  2. 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

๐Ÿ’ฌ

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