Skip to content

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.ApplicationType
  • CouponDoc.OngoingDurationType
  • CouponDoc.OngoingDurationPeriods
  • CouponDoc.ApplicablePlanIds
  • CouponDoc.AppliesToApplicationFee
  • CouponDoc.RestrictToApplicationFeeOnly
  • CouponDoc.DiscountType
  • CouponDoc.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:

  • Id
  • Amount
  • Type
  • MembershipPlanId

Meaning:

  • Id is the debt id in checkout, or a synthetic/current line id in preview flows
  • MembershipPlanId is 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:

  • MembershipCouponHelper remains the one-time invoice/debt helper and rejects ongoing coupons by default.
  • OngoingMembershipCouponHelper owns signup/application ongoing validation.
  • an ongoing coupon must have an eligible MembershipFee line
  • 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 AppliesToApplicationFee behavior for the first signup payment
  • ongoing validation returns RequiredRedemptions = 1, even if a percentage coupon discounts both membership fee and application fee
  • MaxRedemptions limits subscription attachments/redemptions; it is not the billing-period counter for Multiple

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 AppliesToApplicationFee is 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.Ongoing coupons, and rejected by the API when AppliesToApplicationFee is 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 MembershipPlanId matches 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 MembershipPlanId matches 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() parses ApplicationForm.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:

  1. Load coupon
  2. Discard non-positive invoice lines
  3. Filter by plan restriction and application fee rules
  4. Build concrete invoice discount allocations
  5. Check coupon availability using required redemption count
  6. Return:
  7. total discount
  8. optional single target invoice metadata
  9. AppliedInvoiceDiscounts
  10. RequiredRedemptions

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.cs
  • Raklet.Backend/.../settings-coupon-create.html
  • Raklet.Backend/.../settings-coupon-detail.html
  • Raklet.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 OneTime or Ongoing
  • ongoing coupons require a duration type (Forever or Multiple)
  • Multiple ongoing 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 EndDate and MaxRedemptions updates; application type, duration, discount shape, plan scope, and fee scope stay locked

2. Manager coupon list

Relevant files:

  • Raklet.Backend/.../settings-coupons.controller.js
  • Raklet.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.cs
  • Application/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[].DiscountAmount
  • SubMembers[].DiscountAmount
  • RegistrationFeeDiscount

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.cs
  • Application/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 MembershipCouponHelper and reject ongoing coupons

5. Admin waiting-application preview

Relevant files:

  • Raklet.Backend/.../membership-application-edit-controller.js
  • Raklet.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 use MembershipCouponHelper
  • 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.CouponId is attached
  • one-time coupons are not written to CustomMembership.CouponId

7. Checkout / pay

Relevant files:

  • Services/CheckoutService.cs
  • Application/Controllers/CheckoutController.cs
  • Application/Views/Shared/_SortedUnpaidInvoices.cshtml
  • Application/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
  • AppliedInvoiceDiscounts is 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 CouponDoesNotApplyToYourPlan when 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 TryAttachCoupon behavior
  • 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.UnitTests
  • vstest.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 AppliesToApplicationFee disabled is rejected by the API instead of being saved as a no-op coupon
  • application-fee-only coupon cannot be saved as an Ongoing coupon
  • 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