Overview
Billing jobs handle the automated charging of subscription billing cycles. These jobs orchestrate the entire billing process, from scheduling shops for billing to executing individual billing cycle charges.
Job Architecture
The billing job system follows a hierarchical pattern:
- RecurringBillingChargeJob - Triggers the billing process hourly
- ScheduleShopsToChargeBillingCyclesJob - Finds eligible shops and schedules billing
- ChargeBillingCyclesJob - Executes bulk billing for a shop
- RebillSubscriptionJob - Handles failed payment retries
RecurringBillingChargeJob
Entry point for the automated billing process. Runs on a recurring schedule to trigger billing operations.
Configuration
Job queue assignment (uses default queue)
Implementation
export class RecurringBillingChargeJob extends Job<{}> {
async perform(): Promise<void> {
const targetDate = DateTime.utc().startOf('hour').toISO() as string;
const params: Jobs.ScheduleShopsForBillingChargeParameters = {targetDate};
const job = new ScheduleShopsToChargeBillingCyclesJob(params);
await jobs.enqueue(job);
}
}
Source: app/jobs/billing/RecurringBillingChargeJob.ts:8-22
Behavior
- Calculates the current hour in UTC
- Enqueues
ScheduleShopsToChargeBillingCyclesJob with the target date
- Logs scheduling information for debugging
ScheduleShopsToChargeBillingCyclesJob
Finds shops with billing schedules due for charging and creates individual billing jobs.
Parameters
ISO 8601 formatted date/time to evaluate billing schedules against
Configuration
Assigned to the billing queue for priority processing
Implementation
export class ScheduleShopsToChargeBillingCyclesJob extends Job<
Jobs.ScheduleShopsForBillingChargeParameters
> {
public queue: string = 'billing';
async perform(): Promise<void> {
const targetDateUtc = DateTime.fromISO(this.parameters.targetDate, {
setZone: true,
});
await findActiveBillingSchedulesInBatches(async (batch) => {
const results = batch
.map((billingSchedule) =>
new BillingScheduleCalculatorService(
billingSchedule,
targetDateUtc,
),
)
.filter((calc) => calc.isBillable());
const promises = results.map(async (result) => {
const params: Jobs.Parameters<Jobs.ChargeBillingCyclesPayload> = {
shop: billingSchedule.shop,
payload: {
startDate: billingStartTimeUtc.toISO() as string,
endDate: billingEndTimeUtc.toISO() as string,
},
};
const job = new ChargeBillingCyclesJob(params);
return await jobs.enqueue(job);
});
await Promise.all(promises);
});
}
}
Source: app/jobs/billing/ScheduleShopsToChargeBillingCyclesJob.ts:11-67
Behavior
- Retrieves active billing schedules in batches for memory efficiency
- Uses
BillingScheduleCalculatorService to determine billable shops
- Creates
ChargeBillingCyclesJob for each eligible shop with date range
- Processes batches in parallel for performance
The job processes billing schedules in batches to handle large numbers of shops efficiently without exhausting memory.
ChargeBillingCyclesJob
Executes bulk billing for subscription contracts within a specified date range.
Parameters
Shop domain to execute billing for
Start of the billing cycle date range (ISO 8601 format)
End of the billing cycle date range (ISO 8601 format)
Configuration
Assigned to the billing queue
Implementation
export class ChargeBillingCyclesJob extends Job<
Jobs.Parameters<Jobs.ChargeBillingCyclesPayload>
> {
public queue: string = 'billing';
async perform(): Promise<void> {
const {shop, payload} = this.parameters;
const {startDate, endDate} = payload;
const {admin} = await unauthenticated.admin(shop);
const response = await admin.graphql(ChargeBillingCyclesMutation, {
variables: {
startDate,
endDate,
contractStatus: ['ACTIVE' as SubscriptionContractSubscriptionStatus],
billingCycleStatus: [
'UNBILLED' as SubscriptionBillingCycleBillingCycleStatus,
],
billingAttemptStatus:
'NO_ATTEMPT' as SubscriptionBillingCycleBillingAttemptStatus,
},
});
const json = await response.json();
const subscriptionBillingCycleBulkCharge =
json.data?.subscriptionBillingCycleBulkCharge;
if (!subscriptionBillingCycleBulkCharge) {
throw new Error('Failed to process ChargeBillingCyclesJob');
}
const {job, userErrors} = subscriptionBillingCycleBulkCharge;
if (userErrors.length === 0 && job?.id !== undefined) {
this.logger.info(
`Created subscriptionBillingCycleBulkCharge job: ${job.id}`,
);
} else {
throw new Error('Failed to process ChargeBillingCyclesJob');
}
}
}
Source: app/jobs/billing/ChargeBillingCyclesJob.ts:12-63
GraphQL Mutation
Uses subscriptionBillingCycleBulkCharge mutation with filters:
- Contract status:
ACTIVE
- Billing cycle status:
UNBILLED
- Billing attempt status:
NO_ATTEMPT
Error Handling
The job throws an error if the GraphQL mutation returns user errors or fails to create a billing job. This triggers the job runner’s retry mechanism.
RebillSubscriptionJob
Handles rebilling for subscriptions with failed payment attempts.
Parameters
Shop domain for the subscription
payload.subscriptionContractId
GraphQL Admin API ID of the subscription contract to rebill
Original billing attempt timestamp (ISO 8601 format)
Configuration
queue
string
default:"rebilling"
Assigned to dedicated rebilling queue
Implementation
export class RebillSubscriptionJob extends Job<
Jobs.Parameters<Jobs.RebillSubscriptionJobPayload>
> {
public queue: string = 'rebilling';
async perform(): Promise<void> {
const {shop, payload} = this.parameters;
const {subscriptionContractId, originTime} = payload;
const {admin} = await unauthenticated.admin(shop);
const subscriptionContract = await getContractDetailsForRebilling(
admin.graphql,
subscriptionContractId,
);
if (subscriptionContract.lastPaymentStatus === 'SUCCEEDED') {
this.logger.info(
'Terminating, subscription contract already billed successfully',
);
return;
}
const response = await admin.graphql(
SubscriptionBillingCycleChargeMutation,
{
variables: {
subscriptionContractId: subscriptionContractId,
originTime: originTime,
},
},
);
const json = await response.json();
const subscriptionBillingCycleCharge =
json.data?.subscriptionBillingCycleCharge;
if (!subscriptionBillingCycleCharge) {
throw new Error('Failed to process RebillSubscriptionJob');
}
const {userErrors} = subscriptionBillingCycleCharge;
if (userErrors.some(
(error) =>
error.code === 'CONTRACT_PAUSED' ||
error.code === 'BILLING_CYCLE_SKIPPED' ||
error.code === 'CONTRACT_TERMINATED' ||
error.code === 'BILLING_CYCLE_CHARGE_BEFORE_EXPECTED_DATE',
)) {
this.logger.warn(
'Persistent userError returned, terminating RebillSubscriptionJob',
);
} else if (userErrors.length > 0) {
throw new Error('Failed to process RebillSubscriptionJob');
}
}
}
Source: app/jobs/billing/RebillSubscriptionJob.ts:8-83
Behavior
- Checks if subscription was already successfully billed (early termination)
- Calls
subscriptionBillingCycleCharge mutation for single contract
- Handles specific user error codes gracefully (paused, terminated, etc.)
- Throws error for unexpected failures to trigger retry
Error Codes
The job handles these error codes as terminal (non-retryable):
CONTRACT_PAUSED
BILLING_CYCLE_SKIPPED
CONTRACT_TERMINATED
BILLING_CYCLE_CHARGE_BEFORE_EXPECTED_DATE
Terminal errors are logged as warnings but don’t cause the job to retry, as these represent business logic conditions rather than transient failures.
Job Registration
All billing jobs must be registered with the job runner in app/jobs/index.ts:
export const jobs = (() => {
switch (config.jobs.scheduler) {
case 'INLINE':
return new JobRunner<InlineScheduler>(new InlineScheduler(logger), logger);
case 'CLOUD_TASKS':
return new JobRunner<CloudTaskScheduler>(
new CloudTaskScheduler(logger, config.jobs.config),
logger,
);
}
})().register(
ChargeBillingCyclesJob,
RecurringBillingChargeJob,
ScheduleShopsToChargeBillingCyclesJob,
RebillSubscriptionJob,
// ... other jobs
);
Source: app/jobs/index.ts:54-91
Triggering Jobs
Programmatic Enqueueing
import {jobs} from '~/jobs';
import {ChargeBillingCyclesJob} from '~/jobs/billing';
const job = new ChargeBillingCyclesJob({
shop: 'example.myshopify.com',
payload: {
startDate: '2024-01-01T00:00:00Z',
endDate: '2024-01-01T23:59:59Z',
},
});
await jobs.enqueue(job);
Scheduled Execution
RecurringBillingChargeJob should be triggered by an external scheduler (e.g., Cloud Scheduler, cron) to run hourly.
Ensure your scheduler is configured to handle the job’s execution time. Billing operations for large shops may take several minutes.