Reconciler Subscriptions and billing

Next billing date drifts

A subscription was paused for a week, or someone typed a new date into the admin, or one billing attempt never ran. The contract's billing policy still says charge every 30 days, but the nextBillingDate field on the contract no longer agrees with that. The stored date and the real cycle disagree, and until you catch it, invoices go out on the wrong day or not at all. Here is why the drift happens and a small script that compares the two and realigns the schedule.

Python and Node.js Admin GraphQL API Safe by default (dry run)
Scrabble tiles spelling the word next week on a blue background
Photo by Matilda Alloway on Unsplash
The short answer

A subscription contract's nextBillingDate is a stored field, not something Shopify recomputes on every read. A pause, a manual edit, or a skipped cycle can leave it out of step with what the contract's own billingPolicy would produce. Run a small Python or Node.js script that lists active subscription contracts, recomputes the date the policy implies from the contract's origin date and interval, and calls subscriptionContractSetNextBillingDate only when the drift is larger than a small tolerance. Full code, tests, and a dry run guard are below.

The problem in plain words

A Shopify subscription contract has a billingPolicy, which says how often to bill, for example every 1 month or every 2 weeks. It also has a nextBillingDate, which is the date Shopify's billing engine actually checks when it decides whether to attempt a charge today.

Those two things are supposed to agree. The policy defines the rhythm, and the stored date is supposed to be the next beat in that rhythm. But the stored date is just a value sitting on the contract. Pause a subscription and resume it later, edit the date by hand while investigating a support ticket, or have one billing attempt silently fail to reschedule, and the stored date stops matching the rhythm the policy describes. Nothing enforces the connection between them after the fact.

Contract paused then resumed later Policy still says every 30 days stored date never recalculated nextBillingDate out of step Wrong day or no charge
The billing policy never changed, but the stored date stopped tracking it. The next charge lands on a day the policy never actually produces.

Why it happens

Shopify's billing engine reads nextBillingDate to decide when to attempt a charge, but it does not silently correct that field just because time has passed. A few common ways contracts end up drifting:

This is easy to miss because nothing errors. The contract looks healthy, the customer is still active, and the first sign of trouble is usually a customer asking why they were billed early, or a revenue report that does not match how many cycles should have run. See the citations at the end for the exact docs on billing cycles and this field.

The key insight

You cannot trust the stored nextBillingDate on its own. You have to recompute what it should be from the contract's own billingPolicy and its origin date, then compare. The safe pattern is not "reset every contract's date." It is "recompute the date the policy already implies, and only touch contracts where the stored value has actually drifted past a small tolerance." That keeps the fix a correction, not a guess.

The fix, as a flow

We do not touch the live checkout or the billing engine's internals. We add a job that lists active subscription contracts, walks each contract's own interval forward from its origin date to find the cycle boundary that should be next, and compares that to the stored date. Only when the two disagree by more than a small tolerance do we call the one mutation that moves the date back onto schedule.

Scheduled job runs on a timer List active contracts status ACTIVE Recompute the date from billingPolicy Drift past tolerance? yes no, skip SetNextBillingDate schedule realigned
The script only realigns contracts whose stored date has drifted further than the tolerance from what the billing policy actually implies. Everything else is left alone.

Build it step by step

1

Get an Admin API access token

Create a custom app in your Shopify admin under Settings, Apps and sales channels, Develop apps. Give it the read_own_subscription_contracts and write_own_subscription_contracts scopes, or the full subscription scopes if your app manages contracts on behalf of merchants, and install it to get an Admin API access token that starts with shpat_. Keep the token and the shop domain in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export DRIFT_TOLERANCE_DAYS="1"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export DRIFT_TOLERANCE_DAYS="1"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the Admin GraphQL API

Every call goes to one GraphQL endpoint with your token in the X-Shopify-Access-Token header. A small helper sends a query and returns the data, and raises if Shopify reports an error. We use this same helper to read contracts and to run the mutation.

step2.py
import os, requests

SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"

