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 entityPaymentTicket(SQL Server) — the scheduled-payment instructionSubscriptionDebtTable(Azure Table Storage) — the daily intermediate batchDebt(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
- Status — Active, 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:
-
Multiple workers per ticket. The daily timer can produce duplicate
SubscriptionDebtTablerows 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." -
Concurrent writes to
CustomMembership. Multiple call sites can write to the same membership row at the same time: QueueTrigger("create-custom-membership")(ImportExportFunctions.cs)MembershipController.CreateAddSubMember-
MembershipController.Create -
Last-writer-wins on every
_db.SaveChanges(). Read-modify-write of anyCustomMembershipfield 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:
- Marks the existing ticket as
ScheduledCancelled - 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:
ScheduledCancelledrows 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:
CreateScheduledMembershipPaymentalready knows how to compute the right amount, schedule rule, and references.
Known gaps¶
- No concurrency tokens on
CustomMembershiporPaymentTicket. See above. SubscriptionDebtTableis 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.
SubscriptionDebtTablesnapshots 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 |
Related docs¶
docs/coupons/ongoing-membership-coupons.md— coupon attachment rules, validation, and subscription-level flows.docs/coupons/membership-coupon-process.md— signup/checkout-time coupon behavior.