Skip to main content

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:
  1. RecurringBillingChargeJob - Triggers the billing process hourly
  2. ScheduleShopsToChargeBillingCyclesJob - Finds eligible shops and schedules billing
  3. ChargeBillingCyclesJob - Executes bulk billing for a shop
  4. RebillSubscriptionJob - Handles failed payment retries

RecurringBillingChargeJob

Entry point for the automated billing process. Runs on a recurring schedule to trigger billing operations.

Configuration

queue
string
default:"default"
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

targetDate
string
required
ISO 8601 formatted date/time to evaluate billing schedules against

Configuration

queue
string
default:"billing"
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

  1. Retrieves active billing schedules in batches for memory efficiency
  2. Uses BillingScheduleCalculatorService to determine billable shops
  3. Creates ChargeBillingCyclesJob for each eligible shop with date range
  4. 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
string
required
Shop domain to execute billing for
payload.startDate
string
required
Start of the billing cycle date range (ISO 8601 format)
payload.endDate
string
required
End of the billing cycle date range (ISO 8601 format)

Configuration

queue
string
default:"billing"
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
string
required
Shop domain for the subscription
payload.subscriptionContractId
string
required
GraphQL Admin API ID of the subscription contract to rebill
payload.originTime
string
required
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

  1. Checks if subscription was already successfully billed (early termination)
  2. Calls subscriptionBillingCycleCharge mutation for single contract
  3. Handles specific user error codes gracefully (paused, terminated, etc.)
  4. 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.

Build docs developers (and LLMs) love