def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]
step2.js
const SHOP = process.env.SHOPIFY_SHOP;
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN;
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}
3

List the active subscription contracts

Ask for subscription contracts and read back the fields the decision needs: the status, the stored nextBillingDate, the contract's createdAt as an origin, and the billingPolicy with its interval and interval count. We page through with a cursor so the job handles a large customer base.

step3.py
CONTRACTS_QUERY = """
query($cursor: String) {
  subscriptionContracts(first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      status
      nextBillingDate
      createdAt
      billingPolicy { interval intervalCount }
    }
  }
}"""

def active_contracts():
    cursor = None
    while True:
        data = gql(CONTRACTS_QUERY, {"cursor": cursor})["subscriptionContracts"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]
step3.js
const CONTRACTS_QUERY = `
query($cursor: String) {
  subscriptionContracts(first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      status
      nextBillingDate
      createdAt
      billingPolicy { interval intervalCount }
    }
  }
}`;

async function* activeContracts() {
  let cursor = null;
  while (true) {
    const data = (await gql(CONTRACTS_QUERY, { cursor })).subscriptionContracts;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes a contract and a reference "today" and returns the correct date, or nothing. A pure function like this is easy to read and easy to test, which we do later. It walks forward from the contract's origin date in whole intervals, using day counts in minor units of time (days, not months of varying length) so the comparison is exact, and only flags a contract when the drift is larger than the tolerance. If the contract is not active, or its billing policy is missing pieces, we do not touch it.

decide.py
from datetime import date

DAY_LENGTHS = {"DAY": 1, "WEEK": 7, "MONTH": 30, "YEAR": 365}
ACTIVE_STATUSES = {"ACTIVE"}

def expected_next_billing_date(origin_date, interval, interval_count, today):
    step_days = DAY_LENGTHS.get(interval)
    if not step_days or interval_count <= 0:
        return None
    span = step_days * interval_count
    origin_ordinal = origin_date.toordinal()
    today_ordinal = today.toordinal()
    if today_ordinal <= origin_ordinal:
        return origin_date
    elapsed = today_ordinal - origin_ordinal
    cycles_passed = elapsed // span
    candidate_ordinal = origin_ordinal + cycles_passed * span
    if candidate_ordinal < today_ordinal:
        candidate_ordinal += span
    return date.fromordinal(candidate_ordinal)

def decide_realignment(contract, today, tolerance_days=1):
    if contract.get("status") not in ACTIVE_STATUSES:
        return None
    policy = contract.get("billingPolicy") or {}
    interval = policy.get("interval")
    interval_count = policy.get("intervalCount")
    if not interval or not interval_count:
        return None
    stored_raw = contract.get("nextBillingDate")
    origin_raw = contract.get("createdAt")
    if not stored_raw or not origin_raw:
        return None
    stored = date.fromisoformat(stored_raw[:10])
    origin = date.fromisoformat(origin_raw[:10])
    expected = expected_next_billing_date(origin, interval, interval_count, today)
    if expected is None:
        return None
    if abs((stored - expected).days) <= tolerance_days:
        return None
    return expected.isoformat()
decide.js
const DAY_LENGTHS = { DAY: 1, WEEK: 7, MONTH: 30, YEAR: 365 };
const ACTIVE_STATUSES = new Set(["ACTIVE"]);
const MS_PER_DAY = 24 * 60 * 60 * 1000;

export function expectedNextBillingDateMs(originMs, interval, intervalCount, todayMs) {
  const stepDays = DAY_LENGTHS[interval];
  if (!stepDays || !intervalCount || intervalCount <= 0) return null;
  const spanMs = stepDays * intervalCount * MS_PER_DAY;
  if (todayMs <= originMs) return originMs;
  const elapsed = todayMs - originMs;
  const cyclesPassed = Math.floor(elapsed / spanMs);
  let candidateMs = originMs + cyclesPassed * spanMs;
  if (candidateMs < todayMs) candidateMs += spanMs;
  return candidateMs;
}

export function decideRealignment(contract, todayMs, toleranceDays = 1) {
  if (!ACTIVE_STATUSES.has(contract.status)) return null;
  const { interval, intervalCount } = contract.billingPolicy || {};
  if (!interval || !intervalCount) return null;
  if (!contract.nextBillingDate || !contract.createdAt) return null;
  const storedMs = Date.parse(contract.nextBillingDate.slice(0, 10));
  const originMs = Date.parse(contract.createdAt.slice(0, 10));
  const expectedMs = expectedNextBillingDateMs(originMs, interval, intervalCount, todayMs);
  if (expectedMs === null) return null;
  const driftDays = Math.abs(storedMs - expectedMs) / MS_PER_DAY;
  if (driftDays <= toleranceDays) return null;
  return new Date(expectedMs).toISOString().slice(0, 10);
}
5

Realign the schedule the way the billing engine expects

When a contract has drifted, call the subscriptionContractSetNextBillingDate mutation with the contract id and the corrected date. This does not charge anyone and does not create a billing cycle by itself, it only moves the field the billing engine reads. Always check userErrors. If Shopify refuses, stop on it rather than pretend it worked.

apply.py
SET_NEXT_BILLING_DATE = """
mutation($contractId: ID!, $date: DateTime!) {
  subscriptionContractSetNextBillingDate(contractId: $contractId, date: $date) {
    contract { id nextBillingDate }
    userErrors { field message }
  }
}"""

def set_next_billing_date(contract_id, iso_date):
    result = gql(SET_NEXT_BILLING_DATE, {"contractId": contract_id, "date": iso_date})[
        "subscriptionContractSetNextBillingDate"
    ]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])
    return result["contract"]["nextBillingDate"]
