Skip to content

Where this file lives

  • This path (docs/payments/recurring-membership-billing-flow.md) is inside the rakletv3 repo for easy review and maintenance.
  • This document describes the current implemented state of how recurring membership Debts are produced and processed across signup, scheduled webjobs, and plan-change cascades.
  • Discovered during architecture review for RAK-273 (coupon duration support). Future engineers touching subscription billing should start here before tracing code.

Recurring Membership Billing Flow

Repo: rakletadmin/rakletv3 Focus: how a recurring membership produces Debts month over month, who triggers it, and the existing pattern for cascading rate changes onto pending PaymentTickets.

Purpose

When a member subscribes to a recurring membership plan, Raklet does not pre-create a year of Debts at signup. The system uses a deferred model: a single PaymentTicket row schedules the next billing event, and a daily timer-triggered webjob materializes Debts as schedule dates arrive.

This document explains:

  • How the deferred billing model works
  • Where each piece lives in code
  • The existing "cancel-and-recreate" pattern used for plan price changes
  • The concurrency and idempotency model
  • Known gaps to be aware of when extending this path

Scope

Covers:

  • CustomMembership (SQL Server) — the live recurring subscription entity
  • PaymentTicket (SQL Server) — the scheduled-payment instruction
  • SubscriptionDebtTable (Azure Table Storage) — the daily intermediate batch
  • Debt (SQL Server) — the materialized invoice line
  • The webjob chain that connects them
  • The plan-price-change cascade pattern

