Skip to content

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_failed webhook 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_succeeded settles that invoice's dunning state to RECOVERED — no cross-invoice side effects.
  • Reconciliation sweep: a daily job re-checks only invoices currently IN_DUNNING against 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 to RECOVERED. 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 EmailJob onto 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 via EmailJobModelReconstructor (EmailModelSpec.Kind.SUBSCRIPTION, carrying the subscription and invoice ids). Invoice dunning state is persisted before the job is published, so the worker reads nextAttemptAt back 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-queue rather 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 Stripe metadata.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's confirmationCode instead; SecureSubscriptionPaymentController still 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.

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

  1. Manual Trigger: Admin users can trigger sync from /admin/subscriptions or /admin/shopSettings/payment
  2. Per-Site API Calls: Uses each site's Stripe credentials via constructRequestOptions(siteContext)
  3. Incremental Sync: Tracks last sync timestamp per site in configuration
  4. Tenant Isolation: All created records tagged with correct siteId
  5. 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