Skip to content

Subscriptions

A subscription charges a card the customer has already saved, on a plan’s own calendar. It goes through exactly the same path as any other off-session charge, so mandates, receipts, the ledger and authentication recovery all apply — what subscriptions add is when.

A plan is what you sell on repeat: a name, a price, and how often it repeats.

A plan’s price never changes. Charging something different means a new plan. That is on purpose: a subscription can always say what it agreed to, and an old invoice still explains itself. Renaming and metadata are all an update may touch.

import os
from tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
monthly = tuppence.plans.create(name="Box of the month", amount=2500, interval="month")
yearly = tuppence.plans.create(name="Box of the year", amount=25000, interval="year")
print(monthly.amount, monthly.interval) # 2500 month
print(yearly.status) # active
# Renaming is fine. The price is not a field you can send.
renamed = tuppence.plans.update(monthly.id, name="Box of the month (large)")
print(renamed.name, renamed.amount) # Box of the month (large) 2500

Archiving stops new subscriptions and leaves the ones already on it alone:

import os
from tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
old = tuppence.plans.create(name="Retired tier", amount=900, interval="month")
tuppence.plans.archive(old.id)
print([p.name for p in tuppence.plans.list(status="active").data][:2])

A subscription needs a card the customer saved — take one payment with save_card="off_session" first, and the payer agrees to the terms when they pay it.

The first period is charged straight away, unless the plan has a trial.

subscription = tuppence.subscriptions.create(customer=customer.id, plan=monthly.id)
print(subscription.status, subscription.current_period_end) # active …

By default it charges the customer’s most recent saved card; name another with payment_method.

The period never moves. Changing plan is not restarting: a customer who changes twice in a month is not billed three times for it, and they renew on the day they always renewed on.

preview prices the change — the unused time on the old plan credited, the remaining time on the new one charged — and hands back the instant it priced at. Pass that proration_at back to change and the charge is the number you were shown, to the penny.

quote = tuppence.subscriptions.preview(subscription.id, plan=yearly.id)
for line in quote.lines:
print(line.description, line.amount)
print(quote.amount, quote.at_period_end)
tuppence.subscriptions.change(subscription.id, plan=yearly.id, proration_at=quote.proration_at)

Dearer now, cheaper later, because that is the honest way round:

  • An upgrade is charged for the time remaining and takes effect once paid — nobody is moved onto a plan they have not paid for.
  • A cheaper plan waits for the period they have already paid for. at_period_end defaults to true for a downgrade and false for an upgrade.
  • Asked for immediately, a downgrade hands back the unused time as credit against the next renewal, not as a refund to a card that was never charged for that time. Credit larger than the next renewal buys a whole period with no card touched at all.

A failed renewal does not cancel anything. The customer agreed to pay, so the subscription goes past_due and we try again after 1, 3, 7 and 14 days, telling the payer each time — with what is owed, when we will try again, and a link where they can pay with another card. Paying that link collects the money and replaces the card we keep, and the subscription carries on.

If the card never works, it stops after the last try, and the payer is told that too.

You hear about all of it:

Event When
subscription.created It exists
subscription.started The first period is paid
subscription.renewed A period was charged
subscription.updated The plan changed, or one is pending
subscription.payment_failed An attempt failed; attempts says how many
subscription.recovered They paid with another card
subscription.canceled Ended — by you, or by the schedule running out
tuppence.subscriptions.cancel(subscription.id) # at the period end
tuppence.subscriptions.cancel(subscription.id, at_period_end=False) # now

Cancelling at the period end is the kind default: they keep what they have already paid for and are not charged again. Nothing already paid is refunded either way.

Subscriptions renew when you advance a test clock — so a month of billing is a test, not a wait:

tuppence.request("POST", "/v1/test_helpers/clock/advance", body={"days": 32})
print(tuppence.subscriptions.retrieve(subscription.id).current_period_start)

See Testing for the rest of the clock.

A payment link with mode="subscription" starts a subscription when it is paid: the payer gives an email address, pays the first period, and their card is saved — no account, no password, no dashboard. The plan decides the price, and the recurring terms on the page are exactly what we keep as their mandate.

import os
from tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
plan = tuppence.plans.create(name="Coffee club", amount=1200, interval="month")
link = tuppence.payment_links.create(name="Join the coffee club", mode="subscription", plan=plan.id)
print(link.mode, link.url)

A plan with a free trial cannot be sold from a link yet — the card is saved by paying the first period, and a trial has none to pay. Start those with subscriptions.create.

No usage-based billing, no invoices as separate objects, no coupons, and no customer portal yet. Tax is not calculated anywhere: see Not supported.