Where this file lives
- This path (
docs/coupons/membership-coupon-process.md) is inside the rakletv3 repo for easy review and maintenance.- This document describes the current implemented state of membership coupon behavior, including one-time invoice/debt coupons and signup-origin ongoing membership coupons.
Membership coupon process and business rules¶
Generated: 2026-05-08
Repo: rakletadmin/rakletv3
Focus: membership coupon rules, helper boundaries, signup/application approval behavior, and page-level flow alignment
Purpose¶
This document explains how membership coupons currently work across:
- manager coupon configuration
- apply / signup credit card previews
- admin waiting-application preview
- application approval
- checkout / pay for existing members
It replaces older assumptions that treated every coupon as a single-target invoice discount.
Scope¶
This document covers:
CouponType.Membership- one-time vs ongoing membership coupon application modes
- plan restrictions via
ApplicablePlanIds - application fee behavior via
AppliesToApplicationFee - fixed vs percentage allocation rules
- redemption counting rules
- group membership and sub-member coupon behavior
- subscription-level ongoing coupon attachment via
CustomMembership.CouponId
Out of scope¶
This document does not cover:
- event coupons
- non-membership billing coupons
- generic coupon infrastructure outside membership flows
- manager translations and copy quality
- manual assignment of ongoing coupons to already-running subscriptions
Canonical entities and fields¶
Coupon fields¶
Membership coupon behavior is driven by:
CouponDoc.ApplicationTypeCouponDoc.OngoingDurationTypeCouponDoc.OngoingDurationPeriodsCouponDoc.ApplicablePlanIdsCouponDoc.AppliesToApplicationFeeCouponDoc.RestrictToApplicationFeeOnlyCouponDoc.DiscountTypeCouponDoc.Amount
Old coupon documents with missing application type are treated as CouponApplicationType.OneTime.
Subscription attachment field¶
CustomMembership.CouponId stores the ongoing coupon attached to a subscription. This field is only written for ongoing coupons that actually discount a membership fee during signup/application approval or checkout/pay completion.
One-time coupons must not be copied to CustomMembership.CouponId.
Runtime invoice abstraction¶
All major flows convert their payable lines into MembershipCouponHelper.CouponInvoiceItem:
IdAmountTypeMembershipPlanId
Meaning:
Idis the debt id in checkout, or a synthetic/current line id in preview flowsMembershipPlanIdis the plan id used for coupon allowlist matching
This separation is required because checkout line ids are debt ids, but plan restrictions must match the underlying membership plan.
High-level rule summary¶
Percentage coupons¶
When a migrated flow enables splitPercentageAcrossAllQualifyingInvoices:
- the coupon applies to every qualifying invoice
- each discounted invoice consumes one redemption
Current migrated flows:
- signup credit card preview
- apply credit card preview
- admin waiting-application preview
- application approval
- checkout / pay
Fixed coupons¶
Fixed coupons always apply to one qualifying invoice only:
- choose the highest qualifying amount
- if amounts tie, choose the latest item in the original invoice order
- if the chosen invoice amount is lower than the fixed coupon amount, the coupon is invalid
- fixed coupons consume one redemption
Ongoing coupons¶
Ongoing coupons can be attached to a subscription when a real membership-fee line is discounted in signup/application approval or checkout/pay completion.
Rules:
MembershipCouponHelperremains the one-time invoice/debt helper and rejects ongoing coupons by default.OngoingMembershipCouponHelperowns signup/application ongoing validation.- an ongoing coupon must have an eligible
MembershipFeeline - ongoing fixed coupons target membership fee first, not application fee
- ongoing fixed coupons are invalid when the fixed amount is greater than the eligible membership fee
- ongoing percentage coupons preserve existing
AppliesToApplicationFeebehavior for the first signup payment - ongoing validation returns
RequiredRedemptions = 1, even if a percentage coupon discounts both membership fee and application fee MaxRedemptionslimits subscription attachments/redemptions; it is not the billing-period counter forMultiple
Plan restriction rules¶
Application-fee-only scope¶
If RestrictToApplicationFeeOnly is true:
- no membership-fee line is ever eligible, regardless of
ApplicablePlanIds - only the application fee (inscription) line is eligible, and only when
AppliesToApplicationFeeis also true - this field defaults to
false(missing on every coupon created before this rule existed), so existing unrestricted coupons keep applying to every plan exactly as before - not supported for
CouponApplicationType.Ongoingcoupons, and rejected by the API whenAppliesToApplicationFeeis false (such a coupon would never discount anything)
Empty allowlist¶
If ApplicablePlanIds is empty or null and RestrictToApplicationFeeOnly is false:
- every membership plan is eligible
- application fee eligibility is controlled only by
AppliesToApplicationFee
Non-empty allowlist¶
If ApplicablePlanIds contains values:
- only membership fee lines whose
MembershipPlanIdmatches the normalized allowlist are eligible - application fee lines become eligible only when:
AppliesToApplicationFee == true, and- at least one matching membership line exists first
This prevents a coupon for Plan A from discounting an unrelated application fee when the cart only contains Plan B.
Normalization¶
The V2 coupon API normalizes plan ids with trimmed lowercase strings before persistence.
Application fee rules¶
Application fee behavior differs by surface because not every flow models the fee the same way.
Signup / apply / application approval preview¶
These flows use a shared synthetic registration fee line:
CouponInvoiceItem.Id == Guid.Empty- the fee is shared across the current application rather than tied to a real debt row
Rule:
- if any selected membership line matches the coupon allowlist and
AppliesToApplicationFee == true, the shared registration fee is eligible
This is important for group signup / apply:
- the coupon may be restricted to a sub-plan such as
Basic - the shared registration fee is still discounted if any selected plan in the application matches
Checkout / pay for existing members¶
Checkout uses real inscription debts:
- each application fee / inscription row can carry its own
MembershipPlanId
Rule:
- if the coupon is plan-restricted, only inscription rows whose
MembershipPlanIdmatches the allowlist remain eligible
This prevents a plan-restricted coupon from discounting another plan's application fee in checkout.
Group membership and sub-member rules¶
Variable-price group membership¶
For group applications with paid sub-plans:
MembershipCouponHelper.BuildSubMemberFeeLinesForCoupon()parsesApplicationForm.SubMembers- each paid sub-plan becomes its own membership-fee coupon line
- percentage coupons can discount parent plan, sub-member plans, and registration fee together when each line qualifies
Fixed-price group membership¶
For fixed-price group memberships:
- sub-member rows do not create separate coupon fee lines
- the parent/group plan remains the payable membership line
Queue allocation for previews and approval¶
MembershipCouponHelper.BuildMembershipDiscountQueues() splits membership discounts into:
- primary membership discounts
- sub-member membership discounts
The split preserves original line order and avoids assigning a sub-member discount to the wrong same-plan primary line, or vice versa.
Shared helper workflow¶
MembershipCouponHelper.ValidateCouponForInvoices() executes this order:
- Load coupon
- Discard non-positive invoice lines
- Filter by plan restriction and application fee rules
- Build concrete invoice discount allocations
- Check coupon availability using required redemption count
- Return:
- total discount
- optional single target invoice metadata
AppliedInvoiceDiscountsRequiredRedemptions
Ongoing signup/application flows use OngoingMembershipCouponHelper.ValidateSignupCouponForInvoices() instead. That helper delegates one-time coupons back to MembershipCouponHelper and applies ongoing-only rules before returning the same CouponValidationResult shape.
Availability check¶
Availability is checked after invoice filtering and allocation.
Implication:
- percentage coupons ask for as many redemptions as the number of discounted invoices
- fixed coupons ask for exactly one redemption
Surface-by-surface behavior¶
1. Manager coupon create / detail¶
Relevant files:
Raklet.Api/Controllers/V2/V2CouponsController.csRaklet.Backend/.../settings-coupon-create.htmlRaklet.Backend/.../settings-coupon-detail.htmlRaklet.Backend/.../settings-coupon-detail.controller.js
Behavior:
- managers can limit a coupon to one or more membership plans
- managers can choose whether the coupon also applies to the application fee
- managers can choose whether a membership coupon is
OneTimeorOngoing - ongoing coupons require a duration type (
ForeverorMultiple) Multipleongoing coupons require a positive period count- detail screen preserves existing plan restrictions if the plan list fails to load
- detail screen warns and disables restriction editing when plans cannot be loaded safely
- redeemed coupons allow only
EndDateandMaxRedemptionsupdates; application type, duration, discount shape, plan scope, and fee scope stay locked
2. Manager coupon list¶
Relevant files:
Raklet.Backend/.../settings-coupons.controller.jsRaklet.Backend/.../settings-coupons.html
Behavior:
- list page keeps the existing columns for name, id, discount, type, redemptions, and expiry
- each coupon row now also shows:
- plan scope summary
- application fee inclusion (
Yes/No) - if plan ids are known locally, the scope shows concrete plan names
- if some plan ids are missing from the current plan cache, the scope falls back to a count-based summary
3. Signup credit card preview¶
Relevant files:
Application/Controllers/SignupController.csApplication/Views/Signup/CreditCardForm.cshtml
Behavior:
- builds invoice lines for parent plans, shared registration fee, and paid sub-member plans
- validates the coupon through
OngoingMembershipCouponHelper.ValidateSignupCouponForInvoices() - one-time coupons are delegated back to
MembershipCouponHelper - ongoing coupons must include an eligible membership fee
- writes discounts back into:
CardMemberships[].DiscountAmountSubMembers[].DiscountAmountRegistrationFeeDiscount
Public signup view therefore shows line-level discounts for:
- parent plan
- registration fee
- each discounted sub-member subscription
4. Apply credit card preview¶
Relevant files:
Application/Controllers/ApplyController.csApplication/Views/Apply/CreditCardForm.cshtml
Behavior mirrors signup:
- same shared helper allocation pattern
- same line-level registration fee and sub-member discount support
- signup-form applications can accept ongoing coupons through
OngoingMembershipCouponHelper - non-signup application apply flows stay on
MembershipCouponHelperand reject ongoing coupons
5. Admin waiting-application preview¶
Relevant files:
Raklet.Backend/.../membership-application-edit-controller.jsRaklet.Backend/.../templates/application-edit.html
Behavior:
- this screen still computes its preview client-side
- it mirrors the shared helper rules instead of using the server result directly
- it now writes discount amounts to:
- main plan rows
- shared registration fee
- sub-member subscription rows for signup-origin group applications
Important nuance:
- this is a mirrored implementation, not a direct helper call
- future rule changes must keep this controller aligned with
MembershipCouponHelper
6. Application approval¶
Relevant file:
Services/ApplicationFormService.cs
Behavior:
- validates the coupon against the same invoice-line model used for application previews
- signup-origin approvals use
OngoingMembershipCouponHelper; non-signup flows useMembershipCouponHelper - uses queue allocation to apply membership discounts to primary and sub-member payments/debts
- applies registration fee discount to the inscription debt
- increments redemption count according to actual discounted invoice count
- when an ongoing coupon applies to a real membership fee, the matching
CustomMembership.CouponIdis attached - one-time coupons are not written to
CustomMembership.CouponId
7. Checkout / pay¶
Relevant files:
Services/CheckoutService.csApplication/Controllers/CheckoutController.csApplication/Views/Shared/_SortedUnpaidInvoices.cshtmlApplication/Views/Shared/_StripeCheckout.cshtml
Behavior:
- checkout validates coupons against real unpaid debt rows
- percentage coupons can discount every qualifying selected invoice
- fixed coupons still highlight and target one invoice
AppliedInvoiceDiscountsis the source of truth for:- row-level UI pricing
- checkout total
- saved-card payments
- Stripe payments
- coupon-covered zero-total payments
- storing coupon metadata on affected debts
- ongoing coupon attachment to the discounted subscription when a membership-fee debt is discounted
Redemption rules¶
| Scenario | Discounted invoice count | Redemption count |
|---|---|---|
| Fixed coupon on one qualifying line | 1 | 1 |
| Percentage coupon on one qualifying line | 1 | 1 |
| Percentage coupon on 3 qualifying lines | 3 | 3 |
| Ongoing coupon attached during signup approval or checkout/pay | 1 or more first-payment/payment lines | 1 |
For ongoing coupons, billing-period counting is separate from redemption counting. MaxRedemptions limits how many subscription attachments/redemptions can happen. Scheduled billing derives applied periods from real membership-fee debts that carry the subscription coupon id and a positive discount amount.
Scheduled membership billing evaluates ongoing coupon expiry against the billing period date, such as the scheduled payment date or renewal date, not the job run date. A coupon ending on September 30 must not discount an October 1 scheduled membership payment.
Error-message rules¶
If no qualifying invoice remains:
- show
CouponDoesNotApplyToYourPlanwhen the coupon has a plan allowlist and the cart still has membership-fee lines - otherwise show
CouponDoesNotApplyToDiscountableItems
Current limitations / maintenance notes¶
- admin waiting-application preview is still a client-side mirror and can drift if helper rules change without matching JS updates
- old callers can still use the helper's single-target default unless they explicitly enable percentage splitting
- scheduled WebJob application of ongoing coupons is implemented for generated membership-fee debts; WebJob integration/UI QA should still cover grouped scheduled payments and reminder balance behavior
Automated coverage¶
Current automated coverage lives in Raklet.UnitTests/MembershipCouponHelperTests.cs.
Covered rule groups:
- plan restriction filtering
- application-fee on/off behavior
- application-fee-only scope (
RestrictToApplicationFeeOnly) - synthetic registration fee eligibility
- checkout inscription plan matching
- percentage multi-invoice allocation
- fixed single-target allocation
- fixed tie-breaking by original order
- required redemption count for split percentage coupons
- ongoing application type and duration validation
- ongoing helper delegation for one-time coupons
- ongoing membership-fee-first targeting
- ongoing application-fee-only rejection
- ongoing
TryAttachCouponbehavior - primary vs sub-member queue ordering
- variable-price vs fixed-price group sub-member fee expansion
- user-facing helper message path for wrong-plan / non-discountable carts
Latest verified commands on this branch:
.\scripts\dev\build-fast.ps1 Debug -Project Raklet.UnitTestsvstest.console.exe Raklet.UnitTests.dll /TestCaseFilter:"FullyQualifiedName~MembershipCouponHelperTests"
QA checklist¶
- unrestricted percentage coupon discounts every qualifying line
- fixed coupon discounts only one highest qualifying line
- plan-restricted coupon discounts only matching plan rows
- application-fee-only coupon discounts the application fee and never a membership plan, even when the cart also has membership-fee lines
- application-fee-only coupon with
AppliesToApplicationFeedisabled is rejected by the API instead of being saved as a no-op coupon - application-fee-only coupon cannot be saved as an
Ongoingcoupon - plan-restricted coupon with application fee enabled discounts:
- shared registration fee in signup/apply when any selected plan matches
- only matching inscription debts in checkout
- wrong-plan coupon does not discount any unrelated line
- group signup shows discount on parent, registration fee, and sub-member rows when each qualifies
- checkout totals match highlighted discounted invoice rows
- approval persists the same discount shape shown in preview