Where this file lives
- This path (
docs/coupons/ongoing-membership-coupons.md) is inside the rakletv3 repo for review and maintenance.- This document describes the current implemented process for ongoing membership coupons.
Ongoing membership coupons¶
Generated: 2026-05-08
Repo: rakletadmin/rakletv3
Focus: recurring membership coupon setup, validation, subscription attachment, checkout/pay, and scheduled billing
Purpose¶
Membership coupons historically behaved as one-time invoice/debt discounts. Ongoing membership coupons add a subscription-level coupon attachment so a coupon entered during signup/application or checkout/pay can continue affecting future membership billing periods.
This document describes the implemented state through first payment, checkout/pay, subscription attachment, and scheduled billing consumption.
Scope¶
This document covers:
- coupon application mode and duration fields
- admin coupon management controls
- signup/application validation boundaries
- checkout/pay validation boundaries
- ongoing coupon attachment to
CustomMembership - fixed vs percentage targeting rules for the first signup payment
- fixed amount coupon currency matching for eligible membership-fee lines
- redemption and period-counting responsibilities
- automated test coverage added for the helper-level rules
Out of scope¶
This document does not cover:
- event coupons
- non-membership coupons
- a global
MembershipFeegetter/refactor - manual assignment of ongoing coupons to already-running subscriptions
Data model¶
Coupon mode¶
CouponDoc now carries application-mode metadata:
| Field | Meaning |
|---|---|
ApplicationType |
OneTime or Ongoing; old/missing Cosmos values default to OneTime. |
OngoingDurationType |
Forever or Multiple; only meaningful for ongoing coupons. |
OngoingDurationPeriods |
Positive billing-period count required only when duration type is Multiple. |
Enum values:
| Enum | Value |
|---|---|
CouponApplicationType.OneTime |
0 |
CouponApplicationType.Ongoing |
1 |
OngoingDurationType.Forever |
0 |
OngoingDurationType.Multiple |
1 |
Coupon management view model¶
CouponViewModel exposes nullable fields so the admin API can create/update the mode and duration settings:
ApplicationTypeOngoingDurationTypeOngoingDurationPeriods
Subscription attachment¶
CustomMembership.CouponId stores the coupon code/id attached to a subscription.
Only ongoing coupons are written here. One-time coupons must never be copied to CustomMembership.CouponId.
Architecture¶
One-time helper¶
MembershipCouponHelper remains the one-time invoice/debt helper.
Responsibilities:
- filter invoice lines by
ApplicablePlanIds - honor
AppliesToApplicationFee - allocate fixed or percentage discounts
- return
AppliedInvoiceDiscounts - return
RequiredRedemptionsfor invoice/debt coupon usage
Boundary:
MembershipCouponHelper.ValidateCouponForInvoices()rejects ongoing coupons by default.- Non-signup application flows stay on this helper.
- Checkout/pay opts into ongoing behavior through
OngoingMembershipCouponHelper.ValidateCouponForInvoicesAllowingOngoing().
Ongoing helper¶
OngoingMembershipCouponHelper owns signup/application ongoing behavior.
Responsibilities:
- validate ongoing application type and duration settings
- delegate non-ongoing coupons back to
MembershipCouponHelper - require an eligible membership-fee line for ongoing coupons
- enforce membership-fee-first targeting for fixed ongoing coupons
- preserve existing percentage/application-fee split behavior for the first signup payment
- attach coupon ids to subscriptions via
TryAttachCoupon()
Business rules¶
Application type¶
| Coupon state | Behavior |
|---|---|
Missing/null ApplicationType |
Treated as OneTime. |
OneTime |
Uses the existing invoice/debt coupon flow. |
Ongoing outside an ongoing-aware surface |
Invalid. |
Ongoing inside signup/application approval |
Validated by OngoingMembershipCouponHelper and attached only after a real membership-fee discount. |
Ongoing inside checkout/pay |
Valid only for eligible membership-fee debt payment. The checkout path uses the ongoing helper, rejects subscriptions that already have a coupon attachment, and attaches the coupon only to discounted membership-fee subscriptions. |
Duration¶
| Duration | Rule |
|---|---|
Forever |
Coupon remains eligible until EndDate stops it. |
Multiple |
OngoingDurationPeriods must be positive. |
EndDate is the highest-priority stop condition for future scheduled billing.
First signup payment targeting¶
| Coupon shape | Rule |
|---|---|
| Ongoing fixed amount | Must target eligible MembershipFee; application fee cannot be used as the target just because it is larger. |
| Ongoing fixed amount currency | Known membership-fee currency must match the coupon currency. Mismatched membership-fee lines are ignored; if none remain, the coupon is invalid. |
| Ongoing fixed amount greater than membership fee | Invalid. |
Ongoing percentage with AppliesToApplicationFee = true |
Can discount both membership fee and application/registration fee in the first signup payment. |
Ongoing percentage with AppliesToApplicationFee = false |
Discounts membership fee only. |
An ongoing coupon is invalid when the cart has only an application fee and no eligible membership fee.
Redeemed coupon edit lock¶
After RedeemedCount > 0, coupon management updates are limited to:
EndDateMaxRedemptions
The following are locked:
ApplicationTypeOngoingDurationTypeOngoingDurationPeriods- discount type/value
- plan scope
- application fee scope
- name and other non-limit settings
Redemptions vs periods¶
MaxRedemptions is not a billing-period counter.
| Concept | Meaning |
|---|---|
MaxRedemptions |
How many subscriptions can redeem or attach the coupon. |
RedeemedCount |
Increased when the ongoing coupon is actually attached/applied during approval or checkout/pay completion. |
| Applied billing period count | Derived from real membership-fee financial records. |
For Multiple, scheduled billing derives applied periods from records such as membership-fee debts where:
CustomMembershipId == subscription.IdDebtType == MembershipFeeCouponId == subscription.CouponIdDiscountAmount > 0
The first discounted membership-fee record counts as period 1.
Flows¶
Admin coupon management¶
Relevant files:
Raklet.Api/Controllers/V2/V2CouponsController.csRaklet.Backend/Content/scripts/core/manager/_shared/coupons.service.jsRaklet.Backend/Content/scripts/core/manager/membership/settings-coupon-create.controller.jsRaklet.Backend/Content/scripts/core/manager/membership/settings-coupon-detail.controller.jsRaklet.Backend/Content/scripts/core/manager/membership/settings-coupons.controller.js- matching create/detail/list templates
Behavior:
- Manager chooses
OneTimeorOngoing. - For ongoing coupons, manager chooses
ForeverorMultiple. Multiplerequires a positive period count.- API normalizes the mode/duration fields.
- Redeemed coupons keep application/duration/scope fields locked.
Signup credit card preview¶
Relevant file:
Application/Controllers/SignupController.cs
Behavior:
- Builds coupon invoice items for selected membership plans, registration fee, and paid group sub-member plans.
- Calls
OngoingMembershipCouponHelper.ValidateSignupCouponForInvoices(). - One-time coupons are delegated to the existing helper.
- Ongoing coupons must include an eligible membership fee.
- Returned discounts are mapped back to membership, registration fee, and sub-member rows.
Application coupon apply¶
Relevant file:
Application/Controllers/ApplyController.cs
Behavior:
- signup-form applications use
OngoingMembershipCouponHelper - non-signup application flows use
MembershipCouponHelper - ongoing coupons are rejected outside the signup-form application context
Application approval/process¶
Relevant file:
Services/ApplicationFormService.cs
Behavior:
- Approval rebuilds the same invoice-line shape used by signup preview.
- Signup-origin approvals use the ongoing helper.
- Membership discounts are distributed through primary/sub-member queues.
- If an ongoing coupon produced a real membership-fee discount, the matching
CustomMembership.CouponIdis set. - One-time coupons are not attached to subscriptions.
- Coupon redemption is updated once after real discount/attachment.
- During the temporary Mumadoo defer-membership-fee window, qualifying paid sub-member subscriptions still receive the ongoing
CustomMembership.CouponIdeven though child payment/debt rows are skipped.
Scheduled billing¶
Relevant file:
Raklet.WebJobs.Secondary/Payment/ScheduledPaymentFunctions.cs
For the full billing pipeline (PaymentTicket → SubscriptionDebtTable → Debt, cancel-and-recreate pattern, concurrency notes) see docs/payments/recurring-membership-billing-flow.md.
Implemented behavior:
- When scheduled membership debt is generated, check
CustomMembership.CouponId. - Load the coupon document through
SubscriptionCouponPricingService. - Check
EndDatebefore duration/period rules. - For
Multiple, derive applied period count from existing membership-fee debts with the same coupon and positive discount. - Apply the discount only when the coupon is still eligible.
- Persist coupon id and discount amount on the generated debt.
- Use the same discounted amount for scheduled payment amount refresh and before-due reminder balance checks.
Automated coverage¶
Current helper-level tests live in Raklet.UnitTests/MembershipCouponHelperTests.cs.
Ongoing-specific coverage includes:
- old one-time helper rejects ongoing coupons
- missing ongoing duration is invalid
Multiplewithout a positive period count is invalidMultiplewith a positive period count is valid- one-time coupons passed through the ongoing signup helper delegate to the one-time helper
- fixed ongoing coupons target membership fee before application fee
- fixed ongoing coupons reject or ignore known membership-fee currency mismatches
- oversized fixed ongoing coupons cannot be covered by application fee
- percentage ongoing coupons include application fee only when allowed
- ongoing plan restriction wrong-plan path returns the plan message
- application-fee-only carts are invalid for ongoing coupons
TryAttachCoupon()writes only ongoing coupons- already-attached same coupon does not report a new change
Verification used:
.\scripts\dev\build-fast.ps1 Debug -Project Raklet.UnitTests- targeted
vstest.console.exerun forMembershipCouponHelperTests
QA checklist¶
- Create one-time coupon and verify old checkout/apply behavior still works.
- Create ongoing forever coupon and verify duration fields are saved/displayed.
- Create ongoing multiple coupon and verify positive period validation.
- Verify fixed ongoing coupon with high application fee still targets membership fee.
- Verify fixed ongoing coupon larger than membership fee is rejected.
- Verify percentage ongoing coupon with application fee enabled discounts both first-payment lines.
- Verify percentage ongoing coupon with application fee disabled discounts membership fee only.
- Approve a signup application and verify only discounted subscriptions receive
CustomMembership.CouponId. - Verify redeemed coupon detail allows only
EndDateandMaxRedemptionschanges.
Review status¶
Last reviewed: 2026-05-13 against feature/ongoing-membership-coupons at d6a63df40b and origin/master...HEAD.
Copilot review threads were rechecked against the current code. The branch preserves existing coupon application and duration fields on partial PATCH updates, includes the explicit CustomMemberships.CouponId migration, documents checkout/pay and scheduled billing as in scope, passes the ongoing coupon id directly during Stripe checkout attachment, reuses or disposes pricing database contexts, and reuses a pricing service instance for reminder processing.
Remaining coverage to add when a stable harness exists: service/integration coverage for scheduled debt creation, scheduled payment amount refresh, and before-due reminder balance checks.