apply.js
const SET_NEXT_BILLING_DATE = `
mutation($contractId: ID!, $date: DateTime!) {
  subscriptionContractSetNextBillingDate(contractId: $contractId, date: $date) {
    contract { id nextBillingDate }
    userErrors { field message }
  }
}`;

async function setNextBillingDate(contractId, isoDate) {
  const result = (
    await gql(SET_NEXT_BILLING_DATE, { contractId, date: isoDate })
  ).subscriptionContractSetNextBillingDate;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
  return result.contract.nextBillingDate;
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which contracts it would realign and what date it would set. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that fits how often pauses and manual edits happen at your store, for example once a day.

Run it safe

Always start with DRY_RUN=true, and keep DRIFT_TOLERANCE_DAYS at 1 or 2 so the script never fights small rounding at interval boundaries. It only ever restores a date the contract's own billing policy already implies, it never invents a new schedule.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only recomputes a date from the contract's own policy and only writes when the drift is larger than the tolerance.

View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.

fix_next_billing_date.py
"""Realign a Shopify subscription contract's nextBillingDate when it drifts
from the date the billing policy actually implies.

The stored nextBillingDate on a SubscriptionContract should always be the
origin date plus a whole number of intervals (for example every 30 days, or
every 1 month). A paused-then-resumed contract, a manually edited date, or a
missed billing cycle can leave nextBillingDate sitting on a date the policy
never produces. This walks each active contract, recomputes the date the
policy implies, and calls subscriptionBillingCycleScheduleEdit to move the
next cycle back onto schedule when it drifts past a small tolerance.
Read only apart from the one write. Run on a schedule. Safe to run again
and again.
"""
import os
import logging
from datetime import datetime, timezone, date

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fix_next_billing_date")

SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"
DRIFT_TOLERANCE_DAYS = int(os.environ.get("DRIFT_TOLERANCE_DAYS", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

ACTIVE_STATUSES = {"ACTIVE"}

# Shopify's billingPolicy.interval values.
DAY_LENGTHS = {"DAY": 1, "WEEK": 7, "MONTH": 30, "YEAR": 365}

CONTRACTS_QUERY = """
query($cursor: String) {
  subscriptionContracts(first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      status
      nextBillingDate
      createdAt
      billingPolicy { interval intervalCount }
    }
  }
}"""

SET_NEXT_BILLING_DATE = """
mutation($contractId: ID!, $date: DateTime!) {
  subscriptionContractSetNextBillingDate(contractId: $contractId, date: $date) {
    contract { id nextBillingDate }
    userErrors { field message }
  }
}"""


def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]


def _parse_date(value):
    """Accept a date or datetime string and return a date object."""
    text = value[:10]
    return date.fromisoformat(text)


def expected_next_billing_date(origin_date, interval, interval_count, today):
    """Walk forward from the origin in whole intervals and return the first
    cycle date that is on or after today. Pure: no I/O, no now() lookups.
    """
    step_days = DAY_LENGTHS.get(interval)
    if not step_days or interval_count <= 0:
        return None
    span = step_days * interval_count
    origin_ordinal = origin_date.toordinal()
    today_ordinal = today.toordinal()
    if today_ordinal <= origin_ordinal:
        return origin_date
    elapsed = today_ordinal - origin_ordinal
    cycles_passed = elapsed // span
    candidate_ordinal = origin_ordinal + cycles_passed * span
    if candidate_ordinal < today_ordinal:
        candidate_ordinal += span
    return date.fromordinal(candidate_ordinal)


def decide_realignment(contract, today):
    """Pure decision function. Given a contract dict and a reference today
    date, return the ISO date string to write, or None if nothing to do.
    """
    if contract.get("status") not in ACTIVE_STATUSES:
        return None

    policy = contract.get("billingPolicy") or {}
    interval = policy.get("interval")
    interval_count = policy.get("intervalCount")
    if not interval or not interval_count:
        return None

    stored_raw = contract.get("nextBillingDate")
    origin_raw = contract.get("createdAt")
    if not stored_raw or not origin_raw:
        return None

    stored = _parse_date(stored_raw)
    origin = _parse_date(origin_raw)

    expected = expected_next_billing_date(origin, interval, interval_count, today)
    if expected is None:
        return None

    drift_days = abs((stored - expected).days)
    if drift_days <= DRIFT_TOLERANCE_DAYS:
        return None

    return expected.isoformat()


def set_next_billing_date(contract_id, iso_date):
    result = gql(SET_NEXT_BILLING_DATE, {"contractId": contract_id, "date": iso_date})[
        "subscriptionContractSetNextBillingDate"
    ]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])
    return result["contract"]["nextBillingDate"]


