Skip to content

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 MembershipFee getter/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:

  • ApplicationType
  • OngoingDurationType
  • OngoingDurationPeriods

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 RequiredRedemptions for 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:

  • EndDate
  • MaxRedemptions

The following are locked:

  • ApplicationType
  • OngoingDurationType
  • OngoingDurationPeriods
  • 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.Id
  • DebtType == MembershipFee
  • CouponId == subscription.CouponId
  • DiscountAmount > 0

The first discounted membership-fee record counts as period 1.

Flows

Admin coupon management

Relevant files:

  • Raklet.Api/Controllers/V2/V2CouponsController.cs
  • Raklet.Backend/Content/scripts/core/manager/_shared/coupons.service.js
  • Raklet.Backend/Content/scripts/core/manager/membership/settings-coupon-create.controller.js
  • Raklet.Backend/Content/scripts/core/manager/membership/settings-coupon-detail.controller.js
  • Raklet.Backend/Content/scripts/core/manager/membership/settings-coupons.controller.js
  • matching create/detail/list templates

Behavior:

  1. Manager chooses OneTime or Ongoing.
  2. For ongoing coupons, manager chooses Forever or Multiple.
  3. Multiple requires a positive period count.
  4. API normalizes the mode/duration fields.
  5. Redeemed coupons keep application/duration/scope fields locked.

Signup credit card preview

Relevant file:

  • Application/Controllers/SignupController.cs

Behavior:

  1. Builds coupon invoice items for selected membership plans, registration fee, and paid group sub-member plans.
  2. Calls OngoingMembershipCouponHelper.ValidateSignupCouponForInvoices().
  3. One-time coupons are delegated to the existing helper.
  4. Ongoing coupons must include an eligible membership fee.
  5. 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:

  1. Approval rebuilds the same invoice-line shape used by signup preview.
  2. Signup-origin approvals use the ongoing helper.
  3. Membership discounts are distributed through primary/sub-member queues.
  4. If an ongoing coupon produced a real membership-fee discount, the matching CustomMembership.CouponId is set.
  5. One-time coupons are not attached to subscriptions.
  6. Coupon redemption is updated once after real discount/attachment.
  7. During the temporary Mumadoo defer-membership-fee window, qualifying paid sub-member subscriptions still receive the ongoing CustomMembership.CouponId even 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:

  1. When scheduled membership debt is generated, check CustomMembership.CouponId.
  2. Load the coupon document through SubscriptionCouponPricingService.
  3. Check EndDate before duration/period rules.
  4. For Multiple, derive applied period count from existing membership-fee debts with the same coupon and positive discount.
  5. Apply the discount only when the coupon is still eligible.
  6. Persist coupon id and discount amount on the generated debt.
  7. 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
  • Multiple without a positive period count is invalid
  • Multiple with 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.exe run for MembershipCouponHelperTests

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 EndDate and MaxRedemptions changes.

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.