Skip to content

Subscription billing

subscription-billing

Model subscription state, renewal charges, proration, and dunning without treating a renewal as a new one-time checkout. Use when adding or changing recurring billing, plan changes, cancellation, or failed renewal recovery.

Version 0.3.2  ·  MIT  ·  Source  ·  Rule

Install this skill

Copy the skill and its rule into your project:

npx @sadrmanesh/commerce-agent-skills add subscription-billing

The skill goes to .agents/skills/ and the rule to .agents/rules/. See Command line for other directories.

Use this skill for recurring charges. The first payment still follows the payment skill. A renewal is another charge with its own idempotency key, not a replay of the checkout.

WooCommerce core does not include subscriptions. WooCommerce Subscriptions is a separate extension. Shopify subscriptions use selling plans. Use the product the store already runs. Do not invent a cron job that inserts orders with wp_insert_post.

Procedure

  1. Store the contract separately from any one invoice: customer, plan, quantity, currency, interval, current period start and end, and status.
  2. Charge the initial invoice with the payment skill. Move the contract to active or trialing only after that payment is confirmed, unless the plan really has a trial with no charge.
  3. When a period ends, create one renewal invoice and one charge. The idempotency key is the subscription id plus the period start.
  4. On success, advance the period. On failure, follow the dunning schedule. Do not delete the entitlement on the first failure.
  5. Cancel either at period end or immediately, and record which one the shopper chose. Prorate only when the plan change says to.

State transitions are in the state notes. Worked examples and checks are in evals/tasks.json.

Status

Use these statuses unless the platform already has its own set. Map the platform's names onto them in one function.

Status Meaning
trialing Access is on. No charge yet, and a trial end is stored.
active The latest renewal succeeded and the period end is in the future.
past_due A renewal failed and retries remain. Access policy is explicit.
paused The merchant or shopper paused billing. No renewal is attempted.
canceled No further renewals. Access ends now or at the stored period end.

Do not overload active to mean "card on file but the last charge failed".

Renewals

  • One renewal invoice per period. A retried charge uses the same idempotency key.
  • The amount is the plan price at renewal time, plus tax from the tax skill, unless a scheduled price change says otherwise.
  • Store the provider payment id on that invoice. A second webhook does not create a second invoice.
  • Advance current_period_end only after the charge is confirmed.

Changes and cancellation

  • Upgrade or downgrade mid-period either waits until period end or prorates. Record the policy on the plan. Do not do both.
  • Proration is a signed amount on an invoice: unused time credited, new time debited. It is not a silent edit of a paid invoice.
  • Cancel at period end sets cancel_at_period_end. The status stays active until the period ends, then becomes canceled.
  • Immediate cancel stops renewal and sets the entitlement end to now. Refund the unused time only if the policy says so, through the payment skill's refund path.

Dunning

  • Retry a failed renewal on a short written schedule, for example day 1, day 3, and day 7. Those days are a default to configure, not a measured optimum.
  • After the last retry fails, set canceled or leave past_due with access off, according to the plan. Tell the shopper which one happened.
  • A shopper who updates the payment method retries the open invoice. It does not open a second period.

Gotchas

  • Treating the webhook for the first payment as every renewal will extend the period on a retry.
  • Billing today and also billing at period end double-charges the first cycle.
  • Local midnight is not a period boundary. Store period ends in UTC.
  • A paused subscription that still runs the renewal job will charge a shopper who asked to stop.

Verification

  • Two workers processing the same period create one invoice and one charge.
  • A failed renewal does not advance current_period_end.
  • Cancel at period end leaves access on until that timestamp and does not create another invoice.
  • A plan change records proration as its own invoice lines.

Evaluation

Worked examples and the checks they must satisfy are in evals/tasks.json.

References

Subscription states

Allowed transitions

From Event To
trialing Trial converts and the first charge succeeds active
trialing Shopper cancels during the trial canceled
active Renewal charge succeeds active with the next period
active Renewal charge fails past_due
active Cancel at period end active with cancel_at_period_end
active plus cancel_at_period_end Period end canceled
past_due Retry or updated payment method succeeds active
past_due Retry schedule exhausted canceled or access-off, as configured
active or past_due Pause paused
paused Resume active only after a successful charge if a period was missed

No other transition should be reachable from a client request. Unknown provider statuses do not move the contract. Store the raw provider status beside your mapped status so a person can reconcile it.

Platform mapping

  • Shopify: selling plans and subscription contracts are the native model. Follow the subscription APIs for the app's configured version. Do not store the contract only as a metafield you update from the browser.
  • WooCommerce Subscriptions: it is not WooCommerce core. Renewal orders and payment completion belong to that extension. Do not create renewal orders with wp_insert_post or update_post_meta. If you are on HPOS, use the extension's order APIs the same way the WooCommerce plugin skill requires for any other order.

Idempotency key

sub_{subscriptionId}_period_{periodStartIso}. Reuse it for every retry of that period. A plan change that creates a proration invoice uses a different key that includes the change id.

Evaluation

Fixtures from evals/tasks.json. See evaluation fixtures for how the checks work.

one-renewal

Create a single renewal charge for a subscription period and retry it without opening a second period.

Must include: idempotency, current_period_end

Must not include: wp_insert_post

Worked example

Key the renewal by the subscription id and the period start.

Reuse that idempotency key for retries. Advance current_period_end only after the provider confirms the charge.

cancel-at-period-end

Let a shopper cancel at period end without removing access before the period they already paid for.

Must include: cancel_at_period_end, active

Must not include: status = canceled immediately

Worked example

Set cancel_at_period_end and leave the status active until the stored period end.

At that timestamp the contract becomes canceled and no further renewal invoice is created.