Subscriptions¶
BadgerCommerce provides subscription management for recurring payments, integrated with Stripe's subscription billing system.
Key Features¶
Subscription Management¶
- Stripe Integration: Full integration with Stripe Subscriptions API
- Local Data Storage: Subscriptions and invoices stored locally for fast access
- Status Tracking: Active, trialing, past_due, canceled, paused states
- Billing Period Tracking: Current period start/end, trial end dates
- Payment Method Display: Shows card brand and last 4 digits
Invoice Management¶
- Automatic Invoice Storage: Invoices synced from Stripe
- Payment Tracking: Paid status, payment dates, amounts
- PDF Access: Direct links to Stripe-hosted invoice PDFs
- Line Item Details: Full breakdown of invoice line items
Gift Aid Integration¶
- Eligibility Tracking: Subscriptions can be marked Gift Aid eligible
- Donation Creation: Automatic donation records for paid invoices
- HMRC Reporting: Gift Aid donations flow into standard reporting
Failed Payments & Dunning¶
When a recurring payment fails, Stripe owns the retry schedule and the eventual cancel decision (via its Smart Retries / dunning settings on the connected account). Badger's role is to send a branded chaser email to the customer with a link to update their payment method — one email per real Stripe attempt.
How it works¶
- The
invoice.payment_failedwebhook drives everything. On each failed attempt Badger sends exactly one chaser, so the cadence matches Stripe's actual retry schedule rather than a fixed local timer. - Copy follows Stripe's timeline: if the invoice has a
next_payment_attempt, the email says "we'll automatically try again on {date}"; if there's no further attempt, it's the final notice (the subscription will lapse unless the card is updated). - De-duplication: chasers are keyed off Stripe's
attempt_count, so a re-delivered webhook never sends a second email for the same attempt. - Recovery:
invoice.payment_succeededsettles that invoice's dunning state toRECOVERED— no cross-invoice side effects. - Reconciliation sweep: a daily job re-checks only invoices currently
IN_DUNNINGagainst Stripe (the source of truth) as a safety net for missed webhooks. It re-drives a chaser if Stripe attempted again (a no-op otherwise thanks to the dedup) and settles paid/voided invoices toRECOVERED. A failure on one invoice is isolated and never aborts the run.
Dunning state lives on the Invoice (see the Invoice data model below), giving a per-invoice lifecycle: NONE → IN_DUNNING → RECOVERED (or GIVEN_UP when Stripe stops retrying).
Delivery and addressing¶
-
Queued send: the chaser is published as an
EmailJobonto the email-jobs queue rather than sent inline from the webhook handler, so the webhook acknowledges quickly and the send happens out of band. The worker rebuilds the email model at send time viaEmailJobModelReconstructor(EmailModelSpec.Kind.SUBSCRIPTION, carrying the subscription and invoice ids). Invoice dunning state is persisted before the job is published, so the worker readsnextAttemptAtback off the invoice.Retry and parking
A failed send is retried a few times by the email-jobs listener and, if it still fails, parked on
email-jobs-dead-queuerather than dropped — see Transactional Email. Nothing consumes that queue yet, so a parked chaser means a customer was not chased and nobody is told automatically.- Tenant resolution: the invoice webhook handlers resolve the owning site through
SubscriptionInvoiceSiteResolver, which prefers the Stripemetadata.siteId, is scoped to the connected account, and refuses to guess when the result is ambiguous rather than falling back to the first site. This matters on multi-tenant accounts, where a wrong guess would send a chaser branded as the wrong shop. - Secure update link: the "update your payment method" link carries a short-lived signed token (
SubscriptionUpdateLinkTokenService— per-site HMAC, 14-day TTL, signing key auto-provisioned in configuration) which authorises the request and identifies the order and subscription. Links minted before this change passed the order'sconfirmationCodeinstead;SecureSubscriptionPaymentControllerstill honours those as a legacy fallback. Once authorised the customer is redirected into a Stripe Billing Portal session, so card capture stays PCI-safe with Stripe.
- Tenant resolution: the invoice webhook handlers resolve the owning site through
Operator setup¶
Because Badger is the customer-facing voice on failed payments, disable Stripe's own failed-payment customer emails on each connected account (Stripe → Settings → Subscriptions and emails) to avoid duplicate messaging.
Configuration keys (category subscription-dunning):
| Key | Purpose |
|---|---|
subscriptionDunningEnabled |
Master on/off for chaser emails (per site) |
subscriptionDunningFromMessage / subscriptionDunningFromName |
From address / display name |
subscriptionDunningSubjectPattern |
Email subject ({0} = site name) |
subscriptionDunningEmailTemplate |
Template name (default subscriptionPaymentFailed) |
Global keys (System → Global Configuration):
| Key | Purpose |
|---|---|
subscription-dunning-slack-channel |
Slack channel notified each time a chaser goes out for a live-mode invoice (default #badger-prod; blank disables) |
subscription-dunning-slack-test-channel |
Same, for Stripe test-mode invoices (default #badger-stripe-test; blank disables) |
The Slack notice names the site, the recipient (name and email), amount due, Stripe attempt number and next retry date, and flags the final chaser. It's posted when the chaser is queued, so a send that later fails and is parked on email-jobs-dead-queue still shows up in Slack as sent. The Slack post is best-effort, so if it fails the chaser still goes out.
The email template is subscriptionPaymentFailed (with a -text plain-text variant) under commerce-core/.../templates/{theme}/email/.
Billing Schedule¶
By default a subscription's recurring charge is anchored to the moment the subscriber signed up — join on the 20th and you are billed on the 20th thereafter. The first payment is taken at checkout and the subscription is created with a trial end one interval later, so no proration is involved.
Setting subscriptionBillingDayOfMonth (1-28) moves every new monthly subscription onto a common
billing day instead, which is what you want when subscribers need to be settled together — for
example a monthly draw or dispatch that requires everyone's payment to have cleared first.
- 0 (default) keeps the existing behaviour, so enabling it is a per-site decision.
- Only monthly prices are affected. A day-of-month anchor is meaningless for weekly prices and would arbitrarily shift a yearly one.
- Days are capped at 28, since later days do not exist in every month.
- New subscribers get a slightly longer first period rather than being charged twice within days of joining: the first charge is the first occurrence of the billing day on or after the signup-anchored date. Their checkout payment already covers that period, so there is nothing to prorate or refund.
- Existing subscriptions are not rescheduled. The setting applies as subscriptions are created.
The date arithmetic lives in SubscriptionBillingDateCalculator.
Lifecycle Notifications¶
Two changes to a live subscription notify people automatically. Both are detected centrally when the Stripe webhook is applied, so they fire however the change was made — customer self-service, an admin edit, or the Stripe dashboard.
Cancellation¶
When a subscription's status transitions to canceled, SubscriptionCancelledEvent is published and
SubscriptionCancellationEmailListener sends:
- a cancellation confirmation to the subscriber, with a resubscribe link or contact address;
- a reverse-fulfilment notification to the product's fulfiller, telling them to stop fulfilling.
Because the trigger is the status transition rather than the cancellation request, a subscription set to cancel at period end notifies at the period boundary — when fulfilment should actually stop — not when the customer clicked cancel. Cancellations Stripe makes itself after exhausted payment retries notify the same way.
Quantity change¶
When the quantity on a subscription changes, SubscriptionQuantityChangedEvent is published and
SubscriptionQuantityChangedEmailListener sends:
- a confirmation to the subscriber recording the old and new quantity, so an unintended change is
visible to them (suppressible per-site via
subscriptionQuantityChangeCustomerEnabled); - a fulfilment notification to the product's fulfiller with the new quantity to send from the next delivery onwards.
Without this a subscriber dropping from three units to one would keep receiving three — nothing else in the pipeline announces the change.
Only a genuine change publishes. Stripe emits customer.subscription.updated on renewals, payment
method updates and metadata edits too, and those leave the quantity alone.
Fulfiller routing¶
Both listeners route to the fulfiller the same way: Product.fulfilmentMethod names a site config
key whose value is the fulfiller's email address — the convention used by EmailFulfilmentProcessor.
If the product has no fulfilment method, or the key has no address configured, the fulfiller email is
skipped and the customer email still goes out.
Multi-Tenancy¶
The subscription system is fully multi-tenant:
Per-Site Isolation¶
- Each site has its own Stripe account (via Stripe Connect)
- Subscriptions are scoped to
siteId - Invoices are scoped to
siteId - Sync runs independently per site
Sync Process¶
- Manual Trigger: Admin users can trigger sync from
/admin/subscriptionsor/admin/shopSettings/payment - Per-Site API Calls: Uses each site's Stripe credentials via
constructRequestOptions(siteContext) - Incremental Sync: Tracks last sync timestamp per site in configuration
- Tenant Isolation: All created records tagged with correct
siteId - Duplicate Protection: Concurrent sync requests for the same site are blocked
Owner Resolution¶
When syncing subscriptions, the system resolves the Badger user (owner) via:
1. Subscription Metadata: Check for userId or orderId in Stripe metadata
2. Order Lookup: If orderId found, lookup order to get owner
3. Customer Charges: Query Stripe for customer's charges, extract order metadata from charge metadata
Technical Implementation¶
Data Models¶
Subscription (common/.../subscription/Subscription.java)
- siteId: Tenant identifier
- provider: Payment provider (STRIPE)
- externalId: Stripe subscription ID (sub_xxx)
- externalCustomerId: Stripe customer ID (cus_xxx)
- owner: Badger user ID
- orderId/orderNumber: Original order reference
- productSkuId: Badger parent product SKU ID
- skuId: Badger variant SKU ID (for linking back to product catalogue)
- status: Subscription state
- amountInPence, currency, interval, intervalCount: Billing details
- currentPeriodStart, currentPeriodEnd, trialEnd: Dates
- giftAidEligible: For charitable donations
- created: Uses Stripe's created timestamp (not local creation time)
Invoice (common/.../subscription/Invoice.java)
- siteId: Tenant identifier
- subscriptionId: Link to local Subscription record
- externalId: Stripe invoice ID (in_xxx)
- status: draft, open, paid, void, uncollectible
- total, amountPaid, amountDue: Amounts in pence
- paidAt: Payment timestamp
- lineItems: Embedded line item details
- giftAidEligible, donationId: Gift Aid tracking
- dunningState: NONE, IN_DUNNING, RECOVERED, or GIVEN_UP (see Failed Payments & Dunning)
- lastDunnedAttemptCount, lastDunnedAt, nextAttemptAt, dunningEmailsSent: chaser tracking, driven by Stripe's attempt_count / next_payment_attempt
Services¶
SubscriptionService (common/.../subscription/SubscriptionService.java)
- CRUD operations for subscriptions
- createFromStripeSubscription(): Create from Stripe webhook/sync
- resolveOwnerFromStripeCustomer(): Multi-step owner resolution
- calculateMRR(): Monthly recurring revenue calculation
InvoiceService (common/.../subscription/InvoiceService.java)
- CRUD operations for invoices
- createFromStripeInvoice(): Create from Stripe webhook/sync
- createDonationIfEligible(): Gift Aid donation creation
SubscriptionSyncService (commerce-core/.../subscription/SubscriptionSyncService.java)
- syncSite(): Sync single site's subscriptions and invoices
- syncAllSites(): Iterate all sites and sync each
Stripe Integration¶
Metadata on Subscription Creation
When creating a Stripe subscription, the following metadata is attached:
- orderId: Badger order ID
- orderNumber: Human-readable order number
- userId: Badger user ID
- siteId: Site identifier
- productSkuId: Parent product SKU ID
- skuId: Variant SKU ID (or parent SKU if no variant)
This enables owner and product resolution during sync for subscriptions created through the platform.
Product Resolution¶
When syncing subscriptions, the system resolves the Badger product via:
1. Metadata: Check for skuId and productSkuId in Stripe subscription metadata
2. Price Key Lookup: If metadata is missing, search for products with stripe-subscription-price-key attribute matching the subscription's priceId
The stripe-subscription-price-key attribute should be set on either:
- Non-variant products: Set the attribute directly on the product
- Variant products: Set the attribute on the specific variant
Example (variant product):
{
"skuId": "subscription",
"productName": "Monthly Subscription",
"variants": [
{
"skuId": "subscription-10",
"attributes": {
"stripe-subscription-price-key": "price_1JvWB9CujlwdofGIIzJJDSqO"
}
}
]
}
Example (non-variant product):
{
"skuId": "single-subscription",
"productName": "Standard Subscription",
"attributes": {
"stripe-subscription-price-key": "price_1XyZ123AbCdEfGhIjKlMnOpQ"
}
}
Admin Interface¶
Subscription List (/admin/subscriptions)¶
- Filterable by status (All, Active, Trialing, Past Due, Canceled)
- Filterable by subscription product; the product list comes from the catalogue, so a product with no subscribers yet still appears (with a count of zero). Status and product filters combine.
- Shows: Status, Product, Amount, Interval, Customer link, Created, Period End
- Pagination support
- Click-through to subscription detail
Subscription Detail (/admin/subscriptions/view/{id})¶
- Customer Section: Name, email, phone with link to user profile
- Subscription Details: Product, amount, interval, quantity, Gift Aid status
- Billing Period: Current period dates, trial end, created date
- Payment & Reference: Payment method, original order link, Stripe IDs
- Invoices Table: All invoices with status, amounts, PDF links
User Profile Integration¶
- User profile page shows subscriptions section
- Active subscription count and monthly value widgets
- Table of all user's subscriptions with view links
Integration Points¶
The subscription system integrates with: - Order System: Links subscriptions to original orders - User System: Links subscriptions to user profiles - Gift Aid/Donations: Creates donation records for eligible invoices - Stripe Webhooks: Receives subscription and invoice events - Admin Dashboard: MRR statistics and reporting
Key Classes¶
| Class | Location | Purpose |
|---|---|---|
Subscription |
common | Subscription entity |
Invoice |
common | Invoice entity |
SubscriptionService |
common | Business logic |
InvoiceService |
common | Invoice business logic |
SubscriptionSyncService |
commerce-core | Sync orchestration |
SubscriptionCancellationEmailListener |
commerce-core | Cancellation emails (customer + fulfiller) |
SubscriptionQuantityChangedEmailListener |
commerce-core | Quantity change emails (customer + fulfiller) |
SubscriptionAdminController |
web-admin | Admin UI controller |
StripePaymentService |
common | Stripe API operations |
SubscriptionUpdateLinkTokenService |
common | Signs/verifies the dunning update-payment link token |
SubscriptionInvoiceSiteResolver |
worker | Resolves the owning site for an invoice webhook |
SecureSubscriptionPaymentController |
web-mvc | Account-free card update entry point from the chaser email |
Configuration¶
Lifecycle notification emails are configured per-site under the subscription-emails category —
from address, from name, subject, template and BCC for each of the four emails, plus
subscriptionQuantityChangeCustomerEnabled to suppress the customer-facing quantity change
confirmation.
Sync timestamp stored per-site in configuration:
- Key: subscriptionSync.lastRunTimestamp
- Category: sync
- Not editable/visible in admin UI