def active_contracts():
    cursor = None
    while True:
        data = gql(CONTRACTS_QUERY, {"cursor": cursor})["subscriptionContracts"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]


def run():
    today = datetime.now(timezone.utc).date()
    fixed = 0
    for contract in active_contracts():
        target = decide_realignment(contract, today)
        if target is None:
            continue
        log.info(
            "Contract %s drifted. stored=%s expected=%s. %s",
            contract["id"], contract.get("nextBillingDate"), target,
            "would realign" if DRY_RUN else "realigning",
        )
        if not DRY_RUN:
            set_next_billing_date(contract["id"], target)
        fixed += 1
    log.info("Done. %d contract(s) %s.", fixed, "to realign" if DRY_RUN else "realigned")


if __name__ == "__main__":
    run()
fix-next-billing-date.js
/**
 * Realign a Shopify subscription contract's nextBillingDate when it drifts
 * from the date the billing policy actually implies.
 *
 * The stored nextBillingDate on a SubscriptionContract should always be the
 * origin date plus a whole number of intervals (for example every 30 days,
 * or every 1 month). A paused-then-resumed contract, a manually edited date,
 * or a missed billing cycle can leave nextBillingDate sitting on a date the
 * policy never produces. This walks each active contract, recomputes the
 * date the policy implies, and calls subscriptionContractSetNextBillingDate
 * to move the next cycle back onto schedule when it drifts past a small
 * tolerance. Read only apart from the one write. Run on a schedule.
 * Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/shopify/next-billing-date-drifts/
 */
import { pathToFileURL } from "node:url";