Out of scope:

  • One-time event registration billing
  • Stripe Subscriptions (used only for Raklet's own SaaS-tier billing, NOT member billing)
  • Initial signup Debt creation in the catch-up loop (covered separately)

Canonical entities

CustomMembership (Models/Models/CustomMembership.cs)

SQL Server entity (EF). Represents a live recurring relationship between a member and a plan.

Key fields: - Id — subscription id - CustomMemberTypeId — plan id (foreign key to CustomMemberType) - OrganisationMembershipId — member id - StatusActive, PastDue, Frozen, Cancelled, Scheduled - StartDate, EndDate, RenewalDate — lifecycle dates - Balance, PaymentsTotal, DebtsTotal — running totals - ParentMembershipId — group membership parent linkage - CouponId — ongoing coupon attached to this subscription (RAK-273)

No concurrency token. No [Timestamp], no [ConcurrencyCheck], no rowversion. All saves are last-writer-wins. See "Concurrency and Idempotency" below.

PaymentTicket (Models/Models/PaymentTicket.cs)

SQL Server entity (EF). Represents a single scheduled-payment instruction for a future billing event.

Key fields: - Amount (decimal) — the amount shown in member-facing "next charge" UI. NOT the source of the Debt amount — see critical detail below. - ScheduleRule (string) — cron-style schedule expression generated from plan.RenewalInterval - Status (PaymentTicketStatus) — Scheduled, ScheduledCancelled, etc. - CustomMembershipId — link back to the subscription - CustomMemberTypeId — plan id snapshot - OrganisationMembershipId — member id - PaymentDate (DateTime?) — next fire date - IsRecurringPayment (bool) — true for monthly/annual recurring memberships

SubscriptionDebtTable (Azure Table Storage)

Daily intermediate batch. Generated by the SubscriptionDebtsTable timer-triggered webjob and consumed by the SubscriptionDebtsProcess queue-triggered webjob. This is what stages tomorrow's billable subscriptions before any SQL Debts get written.

Each row carries plan-snapshot fields like CustomMemberTypeMembershipFee so the materialized Debt does not require a fresh plan lookup at write time.

Debt (Models/Models/Debt.cs)

SQL Server entity (EF). The materialized invoice line that finance/accounting reads against. Has CouponId and DiscountAmount; since RAK-273 both fields are populated in the renewal path when an ongoing coupon is active on the subscription.

Flow

SIGNUP / CHECKOUT
─────────────────
CheckoutService / MembershipController
        │
        ├── creates CustomMembership row (Status: Active or Scheduled)
        ├── creates initial signup Debt (with coupon if applicable, via MembershipCouponHelper)
        └── PaymentTicketService.CreateScheduledMembershipPayment()
                 │
                 └── inserts PaymentTicket row
                     (Amount = discounted membership fee if coupon active, else plan fee,
                      ScheduleRule = cron from plan.RenewalInterval,
                      CustomMembershipId = subscription.Id)


DAILY RECURRING BILLING (webjob chain)
──────────────────────────────────────
Raklet.WebJobs.ScheduledHourly.Functions
   │
   ├── TimerTrigger 22:00 UTC daily
   │       │
   │       └── enqueues a SubscriptionDebtsTable batch entry
   │           for every PaymentTicket with PaymentDate <= today
   │
   └── (subsequent step in queue chain)

Raklet.WebJobs.Secondary.Payment.ScheduledPaymentFunctions
   │
   ├── QueueTrigger "subscription-debts-process"
   │       │
   │       └── for each row → CreateDebtToSql()
   │                 │
   │                 ├── loads subscription → calls SubscriptionCouponPricingService
   │                 ├── new Debt {
   │                 │     Amount = discountedAmount (or full fee if no coupon),
   │                 │     CouponId = coupon.Id (if applied),
   │                 │     DiscountAmount = discount (if applied),
   │                 │     CustomMemberTypeId, CustomMembershipId, ...
   │                 │   }
   │                 ├── INSERT into Debt SQL table
   │                 └── (subsequent payment-processing step)


PLAN PRICE CHANGE CASCADE  (the "cancel-and-recreate" pattern)
──────────────────────────────────────────────────────────────
Plan price edit → enqueues "recreate-scheduled-payments-of-plan-part" message

Raklet.WebJobs.Secondary.Payment.ScheduledPaymentUpdateFunctions
   │
   └── QueueTrigger "recreate-scheduled-payments-of-plan-part"
           │
           └── RecreateScheduledPaymentsOfPlanPart()
                 │
                 ├── existingTicket = PaymentTicketService.FindScheduledPaymentForSubscriptionId(parentId)
                 ├── existingTicket.Status = ScheduledCancelled
                 ├── PaymentTicketService.UpdateAsync(existingTicket)
                 └── PaymentTicketService.CreateScheduledMembershipPayment(parentId, creditCardTokenId)
                     // creates a fresh PaymentTicket with the NEW Amount from the current plan

Critical detail: the Debt amount source

The materialized Debt's Amount comes from live coupon evaluation at debt-write time, not from the PaymentTicket's Amount field.

Since RAK-273, CreateDebtToSql calls SubscriptionCouponPricingService.CalculateMembershipFee() on the subscription to determine whether a discount applies. The PaymentTicket.Amount is a display value (used in member-facing "next charge" UI) that is refreshed separately; it is not the authoritative source for the Debt amount.

Manager-created future starts

CustomMembershipService.CreateMembershipAsync still normalizes CustomMembership.StartDate through plan-specific start-date rules. For example, a recurring plan with a configured annual start date can store the current billing-cycle anchor rather than the literal date picked by a manager.

When the caller supplied an explicit future StartDate, SubscriptionProcess treats the subscription as scheduled even if the normalized StartDate is not in the future. In that path it sets Status = Scheduled, uses the requested future date as the first RenewalDate, and does not create the first membership-fee Debt immediately. Consumers that need the effective scheduled start should prefer a future RenewalDate over the normalized StartDate.

Concurrency and idempotency

No locks. No optimistic concurrency tokens. The renewal path has three risks:

  1. Multiple workers per ticket. The daily timer can produce duplicate SubscriptionDebtTable rows if it runs twice for the same date (e.g., a manual re-run). Nothing in the consuming queue worker checks "have I already produced a Debt for this subscription on this date."

  2. Concurrent writes to CustomMembership. Multiple call sites can write to the same membership row at the same time:

  3. QueueTrigger("create-custom-membership") (ImportExportFunctions.cs)
  4. MembershipController.CreateAddSubMember
  5. MembershipController.Create

  6. Last-writer-wins on every _db.SaveChanges(). Read-modify-write of any CustomMembership field can lose updates under concurrency.

This has been tolerable historically because Debts are duplicate-detectable downstream (finance can spot extra invoices) and most renewals are not concurrent in practice.

Recommended fix when extending this path: add [Timestamp] rowversion to CustomMembership and have callers catch DbUpdateConcurrencyException. Cheap, EF handles the heavy lifting, queue triggers naturally retry on exception.

Cancel-and-recreate as the extension pattern

When a plan's price changes, Raklet does NOT mutate PaymentTicket.Amount in place. Instead it:

  1. Marks the existing ticket as ScheduledCancelled
  2. Creates a fresh ticket with the new amount

This pattern is the recommended template for any "rate change cascade" — including discount attach, discount expiration, and plan switch. Reasons it's better than in-place mutation:

  • Audit trail: ScheduledCancelled rows stay in the database, so finance can reconstruct "this subscription was set to $X, then changed to $Y on date Z."
  • No state corruption: a partial update would leave the row in an inconsistent state if the process crashed.
  • Reuses existing logic: CreateScheduledMembershipPayment already knows how to compute the right amount, schedule rule, and references.

Known gaps

  • No concurrency tokens on CustomMembership or PaymentTicket. See above.
  • SubscriptionDebtTable is Azure Table Storage, which means the daily batch is eventually consistent. If a coupon is attached at 21:55 UTC and the daily timer fires at 22:00 UTC, the new state might not be visible to the timer.
  • Plan-snapshot drift. SubscriptionDebtTable snapshots plan fees daily. If the plan fee changes between the snapshot write and the debt-materialization step, the snapshot value wins silently.

File reference table

Concern File Symbol / line
Subscription entity Models/Models/CustomMembership.cs CustomMembership class
Schedule entity Models/Models/PaymentTicket.cs PaymentTicket class
Debt entity Models/Models/Debt.cs Debt class
Coupon pricing service Services/SubscriptionCouponPricingService.cs CalculateMembershipFee
Ticket creation at signup Services/PaymentTicketService.cs CreateScheduledMembershipPayment
Daily timer Raklet.WebJobs.ScheduledHourly/Functions.cs WebjobTriggerTimer Key=SubscriptionDebtsTable
Debt materialization Raklet.WebJobs.Secondary/Payment/ScheduledPaymentFunctions.cs CreateDebtToSql
Plan-change cascade Raklet.WebJobs.Secondary/Payment/ScheduledPaymentUpdateFunctions.cs RecreateScheduledPaymentsOfPlanPart