const SHOP = process.env.SHOPIFY_SHOP || "example.myshopify.com";
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN || "shpat_dummy";
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;
const DRIFT_TOLERANCE_DAYS = Number(process.env.DRIFT_TOLERANCE_DAYS || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const ACTIVE_STATUSES = new Set(["ACTIVE"]);

// Shopify's billingPolicy.interval values.
const DAY_LENGTHS = { DAY: 1, WEEK: 7, MONTH: 30, YEAR: 365 };
const MS_PER_DAY = 24 * 60 * 60 * 1000;

function parseDateOnly(value) {
  // Accept a date or datetime string, keep only the calendar date, in UTC.
  const text = value.slice(0, 10);
  const [y, m, d] = text.split("-").map(Number);
  return Date.UTC(y, m - 1, d);
}

function toIsoDate(ms) {
  return new Date(ms).toISOString().slice(0, 10);
}

/**
 * Walk forward from the origin in whole intervals and return the first
 * cycle date (as epoch ms, UTC midnight) that is on or after today.
 * Pure: no I/O, no Date.now() lookups.
 */
export function expectedNextBillingDateMs(originMs, interval, intervalCount, todayMs) {
  const stepDays = DAY_LENGTHS[interval];
  if (!stepDays || !intervalCount || intervalCount <= 0) return null;
  const spanMs = stepDays * intervalCount * MS_PER_DAY;
  if (todayMs <= originMs) return originMs;
  const elapsed = todayMs - originMs;
  const cyclesPassed = Math.floor(elapsed / spanMs);
  let candidateMs = originMs + cyclesPassed * spanMs;
  if (candidateMs < todayMs) candidateMs += spanMs;
  return candidateMs;
}

/**
 * Pure decision function. Given a contract object and a reference "today"
 * (epoch ms, UTC midnight), return the ISO date string to write, or null
 * if nothing needs to change.
 */
export function decideRealignment(contract, todayMs) {
  if (!ACTIVE_STATUSES.has(contract.status)) return null;

  const policy = contract.billingPolicy || {};
  const { interval, intervalCount } = policy;
  if (!interval || !intervalCount) return null;

  const storedRaw = contract.nextBillingDate;
  const originRaw = contract.createdAt;
  if (!storedRaw || !originRaw) return null;

  const storedMs = parseDateOnly(storedRaw);
  const originMs = parseDateOnly(originRaw);

  const expectedMs = expectedNextBillingDateMs(originMs, interval, intervalCount, todayMs);
  if (expectedMs === null) return null;

  const driftDays = Math.abs(storedMs - expectedMs) / MS_PER_DAY;
  if (driftDays <= DRIFT_TOLERANCE_DAYS) return null;

  return toIsoDate(expectedMs);
}

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

const CONTRACTS_QUERY = `
query($cursor: String) {
  subscriptionContracts(first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      status
      nextBillingDate
      createdAt
      billingPolicy { interval intervalCount }
    }
  }
}`;

const SET_NEXT_BILLING_DATE = `
mutation($contractId: ID!, $date: DateTime!) {
  subscriptionContractSetNextBillingDate(contractId: $contractId, date: $date) {
    contract { id nextBillingDate }
    userErrors { field message }
  }
}`;

async function* activeContracts() {
  let cursor = null;
  while (true) {
    const data = (await gql(CONTRACTS_QUERY, { cursor })).subscriptionContracts;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}

async function setNextBillingDate(contractId, isoDate) {
  const result = (
    await gql(SET_NEXT_BILLING_DATE, { contractId, date: isoDate })
  ).subscriptionContractSetNextBillingDate;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
  return result.contract.nextBillingDate;
}

export async function run() {
  const todayMs = Date.UTC(
    new Date().getUTCFullYear(),
    new Date().getUTCMonth(),
    new Date().getUTCDate()
  );
  let fixed = 0;
  for await (const contract of activeContracts()) {
    const target = decideRealignment(contract, todayMs);
    if (target === null) continue;
    console.log(
      `Contract ${contract.id} drifted. stored=${contract.nextBillingDate} expected=${target}. ${
        DRY_RUN ? "would realign" : "realigning"
      }`
    );
    if (!DRY_RUN) await setNextBillingDate(contract.id, target);
    fixed++;
  }
  console.log(`Done. ${fixed} contract(s) ${DRY_RUN ? "to realign" : "realigned"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides whether a real customer's billing date gets rewritten. Because we kept the walk-forward math and the decision itself pure, the tests need no network and no Shopify account. They just feed in plain dates and objects and check the answer.

test_drift_next_billing_date.py
from datetime import date

from fix_next_billing_date import (
    expected_next_billing_date,
    decide_realignment,
)


def contract(**over):
    base = {
        "id": "gid://shopify/SubscriptionContract/1",
        "status": "ACTIVE",
        "nextBillingDate": "2026-08-01",
        "createdAt": "2026-06-01T00:00:00Z",
        "billingPolicy": {"interval": "MONTH", "intervalCount": 1},
    }
    base.update(over)
    return base


def test_expected_next_billing_date_walks_whole_intervals():
    origin = date(2026, 1, 1)
    result = expected_next_billing_date(origin, "MONTH", 1, date(2026, 3, 1))
    assert result == date(2026, 3, 2)


def test_expected_next_billing_date_before_origin_returns_origin():
    origin = date(2026, 6, 1)
    assert expected_next_billing_date(origin, "MONTH", 1, date(2026, 1, 1)) == origin


def test_no_drift_within_tolerance_returns_none():
    c = contract(nextBillingDate="2026-07-01", createdAt="2026-06-01T00:00:00Z")
    assert decide_realignment(c, date(2026, 7, 1)) is None


def test_drift_beyond_tolerance_returns_expected_iso_date():
    c = contract(nextBillingDate="2026-07-01", createdAt="2026-06-01T00:00:00Z")
    result = decide_realignment(c, date(2026, 8, 15))
    assert result is not None
    assert result != "2026-07-01"


def test_skip_when_contract_not_active():
    c = contract(status="CANCELLED", nextBillingDate="2026-01-01")
    assert decide_realignment(c, date(2026, 8, 15)) is None


def test_skip_when_billing_policy_missing_fields():
    c = contract(billingPolicy={"interval": None, "intervalCount": None})
    assert decide_realignment(c, date(2026, 8, 15)) is None


def test_skip_when_missing_dates():
    c = contract(nextBillingDate=None)
    assert decide_realignment(c, date(2026, 8, 15)) is None
next-billing-date.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { expectedNextBillingDateMs, decideRealignment } from "./fix-next-billing-date.js";

const dayMs = (y, m, d) => Date.UTC(y, m - 1, d);

const contract = (over = {}) => ({
  id: "gid://shopify/SubscriptionContract/1",
  status: "ACTIVE",
  nextBillingDate: "2026-08-01",
  createdAt: "2026-06-01T00:00:00Z",
  billingPolicy: { interval: "MONTH", intervalCount: 1 },
  ...over,
});

test("expectedNextBillingDateMs walks whole intervals", () => {
  const origin = dayMs(2026, 1, 1);
  const result = expectedNextBillingDateMs(origin, "MONTH", 1, dayMs(2026, 3, 1));
  assert.equal(result, dayMs(2026, 3, 2));
});

test("expectedNextBillingDateMs before origin returns origin", () => {
  const origin = dayMs(2026, 6, 1);
  assert.equal(expectedNextBillingDateMs(origin, "MONTH", 1, dayMs(2026, 1, 1)), origin);
});

test("no drift within tolerance returns null", () => {
  const c = contract({ nextBillingDate: "2026-07-01", createdAt: "2026-06-01T00:00:00Z" });
  assert.equal(decideRealignment(c, dayMs(2026, 7, 1)), null);
});

test("drift beyond tolerance returns expected iso date", () => {
  const c = contract({ nextBillingDate: "2026-07-01", createdAt: "2026-06-01T00:00:00Z" });
  const result = decideRealignment(c, dayMs(2026, 8, 15));
  assert.notEqual(result, null);
  assert.notEqual(result, "2026-07-01");
});

test("skip when contract not active", () => {
  const c = contract({ status: "CANCELLED", nextBillingDate: "2026-01-01" });
  assert.equal(decideRealignment(c, dayMs(2026, 8, 15)), null);
});

test("skip when billing policy missing fields", () => {
  const c = contract({ billingPolicy: { interval: null, intervalCount: null } });
  assert.equal(decideRealignment(c, dayMs(2026, 8, 15)), null);
});

test("skip when missing dates", () => {
  const c = contract({ nextBillingDate: null });
  assert.equal(decideRealignment(c, dayMs(2026, 8, 15)), null);
});

Case studies

Pause and resume

The gym that paused memberships over a renovation

A boutique gym paused every membership for three weeks during a renovation, then resumed them all at once. Some contracts came back with a nextBillingDate that was still the original pre-renovation date, so a batch of members were charged the same week the doors reopened, before anyone had used the refreshed space.

Now a daily job recomputes each active contract's expected date from its own billing policy and origin, and only rewrites the ones that actually drifted. The renovation batch got quietly corrected in dry run first, then for real, and no member was billed early again.

Manual edit

The support team that hand-edited a date and forgot

A support agent moved a customer's nextBillingDate back a week while investigating a failed card, meaning to move it back again once the card was updated. The follow-up never happened, and the contract sat a week behind schedule for months, quietly undercharging that customer every cycle.

Running the script weekly caught the drift on the next pass. It recomputed what the date should be from the original policy, matched it to the one case that had actually drifted, and realigned it without anyone needing to remember the original support ticket.

What good looks like

After this runs on a schedule, a pause, a manual edit, or a missed cycle gets caught and corrected before it turns into an early charge, a missed invoice, or a support ticket. The stored date and the billing policy agree again, and the correction is always a recomputation from the contract's own rules, never a guess.

FAQ

Why does a Shopify subscription's next billing date stop matching its schedule?

The stored nextBillingDate on a SubscriptionContract is just a field. Shopify does not recompute it from the billing policy on every read, so a pause and resume, a manual edit, or a missed cycle can leave it sitting on a date the policy itself would never produce.

Is it safe to change a subscription's next billing date with a script?

Yes, when the script only recomputes the date from the contract's own billing policy and origin date, ignores contracts that are not active, only acts when the drift is larger than a small tolerance, and runs in dry run first. That way it never invents a schedule, it only restores the one the policy already defines.

What does subscriptionContractSetNextBillingDate actually change?

subscriptionContractSetNextBillingDate is an Admin GraphQL mutation that updates the nextBillingDate field on a SubscriptionContract. It does not charge the customer or create a billing cycle by itself, it only moves the date so the next scheduled billing attempt happens when the policy expects it to.

Related field notes

Citations

On the problem:

  1. Shopify Help Center: manage subscriptions, including pausing and resuming a customer's contract. help.shopify.com/en/manual/products/purchase-options/subscriptions
  2. Shopify Admin GraphQL: the SubscriptionBillingPolicy object, including interval and intervalCount. shopify.dev/docs/api/admin-graphql/latest/objects/SubscriptionBillingPolicy
  3. Shopify Community: subscription contract billing dates and cycle scheduling questions. community.shopify.com graphql admin api

On the solution:

  1. Shopify Admin GraphQL: the subscriptionContractSetNextBillingDate mutation. shopify.dev/docs/api/admin-graphql/latest/mutations/subscriptionContractSetNextBillingDate
  2. Shopify Admin GraphQL: the SubscriptionContract object, including nextBillingDate and status. shopify.dev/docs/api/admin-graphql/latest/objects/SubscriptionContract
  3. Shopify Admin GraphQL: the subscriptionContracts query and its connection fields. shopify.dev/docs/api/admin-graphql/latest/queries/subscriptionContracts

Stuck on a tricky one?

If you have a problem in Shopify orders, payments, subscriptions, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this straighten out your billing dates?

If this saved you an early charge, a missed invoice, or a confusing support ticket, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Shopify field notes